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:
Simonson, Andrew
2026-02-18 13:59:53 -06:00
parent 6e0f82835a
commit 8118a62242
54 changed files with 3505 additions and 1 deletions

View 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