Add Flask web UI, Docker Compose, core engine + tests
- physcom core: CLI, 5-pass pipeline, SQLite repo, 37 tests - physcom_web: Flask app with HTMX for entity/domain/pipeline/results CRUD - Docker Compose: web + cli services sharing a named volume for the DB - Clean up local settings to use wildcard permissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
src/physcom/engine/__init__.py
Normal file
0
src/physcom/engine/__init__.py
Normal file
33
src/physcom/engine/combinator.py
Normal file
33
src/physcom/engine/combinator.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Cartesian product generator across dimensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
from physcom.models.entity import Entity
|
||||
from physcom.models.combination import Combination
|
||||
from physcom.db.repository import Repository
|
||||
|
||||
|
||||
def generate_combinations(
|
||||
repo: Repository,
|
||||
dimensions: list[str],
|
||||
) -> list[Combination]:
|
||||
"""Generate all Cartesian-product combinations across the given dimensions.
|
||||
|
||||
Each combination contains exactly one entity per dimension.
|
||||
Deduplication is handled by the repository via hash.
|
||||
"""
|
||||
entity_groups: list[list[Entity]] = []
|
||||
for dim in dimensions:
|
||||
entities = repo.list_entities(dimension=dim)
|
||||
if not entities:
|
||||
raise ValueError(f"No entities found for dimension '{dim}'")
|
||||
entity_groups.append(entities)
|
||||
|
||||
combinations = []
|
||||
for entity_tuple in itertools.product(*entity_groups):
|
||||
combo = Combination(entities=list(entity_tuple))
|
||||
combinations.append(combo)
|
||||
|
||||
return combinations
|
||||
180
src/physcom/engine/constraint_resolver.py
Normal file
180
src/physcom/engine/constraint_resolver.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Dependency contradiction detection engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from physcom.models.combination import Combination
|
||||
from physcom.models.entity import Dependency
|
||||
|
||||
|
||||
# Mutual exclusion registry: for a given key, which value-sets contradict.
|
||||
# Values within the same set are compatible; values in different sets contradict.
|
||||
MUTEX_VALUES: dict[str, list[set[str]]] = {
|
||||
"atmosphere": [{"vacuum", "vacuum_or_thin"}, {"dense", "standard"}],
|
||||
"medium": [{"ground"}, {"water"}, {"air"}, {"space"}],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstraintResult:
|
||||
"""Outcome of constraint resolution for a combination."""
|
||||
|
||||
status: str = "valid" # valid, blocked, conditional
|
||||
violations: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ConstraintResolver:
|
||||
"""Checks a Combination's entities for dependency contradictions."""
|
||||
|
||||
def __init__(self, mutex_registry: dict[str, list[set[str]]] | None = None) -> None:
|
||||
self.mutex = mutex_registry or MUTEX_VALUES
|
||||
|
||||
def resolve(self, combination: Combination) -> ConstraintResult:
|
||||
result = ConstraintResult()
|
||||
all_deps: list[tuple[str, Dependency]] = []
|
||||
for entity in combination.entities:
|
||||
for dep in entity.dependencies:
|
||||
all_deps.append((entity.name, dep))
|
||||
|
||||
self._check_requires_vs_excludes(all_deps, result)
|
||||
self._check_mutual_exclusion(all_deps, result)
|
||||
self._check_range_incompatibility(all_deps, result)
|
||||
self._check_force_scale(combination, result)
|
||||
self._check_unmet_requirements(all_deps, result)
|
||||
|
||||
if result.violations:
|
||||
result.status = "blocked"
|
||||
elif result.warnings:
|
||||
result.status = "conditional"
|
||||
|
||||
return result
|
||||
|
||||
def _check_requires_vs_excludes(
|
||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||
) -> None:
|
||||
"""Rule 1: If A requires key=X and B excludes key=X → BLOCKED."""
|
||||
requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"]
|
||||
excludes = [(name, d) for name, d in all_deps if d.constraint_type == "excludes"]
|
||||
|
||||
for req_name, req in requires:
|
||||
for exc_name, exc in excludes:
|
||||
if req_name == exc_name:
|
||||
continue
|
||||
if req.key == exc.key and req.value == exc.value:
|
||||
result.violations.append(
|
||||
f"{req_name} requires {req.key}={req.value} "
|
||||
f"but {exc_name} excludes it"
|
||||
)
|
||||
|
||||
def _check_mutual_exclusion(
|
||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||
) -> None:
|
||||
"""Rule 2: If A requires key=X and B requires key=Y where X,Y are mutex → BLOCKED."""
|
||||
requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"]
|
||||
|
||||
for i, (name_a, dep_a) in enumerate(requires):
|
||||
for name_b, dep_b in requires[i + 1:]:
|
||||
if name_a == name_b:
|
||||
continue
|
||||
if dep_a.key != dep_b.key:
|
||||
continue
|
||||
if dep_a.value == dep_b.value:
|
||||
continue
|
||||
# Check if values are in different mutex sets
|
||||
if dep_a.key in self.mutex:
|
||||
set_a = self._find_mutex_set(dep_a.key, dep_a.value)
|
||||
set_b = self._find_mutex_set(dep_b.key, dep_b.value)
|
||||
if set_a is not None and set_b is not None and set_a is not set_b:
|
||||
result.violations.append(
|
||||
f"{name_a} requires {dep_a.key}={dep_a.value} "
|
||||
f"but {name_b} requires {dep_b.key}={dep_b.value} "
|
||||
f"(mutually exclusive)"
|
||||
)
|
||||
|
||||
def _find_mutex_set(self, key: str, value: str) -> set[str] | None:
|
||||
"""Find which mutex set a value belongs to, or None."""
|
||||
for value_set in self.mutex.get(key, []):
|
||||
if value in value_set:
|
||||
return value_set
|
||||
return None
|
||||
|
||||
def _check_range_incompatibility(
|
||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||
) -> None:
|
||||
"""Rule 3: If A range_min > B range_max for the same key → BLOCKED."""
|
||||
range_mins: dict[str, list[tuple[str, float]]] = {}
|
||||
range_maxs: dict[str, list[tuple[str, float]]] = {}
|
||||
|
||||
for name, dep in all_deps:
|
||||
if dep.constraint_type == "range_min":
|
||||
range_mins.setdefault(dep.key, []).append((name, float(dep.value)))
|
||||
elif dep.constraint_type == "range_max":
|
||||
range_maxs.setdefault(dep.key, []).append((name, float(dep.value)))
|
||||
|
||||
for key in set(range_mins) & set(range_maxs):
|
||||
for min_name, min_val in range_mins[key]:
|
||||
for max_name, max_val in range_maxs[key]:
|
||||
if min_name == max_name:
|
||||
continue
|
||||
if min_val > max_val:
|
||||
result.violations.append(
|
||||
f"{min_name} requires {key} >= {min_val} "
|
||||
f"but {max_name} limits {key} <= {max_val}"
|
||||
)
|
||||
|
||||
def _check_force_scale(
|
||||
self, combination: Combination, result: ConstraintResult
|
||||
) -> None:
|
||||
"""Rule 4: If power source output << platform requirement → warn/block."""
|
||||
force_provided: list[tuple[str, float]] = []
|
||||
force_required: list[tuple[str, float]] = []
|
||||
|
||||
for entity in combination.entities:
|
||||
for dep in entity.dependencies:
|
||||
if dep.key == "force_output_watts" and dep.constraint_type == "provides":
|
||||
force_provided.append((entity.name, float(dep.value)))
|
||||
elif dep.key == "force_required_watts" and dep.constraint_type == "range_min":
|
||||
force_required.append((entity.name, float(dep.value)))
|
||||
|
||||
for req_name, req_watts in force_required:
|
||||
for prov_name, prov_watts in force_provided:
|
||||
if prov_watts < req_watts * 0.01:
|
||||
# Off by more than 100x — hard block
|
||||
result.violations.append(
|
||||
f"{prov_name} provides {prov_watts}W but "
|
||||
f"{req_name} requires {req_watts}W "
|
||||
f"(force deficit > 100x)"
|
||||
)
|
||||
elif prov_watts < req_watts:
|
||||
# Under-powered but not impossibly so — warn
|
||||
result.warnings.append(
|
||||
f"{prov_name} provides {prov_watts}W but "
|
||||
f"{req_name} requires {req_watts}W "
|
||||
f"(under-powered)"
|
||||
)
|
||||
|
||||
def _check_unmet_requirements(
|
||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||
) -> None:
|
||||
"""Rule 5: Required condition not provided by any entity → conditional."""
|
||||
provides = {(d.key, d.value) for _, d in all_deps if d.constraint_type == "provides"}
|
||||
# Ambient conditions that don't need to be explicitly provided
|
||||
ambient = {
|
||||
("ground_surface", "true"),
|
||||
("gravity", "true"),
|
||||
("star_proximity", "true"),
|
||||
}
|
||||
|
||||
for name, dep in all_deps:
|
||||
if dep.constraint_type != "requires":
|
||||
continue
|
||||
if dep.category == "infrastructure":
|
||||
continue # Infrastructure is external, not checked here
|
||||
key_val = (dep.key, dep.value)
|
||||
if key_val not in provides and key_val not in ambient:
|
||||
result.warnings.append(
|
||||
f"{name} requires {dep.key}={dep.value} "
|
||||
f"but no entity in this combination provides it"
|
||||
)
|
||||
246
src/physcom/engine/pipeline.py
Normal file
246
src/physcom/engine/pipeline.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Multi-pass pipeline orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from physcom.db.repository import Repository
|
||||
from physcom.engine.combinator import generate_combinations
|
||||
from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult
|
||||
from physcom.engine.scorer import Scorer
|
||||
from physcom.llm.base import LLMProvider
|
||||
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
||||
from physcom.models.combination import Combination, ScoredResult
|
||||
from physcom.models.domain import Domain
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
"""Summary of a pipeline run."""
|
||||
|
||||
total_generated: int = 0
|
||||
pass1_valid: int = 0
|
||||
pass1_blocked: int = 0
|
||||
pass1_conditional: int = 0
|
||||
pass2_estimated: int = 0
|
||||
pass3_above_threshold: int = 0
|
||||
pass4_reviewed: int = 0
|
||||
pass5_human_reviewed: int = 0
|
||||
top_results: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _describe_combination(combo: Combination) -> str:
|
||||
"""Build a natural-language description of a combination."""
|
||||
parts = [f"{e.dimension}: {e.name}" for e in combo.entities]
|
||||
descriptions = [e.description for e in combo.entities if e.description]
|
||||
header = " + ".join(parts)
|
||||
detail = "; ".join(descriptions)
|
||||
return f"{header}. {detail}"
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Orchestrates the multi-pass viability pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Repository,
|
||||
resolver: ConstraintResolver,
|
||||
scorer: Scorer,
|
||||
llm: LLMProvider | None = None,
|
||||
) -> None:
|
||||
self.repo = repo
|
||||
self.resolver = resolver
|
||||
self.scorer = scorer
|
||||
self.llm = llm
|
||||
|
||||
def run(
|
||||
self,
|
||||
domain: Domain,
|
||||
dimensions: list[str],
|
||||
score_threshold: float = 0.1,
|
||||
passes: list[int] | None = None,
|
||||
) -> PipelineResult:
|
||||
if passes is None:
|
||||
passes = [1, 2, 3, 4, 5]
|
||||
|
||||
result = PipelineResult()
|
||||
|
||||
# Generate all combinations
|
||||
combos = generate_combinations(self.repo, dimensions)
|
||||
result.total_generated = len(combos)
|
||||
|
||||
# Save all combinations to DB
|
||||
for combo in combos:
|
||||
self.repo.save_combination(combo)
|
||||
|
||||
# ── Pass 1: Constraint Resolution ───────────────────────
|
||||
valid_combos: list[Combination] = []
|
||||
if 1 in passes:
|
||||
valid_combos = self._pass1_constraints(combos, result)
|
||||
else:
|
||||
valid_combos = combos
|
||||
|
||||
# ── Pass 2: Physics Estimation ──────────────────────────
|
||||
estimated: list[tuple[Combination, dict[str, float]]] = []
|
||||
if 2 in passes:
|
||||
estimated = self._pass2_estimation(valid_combos, domain, result)
|
||||
else:
|
||||
# Skip estimation, use zeros
|
||||
estimated = [(c, {}) for c in valid_combos]
|
||||
|
||||
# ── Pass 3: Scoring & Ranking ───────────────────────────
|
||||
scored: list[tuple[Combination, ScoredResult]] = []
|
||||
if 3 in passes:
|
||||
scored = self._pass3_scoring(estimated, domain, score_threshold, result)
|
||||
|
||||
# ── Pass 4: LLM Review ──────────────────────────────────
|
||||
if 4 in passes and self.llm:
|
||||
self._pass4_llm_review(scored, domain, result)
|
||||
|
||||
# ── Save results after scoring ─────────────────────────
|
||||
if 3 in passes:
|
||||
max_pass = max(p for p in passes if p <= 5)
|
||||
for combo, sr in scored:
|
||||
self.repo.save_result(
|
||||
combo.id, domain.id, sr.composite_score,
|
||||
pass_reached=max_pass,
|
||||
novelty_flag=sr.novelty_flag,
|
||||
llm_review=sr.llm_review,
|
||||
)
|
||||
self.repo.update_combination_status(combo.id, "scored")
|
||||
|
||||
# Collect top results
|
||||
result.top_results = self.repo.get_top_results(domain.name, limit=20)
|
||||
return result
|
||||
|
||||
def _pass1_constraints(
|
||||
self, combos: list[Combination], result: PipelineResult
|
||||
) -> list[Combination]:
|
||||
valid = []
|
||||
for combo in combos:
|
||||
cr: ConstraintResult = self.resolver.resolve(combo)
|
||||
if cr.status == "blocked":
|
||||
combo.status = "blocked"
|
||||
combo.block_reason = "; ".join(cr.violations)
|
||||
self.repo.update_combination_status(
|
||||
combo.id, "blocked", combo.block_reason
|
||||
)
|
||||
result.pass1_blocked += 1
|
||||
elif cr.status == "conditional":
|
||||
combo.status = "valid"
|
||||
self.repo.update_combination_status(combo.id, "valid")
|
||||
valid.append(combo)
|
||||
result.pass1_conditional += 1
|
||||
else:
|
||||
combo.status = "valid"
|
||||
self.repo.update_combination_status(combo.id, "valid")
|
||||
valid.append(combo)
|
||||
result.pass1_valid += 1
|
||||
return valid
|
||||
|
||||
def _pass2_estimation(
|
||||
self,
|
||||
combos: list[Combination],
|
||||
domain: Domain,
|
||||
result: PipelineResult,
|
||||
) -> list[tuple[Combination, dict[str, float]]]:
|
||||
metric_names = [mb.metric_name for mb in domain.metric_bounds]
|
||||
estimated = []
|
||||
|
||||
for combo in combos:
|
||||
description = _describe_combination(combo)
|
||||
if self.llm:
|
||||
raw_metrics = self.llm.estimate_physics(description, metric_names)
|
||||
else:
|
||||
# Stub estimation: derive from dependencies where possible
|
||||
raw_metrics = self._stub_estimate(combo, metric_names)
|
||||
estimated.append((combo, raw_metrics))
|
||||
result.pass2_estimated += 1
|
||||
|
||||
return estimated
|
||||
|
||||
def _pass3_scoring(
|
||||
self,
|
||||
estimated: list[tuple[Combination, dict[str, float]]],
|
||||
domain: Domain,
|
||||
threshold: float,
|
||||
result: PipelineResult,
|
||||
) -> list[tuple[Combination, ScoredResult]]:
|
||||
scored = []
|
||||
for combo, raw_metrics in estimated:
|
||||
sr = self.scorer.score_combination(combo, raw_metrics)
|
||||
if sr.composite_score >= threshold:
|
||||
scored.append((combo, sr))
|
||||
result.pass3_above_threshold += 1
|
||||
# Persist per-metric scores
|
||||
score_dicts = []
|
||||
bounds_by_name = {mb.metric_name: mb for mb in domain.metric_bounds}
|
||||
for s in sr.scores:
|
||||
mb = bounds_by_name.get(s.metric_name)
|
||||
if mb and mb.metric_id:
|
||||
score_dicts.append({
|
||||
"metric_id": mb.metric_id,
|
||||
"raw_value": s.raw_value,
|
||||
"normalized_score": s.normalized_score,
|
||||
"estimation_method": s.estimation_method,
|
||||
"confidence": s.confidence,
|
||||
})
|
||||
if score_dicts:
|
||||
self.repo.save_scores(combo.id, domain.id, score_dicts)
|
||||
|
||||
# Sort by composite score descending
|
||||
scored.sort(key=lambda x: x[1].composite_score, reverse=True)
|
||||
return scored
|
||||
|
||||
def _pass4_llm_review(
|
||||
self,
|
||||
scored: list[tuple[Combination, ScoredResult]],
|
||||
domain: Domain,
|
||||
result: PipelineResult,
|
||||
) -> None:
|
||||
for combo, sr in scored:
|
||||
description = _describe_combination(combo)
|
||||
score_dict = {s.metric_name: s.normalized_score for s in sr.scores}
|
||||
review = self.llm.review_plausibility(description, score_dict)
|
||||
sr.llm_review = review
|
||||
result.pass4_reviewed += 1
|
||||
|
||||
def _stub_estimate(
|
||||
self, combo: Combination, metric_names: list[str]
|
||||
) -> dict[str, float]:
|
||||
"""Simple heuristic estimation from dependency data."""
|
||||
raw: dict[str, float] = {m: 0.0 for m in metric_names}
|
||||
|
||||
# Extract force output from power source
|
||||
force_watts = 0.0
|
||||
mass_kg = 100.0 # default
|
||||
for entity in combo.entities:
|
||||
for dep in entity.dependencies:
|
||||
if dep.key == "force_output_watts" and dep.constraint_type == "provides":
|
||||
force_watts = max(force_watts, float(dep.value))
|
||||
if dep.key == "min_mass_kg" and dep.constraint_type == "range_min":
|
||||
mass_kg = max(mass_kg, float(dep.value))
|
||||
|
||||
# Rough speed estimate: F=ma -> v proportional to power/mass
|
||||
if "speed" in raw and mass_kg > 0:
|
||||
# Very rough: speed ~ power / (mass * drag_coeff)
|
||||
raw["speed"] = min(force_watts / mass_kg * 0.5, 300000)
|
||||
|
||||
if "cost_efficiency" in raw:
|
||||
# Lower force = cheaper per km (roughly)
|
||||
raw["cost_efficiency"] = max(0.01, 2.0 - force_watts / 100000)
|
||||
|
||||
if "safety" in raw:
|
||||
raw["safety"] = 0.5 # default mid-range
|
||||
|
||||
if "availability" in raw:
|
||||
raw["availability"] = 0.5
|
||||
|
||||
if "range_fuel" in raw:
|
||||
# More power = more range (very rough)
|
||||
raw["range_fuel"] = min(force_watts * 0.01, 1e10)
|
||||
|
||||
if "range_degradation" in raw:
|
||||
raw["range_degradation"] = 365 # 1 year default
|
||||
|
||||
return raw
|
||||
89
src/physcom/engine/scorer.py
Normal file
89
src/physcom/engine/scorer.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Multiplicative logarithmic scoring engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from physcom.models.domain import Domain, MetricBound
|
||||
from physcom.models.combination import Combination, Score, ScoredResult
|
||||
|
||||
|
||||
def normalize(raw_value: float, norm_min: float, norm_max: float) -> float:
|
||||
"""Log-normalize a raw value to 0-1 within domain bounds.
|
||||
|
||||
Values at or below norm_min -> 0.0
|
||||
Values at or above norm_max -> 1.0
|
||||
Values between are log-interpolated.
|
||||
"""
|
||||
if norm_max <= norm_min:
|
||||
return 0.0
|
||||
if raw_value <= norm_min:
|
||||
return 0.0
|
||||
if raw_value >= norm_max:
|
||||
return 1.0
|
||||
log_min = math.log1p(norm_min)
|
||||
log_max = math.log1p(norm_max)
|
||||
log_val = math.log1p(raw_value)
|
||||
return (log_val - log_min) / (log_max - log_min)
|
||||
|
||||
|
||||
def composite_score(scores: list[float], weights: list[float]) -> float:
|
||||
"""Weighted geometric mean. Any score near 0 drives the result to 0.
|
||||
|
||||
score = product(s_i ^ w_i) for all metrics i
|
||||
"""
|
||||
if not scores or not weights:
|
||||
return 0.0
|
||||
result = 1.0
|
||||
for score, weight in zip(scores, weights):
|
||||
if score <= 0.0:
|
||||
return 0.0
|
||||
result *= score ** weight
|
||||
return result
|
||||
|
||||
|
||||
class Scorer:
|
||||
"""Scores combinations against a domain's metric bounds."""
|
||||
|
||||
def __init__(self, domain: Domain) -> None:
|
||||
self.domain = domain
|
||||
self._bounds_by_name: dict[str, MetricBound] = {
|
||||
mb.metric_name: mb for mb in domain.metric_bounds
|
||||
}
|
||||
|
||||
def score_combination(
|
||||
self,
|
||||
combination: Combination,
|
||||
raw_metrics: dict[str, float],
|
||||
estimation_method: str = "physics_calc",
|
||||
confidence: float = 1.0,
|
||||
) -> ScoredResult:
|
||||
"""Score a combination given raw metric values.
|
||||
|
||||
raw_metrics maps metric_name -> raw estimated value.
|
||||
"""
|
||||
scores: list[Score] = []
|
||||
norm_scores: list[float] = []
|
||||
weights: list[float] = []
|
||||
|
||||
for mb in self.domain.metric_bounds:
|
||||
raw = raw_metrics.get(mb.metric_name, 0.0)
|
||||
normed = normalize(raw, mb.norm_min, mb.norm_max)
|
||||
scores.append(Score(
|
||||
metric_name=mb.metric_name,
|
||||
raw_value=raw,
|
||||
normalized_score=normed,
|
||||
estimation_method=estimation_method,
|
||||
confidence=confidence,
|
||||
))
|
||||
norm_scores.append(normed)
|
||||
weights.append(mb.weight)
|
||||
|
||||
comp = composite_score(norm_scores, weights)
|
||||
|
||||
return ScoredResult(
|
||||
combination_id=combination.id,
|
||||
domain_name=self.domain.name,
|
||||
scores=scores,
|
||||
composite_score=comp,
|
||||
)
|
||||
Reference in New Issue
Block a user