Compare commits
14 Commits
63295ab80e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3429bce8d0 | |||
| 3795a7e826 | |||
| 81b36e6bbe | |||
| fb38093e6c | |||
| f786f3da79 | |||
| 3ed3918964 | |||
| d1f14dbf14 | |||
| 6cdd308583 | |||
| d871635779 | |||
| 76f460499a | |||
| 730a23bac3 | |||
| be25a837ff | |||
| 45ad1e8d44 | |||
| 434df718d7 |
@@ -60,7 +60,7 @@ tests/ # pytest, uses seeded_repo fixture from conftest.py
|
|||||||
## Data flow (pipeline passes)
|
## Data flow (pipeline passes)
|
||||||
|
|
||||||
1. **Pass 1 — Constraints**: `ConstraintResolver.resolve()` → blocked/conditional/valid. Blocked combos get a result row and `continue`.
|
1. **Pass 1 — Constraints**: `ConstraintResolver.resolve()` → blocked/conditional/valid. Blocked combos get a result row and `continue`.
|
||||||
2. **Pass 2 — Estimation**: LLM or `_stub_estimate()` → raw metric values. Saved immediately via `save_raw_estimates()` (normalized_score=NULL).
|
2. **Pass 2 — Estimation**: `_estimate_physics()` (deterministic physics engine; estimator-only, no LLM) → raw metric values. Saved immediately via `save_raw_estimates()` (normalized_score=NULL).
|
||||||
3. **Pass 3 — Scoring**: `Scorer.score_combination()` → log-normalized scores + weighted geometric mean composite. Saves via `save_scores()` + `save_result()`.
|
3. **Pass 3 — Scoring**: `Scorer.score_combination()` → log-normalized scores + weighted geometric mean composite. Saves via `save_scores()` + `save_result()`.
|
||||||
4. **Pass 4 — LLM Review**: Only for above-threshold combos with an LLM provider. No real provider yet (only `MockLLMProvider`).
|
4. **Pass 4 — LLM Review**: Only for above-threshold combos with an LLM provider. No real provider yet (only `MockLLMProvider`).
|
||||||
5. **Pass 5 — Human Review**: Manual via web UI results page.
|
5. **Pass 5 — Human Review**: Manual via web UI results page.
|
||||||
|
|||||||
34
LOGIC DOCS/003-gpu-batching-for-scaled-optimizer.md
Normal file
34
LOGIC DOCS/003-gpu-batching-for-scaled-optimizer.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# GPU batching for the mass-allocation optimizer — not yet, here's the threshold
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`Pipeline._decide_masses`'s joint platform/actuator/storage optimizer (coarse-to-fine grid search, see `_search_best_allocation`) calls its objective function roughly 11,700 times per combo. Profiling confirmed this dominates pipeline runtime: for 50 combos, 582,920 objective-function calls, each doing scalar arithmetic (power_density, the drag cubic solve, normalize, composite_score) on one `(platform, actuator, storage)` triple. The cost is Python's per-call overhead (bytecode dispatch, refcounting, attribute lookups), not the arithmetic itself — the individual formulas are cheap.
|
||||||
|
|
||||||
|
## Why GPU doesn't fit today
|
||||||
|
|
||||||
|
A single combo's grid is only ~169 points per round (13×13). GPUs pay off when there's enough independent parallel work to amortize kernel-launch and host↔device transfer overhead (each typically tens of microseconds to low milliseconds); 169 elements doesn't come close, and that overhead would be paid repeatedly — once per grid round, ~6-10 rounds per combo.
|
||||||
|
|
||||||
|
The parallelism that actually exists is **across combos**, not within one combo's grid — every combo's optimization is fully independent of every other's. At current scale (~180 combos reach the optimizer per domain after Pass 1 filtering), batching every combo's grid into one array gives ~180×169 ≈ 30K elements per round — borderline, probably a wash against plain CPU numpy.
|
||||||
|
|
||||||
|
## The actual threshold
|
||||||
|
|
||||||
|
Combo count scales **multiplicatively** with added dimensions or entities per dimension (today: 11 platforms × 15 actuators × 18 storages ≈ 2,970 combos, ~180 of which reach the optimizer). Add a 4th dimension with even 10 options and total combos scale to ~30,000, with optimizer-eligible combos likely growing roughly proportionally to ~1,800/domain — batched grid size ≈ 300K elements/round. A 5th dimension does it again, into the low millions. That's the regime where a GPU's thousands of cores start meaningfully outrunning a CPU's 4-16-wide SIMD lanes.
|
||||||
|
|
||||||
|
So: not "more dimensions" directly, but the combo×grid batch size those dimensions produce. Rough rule of thumb from this discussion:
|
||||||
|
|
||||||
|
- **Tens of thousands of elements/round** (current scale, or a modest one-dimension addition): plain CPU numpy vectorization is enough, no GPU.
|
||||||
|
- **Hundreds of thousands to low millions**: GPU batching across combos starts being worth evaluating.
|
||||||
|
|
||||||
|
## What GPU batching would actually require
|
||||||
|
|
||||||
|
Not just "swap numpy for cupy." It means restructuring `_process_pass2` from combo-first (one combo through the optimizer at a time) to batch-first (a chunk of N combos' grids evaluated together as one array with a "combo" axis, broadcasting each combo's own constants — `k_act`, `k_med`, `e_dens`, drag coefficients, mass bounds — across that axis). That's a real architectural change, not a drop-in acceleration:
|
||||||
|
|
||||||
|
- **CLAUDE.md documents the pipeline as deliberately combo-first**: "each combo goes through all requested passes before the next combo starts... Progress is persisted per-combo (crash-safe, resumable)." Batching means checkpointing per-*batch*, not per-combo — a real (if manageable) tradeoff against that resumability guarantee, not something that comes free alongside the speedup.
|
||||||
|
- Every branch in `_raw_physics_from_masses` / `_solve_achievable_speed_mps` (biological floors, ambient energy forms, degenerate fallbacks, the cubic's edge cases) needs to become `np.where(condition, a, b)` instead of `if/else` — careful, error-prone translation work, not mechanical.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Don't build this now — no current need at ~180 combos/domain. If dimension count grows enough to matter, do it in two steps:
|
||||||
|
|
||||||
|
1. **CPU numpy vectorization first** (batch one combo's grid into arrays, evaluate with vectorized ops instead of a Python double-loop). This is needed regardless of GPU or not, since it's the same rewrite either way, and profiling suggests it could plausibly give 10-50x on its own by replacing ~11,700 Python calls/combo with a couple dozen numpy batch calls.
|
||||||
|
2. **Re-profile at the new scale.** Only reach for GPU batching-across-combos if CPU numpy is still the dominant cost after step 1, and only once the batched element count is actually in GPU-favorable territory (see thresholds above) — this is a "measure, then decide" call, not something to build ahead of need.
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from physcom.models.entity import Dependency, Entity
|
from physcom.models.entity import Dependency, Entity
|
||||||
from physcom.models.domain import Domain, DomainConstraint, MetricBound
|
from physcom.models.domain import Domain, DomainConstraint, FreeVariable, MetricBound, MetricFormula
|
||||||
from physcom.models.combination import Combination
|
from physcom.models.combination import Combination
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,14 @@ class Repository:
|
|||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
"""Explicit flush point, for callers batching writes with commit=False
|
||||||
|
below (see Pipeline.run: instant/deterministic passes defer commits
|
||||||
|
and flush in bulk, since a crash there just means cheap recompute;
|
||||||
|
LLM-call results still commit immediately, since those are slow/
|
||||||
|
expensive to redo)."""
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
# ── Dimensions ──────────────────────────────────────────────
|
# ── Dimensions ──────────────────────────────────────────────
|
||||||
|
|
||||||
def ensure_dimension(self, name: str, description: str = "") -> int:
|
def ensure_dimension(self, name: str, description: str = "") -> int:
|
||||||
@@ -222,14 +230,37 @@ class Repository:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return row["id"]
|
return row["id"]
|
||||||
|
|
||||||
def backfill_lower_is_better(self, domain_name: str, metric_name: str) -> None:
|
def sync_domain_metric_weights(self, domain: Domain) -> None:
|
||||||
"""Set lower_is_better=1 for an existing domain-metric row that still has the default 0."""
|
"""Make domain_metric_weights exactly match domain.metric_bounds on an
|
||||||
|
already-seeded domain: upserts weight/norm_min/norm_max/unit for every
|
||||||
|
currently-declared metric, and deletes any row for a metric that's been
|
||||||
|
removed from the domain (e.g. safety/availability dropped from the
|
||||||
|
scored set). Safe to call whether the domain was just freshly inserted
|
||||||
|
or already existed.
|
||||||
|
"""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT id FROM domains WHERE name = ?", (domain.name,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
domain_id = row["id"]
|
||||||
|
keep_ids = []
|
||||||
|
for mb in domain.metric_bounds:
|
||||||
|
metric_id = self.ensure_metric(mb.metric_name, unit=mb.unit)
|
||||||
|
keep_ids.append(metric_id)
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""UPDATE domain_metric_weights SET lower_is_better = 1
|
"""INSERT OR REPLACE INTO domain_metric_weights
|
||||||
WHERE lower_is_better = 0
|
(domain_id, metric_id, weight, norm_min, norm_max, lower_is_better, unit)
|
||||||
AND domain_id = (SELECT id FROM domains WHERE name = ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
AND metric_id = (SELECT id FROM metrics WHERE name = ?)""",
|
(domain_id, metric_id, mb.weight, mb.norm_min, mb.norm_max,
|
||||||
(domain_name, metric_name),
|
int(mb.lower_is_better), mb.unit),
|
||||||
|
)
|
||||||
|
if keep_ids:
|
||||||
|
placeholders = ",".join("?" * len(keep_ids))
|
||||||
|
self.conn.execute(
|
||||||
|
f"""DELETE FROM domain_metric_weights
|
||||||
|
WHERE domain_id = ? AND metric_id NOT IN ({placeholders})""",
|
||||||
|
(domain_id, *keep_ids),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
@@ -244,10 +275,10 @@ class Repository:
|
|||||||
mb.metric_id = metric_id
|
mb.metric_id = metric_id
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT INTO domain_metric_weights
|
"""INSERT INTO domain_metric_weights
|
||||||
(domain_id, metric_id, weight, norm_min, norm_max, lower_is_better)
|
(domain_id, metric_id, weight, norm_min, norm_max, lower_is_better, unit)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(domain.id, metric_id, mb.weight, mb.norm_min, mb.norm_max,
|
(domain.id, metric_id, mb.weight, mb.norm_min, mb.norm_max,
|
||||||
int(mb.lower_is_better)),
|
int(mb.lower_is_better), mb.unit),
|
||||||
)
|
)
|
||||||
for dc in domain.constraints:
|
for dc in domain.constraints:
|
||||||
for val in dc.allowed_values:
|
for val in dc.allowed_values:
|
||||||
@@ -255,6 +286,10 @@ class Repository:
|
|||||||
"INSERT OR IGNORE INTO domain_constraints (domain_id, key, value) VALUES (?, ?, ?)",
|
"INSERT OR IGNORE INTO domain_constraints (domain_id, key, value) VALUES (?, ?, ?)",
|
||||||
(domain.id, dc.key, val),
|
(domain.id, dc.key, val),
|
||||||
)
|
)
|
||||||
|
for fv in domain.free_variables:
|
||||||
|
self.add_free_variable(domain.id, fv, commit=False)
|
||||||
|
for mf in domain.metric_formulas:
|
||||||
|
self.add_metric_formula(domain.id, mf, commit=False)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
@@ -268,12 +303,37 @@ class Repository:
|
|||||||
by_key.setdefault(r["key"], []).append(r["value"])
|
by_key.setdefault(r["key"], []).append(r["value"])
|
||||||
return [DomainConstraint(key=k, allowed_values=v) for k, v in by_key.items()]
|
return [DomainConstraint(key=k, allowed_values=v) for k, v in by_key.items()]
|
||||||
|
|
||||||
|
def _load_free_variables(self, domain_id: int) -> list[FreeVariable]:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""SELECT id, name, sort_order, floor_formula, ceiling_formula
|
||||||
|
FROM domain_free_variables WHERE domain_id = ? ORDER BY sort_order""",
|
||||||
|
(domain_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
FreeVariable(
|
||||||
|
id=r["id"], name=r["name"], sort_order=r["sort_order"],
|
||||||
|
floor_formula=r["floor_formula"], ceiling_formula=r["ceiling_formula"],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def _load_metric_formulas(self, domain_id: int) -> list[MetricFormula]:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""SELECT id, metric_name, formula
|
||||||
|
FROM domain_metric_formulas WHERE domain_id = ? ORDER BY metric_name""",
|
||||||
|
(domain_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
MetricFormula(id=r["id"], metric_name=r["metric_name"], formula=r["formula"])
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
def _load_domain(self, where: str, param: str | int) -> Domain | None:
|
def _load_domain(self, where: str, param: str | int) -> Domain | None:
|
||||||
row = self.conn.execute(f"SELECT * FROM domains WHERE {where} = ?", (param,)).fetchone()
|
row = self.conn.execute(f"SELECT * FROM domains WHERE {where} = ?", (param,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
weights = self.conn.execute(
|
weights = self.conn.execute(
|
||||||
"""SELECT m.name, m.unit, dmw.weight, dmw.norm_min, dmw.norm_max,
|
"""SELECT m.name, dmw.unit, dmw.weight, dmw.norm_min, dmw.norm_max,
|
||||||
dmw.metric_id, dmw.lower_is_better
|
dmw.metric_id, dmw.lower_is_better
|
||||||
FROM domain_metric_weights dmw
|
FROM domain_metric_weights dmw
|
||||||
JOIN metrics m ON dmw.metric_id = m.id
|
JOIN metrics m ON dmw.metric_id = m.id
|
||||||
@@ -294,6 +354,8 @@ class Repository:
|
|||||||
for w in weights
|
for w in weights
|
||||||
],
|
],
|
||||||
constraints=self._load_domain_constraints(row["id"]),
|
constraints=self._load_domain_constraints(row["id"]),
|
||||||
|
free_variables=self._load_free_variables(row["id"]),
|
||||||
|
metric_formulas=self._load_metric_formulas(row["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_domain(self, name: str) -> Domain | None:
|
def get_domain(self, name: str) -> Domain | None:
|
||||||
@@ -318,10 +380,10 @@ class Repository:
|
|||||||
mb.metric_id = metric_id
|
mb.metric_id = metric_id
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT OR REPLACE INTO domain_metric_weights
|
"""INSERT OR REPLACE INTO domain_metric_weights
|
||||||
(domain_id, metric_id, weight, norm_min, norm_max, lower_is_better)
|
(domain_id, metric_id, weight, norm_min, norm_max, lower_is_better, unit)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(domain_id, metric_id, mb.weight, mb.norm_min, mb.norm_max,
|
(domain_id, metric_id, mb.weight, mb.norm_min, mb.norm_max,
|
||||||
int(mb.lower_is_better)),
|
int(mb.lower_is_better), mb.unit),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return mb
|
return mb
|
||||||
@@ -332,14 +394,9 @@ class Repository:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""UPDATE domain_metric_weights
|
"""UPDATE domain_metric_weights
|
||||||
SET weight = ?, norm_min = ?, norm_max = ?, lower_is_better = ?
|
SET weight = ?, norm_min = ?, norm_max = ?, lower_is_better = ?, unit = ?
|
||||||
WHERE domain_id = ? AND metric_id = ?""",
|
WHERE domain_id = ? AND metric_id = ?""",
|
||||||
(weight, norm_min, norm_max, int(lower_is_better), domain_id, metric_id),
|
(weight, norm_min, norm_max, int(lower_is_better), unit, domain_id, metric_id),
|
||||||
)
|
|
||||||
if unit:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE metrics SET unit = ? WHERE id = ?",
|
|
||||||
(unit, metric_id),
|
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
@@ -350,12 +407,63 @@ class Repository:
|
|||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── Free variables & metric formulas ──────────────────────────
|
||||||
|
|
||||||
|
def add_free_variable(self, domain_id: int, fv: FreeVariable, commit: bool = True) -> FreeVariable:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""INSERT INTO domain_free_variables
|
||||||
|
(domain_id, name, sort_order, floor_formula, ceiling_formula)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(domain_id, fv.name, fv.sort_order, fv.floor_formula, fv.ceiling_formula),
|
||||||
|
)
|
||||||
|
fv.id = cur.lastrowid
|
||||||
|
if commit:
|
||||||
|
self.conn.commit()
|
||||||
|
return fv
|
||||||
|
|
||||||
|
def update_free_variable(self, fv_id: int, fv: FreeVariable) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""UPDATE domain_free_variables
|
||||||
|
SET name = ?, sort_order = ?, floor_formula = ?, ceiling_formula = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(fv.name, fv.sort_order, fv.floor_formula, fv.ceiling_formula, fv_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_free_variable(self, fv_id: int) -> None:
|
||||||
|
self.conn.execute("DELETE FROM domain_free_variables WHERE id = ?", (fv_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_metric_formula(self, domain_id: int, mf: MetricFormula, commit: bool = True) -> MetricFormula:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO domain_metric_formulas (domain_id, metric_name, formula)
|
||||||
|
VALUES (?, ?, ?)""",
|
||||||
|
(domain_id, mf.metric_name, mf.formula),
|
||||||
|
)
|
||||||
|
mf.id = cur.lastrowid
|
||||||
|
if commit:
|
||||||
|
self.conn.commit()
|
||||||
|
return mf
|
||||||
|
|
||||||
|
def update_metric_formula(self, mf_id: int, mf: MetricFormula) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE domain_metric_formulas SET metric_name = ?, formula = ? WHERE id = ?",
|
||||||
|
(mf.metric_name, mf.formula, mf_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_metric_formula(self, mf_id: int) -> None:
|
||||||
|
self.conn.execute("DELETE FROM domain_metric_formulas WHERE id = ?", (mf_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
def delete_domain(self, domain_id: int) -> None:
|
def delete_domain(self, domain_id: int) -> None:
|
||||||
self.conn.execute("DELETE FROM pipeline_runs WHERE domain_id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM pipeline_runs WHERE domain_id = ?", (domain_id,))
|
||||||
self.conn.execute("DELETE FROM combination_results WHERE domain_id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM combination_results WHERE domain_id = ?", (domain_id,))
|
||||||
self.conn.execute("DELETE FROM combination_scores WHERE domain_id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM combination_scores WHERE domain_id = ?", (domain_id,))
|
||||||
self.conn.execute("DELETE FROM domain_metric_weights WHERE domain_id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM domain_metric_weights WHERE domain_id = ?", (domain_id,))
|
||||||
self.conn.execute("DELETE FROM domain_constraints WHERE domain_id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM domain_constraints WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM domain_free_variables WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM domain_metric_formulas WHERE domain_id = ?", (domain_id,))
|
||||||
self.conn.execute("DELETE FROM domains WHERE id = ?", (domain_id,))
|
self.conn.execute("DELETE FROM domains WHERE id = ?", (domain_id,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
@@ -417,7 +525,7 @@ class Repository:
|
|||||||
key = ",".join(str(eid) for eid in sorted(entity_ids))
|
key = ",".join(str(eid) for eid in sorted(entity_ids))
|
||||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
def save_combination(self, combination: Combination) -> Combination:
|
def save_combination(self, combination: Combination, commit: bool = True) -> Combination:
|
||||||
entity_ids = [e.id for e in combination.entities]
|
entity_ids = [e.id for e in combination.entities]
|
||||||
combination.hash = self.compute_hash(entity_ids)
|
combination.hash = self.compute_hash(entity_ids)
|
||||||
|
|
||||||
@@ -441,14 +549,15 @@ class Repository:
|
|||||||
"INSERT INTO combination_entities (combination_id, entity_id) VALUES (?, ?)",
|
"INSERT INTO combination_entities (combination_id, entity_id) VALUES (?, ?)",
|
||||||
(combination.id, eid),
|
(combination.id, eid),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return combination
|
return combination
|
||||||
|
|
||||||
def update_combination_status(
|
def update_combination_status(
|
||||||
self, combo_id: int, status: str, block_reason: str | None = None
|
self, combo_id: int, status: str, block_reason: str | None = None, commit: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
# Don't downgrade from higher pass states — preserves human/LLM review data
|
# Don't downgrade from higher pass states — preserves human/LLM review data
|
||||||
if status in ("scored", "llm_reviewed") or status.endswith("_fail"):
|
if status in ("scored", "llm_reviewed", "valid") or status.endswith("_fail"):
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
"SELECT status FROM combinations WHERE id = ?", (combo_id,)
|
"SELECT status FROM combinations WHERE id = ?", (combo_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -461,10 +570,18 @@ class Repository:
|
|||||||
return
|
return
|
||||||
if status == "llm_reviewed" and cur == "reviewed":
|
if status == "llm_reviewed" and cur == "reviewed":
|
||||||
return
|
return
|
||||||
|
# "valid" is pass 1's domain-agnostic result -- a combo
|
||||||
|
# already at any later pass state (or a fail state) has
|
||||||
|
# progressed past pass 1 already, in this domain or
|
||||||
|
# another one sharing the same combo. Pass 1 re-running
|
||||||
|
# for a different domain must not silently revert that.
|
||||||
|
if status == "valid" and cur not in (None, "valid"):
|
||||||
|
return
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?",
|
"UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?",
|
||||||
(status, block_reason, combo_id),
|
(status, block_reason, combo_id),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_combination(self, combo_id: int) -> Combination | None:
|
def get_combination(self, combo_id: int) -> Combination | None:
|
||||||
@@ -553,6 +670,7 @@ class Repository:
|
|||||||
combo_id: int,
|
combo_id: int,
|
||||||
domain_id: int,
|
domain_id: int,
|
||||||
scores: list[dict],
|
scores: list[dict],
|
||||||
|
commit: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save per-metric scores. Each dict: metric_id, raw_value, normalized_score, estimation_method, confidence."""
|
"""Save per-metric scores. Each dict: metric_id, raw_value, normalized_score, estimation_method, confidence."""
|
||||||
for s in scores:
|
for s in scores:
|
||||||
@@ -564,6 +682,7 @@ class Repository:
|
|||||||
(combo_id, domain_id, s["metric_id"], s["raw_value"],
|
(combo_id, domain_id, s["metric_id"], s["raw_value"],
|
||||||
s["normalized_score"], s["estimation_method"], s["confidence"]),
|
s["normalized_score"], s["estimation_method"], s["confidence"]),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def save_result(
|
def save_result(
|
||||||
@@ -576,15 +695,20 @@ class Repository:
|
|||||||
llm_review: str | None = None,
|
llm_review: str | None = None,
|
||||||
human_notes: str | None = None,
|
human_notes: str | None = None,
|
||||||
domain_block_reason: str | None = None,
|
domain_block_reason: str | None = None,
|
||||||
|
qualitative_rating: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT OR REPLACE INTO combination_results
|
"""INSERT OR REPLACE INTO combination_results
|
||||||
(combination_id, domain_id, composite_score, novelty_flag,
|
(combination_id, domain_id, composite_score, novelty_flag,
|
||||||
llm_review, human_notes, pass_reached, domain_block_reason)
|
llm_review, human_notes, pass_reached, domain_block_reason,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
qualitative_rating)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(combo_id, domain_id, composite_score, novelty_flag,
|
(combo_id, domain_id, composite_score, novelty_flag,
|
||||||
llm_review, human_notes, pass_reached, domain_block_reason),
|
llm_review, human_notes, pass_reached, domain_block_reason,
|
||||||
|
qualitative_rating),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_combination_scores(self, combo_id: int, domain_id: int) -> list[dict]:
|
def get_combination_scores(self, combo_id: int, domain_id: int) -> list[dict]:
|
||||||
@@ -601,13 +725,19 @@ class Repository:
|
|||||||
def count_combinations_by_status(self, domain_name: str | None = None) -> dict[str, int]:
|
def count_combinations_by_status(self, domain_name: str | None = None) -> dict[str, int]:
|
||||||
"""Count combos by status. If domain_name given, only combos with results in that domain."""
|
"""Count combos by status. If domain_name given, only combos with results in that domain."""
|
||||||
if domain_name:
|
if domain_name:
|
||||||
|
# combinations.status is domain-agnostic (a combo can be "valid"
|
||||||
|
# generically but blocked by one domain's own constraints), so a
|
||||||
|
# domain-scoped count must bucket domain_block_reason rows on
|
||||||
|
# their own rather than trusting c.status.
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
"""SELECT c.status, COUNT(*) as cnt
|
"""SELECT CASE WHEN cr.domain_block_reason IS NOT NULL
|
||||||
|
THEN 'domain_blocked' ELSE c.status END as status,
|
||||||
|
COUNT(*) as cnt
|
||||||
FROM combination_results cr
|
FROM combination_results cr
|
||||||
JOIN combinations c ON cr.combination_id = c.id
|
JOIN combinations c ON cr.combination_id = c.id
|
||||||
JOIN domains d ON cr.domain_id = d.id
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
WHERE d.name = ?
|
WHERE d.name = ?
|
||||||
GROUP BY c.status""",
|
GROUP BY status""",
|
||||||
(domain_name,),
|
(domain_name,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
@@ -616,6 +746,20 @@ class Repository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return {r["status"]: r["cnt"] for r in rows}
|
return {r["status"]: r["cnt"] for r in rows}
|
||||||
|
|
||||||
|
def count_results_by_rating(self, domain_name: str) -> dict[str, int]:
|
||||||
|
"""Count results by qualitative_rating (LOW/MEDIUM/HIGH) for a domain.
|
||||||
|
Rows with no rating (not yet pass-4 reviewed, or reviewed before this
|
||||||
|
existed) are excluded, not bucketed as a pseudo-status."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""SELECT cr.qualitative_rating as rating, COUNT(*) as cnt
|
||||||
|
FROM combination_results cr
|
||||||
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
|
WHERE d.name = ? AND cr.qualitative_rating IS NOT NULL
|
||||||
|
GROUP BY cr.qualitative_rating""",
|
||||||
|
(domain_name,),
|
||||||
|
).fetchall()
|
||||||
|
return {r["rating"]: r["cnt"] for r in rows}
|
||||||
|
|
||||||
def get_pipeline_summary(self, domain_name: str) -> dict | None:
|
def get_pipeline_summary(self, domain_name: str) -> dict | None:
|
||||||
"""Return a summary of results for a domain, or None if no results."""
|
"""Return a summary of results for a domain, or None if no results."""
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
@@ -636,7 +780,8 @@ class Repository:
|
|||||||
FROM combinations c
|
FROM combinations c
|
||||||
JOIN combination_results cr ON cr.combination_id = c.id
|
JOIN combination_results cr ON cr.combination_id = c.id
|
||||||
JOIN domains d ON cr.domain_id = d.id
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
WHERE c.status LIKE '%\\_fail' ESCAPE '\\' AND d.name = ?""",
|
WHERE (c.status LIKE '%\\_fail' ESCAPE '\\' OR cr.domain_block_reason IS NOT NULL)
|
||||||
|
AND d.name = ?""",
|
||||||
(domain_name,),
|
(domain_name,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return {
|
return {
|
||||||
@@ -659,17 +804,26 @@ class Repository:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def get_all_results(self, domain_name: str, status: str | None = None) -> list[dict]:
|
def get_all_results(
|
||||||
"""Return all results for a domain, optionally filtered by combo status."""
|
self, domain_name: str, status: str | None = None, rating: str | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return all results for a domain, optionally filtered by combo
|
||||||
|
status and/or qualitative_rating (LOW/MEDIUM/HIGH, independent filters
|
||||||
|
that combine with AND)."""
|
||||||
query = """SELECT cr.*, c.hash, c.status as combo_status, d.name as domain_name
|
query = """SELECT cr.*, c.hash, c.status as combo_status, d.name as domain_name
|
||||||
FROM combination_results cr
|
FROM combination_results cr
|
||||||
JOIN combinations c ON cr.combination_id = c.id
|
JOIN combinations c ON cr.combination_id = c.id
|
||||||
JOIN domains d ON cr.domain_id = d.id
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
WHERE d.name = ?"""
|
WHERE d.name = ?"""
|
||||||
params: list = [domain_name]
|
params: list = [domain_name]
|
||||||
if status:
|
if status == "domain_blocked":
|
||||||
query += " AND c.status = ?"
|
query += " AND cr.domain_block_reason IS NOT NULL"
|
||||||
|
elif status:
|
||||||
|
query += " AND c.status = ? AND cr.domain_block_reason IS NULL"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
if rating:
|
||||||
|
query += " AND cr.qualitative_rating = ?"
|
||||||
|
params.append(rating)
|
||||||
query += " ORDER BY cr.composite_score DESC"
|
query += " ORDER BY cr.composite_score DESC"
|
||||||
rows = self.conn.execute(query, params).fetchall()
|
rows = self.conn.execute(query, params).fetchall()
|
||||||
combo_ids = [r["combination_id"] for r in rows]
|
combo_ids = [r["combination_id"] for r in rows]
|
||||||
@@ -684,6 +838,7 @@ class Repository:
|
|||||||
"pass_reached": r["pass_reached"],
|
"pass_reached": r["pass_reached"],
|
||||||
"domain_id": r["domain_id"],
|
"domain_id": r["domain_id"],
|
||||||
"domain_block_reason": r["domain_block_reason"],
|
"domain_block_reason": r["domain_block_reason"],
|
||||||
|
"qualitative_rating": r["qualitative_rating"],
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -793,7 +948,7 @@ class Repository:
|
|||||||
return row["pass_reached"] if row else None
|
return row["pass_reached"] if row else None
|
||||||
|
|
||||||
def save_raw_estimates(
|
def save_raw_estimates(
|
||||||
self, combo_id: int, domain_id: int, estimates: list[dict]
|
self, combo_id: int, domain_id: int, estimates: list[dict], commit: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save raw metric estimates (pass 2) with normalized_score=NULL.
|
"""Save raw metric estimates (pass 2) with normalized_score=NULL.
|
||||||
|
|
||||||
@@ -808,6 +963,7 @@ class Repository:
|
|||||||
(combo_id, domain_id, e["metric_id"], e["raw_value"],
|
(combo_id, domain_id, e["metric_id"], e["raw_value"],
|
||||||
e["estimation_method"], e["confidence"]),
|
e["estimation_method"], e["confidence"]),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_existing_result(self, combo_id: int, domain_id: int) -> dict | None:
|
def get_existing_result(self, combo_id: int, domain_id: int) -> dict | None:
|
||||||
@@ -832,6 +988,8 @@ class Repository:
|
|||||||
self.conn.execute("DELETE FROM entities")
|
self.conn.execute("DELETE FROM entities")
|
||||||
self.conn.execute("DELETE FROM domain_metric_weights")
|
self.conn.execute("DELETE FROM domain_metric_weights")
|
||||||
self.conn.execute("DELETE FROM domain_constraints")
|
self.conn.execute("DELETE FROM domain_constraints")
|
||||||
|
self.conn.execute("DELETE FROM domain_free_variables")
|
||||||
|
self.conn.execute("DELETE FROM domain_metric_formulas")
|
||||||
self.conn.execute("DELETE FROM domains")
|
self.conn.execute("DELETE FROM domains")
|
||||||
self.conn.execute("DELETE FROM metrics")
|
self.conn.execute("DELETE FROM metrics")
|
||||||
self.conn.execute("DELETE FROM dimensions")
|
self.conn.execute("DELETE FROM dimensions")
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS domain_metric_weights (
|
|||||||
norm_min REAL,
|
norm_min REAL,
|
||||||
norm_max REAL,
|
norm_max REAL,
|
||||||
lower_is_better INTEGER NOT NULL DEFAULT 0,
|
lower_is_better INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT,
|
||||||
UNIQUE(domain_id, metric_id)
|
UNIQUE(domain_id, metric_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ CREATE TABLE IF NOT EXISTS combination_results (
|
|||||||
human_notes TEXT,
|
human_notes TEXT,
|
||||||
pass_reached INTEGER,
|
pass_reached INTEGER,
|
||||||
domain_block_reason TEXT,
|
domain_block_reason TEXT,
|
||||||
|
qualitative_rating TEXT,
|
||||||
UNIQUE(combination_id, domain_id)
|
UNIQUE(combination_id, domain_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -118,6 +120,24 @@ CREATE TABLE IF NOT EXISTS domain_constraints (
|
|||||||
UNIQUE(domain_id, key, value)
|
UNIQUE(domain_id, key, value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_free_variables (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id INTEGER NOT NULL REFERENCES domains(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
floor_formula TEXT NOT NULL,
|
||||||
|
ceiling_formula TEXT NOT NULL,
|
||||||
|
UNIQUE(domain_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_metric_formulas (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id INTEGER NOT NULL REFERENCES domains(id),
|
||||||
|
metric_name TEXT NOT NULL,
|
||||||
|
formula TEXT NOT NULL,
|
||||||
|
UNIQUE(domain_id, metric_name)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_deps_entity ON dependencies(entity_id);
|
CREATE INDEX IF NOT EXISTS idx_deps_entity ON dependencies(entity_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deps_category_key ON dependencies(category, key);
|
CREATE INDEX IF NOT EXISTS idx_deps_category_key ON dependencies(category, key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_combo_status ON combinations(status);
|
CREATE INDEX IF NOT EXISTS idx_combo_status ON combinations(status);
|
||||||
@@ -134,6 +154,16 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE domain_metric_weights ADD COLUMN lower_is_better INTEGER NOT NULL DEFAULT 0"
|
"ALTER TABLE domain_metric_weights ADD COLUMN lower_is_better INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
if "unit" not in cols:
|
||||||
|
conn.execute("ALTER TABLE domain_metric_weights ADD COLUMN unit TEXT")
|
||||||
|
# Best-effort backfill from the old (metric-name-global) unit column —
|
||||||
|
# only correct for domains that happen to agree on that metric's unit.
|
||||||
|
# Seed data re-applies each domain's real per-domain unit on next load.
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE domain_metric_weights
|
||||||
|
SET unit = (SELECT m.unit FROM metrics m WHERE m.id = domain_metric_weights.metric_id)
|
||||||
|
WHERE unit IS NULL"""
|
||||||
|
)
|
||||||
|
|
||||||
# Create domain_constraints table if missing (added after initial schema)
|
# Create domain_constraints table if missing (added after initial schema)
|
||||||
tables = {r[0] for r in conn.execute(
|
tables = {r[0] for r in conn.execute(
|
||||||
@@ -154,6 +184,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE combination_results ADD COLUMN domain_block_reason TEXT"
|
"ALTER TABLE combination_results ADD COLUMN domain_block_reason TEXT"
|
||||||
)
|
)
|
||||||
|
if "qualitative_rating" not in result_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE combination_results ADD COLUMN qualitative_rating TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
# Backfill: cost_efficiency is lower-is-better in all domains
|
# Backfill: cost_efficiency is lower-is-better in all domains
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -16,23 +16,83 @@ MUTEX_VALUES: dict[str, list[set[str]]] = {
|
|||||||
"medium": [{"ground"}, {"water"}, {"air"}, {"space"}],
|
"medium": [{"ground"}, {"water"}, {"air"}, {"space"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Conditions assumed always available (don't need an explicit provides)
|
# Conditions assumed always available (don't need an explicit provides).
|
||||||
|
# ground_surface and gravity are deliberately NOT here — unlike star_proximity
|
||||||
|
# (only relevant to space-adjacent entities) they're things most, but not all,
|
||||||
|
# platforms actually have (a Spaceship in orbital freefall has neither in the
|
||||||
|
# sense a ground-rolling actuator needs); those platforms must `provide` them.
|
||||||
AMBIENT_CONDITIONS: set[tuple[str, str]] = {
|
AMBIENT_CONDITIONS: set[tuple[str, str]] = {
|
||||||
("ground_surface", "true"),
|
|
||||||
("gravity", "true"),
|
|
||||||
("star_proximity", "true"),
|
("star_proximity", "true"),
|
||||||
|
("water_surface", "true"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-category behavior for unmet requirements:
|
# Per-category behavior for unmet requirements:
|
||||||
# "block" = hard violation, "warn" = conditional warning, "skip" = ignore
|
# "block" = hard violation, "warn" = conditional warning, "skip" = ignore
|
||||||
CATEGORY_SEVERITY: dict[str, str] = {
|
CATEGORY_SEVERITY: dict[str, str] = {
|
||||||
"energy": "block",
|
"energy": "block",
|
||||||
|
"environment": "block",
|
||||||
"infrastructure": "skip",
|
"infrastructure": "skip",
|
||||||
|
# Safety-critical physical necessities (radiation shielding, containment,
|
||||||
|
# etc.) -- same severity as energy/environment, not the softer default
|
||||||
|
# "warn" every other category falls through to. Missing this entry meant
|
||||||
|
# Nuclear Thermal Drive/Nuclear Fuel's "material" requires (radiation_
|
||||||
|
# shielding) defaulted to a non-blocking warning nothing in the catalog
|
||||||
|
# ever satisfies -- see LOGIC DOCS/002's "silent guardrail hole" pattern.
|
||||||
|
"material": "block",
|
||||||
}
|
}
|
||||||
|
|
||||||
# For provides-vs-range_min: deficit > this ratio = hard block, else warning
|
# For provides-vs-range_min: deficit > this ratio = hard block, else warning
|
||||||
DEFICIT_THRESHOLD: float = 0.25
|
DEFICIT_THRESHOLD: float = 0.25
|
||||||
|
|
||||||
|
# How multiple entities' numbers on the same key combine into one system-level
|
||||||
|
# number. "sum" = extensive (component contributions add into one vehicle);
|
||||||
|
# any key not listed defaults to "max" (today's pairwise behavior — the
|
||||||
|
# strongest/most-demanding single entity wins).
|
||||||
|
KEY_AGGREGATION: dict[str, str] = {
|
||||||
|
"mass": "sum",
|
||||||
|
"footprint": "sum",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sum-of-floors is an estimate built from independent component minima, not a
|
||||||
|
# measurement. Overrun inside this band warns; beyond it blocks.
|
||||||
|
# ponytail: single global tolerance; per-key band if mass and footprint ever
|
||||||
|
# need different slack.
|
||||||
|
OVERRUN_TOLERANCE: float = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_dependency_value(
|
||||||
|
combination: Combination,
|
||||||
|
key: str,
|
||||||
|
constraint_type: str,
|
||||||
|
key_aggregation: dict[str, str] | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Collapse every numeric dependency matching (key, constraint_type)
|
||||||
|
across a combination's entities into one system-level number: summed for
|
||||||
|
extensive keys (KEY_AGGREGATION says "sum", e.g. mass/footprint --
|
||||||
|
independent components sharing one physical vehicle), otherwise the
|
||||||
|
strongest single value wins (today's default pairwise behavior). Returns
|
||||||
|
None if no entity declares a matching numeric dependency. Shared by
|
||||||
|
ConstraintResolver._check_provides_vs_range and the formula evaluator's
|
||||||
|
injected dep() builtin (see engine/formula.py, engine/pipeline.py) so
|
||||||
|
there's one implementation of "how do these entities' declared numbers
|
||||||
|
combine," not two.
|
||||||
|
"""
|
||||||
|
key_aggregation = KEY_AGGREGATION if key_aggregation is None else key_aggregation
|
||||||
|
values: list[float] = []
|
||||||
|
for entity in combination.entities:
|
||||||
|
for dep in entity.dependencies:
|
||||||
|
if dep.key != key or dep.constraint_type != constraint_type:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values.append(float(dep.value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
if key_aggregation.get(key) == "sum":
|
||||||
|
return sum(values)
|
||||||
|
return max(values)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConstraintResult:
|
class ConstraintResult:
|
||||||
@@ -52,6 +112,8 @@ class ConstraintResolver:
|
|||||||
ambient_conditions=None,
|
ambient_conditions=None,
|
||||||
category_severity=None,
|
category_severity=None,
|
||||||
deficit_threshold=None,
|
deficit_threshold=None,
|
||||||
|
key_aggregation=None,
|
||||||
|
overrun_tolerance=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.mutex = mutex_registry or MUTEX_VALUES
|
self.mutex = mutex_registry or MUTEX_VALUES
|
||||||
self.ambient = ambient_conditions or AMBIENT_CONDITIONS
|
self.ambient = ambient_conditions or AMBIENT_CONDITIONS
|
||||||
@@ -59,6 +121,10 @@ class ConstraintResolver:
|
|||||||
self.deficit_threshold = (
|
self.deficit_threshold = (
|
||||||
deficit_threshold if deficit_threshold is not None else DEFICIT_THRESHOLD
|
deficit_threshold if deficit_threshold is not None else DEFICIT_THRESHOLD
|
||||||
)
|
)
|
||||||
|
self.key_aggregation = key_aggregation or KEY_AGGREGATION
|
||||||
|
self.overrun_tolerance = (
|
||||||
|
overrun_tolerance if overrun_tolerance is not None else OVERRUN_TOLERANCE
|
||||||
|
)
|
||||||
|
|
||||||
def resolve(self, combination: Combination) -> ConstraintResult:
|
def resolve(self, combination: Combination) -> ConstraintResult:
|
||||||
result = ConstraintResult()
|
result = ConstraintResult()
|
||||||
@@ -72,6 +138,7 @@ class ConstraintResolver:
|
|||||||
self._check_range_incompatibility(all_deps, result)
|
self._check_range_incompatibility(all_deps, result)
|
||||||
self._check_provides_vs_range(combination, result)
|
self._check_provides_vs_range(combination, result)
|
||||||
self._check_unmet_requirements(all_deps, result)
|
self._check_unmet_requirements(all_deps, result)
|
||||||
|
self._check_propulsion_viability(combination, result)
|
||||||
|
|
||||||
if result.violations:
|
if result.violations:
|
||||||
result.status = "p1_fail"
|
result.status = "p1_fail"
|
||||||
@@ -91,11 +158,24 @@ class ConstraintResolver:
|
|||||||
for exc_name, exc in excludes:
|
for exc_name, exc in excludes:
|
||||||
if req_name == exc_name:
|
if req_name == exc_name:
|
||||||
continue
|
continue
|
||||||
if req.key == exc.key and req.value == exc.value:
|
if req.key != exc.key:
|
||||||
|
continue
|
||||||
|
if req.value == exc.value:
|
||||||
result.violations.append(
|
result.violations.append(
|
||||||
f"{req_name} requires {req.key}={req.value} "
|
f"{req_name} requires {req.key}={req.value} "
|
||||||
f"but {exc_name} excludes it"
|
f"but {exc_name} excludes it"
|
||||||
)
|
)
|
||||||
|
elif req.key in self.mutex:
|
||||||
|
# Excluding one value in a mutex family excludes the
|
||||||
|
# whole family (e.g. excludes atmosphere=standard also
|
||||||
|
# rules out other "dense" values in the same set).
|
||||||
|
exc_set = self._find_mutex_set(exc.key, exc.value)
|
||||||
|
if exc_set is not None and req.value in exc_set:
|
||||||
|
result.violations.append(
|
||||||
|
f"{req_name} requires {req.key}={req.value} "
|
||||||
|
f"but {exc_name} excludes {exc.key}={exc.value} "
|
||||||
|
f"(same mutex family)"
|
||||||
|
)
|
||||||
|
|
||||||
def _check_mutual_exclusion(
|
def _check_mutual_exclusion(
|
||||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||||
@@ -111,11 +191,14 @@ class ConstraintResolver:
|
|||||||
continue
|
continue
|
||||||
if dep_a.value == dep_b.value:
|
if dep_a.value == dep_b.value:
|
||||||
continue
|
continue
|
||||||
# Check if values are in different mutex sets
|
# Check if values are in different mutex sets. An unrecognized
|
||||||
|
# value (not in any registered set) is treated as conflicting
|
||||||
|
# with any recognized value on the same key — fail closed
|
||||||
|
# rather than silently letting unknown values through.
|
||||||
if dep_a.key in self.mutex:
|
if dep_a.key in self.mutex:
|
||||||
set_a = self._find_mutex_set(dep_a.key, dep_a.value)
|
set_a = self._find_mutex_set(dep_a.key, dep_a.value)
|
||||||
set_b = self._find_mutex_set(dep_b.key, dep_b.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:
|
if set_a is not set_b:
|
||||||
result.violations.append(
|
result.violations.append(
|
||||||
f"{name_a} requires {dep_a.key}={dep_a.value} "
|
f"{name_a} requires {dep_a.key}={dep_a.value} "
|
||||||
f"but {name_b} requires {dep_b.key}={dep_b.value} "
|
f"but {name_b} requires {dep_b.key}={dep_b.value} "
|
||||||
@@ -132,7 +215,13 @@ class ConstraintResolver:
|
|||||||
def _check_range_incompatibility(
|
def _check_range_incompatibility(
|
||||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Rule 3: If A range_min > B range_max for the same key → BLOCKED."""
|
"""Rule 3: floors on a key must fit under the tightest ceiling on that key.
|
||||||
|
|
||||||
|
Extensive keys ("sum" in key_aggregation) aggregate every entity's
|
||||||
|
floor before the comparison, since they represent components sharing
|
||||||
|
one physical vehicle (mass, footprint). Every other key keeps the
|
||||||
|
original pairwise floor-vs-ceiling check.
|
||||||
|
"""
|
||||||
range_mins: dict[str, list[tuple[str, float]]] = {}
|
range_mins: dict[str, list[tuple[str, float]]] = {}
|
||||||
range_maxs: dict[str, list[tuple[str, float]]] = {}
|
range_maxs: dict[str, list[tuple[str, float]]] = {}
|
||||||
|
|
||||||
@@ -143,6 +232,7 @@ class ConstraintResolver:
|
|||||||
range_maxs.setdefault(dep.key, []).append((name, float(dep.value)))
|
range_maxs.setdefault(dep.key, []).append((name, float(dep.value)))
|
||||||
|
|
||||||
for key in set(range_mins) & set(range_maxs):
|
for key in set(range_mins) & set(range_maxs):
|
||||||
|
if self.key_aggregation.get(key) != "sum":
|
||||||
for min_name, min_val in range_mins[key]:
|
for min_name, min_val in range_mins[key]:
|
||||||
for max_name, max_val in range_maxs[key]:
|
for max_name, max_val in range_maxs[key]:
|
||||||
if min_name == max_name:
|
if min_name == max_name:
|
||||||
@@ -152,11 +242,33 @@ class ConstraintResolver:
|
|||||||
f"{min_name} requires {key} >= {min_val} "
|
f"{min_name} requires {key} >= {min_val} "
|
||||||
f"but {max_name} limits {key} <= {max_val}"
|
f"but {max_name} limits {key} <= {max_val}"
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = sum(val for _, val in range_mins[key])
|
||||||
|
ceil_name, ceiling = min(range_maxs[key], key=lambda t: t[1])
|
||||||
|
if total <= ceiling:
|
||||||
|
continue
|
||||||
|
parts = ", ".join(f"{name} {val:g}" for name, val in range_mins[key])
|
||||||
|
msg = (
|
||||||
|
f"combined {key} {total:g} ({parts}) exceeds "
|
||||||
|
f"{ceil_name} limit of {ceiling:g}"
|
||||||
|
)
|
||||||
|
if total > ceiling * (1 + self.overrun_tolerance):
|
||||||
|
result.violations.append(msg)
|
||||||
|
else:
|
||||||
|
result.warnings.append(msg)
|
||||||
|
|
||||||
def _check_provides_vs_range(
|
def _check_provides_vs_range(
|
||||||
self, combination: Combination, result: ConstraintResult
|
self, combination: Combination, result: ConstraintResult
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generic: provides(key, N) < range_min(key, M) → block/warn."""
|
"""Generic: provides(key, N) < range_min(key, M) → block/warn.
|
||||||
|
|
||||||
|
Multiple providers of the same key are reduced to one number before
|
||||||
|
comparing: summed for extensive keys, otherwise the strongest single
|
||||||
|
provider wins (a weak secondary source, e.g. backup solar panels
|
||||||
|
alongside a nuclear reactor, must not drag down a combo that's
|
||||||
|
already satisfied by its best provider).
|
||||||
|
"""
|
||||||
provided: dict[str, list[tuple[str, float]]] = {}
|
provided: dict[str, list[tuple[str, float]]] = {}
|
||||||
required: dict[str, list[tuple[str, float]]] = {}
|
required: dict[str, list[tuple[str, float]]] = {}
|
||||||
|
|
||||||
@@ -172,8 +284,15 @@ class ConstraintResolver:
|
|||||||
required.setdefault(dep.key, []).append((entity.name, val))
|
required.setdefault(dep.key, []).append((entity.name, val))
|
||||||
|
|
||||||
for key in set(provided) & set(required):
|
for key in set(provided) & set(required):
|
||||||
|
prov_val = aggregate_dependency_value(
|
||||||
|
combination, key, "provides", self.key_aggregation
|
||||||
|
)
|
||||||
|
if self.key_aggregation.get(key) == "sum":
|
||||||
|
prov_name = " + ".join(name for name, _ in provided[key])
|
||||||
|
else:
|
||||||
|
prov_name, _ = max(provided[key], key=lambda t: t[1])
|
||||||
|
|
||||||
for req_name, req_val in required[key]:
|
for req_name, req_val in required[key]:
|
||||||
for prov_name, prov_val in provided[key]:
|
|
||||||
if prov_val < req_val * self.deficit_threshold:
|
if prov_val < req_val * self.deficit_threshold:
|
||||||
result.violations.append(
|
result.violations.append(
|
||||||
f"{prov_name} provides {key}={prov_val:.0f} but "
|
f"{prov_name} provides {key}={prov_val:.0f} but "
|
||||||
@@ -206,6 +325,86 @@ class ConstraintResolver:
|
|||||||
result.status = "p1_fail"
|
result.status = "p1_fail"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _check_propulsion_viability(
|
||||||
|
self, combination: Combination, result: ConstraintResult
|
||||||
|
) -> None:
|
||||||
|
"""Rule 6: an entity providing specific_thrust (N/kg of its own mass)
|
||||||
|
must be able to mass enough, within the vehicle's mass budget, to
|
||||||
|
accelerate the whole combo past the platform's min_effective_accel
|
||||||
|
(m/s²) — the same physics whether that's overcoming rolling
|
||||||
|
resistance or hovering against gravity, just different constants.
|
||||||
|
|
||||||
|
Skips silently if no entity declares min_effective_accel or no
|
||||||
|
entity declares specific_thrust — this only fires where both
|
||||||
|
numbers are actually known.
|
||||||
|
"""
|
||||||
|
min_accel = next(
|
||||||
|
(
|
||||||
|
float(dep.value)
|
||||||
|
for entity in combination.entities
|
||||||
|
for dep in entity.dependencies
|
||||||
|
if dep.key == "min_effective_accel" and dep.constraint_type == "range_min"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if min_accel is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for actuator in combination.entities:
|
||||||
|
specific_thrust = next(
|
||||||
|
(
|
||||||
|
float(dep.value)
|
||||||
|
for dep in actuator.dependencies
|
||||||
|
if dep.key == "specific_thrust" and dep.constraint_type == "provides"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if specific_thrust is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
other_mass = sum(
|
||||||
|
float(dep.value)
|
||||||
|
for entity in combination.entities
|
||||||
|
if entity is not actuator
|
||||||
|
for dep in entity.dependencies
|
||||||
|
if dep.key == "mass" and dep.constraint_type == "range_min"
|
||||||
|
)
|
||||||
|
|
||||||
|
if specific_thrust <= min_accel:
|
||||||
|
result.violations.append(
|
||||||
|
f"{actuator.name} specific thrust {specific_thrust:g} N/kg can "
|
||||||
|
f"never exceed the {min_accel:g} m/s² minimum this vehicle "
|
||||||
|
f"needs, regardless of scale"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
required_mass = min_accel * other_mass / (specific_thrust - min_accel)
|
||||||
|
actuator_floor = next(
|
||||||
|
(
|
||||||
|
float(dep.value)
|
||||||
|
for dep in actuator.dependencies
|
||||||
|
if dep.key == "mass" and dep.constraint_type == "range_min"
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
effective_mass = max(required_mass, actuator_floor)
|
||||||
|
|
||||||
|
ceiling = next(
|
||||||
|
(
|
||||||
|
float(dep.value)
|
||||||
|
for entity in combination.entities
|
||||||
|
for dep in entity.dependencies
|
||||||
|
if dep.key == "mass" and dep.constraint_type == "range_max"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if ceiling is not None and effective_mass + other_mass > ceiling:
|
||||||
|
result.violations.append(
|
||||||
|
f"{actuator.name} would need >= {effective_mass:.0f}kg to move "
|
||||||
|
f"this vehicle at {min_accel:g} m/s², exceeding its "
|
||||||
|
f"{ceiling:g}kg mass ceiling"
|
||||||
|
)
|
||||||
|
|
||||||
def _check_unmet_requirements(
|
def _check_unmet_requirements(
|
||||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -215,6 +414,11 @@ class ConstraintResolver:
|
|||||||
for name, dep in all_deps:
|
for name, dep in all_deps:
|
||||||
if dep.constraint_type != "requires":
|
if dep.constraint_type != "requires":
|
||||||
continue
|
continue
|
||||||
|
if dep.key in self.mutex:
|
||||||
|
# Agreement key (e.g. medium, atmosphere) — entities must
|
||||||
|
# concur, not supply/demand. Rule 2 owns compatibility here;
|
||||||
|
# no entity is expected to "provide" it.
|
||||||
|
continue
|
||||||
severity = self.category_severity.get(dep.category, "warn")
|
severity = self.category_severity.get(dep.category, "warn")
|
||||||
if severity == "skip":
|
if severity == "skip":
|
||||||
continue
|
continue
|
||||||
|
|||||||
139
src/physcom/engine/formula.py
Normal file
139
src/physcom/engine/formula.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Safe arithmetic expression language for domain-authored estimator formulas.
|
||||||
|
|
||||||
|
No eval()/exec() anywhere -- compile_formula validates every AST node against
|
||||||
|
a fixed whitelist (arithmetic, numeric/string constants, name lookups, calls
|
||||||
|
to an explicitly supplied function table) before evaluate_formula ever walks
|
||||||
|
it, so a formula can express "how much drawback_force does this combo
|
||||||
|
produce" but never anything with attribute/subscript/import access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import math
|
||||||
|
import operator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class FormulaError(Exception):
|
||||||
|
"""Raised for invalid formula syntax/structure or a failed evaluation."""
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_NODES = (
|
||||||
|
ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.Name, ast.Load,
|
||||||
|
ast.Call, ast.keyword,
|
||||||
|
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub, ast.UAdd,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BINOPS: dict[type, Callable[[float, float], float]] = {
|
||||||
|
ast.Add: operator.add,
|
||||||
|
ast.Sub: operator.sub,
|
||||||
|
ast.Mult: operator.mul,
|
||||||
|
ast.Div: operator.truediv,
|
||||||
|
ast.Pow: operator.pow,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UNARYOPS: dict[type, Callable[[float], float]] = {
|
||||||
|
ast.USub: operator.neg,
|
||||||
|
ast.UAdd: operator.pos,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_FUNCTIONS: dict[str, Callable[..., float]] = {
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
"abs": abs,
|
||||||
|
"sqrt": math.sqrt,
|
||||||
|
"log": math.log,
|
||||||
|
"log1p": math.log1p,
|
||||||
|
"exp": math.exp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CompiledFormula:
|
||||||
|
source: str
|
||||||
|
_tree: ast.Expression
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(tree: ast.AST) -> None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, _ALLOWED_NODES):
|
||||||
|
raise FormulaError(
|
||||||
|
f"disallowed expression element: {type(node).__name__}"
|
||||||
|
)
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float, str)):
|
||||||
|
raise FormulaError(
|
||||||
|
f"disallowed constant type: {type(node.value).__name__}"
|
||||||
|
)
|
||||||
|
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
||||||
|
raise FormulaError(f"disallowed name: {node.id}")
|
||||||
|
if isinstance(node, ast.Call) and not isinstance(node.func, ast.Name):
|
||||||
|
raise FormulaError("only direct function calls are allowed")
|
||||||
|
|
||||||
|
|
||||||
|
def compile_formula(source: str) -> CompiledFormula:
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source, mode="eval")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise FormulaError(f"invalid syntax in '{source}': {exc}") from exc
|
||||||
|
_validate(tree)
|
||||||
|
return CompiledFormula(source=source, _tree=tree)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval(node: ast.AST, variables: dict[str, float], functions: dict[str, Callable]):
|
||||||
|
if isinstance(node, ast.Expression):
|
||||||
|
return _eval(node.body, variables, functions)
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
# Numeric constants are cast to float (not left as int) so a formula
|
||||||
|
# like a**b**c can't build an arbitrary-precision giant int before
|
||||||
|
# ever raising -- float exponentiation overflows to inf/OverflowError
|
||||||
|
# quickly instead. String constants (dep() key/constraint_type args)
|
||||||
|
# pass through unchanged.
|
||||||
|
return node.value if isinstance(node.value, str) else float(node.value)
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
if node.id not in variables:
|
||||||
|
raise FormulaError(f"unknown variable '{node.id}'")
|
||||||
|
return variables[node.id]
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
op = _BINOPS.get(type(node.op))
|
||||||
|
if op is None:
|
||||||
|
raise FormulaError(f"unsupported operator: {type(node.op).__name__}")
|
||||||
|
return op(
|
||||||
|
_eval(node.left, variables, functions),
|
||||||
|
_eval(node.right, variables, functions),
|
||||||
|
)
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
op = _UNARYOPS.get(type(node.op))
|
||||||
|
if op is None:
|
||||||
|
raise FormulaError(f"unsupported operator: {type(node.op).__name__}")
|
||||||
|
return op(_eval(node.operand, variables, functions))
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
fname = node.func.id # validated as ast.Name by _validate
|
||||||
|
func = functions.get(fname)
|
||||||
|
if func is None:
|
||||||
|
raise FormulaError(f"unknown function '{fname}'")
|
||||||
|
args = [_eval(a, variables, functions) for a in node.args]
|
||||||
|
kwargs = {kw.arg: _eval(kw.value, variables, functions) for kw in node.keywords}
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
raise FormulaError(f"unsupported expression element: {type(node).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_formula(
|
||||||
|
compiled: CompiledFormula,
|
||||||
|
variables: dict[str, float],
|
||||||
|
functions: dict[str, Callable] | None = None,
|
||||||
|
) -> float:
|
||||||
|
effective_functions = {**DEFAULT_FUNCTIONS, **(functions or {})}
|
||||||
|
try:
|
||||||
|
result = _eval(compiled._tree, variables, effective_functions)
|
||||||
|
except FormulaError:
|
||||||
|
raise
|
||||||
|
except (TypeError, ValueError, ArithmeticError) as exc:
|
||||||
|
raise FormulaError(f"error evaluating '{compiled.source}': {exc}") from exc
|
||||||
|
if not isinstance(result, (int, float)) or isinstance(result, bool):
|
||||||
|
raise FormulaError(
|
||||||
|
f"formula '{compiled.source}' did not evaluate to a number"
|
||||||
|
)
|
||||||
|
return float(result)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from physcom.models.domain import Domain, MetricBound
|
||||||
|
|
||||||
|
|
||||||
class LLMRateLimitError(Exception):
|
class LLMRateLimitError(Exception):
|
||||||
"""Raised by a provider when the API rate limit is exceeded.
|
"""Raised by a provider when the API rate limit is exceeded.
|
||||||
@@ -22,16 +24,37 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def estimate_physics(
|
def estimate_physics(
|
||||||
self, combination_description: str, metrics: list[str]
|
self, combination_description: str, metrics: list[MetricBound]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
"""Given a natural-language description of a combination,
|
"""Given a natural-language description of a combination,
|
||||||
estimate raw metric values. Returns {metric_name: estimated_value}."""
|
estimate raw metric values. `metrics` carries each metric's unit and
|
||||||
|
expected norm_min/norm_max so the estimate lands in the right
|
||||||
|
magnitude — a bare metric name gives no hint that "cost_efficiency"
|
||||||
|
means dollars per meter in the 1e-5 range, not a 0-1 score.
|
||||||
|
Returns {metric_name: estimated_value}."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def review_plausibility(
|
def review_plausibility(
|
||||||
self, combination_description: str, scores: dict[str, float]
|
self,
|
||||||
|
combination_description: str,
|
||||||
|
raw_metrics: dict[str, float],
|
||||||
|
normalized_scores: dict[str, float],
|
||||||
|
domain: Domain,
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
"""Given a combination and its scores, return a (text, is_plausible)
|
"""Given a combination, its raw physical estimates, and their
|
||||||
tuple: natural-language assessment and whether the concept is plausible."""
|
normalized scores, return a (text, is_plausible) tuple:
|
||||||
|
natural-language assessment and whether the concept is plausible.
|
||||||
|
|
||||||
|
Both raw_metrics and normalized_scores are given (not just the
|
||||||
|
normalized score) so the review can reason from the actual physics
|
||||||
|
rather than only a compressed 0-1 number, which can look
|
||||||
|
deceptively bad for a metric whose scale was built for a different
|
||||||
|
kind of vehicle. `domain` carries both each metric's unit (via
|
||||||
|
domain.metric_bounds, for formatting the raw value meaningfully)
|
||||||
|
and the domain's own name/description, so the review judges a
|
||||||
|
metric like range against what THIS domain actually needs rather
|
||||||
|
than generic real-world expectations for the platform category
|
||||||
|
(e.g. a short-hop domain shouldn't get judged against typical
|
||||||
|
long-haul aircraft range)."""
|
||||||
...
|
...
|
||||||
|
|||||||
37
src/physcom/llm/parsing.py
Normal file
37
src/physcom/llm/parsing.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Shared response-parsing helpers for LLM providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from physcom.models.domain import MetricBound
|
||||||
|
|
||||||
|
|
||||||
|
def parse_verdict(text: str) -> bool:
|
||||||
|
"""Extract VERDICT: PLAUSIBLE/IMPLAUSIBLE from response; default to True."""
|
||||||
|
m = re.search(r"VERDICT:\s*(PLAUSIBLE|IMPLAUSIBLE)", text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(1).upper() == "PLAUSIBLE"
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rating(text: str) -> str | None:
|
||||||
|
"""Extract RATING: LOW/MEDIUM/HIGH from response; None if absent (older
|
||||||
|
reviews saved before this existed, or a malformed response)."""
|
||||||
|
m = re.search(r"RATING:\s*(LOW|MEDIUM|HIGH)", text, re.IGNORECASE)
|
||||||
|
return m.group(1).upper() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_metric_json(text: str, metrics: list[MetricBound]) -> dict[str, float]:
|
||||||
|
"""Strip markdown fences and parse JSON; fall back to each metric's own
|
||||||
|
norm_min/norm_max midpoint on error — a flat constant like 0.5 is
|
||||||
|
guaranteed wrong-magnitude for at least some metrics regardless of unit.
|
||||||
|
"""
|
||||||
|
names = {mb.metric_name for mb in metrics}
|
||||||
|
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
return {k: float(v) for k, v in data.items() if k in names}
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return {mb.metric_name: (mb.norm_min + mb.norm_max) / 2 for mb in metrics}
|
||||||
@@ -1,5 +1,54 @@
|
|||||||
"""Prompt templates for LLM-assisted passes."""
|
"""Prompt templates for LLM-assisted passes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from physcom.models.domain import MetricBound
|
||||||
|
|
||||||
|
|
||||||
|
def format_metrics_for_prompt(metrics: list["MetricBound"]) -> str:
|
||||||
|
"""Render each metric with its unit and expected range, so the model
|
||||||
|
anchors on the right order of magnitude instead of a generic decimal."""
|
||||||
|
lines = []
|
||||||
|
for mb in metrics:
|
||||||
|
unit = mb.unit or "dimensionless"
|
||||||
|
lines.append(
|
||||||
|
f"- {mb.metric_name} ({unit}): typical range {mb.norm_min:g} to {mb.norm_max:g}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_scores_for_prompt(
|
||||||
|
raw_metrics: dict[str, float],
|
||||||
|
normalized_scores: dict[str, float],
|
||||||
|
metrics: list["MetricBound"],
|
||||||
|
) -> str:
|
||||||
|
"""Render each metric with BOTH its raw physical value and its
|
||||||
|
normalized score, so the reviewing pass can reason from the actual
|
||||||
|
physics instead of only ever seeing a compressed 0-1 number.
|
||||||
|
|
||||||
|
A real, correct estimate can still look damning once log-normalized
|
||||||
|
against a scale built for a different kind of vehicle (a cyclist's
|
||||||
|
real ~5 W/kg reads as "0.159" next to a car's 2000 W/kg ceiling) --
|
||||||
|
a reviewer that only sees the 0.159 has no way to notice that. See
|
||||||
|
the labeled-set calibration note on PLAUSIBILITY_REVIEW_PROMPT below.
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
for mb in metrics:
|
||||||
|
normed = normalized_scores.get(mb.metric_name)
|
||||||
|
if normed is None:
|
||||||
|
continue
|
||||||
|
raw = raw_metrics.get(mb.metric_name)
|
||||||
|
unit = mb.unit or "dimensionless"
|
||||||
|
raw_str = f"{raw:g} {unit}" if raw is not None else "unknown"
|
||||||
|
lines.append(
|
||||||
|
f"- {mb.metric_name}: raw estimate {raw_str} — normalized score {normed:.3f}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
PHYSICS_ESTIMATION_PROMPT = """\
|
PHYSICS_ESTIMATION_PROMPT = """\
|
||||||
You are a physics estimation assistant. Given the following transportation concept, \
|
You are a physics estimation assistant. Given the following transportation concept, \
|
||||||
estimate the requested metrics using order-of-magnitude physics reasoning.
|
estimate the requested metrics using order-of-magnitude physics reasoning.
|
||||||
@@ -8,33 +57,150 @@ estimate the requested metrics using order-of-magnitude physics reasoning.
|
|||||||
{description}
|
{description}
|
||||||
|
|
||||||
## Metrics to estimate
|
## Metrics to estimate
|
||||||
|
Each metric's unit and the typical range values fall in for this domain are given —
|
||||||
|
match that magnitude, don't guess a generically "reasonable-looking" decimal.
|
||||||
{metrics}
|
{metrics}
|
||||||
|
|
||||||
## Instructions
|
## Instructions
|
||||||
- Use real-world physics to estimate each metric.
|
- Use real-world physics to estimate each metric, in the exact unit given.
|
||||||
|
- For "safety" specifically: consider hazards that arise from THIS combination's
|
||||||
|
specific interactions — a fuel that's safe in an open vehicle can be far more
|
||||||
|
dangerous inside a sealed tube or enclosed structure, a stable actuator on a
|
||||||
|
fragile platform can be a real risk even if neither is risky alone. Don't just
|
||||||
|
rate how safe the platform or actuator would be in isolation.
|
||||||
- If the concept is implausible, still provide your best estimate.
|
- If the concept is implausible, still provide your best estimate.
|
||||||
- Return ONLY valid JSON mapping metric names to numeric values.
|
- Return ONLY valid JSON mapping metric names to numeric values, e.g.
|
||||||
- Example: {{"power_density": 500.0, "cost_efficiency": 0.15, "safety": 0.7}}
|
{{"some_metric": <number>, "another_metric": <number>}} — no explanatory text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ponytail: pass 4 used to see only pass 2's normalized scores, not the raw
|
||||||
|
# physical numbers or any reasoning behind them. Fixed the raw-value half of
|
||||||
|
# that gap: format_scores_for_prompt() now shows both, since a correct raw
|
||||||
|
# estimate can look damning once log-normalized against a scale built for a
|
||||||
|
# different kind of vehicle (a cyclist's real ~5 W/kg reads as "0.159" next
|
||||||
|
# to a car's 2000 W/kg ceiling) -- gemma2:27b did exactly this on a real
|
||||||
|
# bicycle combo, citing "extremely low power density (0.159)" as grounds for
|
||||||
|
# IMPLAUSIBLE while never reasoning from the actual (correct) 5 W/kg. The
|
||||||
|
# reasoning-text half of the gap is still open: estimate_physics() doesn't
|
||||||
|
# return a per-metric rationale, so pass 4 still can't see WHY pass 2 landed
|
||||||
|
# on a number, only what the number is. Upgrade path if the raw value alone
|
||||||
|
# isn't enough in practice: have estimate_physics() also return a short
|
||||||
|
# per-metric reason, persist it alongside raw_value (new nullable column),
|
||||||
|
# and feed it into this prompt. Deferred because it needs a schema/interface
|
||||||
|
# change across LLMProvider + both providers + pipeline + scorer +
|
||||||
|
# repository, and more generated tokens per combo.
|
||||||
|
#
|
||||||
|
# If we plan to LLM-review every p2 pass then maybe p2 and p4 should be combined.
|
||||||
|
#
|
||||||
|
# Calibration history: the original wording asked the model to weigh "novelty"
|
||||||
|
# and "genuinely interesting innovation or nonsense" as part of the verdict,
|
||||||
|
# which measurably biased weaker/harsher models toward IMPLAUSIBLE on ordinary,
|
||||||
|
# working concepts just for being unoriginal (gemma2:27b scored 4/8 on a
|
||||||
|
# labeled test set, wrongly rejecting an ordinary commuter car). Rewritten to:
|
||||||
|
# separate "interesting" from "viable" entirely, state explicitly that scores
|
||||||
|
# are 0-1 where higher is always better (models were misreading a normalized
|
||||||
|
# 0.85 as a bad sign), require a *specific, named* mechanism for IMPLAUSIBLE
|
||||||
|
# rather than a vague "seems risky," and lower the bar from "must be proven
|
||||||
|
# physically impossible" to "a well-reasoned specific danger is enough" (the
|
||||||
|
# original wording let a careful reasoner argue its way out of flagging a
|
||||||
|
# genuinely hazardous combo on a technicality). Deliberately does NOT ask the
|
||||||
|
# model to weigh whether the concept or something like it already exists in
|
||||||
|
# the real world — that's a bias on physical/safety judgment, not a physics
|
||||||
|
# argument, and it papers over rather than fixes weak per-metric reasoning.
|
||||||
|
# Post-fix labeled-set accuracy: phi4 8/8, qwq 8/8, gemma2:27b 4/8->7/8,
|
||||||
|
# qwen2.5:7b 5/8->4/8 (a real capability ceiling on this model for this
|
||||||
|
# judgment task, not a prompt regression -- left as-is rather than chasing
|
||||||
|
# further prompt-specific patches for one weak model).
|
||||||
PLAUSIBILITY_REVIEW_PROMPT = """\
|
PLAUSIBILITY_REVIEW_PROMPT = """\
|
||||||
You are reviewing a novel transportation concept for social and practical viability.
|
You are reviewing a transportation concept for real-world viability — could this
|
||||||
|
actually be built and operated safely. Whether it is new, exciting, or original
|
||||||
|
is NOT the question.
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
This concept is being evaluated for "{domain_name}": {domain_description}
|
||||||
|
Judge every metric against what THIS domain actually needs, not general
|
||||||
|
expectations for the platform category. A range far beyond what this domain
|
||||||
|
requires is a strength or a non-issue, never a weakness -- don't reason about
|
||||||
|
range, speed, or capacity by comparing to what other vehicles of this type
|
||||||
|
typically have in general use; compare to what this specific domain calls for.
|
||||||
|
|
||||||
## Concept
|
## Concept
|
||||||
{description}
|
{description}
|
||||||
|
|
||||||
## Metric Scores
|
## Metric Scores
|
||||||
|
Each metric below is given as its raw estimated physical value (in the unit
|
||||||
|
shown) AND a normalized score from 0-1, where HIGHER IS ALWAYS BETTER for
|
||||||
|
every metric listed regardless of what it measures (this already accounts
|
||||||
|
for things like "lower cost is better" — you don't need to invert anything).
|
||||||
|
A score of 1.0 means excellent, not "pegged" or "maxed out badly."
|
||||||
|
|
||||||
|
Reason from the RAW value first — it's the actual physics. The normalized
|
||||||
|
score is a summary, not a fact on its own: a real, correct estimate can
|
||||||
|
still normalize to a low-looking number simply because the domain's scale
|
||||||
|
was built for a different, more demanding kind of vehicle (a cyclist's real
|
||||||
|
~5 W/kg legitimately normalizes to ~0.16 next to a car engine's 2000 W/kg
|
||||||
|
ceiling — that low score doesn't mean the estimate is bad or the concept is
|
||||||
|
weak, it means human power is small next to a car engine, which everyone
|
||||||
|
already knows). If a normalized score looks alarming, check whether the raw
|
||||||
|
value is actually reasonable for what this component fundamentally is
|
||||||
|
before treating the score as evidence of a problem.
|
||||||
|
|
||||||
{scores}
|
{scores}
|
||||||
|
|
||||||
## Instructions
|
Safety and accessibility (infrastructure/regulatory availability) are NOT
|
||||||
Review this concept for:
|
among the scores above — neither reduces to a physics formula the way the
|
||||||
1. Social viability — would people actually use this?
|
metrics above do, so nothing here estimates them numerically. Reason about
|
||||||
2. Practical barriers — what engineering or regulatory obstacles exist?
|
both directly from the concept description: does this combination carry a
|
||||||
3. Novelty — does anything similar already exist?
|
specific safety hazard, and is the infrastructure/regulatory environment it
|
||||||
4. Overall plausibility — is this a genuinely interesting innovation or nonsense?
|
needs realistic? Both feed into the RATING below as qualitative judgment
|
||||||
|
calls, not as scores of their own.
|
||||||
|
|
||||||
Provide a concise 2-4 sentence assessment, then on a final line write exactly:
|
## What makes something IMPLAUSIBLE
|
||||||
|
Mark IMPLAUSIBLE if either of these is true:
|
||||||
|
- It is physically or engineering-wise impossible given the components
|
||||||
|
described, OR
|
||||||
|
- Combining these SPECIFIC components creates a serious, specific danger
|
||||||
|
that goes beyond what either component already carries on its own —
|
||||||
|
this does NOT require proof of outright impossibility, a well-reasoned,
|
||||||
|
specific, serious danger is enough (e.g. repeated explosive recoil
|
||||||
|
fatiguing a hull over time is a real structural risk, not just "explosives
|
||||||
|
are dangerous in general"; a fuel that's fine in the open becoming
|
||||||
|
concentrated in a sealed tube is a real risk, not just "fuel is
|
||||||
|
flammable").
|
||||||
|
- Or: a real regulatory/infrastructure barrier with no plausible workaround.
|
||||||
|
|
||||||
|
None of these make something implausible on their own:
|
||||||
|
- being unoriginal or something like it already exists
|
||||||
|
- being expensive, slow, or short-range
|
||||||
|
- a single mediocre score on one metric
|
||||||
|
|
||||||
|
Most concepts that reach this review are ordinary and workable; reserve
|
||||||
|
IMPLAUSIBLE for a real, specific problem you can name — but don't require
|
||||||
|
airtight proof of impossibility when the danger is already clear and specific.
|
||||||
|
Reason from the physics and engineering actually described here, not from
|
||||||
|
whether something like it already exists — novelty or lack of it is not
|
||||||
|
evidence either way.
|
||||||
|
|
||||||
|
## Overall Rating
|
||||||
|
Separately from the plausibility verdict, give ONE holistic rating —
|
||||||
|
LOW, MEDIUM, or HIGH — for how good this combination is overall. This is a
|
||||||
|
single combined judgment, not a separate score per attribute: weigh the
|
||||||
|
metric scores above together with your own qualitative read on safety and
|
||||||
|
accessibility into one rating, the way a person sizing up the whole concept
|
||||||
|
would, not a checklist of independent numbers.
|
||||||
|
|
||||||
|
## What to write
|
||||||
|
In 2-4 sentences, give your reasoning, then check it against the scores
|
||||||
|
above: if your reasoning conflicts with a score (e.g. you believe cost is
|
||||||
|
a serious problem but its cost score is high), name the metric and say so
|
||||||
|
explicitly — don't silently contradict a given score.
|
||||||
|
|
||||||
|
Finish with exactly two lines. For the first, pick exactly one:
|
||||||
|
RATING: LOW
|
||||||
|
RATING: MEDIUM
|
||||||
|
RATING: HIGH
|
||||||
|
|
||||||
|
Then, for the second, pick exactly one:
|
||||||
VERDICT: PLAUSIBLE
|
VERDICT: PLAUSIBLE
|
||||||
or
|
|
||||||
VERDICT: IMPLAUSIBLE
|
VERDICT: IMPLAUSIBLE
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from physcom.llm.base import LLMProvider, LLMRateLimitError
|
from physcom.llm.base import LLMProvider, LLMRateLimitError
|
||||||
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
from physcom.llm.parsing import parse_metric_json, parse_verdict
|
||||||
|
from physcom.llm.prompts import (
|
||||||
|
PHYSICS_ESTIMATION_PROMPT,
|
||||||
|
PLAUSIBILITY_REVIEW_PROMPT,
|
||||||
|
format_metrics_for_prompt,
|
||||||
|
format_scores_for_prompt,
|
||||||
|
)
|
||||||
|
from physcom.models.domain import Domain, MetricBound
|
||||||
|
|
||||||
|
|
||||||
class GeminiLLMProvider(LLMProvider):
|
class GeminiLLMProvider(LLMProvider):
|
||||||
@@ -24,11 +30,11 @@ class GeminiLLMProvider(LLMProvider):
|
|||||||
self._model = model
|
self._model = model
|
||||||
|
|
||||||
def estimate_physics(
|
def estimate_physics(
|
||||||
self, combination_description: str, metrics: list[str]
|
self, combination_description: str, metrics: list[MetricBound]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
prompt = PHYSICS_ESTIMATION_PROMPT.format(
|
prompt = PHYSICS_ESTIMATION_PROMPT.format(
|
||||||
description=combination_description,
|
description=combination_description,
|
||||||
metrics=", ".join(metrics),
|
metrics=format_metrics_for_prompt(metrics),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = self._client.models.generate_content(
|
response = self._client.models.generate_content(
|
||||||
@@ -38,15 +44,21 @@ class GeminiLLMProvider(LLMProvider):
|
|||||||
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
|
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
|
||||||
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
||||||
raise
|
raise
|
||||||
return self._parse_json(response.text, metrics)
|
return parse_metric_json(response.text, metrics)
|
||||||
|
|
||||||
def review_plausibility(
|
def review_plausibility(
|
||||||
self, combination_description: str, scores: dict[str, float]
|
self,
|
||||||
|
combination_description: str,
|
||||||
|
raw_metrics: dict[str, float],
|
||||||
|
normalized_scores: dict[str, float],
|
||||||
|
domain: Domain,
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
scores_str = "\n".join(f"- {k}: {v:.3f}" for k, v in scores.items())
|
scores_str = format_scores_for_prompt(raw_metrics, normalized_scores, domain.metric_bounds)
|
||||||
prompt = PLAUSIBILITY_REVIEW_PROMPT.format(
|
prompt = PLAUSIBILITY_REVIEW_PROMPT.format(
|
||||||
description=combination_description,
|
description=combination_description,
|
||||||
scores=scores_str,
|
scores=scores_str,
|
||||||
|
domain_name=domain.name,
|
||||||
|
domain_description=domain.description,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = self._client.models.generate_content(
|
response = self._client.models.generate_content(
|
||||||
@@ -57,26 +69,9 @@ class GeminiLLMProvider(LLMProvider):
|
|||||||
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
||||||
raise
|
raise
|
||||||
text = response.text.strip()
|
text = response.text.strip()
|
||||||
plausible = self._parse_verdict(text)
|
return (text, parse_verdict(text))
|
||||||
return (text, plausible)
|
|
||||||
|
|
||||||
def _parse_verdict(self, text: str) -> bool:
|
|
||||||
"""Extract VERDICT: PLAUSIBLE/IMPLAUSIBLE from response; default to True."""
|
|
||||||
m = re.search(r"VERDICT:\s*(PLAUSIBLE|IMPLAUSIBLE)", text, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return m.group(1).upper() == "PLAUSIBLE"
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _parse_retry_after(self, exc: Exception) -> int:
|
def _parse_retry_after(self, exc: Exception) -> int:
|
||||||
"""Extract retry delay from the error message, with a safe default."""
|
"""Extract retry delay from the error message, with a safe default."""
|
||||||
m = re.search(r"retry in (\d+(?:\.\d+)?)", str(exc))
|
m = re.search(r"retry in (\d+(?:\.\d+)?)", str(exc))
|
||||||
return math.ceil(float(m.group(1))) + 5 if m else 65
|
return math.ceil(float(m.group(1))) + 5 if m else 65
|
||||||
|
|
||||||
def _parse_json(self, text: str, metrics: list[str]) -> dict[str, float]:
|
|
||||||
"""Strip markdown fences and parse JSON; fall back to 0.5 per metric on error."""
|
|
||||||
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
return {k: float(v) for k, v in data.items() if k in metrics}
|
|
||||||
except (json.JSONDecodeError, ValueError, TypeError):
|
|
||||||
return {m: 0.5 for m in metrics}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from physcom.llm.base import LLMProvider
|
from physcom.llm.base import LLMProvider
|
||||||
|
from physcom.models.domain import Domain, MetricBound
|
||||||
|
|
||||||
|
|
||||||
class MockLLMProvider(LLMProvider):
|
class MockLLMProvider(LLMProvider):
|
||||||
@@ -12,17 +13,21 @@ class MockLLMProvider(LLMProvider):
|
|||||||
self._defaults = default_estimates or {}
|
self._defaults = default_estimates or {}
|
||||||
|
|
||||||
def estimate_physics(
|
def estimate_physics(
|
||||||
self, combination_description: str, metrics: list[str]
|
self, combination_description: str, metrics: list[MetricBound]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
result = {}
|
result = {}
|
||||||
for metric in metrics:
|
for mb in metrics:
|
||||||
result[metric] = self._defaults.get(metric, 0.5)
|
result[mb.metric_name] = self._defaults.get(mb.metric_name, 0.5)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def review_plausibility(
|
def review_plausibility(
|
||||||
self, combination_description: str, scores: dict[str, float]
|
self,
|
||||||
|
combination_description: str,
|
||||||
|
raw_metrics: dict[str, float],
|
||||||
|
normalized_scores: dict[str, float],
|
||||||
|
domain: Domain,
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
avg = sum(scores.values()) / max(len(scores), 1)
|
avg = sum(normalized_scores.values()) / max(len(normalized_scores), 1)
|
||||||
if avg > 0.5:
|
if avg > 0.5:
|
||||||
return ("This concept appears plausible and worth further investigation.", True)
|
return ("This concept appears plausible and worth further investigation.", True)
|
||||||
return ("This concept has significant feasibility challenges.", False)
|
return ("This concept has significant feasibility challenges.", False)
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from physcom.llm.base import LLMProvider
|
from physcom.llm.base import LLMProvider
|
||||||
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
from physcom.llm.parsing import parse_metric_json, parse_verdict
|
||||||
|
from physcom.llm.prompts import (
|
||||||
|
PHYSICS_ESTIMATION_PROMPT,
|
||||||
|
PLAUSIBILITY_REVIEW_PROMPT,
|
||||||
|
format_metrics_for_prompt,
|
||||||
|
format_scores_for_prompt,
|
||||||
|
)
|
||||||
|
from physcom.models.domain import Domain, MetricBound
|
||||||
|
|
||||||
|
|
||||||
class OllamaLLMProvider(LLMProvider):
|
class OllamaLLMProvider(LLMProvider):
|
||||||
@@ -19,25 +25,31 @@ class OllamaLLMProvider(LLMProvider):
|
|||||||
self._host = host.rstrip("/")
|
self._host = host.rstrip("/")
|
||||||
|
|
||||||
def estimate_physics(
|
def estimate_physics(
|
||||||
self, combination_description: str, metrics: list[str]
|
self, combination_description: str, metrics: list[MetricBound]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
prompt = PHYSICS_ESTIMATION_PROMPT.format(
|
prompt = PHYSICS_ESTIMATION_PROMPT.format(
|
||||||
description=combination_description,
|
description=combination_description,
|
||||||
metrics=", ".join(metrics),
|
metrics=format_metrics_for_prompt(metrics),
|
||||||
)
|
)
|
||||||
text = self._generate(prompt, json_mode=True)
|
text = self._generate(prompt, json_mode=True)
|
||||||
return self._parse_json(text, metrics)
|
return parse_metric_json(text, metrics)
|
||||||
|
|
||||||
def review_plausibility(
|
def review_plausibility(
|
||||||
self, combination_description: str, scores: dict[str, float]
|
self,
|
||||||
|
combination_description: str,
|
||||||
|
raw_metrics: dict[str, float],
|
||||||
|
normalized_scores: dict[str, float],
|
||||||
|
domain: Domain,
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
scores_str = "\n".join(f"- {k}: {v:.3f}" for k, v in scores.items())
|
scores_str = format_scores_for_prompt(raw_metrics, normalized_scores, domain.metric_bounds)
|
||||||
prompt = PLAUSIBILITY_REVIEW_PROMPT.format(
|
prompt = PLAUSIBILITY_REVIEW_PROMPT.format(
|
||||||
description=combination_description,
|
description=combination_description,
|
||||||
scores=scores_str,
|
scores=scores_str,
|
||||||
|
domain_name=domain.name,
|
||||||
|
domain_description=domain.description,
|
||||||
)
|
)
|
||||||
text = self._generate(prompt, json_mode=False).strip()
|
text = self._generate(prompt, json_mode=False).strip()
|
||||||
return (text, self._parse_verdict(text))
|
return (text, parse_verdict(text))
|
||||||
|
|
||||||
def _generate(self, prompt: str, json_mode: bool) -> str:
|
def _generate(self, prompt: str, json_mode: bool) -> str:
|
||||||
payload = {"model": self._model, "prompt": prompt, "stream": False}
|
payload = {"model": self._model, "prompt": prompt, "stream": False}
|
||||||
@@ -49,25 +61,9 @@ class OllamaLLMProvider(LLMProvider):
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||||
return json.loads(resp.read())["response"]
|
return json.loads(resp.read())["response"]
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
f"Could not reach Ollama at {self._host} (is `ollama serve` running?)"
|
f"Could not reach Ollama at {self._host} (is `ollama serve` running?)"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def _parse_verdict(self, text: str) -> bool:
|
|
||||||
"""Extract VERDICT: PLAUSIBLE/IMPLAUSIBLE from response; default to True."""
|
|
||||||
m = re.search(r"VERDICT:\s*(PLAUSIBLE|IMPLAUSIBLE)", text, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return m.group(1).upper() == "PLAUSIBLE"
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _parse_json(self, text: str, metrics: list[str]) -> dict[str, float]:
|
|
||||||
"""Strip markdown fences and parse JSON; fall back to 0.5 per metric on error."""
|
|
||||||
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
return {k: float(v) for k, v in data.items() if k in metrics}
|
|
||||||
except (json.JSONDecodeError, ValueError, TypeError):
|
|
||||||
return {m: 0.5 for m in metrics}
|
|
||||||
|
|||||||
@@ -7,32 +7,40 @@ import os
|
|||||||
from physcom.llm.base import LLMProvider
|
from physcom.llm.base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
def build_llm_provider() -> LLMProvider | None:
|
def build_llm_provider(
|
||||||
"""Return an LLMProvider based on env vars, or None if not configured.
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> LLMProvider | None:
|
||||||
|
"""Return an LLMProvider, or None if not configured.
|
||||||
|
|
||||||
|
Explicit args (e.g. from a per-request web form) override env vars;
|
||||||
|
passing nothing falls back to the env-var-only behavior below.
|
||||||
|
|
||||||
LLM_PROVIDER — provider name ('gemini', 'ollama'; more can be added)
|
LLM_PROVIDER — provider name ('gemini', 'ollama'; more can be added)
|
||||||
GEMINI_API_KEY — required when LLM_PROVIDER=gemini
|
GEMINI_API_KEY — required when provider is 'gemini' (server env only,
|
||||||
|
never accepted as a request param)
|
||||||
GEMINI_MODEL — optional Gemini model name (default: gemini-2.0-flash)
|
GEMINI_MODEL — optional Gemini model name (default: gemini-2.0-flash)
|
||||||
OLLAMA_MODEL — optional Ollama model name (default: qwen2.5:7b)
|
OLLAMA_MODEL — optional Ollama model name (default: qwen2.5:7b)
|
||||||
OLLAMA_HOST — optional Ollama server URL (default: http://localhost:11434)
|
OLLAMA_HOST — optional Ollama server URL (default: http://localhost:11434)
|
||||||
"""
|
"""
|
||||||
provider = os.environ.get("LLM_PROVIDER", "").lower().strip()
|
provider = (provider or os.environ.get("LLM_PROVIDER", "")).lower().strip()
|
||||||
|
|
||||||
if not provider:
|
if not provider or provider == "stub":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if provider == "gemini":
|
if provider == "gemini":
|
||||||
api_key = os.environ.get("GEMINI_API_KEY", "")
|
api_key = os.environ.get("GEMINI_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError("LLM_PROVIDER=gemini requires GEMINI_API_KEY to be set")
|
raise ValueError("Gemini requires GEMINI_API_KEY to be set in the server environment")
|
||||||
model = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash")
|
model = model or os.environ.get("GEMINI_MODEL", "gemini-2.0-flash")
|
||||||
from physcom.llm.providers.gemini import GeminiLLMProvider
|
from physcom.llm.providers.gemini import GeminiLLMProvider
|
||||||
return GeminiLLMProvider(api_key=api_key, model=model)
|
return GeminiLLMProvider(api_key=api_key, model=model)
|
||||||
|
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
model = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
|
model = model or os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
|
||||||
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
host = host or os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
||||||
from physcom.llm.providers.ollama import OllamaLLMProvider
|
from physcom.llm.providers.ollama import OllamaLLMProvider
|
||||||
return OllamaLLMProvider(model=model, host=host)
|
return OllamaLLMProvider(model=model, host=host)
|
||||||
|
|
||||||
raise ValueError(f"Unknown LLM_PROVIDER: {provider!r}. Supported: gemini, ollama")
|
raise ValueError(f"Unknown LLM provider: {provider!r}. Supported: gemini, ollama, stub")
|
||||||
|
|||||||
@@ -26,6 +26,32 @@ class DomainConstraint:
|
|||||||
allowed_values: list[str] = field(default_factory=list) # e.g. ["ground", "air"]
|
allowed_values: list[str] = field(default_factory=list) # e.g. ["ground", "air"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FreeVariable:
|
||||||
|
"""A domain-declared quantity pass 2's estimator searches to maximize
|
||||||
|
the composite score (see Pipeline._estimate_via_formulas), e.g.
|
||||||
|
"actuator_mass". floor_formula/ceiling_formula are evaluated per-combo
|
||||||
|
and may reference dep(...) and any free variable declared at a lower
|
||||||
|
sort_order (mirrors the outer/inner nesting the built-in transport
|
||||||
|
physics model already does by hand)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
floor_formula: str
|
||||||
|
ceiling_formula: str
|
||||||
|
sort_order: int = 0
|
||||||
|
id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MetricFormula:
|
||||||
|
"""A domain-declared formula computing one metric's raw value, evaluated
|
||||||
|
against dep(...) lookups and the domain's resolved free variables."""
|
||||||
|
|
||||||
|
metric_name: str
|
||||||
|
formula: str
|
||||||
|
id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Domain:
|
class Domain:
|
||||||
"""A context frame that defines what 'good' means (e.g., urban_commuting)."""
|
"""A context frame that defines what 'good' means (e.g., urban_commuting)."""
|
||||||
@@ -34,4 +60,6 @@ class Domain:
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
metric_bounds: list[MetricBound] = field(default_factory=list)
|
metric_bounds: list[MetricBound] = field(default_factory=list)
|
||||||
constraints: list[DomainConstraint] = field(default_factory=list)
|
constraints: list[DomainConstraint] = field(default_factory=list)
|
||||||
|
free_variables: list[FreeVariable] = field(default_factory=list)
|
||||||
|
metric_formulas: list[MetricFormula] = field(default_factory=list)
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ GROUND_PLATFORMS: list[Entity] = [
|
|||||||
description="Generic wheeled road vehicle — from motorcycles to trucks",
|
description="Generic wheeled road vehicle — from motorcycles to trucks",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "50", "m²", "range_max"),
|
Dependency("physical", "footprint", "50", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "0.5", "m²", "range_min"),
|
Dependency("physical", "footprint", "0.5", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "36000", "kg", "range_max"),
|
Dependency("physical", "mass", "36000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "50", "kg", "range_min"),
|
Dependency("physical", "mass", "50", "kg", "range_min"),
|
||||||
Dependency("infrastructure", "road_network", "true", None, "requires"),
|
Dependency("infrastructure", "road_network", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
|
Dependency("physical", "target_velocity", "25", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -30,13 +33,16 @@ GROUND_PLATFORMS: list[Entity] = [
|
|||||||
description="Small human-scale vehicle — bicycles, skateboards, wheelchairs",
|
description="Small human-scale vehicle — bicycles, skateboards, wheelchairs",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "3", "m²", "range_max"),
|
Dependency("physical", "footprint", "3", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "0.3", "m²", "range_min"),
|
Dependency("physical", "footprint", "0.3", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "60", "kg", "range_max"),
|
Dependency("physical", "mass", "60", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "5", "kg", "range_min"),
|
Dependency("physical", "mass", "5", "kg", "range_min"),
|
||||||
Dependency("infrastructure", "road_network", "true", None, "requires"),
|
Dependency("infrastructure", "road_network", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
|
Dependency("physical", "target_velocity", "6", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -45,13 +51,16 @@ GROUND_PLATFORMS: list[Entity] = [
|
|||||||
description="Rail-guided vehicle — from trams to high-speed trains",
|
description="Rail-guided vehicle — from trams to high-speed trains",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "200", "m²", "range_max"),
|
Dependency("physical", "footprint", "200", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "40000", "kg", "range_max"),
|
Dependency("physical", "mass", "40000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "10000", "kg", "range_min"),
|
Dependency("physical", "mass", "10000", "kg", "range_min"),
|
||||||
Dependency("infrastructure", "rail_network", "true", None, "requires"),
|
Dependency("infrastructure", "rail_network", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
|
Dependency("physical", "target_velocity", "30", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -67,11 +76,13 @@ WATER_PLATFORMS: list[Entity] = [
|
|||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "water_surface", "true", None, "requires"),
|
Dependency("environment", "water_surface", "true", None, "requires"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "2000", "m²", "range_max"),
|
Dependency("physical", "footprint", "2000", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "2", "m²", "range_min"),
|
Dependency("physical", "footprint", "2", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "100000", "kg", "range_max"),
|
Dependency("physical", "mass", "100000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "30", "kg", "range_min"),
|
Dependency("physical", "mass", "30", "kg", "range_min"),
|
||||||
Dependency("environment", "medium", "water", None, "requires"),
|
Dependency("environment", "medium", "water", None, "requires"),
|
||||||
|
Dependency("physical", "target_velocity", "8", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -81,11 +92,14 @@ WATER_PLATFORMS: list[Entity] = [
|
|||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "water_surface", "true", None, "requires"),
|
Dependency("environment", "water_surface", "true", None, "requires"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "200", "m²", "range_max"),
|
Dependency("physical", "footprint", "200", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
||||||
|
Dependency("physical", "mass", "20000000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "10000", "kg", "range_min"),
|
Dependency("physical", "mass", "10000", "kg", "range_min"),
|
||||||
Dependency("environment", "medium", "water", None, "requires"),
|
Dependency("environment", "medium", "water", None, "requires"),
|
||||||
Dependency("physical", "energy_density", "720000", "J/kg", "range_min"),
|
Dependency("physical", "energy_density", "720000", "J/kg", "range_min"),
|
||||||
|
Dependency("physical", "target_velocity", "8", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -101,6 +115,7 @@ AIR_PLATFORMS: list[Entity] = [
|
|||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "500", "m²", "range_max"),
|
Dependency("physical", "footprint", "500", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "10", "m²", "range_min"),
|
Dependency("physical", "footprint", "10", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "100000", "kg", "range_max"),
|
Dependency("physical", "mass", "100000", "kg", "range_max"),
|
||||||
@@ -108,6 +123,8 @@ AIR_PLATFORMS: list[Entity] = [
|
|||||||
Dependency("infrastructure", "runway", "true", None, "requires"),
|
Dependency("infrastructure", "runway", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "air", None, "requires"),
|
Dependency("environment", "medium", "air", None, "requires"),
|
||||||
Dependency("physical", "energy_density", "1440000", "J/kg", "range_min"),
|
Dependency("physical", "energy_density", "1440000", "J/kg", "range_min"),
|
||||||
|
Dependency("physical", "min_effective_accel", "2.0", "m/s²", "range_min"),
|
||||||
|
Dependency("physical", "target_velocity", "60", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -117,12 +134,15 @@ AIR_PLATFORMS: list[Entity] = [
|
|||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "20", "m²", "range_max"),
|
Dependency("physical", "footprint", "20", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "0.5", "m²", "range_min"),
|
Dependency("physical", "footprint", "0.5", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "5000", "kg", "range_max"),
|
Dependency("physical", "mass", "5000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "1", "kg", "range_min"),
|
Dependency("physical", "mass", "1", "kg", "range_min"),
|
||||||
Dependency("environment", "medium", "air", None, "requires"),
|
Dependency("environment", "medium", "air", None, "requires"),
|
||||||
Dependency("physical", "energy_density", "720000", "J/kg", "range_min"),
|
Dependency("physical", "energy_density", "720000", "J/kg", "range_min"),
|
||||||
|
Dependency("physical", "min_effective_accel", "10", "m/s²", "range_min"),
|
||||||
|
Dependency("physical", "target_velocity", "30", "m/s", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -132,6 +152,7 @@ AIR_PLATFORMS: list[Entity] = [
|
|||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "1000", "m²", "range_max"),
|
Dependency("physical", "footprint", "1000", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "50", "m²", "range_min"),
|
Dependency("physical", "footprint", "50", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "20000", "kg", "range_max"),
|
Dependency("physical", "mass", "20000", "kg", "range_max"),
|
||||||
@@ -139,21 +160,6 @@ AIR_PLATFORMS: list[Entity] = [
|
|||||||
Dependency("environment", "medium", "air", None, "requires"),
|
Dependency("environment", "medium", "air", None, "requires"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
|
||||||
name="Glider",
|
|
||||||
dimension="platform",
|
|
||||||
description="Unpowered fixed-wing aircraft — sailplanes, hang gliders, paragliders",
|
|
||||||
dependencies=[
|
|
||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
|
||||||
Dependency("physical", "footprint", "20", "m²", "range_max"),
|
|
||||||
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
|
||||||
Dependency("physical", "mass", "600", "kg", "range_max"),
|
|
||||||
Dependency("physical", "mass", "5", "kg", "range_min"),
|
|
||||||
Dependency("infrastructure", "tow_or_winch", "true", None, "requires"),
|
|
||||||
Dependency("environment", "medium", "air", None, "requires"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +178,7 @@ SPACE_PLATFORMS: list[Entity] = [
|
|||||||
Dependency("infrastructure", "launch_facility", "true", None, "requires"),
|
Dependency("infrastructure", "launch_facility", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "space", None, "requires"),
|
Dependency("environment", "medium", "space", None, "requires"),
|
||||||
Dependency("physical", "energy_density", "7200000", "J/kg", "range_min"),
|
Dependency("physical", "energy_density", "7200000", "J/kg", "range_min"),
|
||||||
|
Dependency("physical", "min_effective_accel", "0", "m/s²", "range_min"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -185,11 +192,27 @@ MULTI_PLATFORMS: list[Entity] = [
|
|||||||
dimension="platform",
|
dimension="platform",
|
||||||
description="Vehicle capable of operation on land, water, or both",
|
description="Vehicle capable of operation on land, water, or both",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "100", "m²", "range_max"),
|
Dependency("physical", "footprint", "100", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "10000", "kg", "range_max"),
|
Dependency("physical", "mass", "10000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "1500", "kg", "range_min"),
|
Dependency("physical", "mass", "1500", "kg", "range_min"),
|
||||||
|
# No requires here previously -- vacuously satisfied every
|
||||||
|
# domain's medium DomainConstraint (check_domain_constraints
|
||||||
|
# only flags a violation when an entity DECLARES a requires
|
||||||
|
# for the constrained key), including space-only
|
||||||
|
# interplanetary_travel. The current requires/domain-constraint
|
||||||
|
# model only supports one value per key -- there's no OR
|
||||||
|
# mechanism for "ground or water" -- so this picks ground
|
||||||
|
# (its primary, most-common domain) rather than leaving it
|
||||||
|
# undeclared. Real tradeoff: it can no longer participate in
|
||||||
|
# maritime_shipping (water-only) either, losing the water half
|
||||||
|
# of "amphibious." Closes the vacuous-pass hole; genuine
|
||||||
|
# multi-medium support would need OR semantics added to
|
||||||
|
# check_domain_constraints, a separate, bigger change.
|
||||||
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -198,30 +221,22 @@ MULTI_PLATFORMS: list[Entity] = [
|
|||||||
# ── Platforms — Fictional / Speculative ─────────────────────────
|
# ── Platforms — Fictional / Speculative ─────────────────────────
|
||||||
|
|
||||||
FICTIONAL_PLATFORMS: list[Entity] = [
|
FICTIONAL_PLATFORMS: list[Entity] = [
|
||||||
Entity(
|
|
||||||
name="Teleporter",
|
|
||||||
dimension="platform",
|
|
||||||
description="Hypothetical matter transmission device",
|
|
||||||
dependencies=[
|
|
||||||
Dependency("physical", "footprint", "10", "m²", "range_max"),
|
|
||||||
Dependency("physical", "footprint", "1", "m²", "range_min"),
|
|
||||||
Dependency("physical", "mass", "0", "kg", "range_min"),
|
|
||||||
Dependency("infrastructure", "teleport_network", "true", None, "requires"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Entity(
|
Entity(
|
||||||
name="Hyperloop",
|
name="Hyperloop",
|
||||||
dimension="platform",
|
dimension="platform",
|
||||||
description="Sealed low-pressure tube with passenger pods at near-sonic speed",
|
description="Sealed low-pressure tube with passenger pods at near-sonic speed",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "footprint", "50", "m²", "range_max"),
|
Dependency("physical", "footprint", "50", "m²", "range_max"),
|
||||||
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
||||||
Dependency("physical", "mass", "20000", "kg", "range_max"),
|
Dependency("physical", "mass", "20000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "5000", "kg", "range_min"),
|
Dependency("physical", "mass", "5000", "kg", "range_min"),
|
||||||
Dependency("infrastructure", "hyperloop_tube", "true", None, "requires"),
|
Dependency("infrastructure", "hyperloop_tube", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
|
Dependency("physical", "target_velocity", "270", "m/s", "provides"), # near-sonic, per its own description
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -264,6 +279,7 @@ COMBUSTION_ACTUATORS: list[Entity] = [
|
|||||||
Dependency("physical", "mass", "200", "kg", "range_min"),
|
Dependency("physical", "mass", "200", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
||||||
Dependency("force", "power_density", "5000", "W/kg", "provides"),
|
Dependency("force", "power_density", "5000", "W/kg", "provides"),
|
||||||
|
Dependency("force", "specific_thrust", "50", "N/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -309,7 +325,7 @@ BIOLOGICAL_ACTUATORS: list[Entity] = [
|
|||||||
Dependency("energy", "energy_form", "biological", None, "requires"),
|
Dependency("energy", "energy_form", "biological", None, "requires"),
|
||||||
Dependency("physical", "mass", "0", "kg", "range_min"),
|
Dependency("physical", "mass", "0", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
||||||
Dependency("force", "power_density", "1.5", "W/kg", "provides"),
|
Dependency("force", "power_density", "5.5", "W/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -368,9 +384,11 @@ ROCKET_ACTUATORS: list[Entity] = [
|
|||||||
description="Thrust from expanding combustion gases through a nozzle",
|
description="Thrust from expanding combustion gases through a nozzle",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("energy", "energy_form", "chemical_propellant", None, "requires"),
|
Dependency("energy", "energy_form", "chemical_propellant", None, "requires"),
|
||||||
|
Dependency("environment", "medium", "water", None, "excludes"),
|
||||||
Dependency("physical", "mass", "150", "kg", "range_min"),
|
Dependency("physical", "mass", "150", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "extreme_burst", None, "provides"),
|
Dependency("force", "thrust_profile", "extreme_burst", None, "provides"),
|
||||||
Dependency("force", "power_density", "10000", "W/kg", "provides"),
|
Dependency("force", "power_density", "10000", "W/kg", "provides"),
|
||||||
|
Dependency("force", "specific_thrust", "1500", "N/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -384,6 +402,7 @@ ROCKET_ACTUATORS: list[Entity] = [
|
|||||||
Dependency("physical", "mass", "8", "kg", "range_min"),
|
Dependency("physical", "mass", "8", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "continuous_low", None, "provides"),
|
Dependency("force", "thrust_profile", "continuous_low", None, "provides"),
|
||||||
Dependency("force", "power_density", "30", "W/kg", "provides"),
|
Dependency("force", "power_density", "30", "W/kg", "provides"),
|
||||||
|
Dependency("force", "specific_thrust", "0.01", "N/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -396,6 +415,7 @@ ROCKET_ACTUATORS: list[Entity] = [
|
|||||||
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
||||||
Dependency("force", "power_density", "50", "W/kg", "provides"),
|
Dependency("force", "power_density", "50", "W/kg", "provides"),
|
||||||
|
Dependency("force", "specific_thrust", "200", "N/kg", "provides"),
|
||||||
Dependency("material", "radiation_shielding", "true", None, "requires"),
|
Dependency("material", "radiation_shielding", "true", None, "requires"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -411,6 +431,7 @@ EXOTIC_ACTUATORS: list[Entity] = [
|
|||||||
description="Propulsion via sequential cannon blasts",
|
description="Propulsion via sequential cannon blasts",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("energy", "energy_form", "chemical_explosive", None, "requires"),
|
Dependency("energy", "energy_form", "chemical_explosive", None, "requires"),
|
||||||
|
Dependency("environment", "medium", "water", None, "excludes"),
|
||||||
Dependency("physical", "mass", "80", "kg", "range_min"),
|
Dependency("physical", "mass", "80", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "high_burst", None, "provides"),
|
Dependency("force", "thrust_profile", "high_burst", None, "provides"),
|
||||||
Dependency("force", "power_density", "3000", "W/kg", "provides"),
|
Dependency("force", "power_density", "3000", "W/kg", "provides"),
|
||||||
@@ -722,11 +743,29 @@ URBAN_COMMUTING = Domain(
|
|||||||
name="urban_commuting",
|
name="urban_commuting",
|
||||||
description="Daily travel within a city, 1-50km range",
|
description="Daily travel within a city, 1-50km range",
|
||||||
metric_bounds=[
|
metric_bounds=[
|
||||||
|
# safety and availability removed from the scored/weighted metric set:
|
||||||
|
# both are judgment calls (risk assessment, infrastructure prevalence),
|
||||||
|
# not physics quantities with a formula, and running them through the
|
||||||
|
# same log-normalize() built for physical quantities produced
|
||||||
|
# incoherent results (a safety raw value already declared as "0-1"
|
||||||
|
# getting re-normalized into a different, unexplainable 0-1 number --
|
||||||
|
# see combo 1540's review, where phi4 could only cite the post-
|
||||||
|
# normalization number with no way to justify it). Safety is now a
|
||||||
|
# qualitative consideration folded into pass 4's holistic RATING
|
||||||
|
# instead. Availability needs real per-infrastructure-type research
|
||||||
|
# this project hasn't done -- not scored anywhere for now rather than
|
||||||
|
# pretend a quick formula or an equally uninformed LLM guess settles it.
|
||||||
|
# Weights renormalized to sum to 1.0 across the remaining metrics.
|
||||||
|
# speed and cargo_capacity_kg added -- a commute's actual travel
|
||||||
|
# time and whether the vehicle can carry groceries/passengers/gear
|
||||||
|
# both matter as much as raw power_density did on their own; speed
|
||||||
|
# is a genuine build OUTPUT (see _raw_physics_from_masses), not a
|
||||||
|
# platform-declared constant.
|
||||||
MetricBound("power_density", weight=0.25, norm_min=1, norm_max=2000, unit="W/kg"),
|
MetricBound("power_density", weight=0.25, norm_min=1, norm_max=2000, unit="W/kg"),
|
||||||
MetricBound("cost_efficiency", weight=0.25, norm_min=1e-5, norm_max=2e-3, unit="$/m", lower_is_better=True),
|
MetricBound("cost_efficiency", weight=0.25, norm_min=1e-5, norm_max=2e-3, unit="$/m", lower_is_better=True),
|
||||||
MetricBound("safety", weight=0.25, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
MetricBound("speed", weight=0.25, norm_min=2, norm_max=30, unit="m/s"),
|
||||||
MetricBound("availability", weight=0.15, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
|
||||||
MetricBound("range_fuel", weight=0.10, norm_min=5000, norm_max=500000, unit="m"),
|
MetricBound("range_fuel", weight=0.10, norm_min=5000, norm_max=500000, unit="m"),
|
||||||
|
MetricBound("cargo_capacity_kg", weight=0.15, norm_min=1, norm_max=500, unit="kg"),
|
||||||
],
|
],
|
||||||
constraints=[DomainConstraint("medium", ["ground", "air"])],
|
constraints=[DomainConstraint("medium", ["ground", "air"])],
|
||||||
)
|
)
|
||||||
@@ -735,11 +774,12 @@ INTERPLANETARY = Domain(
|
|||||||
name="interplanetary_travel",
|
name="interplanetary_travel",
|
||||||
description="Travel between planets within a solar system",
|
description="Travel between planets within a solar system",
|
||||||
metric_bounds=[
|
metric_bounds=[
|
||||||
MetricBound("power_density", weight=0.30, norm_min=10, norm_max=10000, unit="W/kg"),
|
# safety removed -- see URBAN_COMMUTING comment above. Weights
|
||||||
MetricBound("range_fuel", weight=0.30, norm_min=1e9, norm_max=1e13, unit="m"),
|
# renormalized across the remaining metrics.
|
||||||
MetricBound("safety", weight=0.20, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
MetricBound("power_density", weight=0.375, norm_min=10, norm_max=10000, unit="W/kg"),
|
||||||
MetricBound("cost_efficiency", weight=0.10, norm_min=1.0, norm_max=1e6, unit="$/m", lower_is_better=True),
|
MetricBound("range_fuel", weight=0.375, norm_min=1e9, norm_max=1e13, unit="m"),
|
||||||
MetricBound("range_degradation", weight=0.10, norm_min=8640000, norm_max=3.1536e9, unit="s"),
|
MetricBound("cost_efficiency", weight=0.125, norm_min=1.0, norm_max=1e6, unit="$/m", lower_is_better=True),
|
||||||
|
MetricBound("range_degradation", weight=0.125, norm_min=8640000, norm_max=3.1536e9, unit="s"),
|
||||||
],
|
],
|
||||||
constraints=[DomainConstraint("medium", ["space"])],
|
constraints=[DomainConstraint("medium", ["space"])],
|
||||||
)
|
)
|
||||||
@@ -748,11 +788,12 @@ MARITIME_SHIPPING = Domain(
|
|||||||
name="maritime_shipping",
|
name="maritime_shipping",
|
||||||
description="Ocean cargo transport between ports, 100-40000km range",
|
description="Ocean cargo transport between ports, 100-40000km range",
|
||||||
metric_bounds=[
|
metric_bounds=[
|
||||||
MetricBound("power_density", weight=0.15, norm_min=1, norm_max=1000, unit="W/kg"),
|
# safety removed -- see URBAN_COMMUTING comment above. Weights
|
||||||
MetricBound("cargo_capacity", weight=0.25, norm_min=1000, norm_max=2e8, unit="kg"),
|
# renormalized across the remaining metrics.
|
||||||
MetricBound("cost_efficiency", weight=0.25, norm_min=1e-9, norm_max=1e-6, unit="$/(kg\u00b7m)", lower_is_better=True),
|
MetricBound("power_density", weight=0.1875, norm_min=1, norm_max=1000, unit="W/kg"),
|
||||||
MetricBound("safety", weight=0.20, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
MetricBound("cargo_capacity", weight=0.3125, norm_min=1000, norm_max=2e8, unit="kg"),
|
||||||
MetricBound("range_fuel", weight=0.15, norm_min=100000, norm_max=40000000, unit="m"),
|
MetricBound("cost_efficiency", weight=0.3125, norm_min=1e-9, norm_max=1e-6, unit="$/(kg\u00b7m)", lower_is_better=True),
|
||||||
|
MetricBound("range_fuel", weight=0.1875, norm_min=100000, norm_max=40000000, unit="m"),
|
||||||
],
|
],
|
||||||
constraints=[DomainConstraint("medium", ["water"])],
|
constraints=[DomainConstraint("medium", ["water"])],
|
||||||
)
|
)
|
||||||
@@ -761,11 +802,12 @@ LAST_MILE_DELIVERY = Domain(
|
|||||||
name="last_mile_delivery",
|
name="last_mile_delivery",
|
||||||
description="Short-range package delivery within neighborhoods, 0.5-15km",
|
description="Short-range package delivery within neighborhoods, 0.5-15km",
|
||||||
metric_bounds=[
|
metric_bounds=[
|
||||||
MetricBound("power_density", weight=0.25, norm_min=1, norm_max=500, unit="W/kg"),
|
# safety removed -- see URBAN_COMMUTING comment above. Weights
|
||||||
MetricBound("cost_efficiency", weight=0.30, norm_min=1e-5, norm_max=5e-3, unit="$/m", lower_is_better=True),
|
# renormalized across the remaining metrics.
|
||||||
MetricBound("cargo_capacity_kg", weight=0.20, norm_min=1, norm_max=500, unit="kg"),
|
MetricBound("power_density", weight=0.2941, norm_min=1, norm_max=500, unit="W/kg"),
|
||||||
MetricBound("safety", weight=0.15, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
MetricBound("cost_efficiency", weight=0.3529, norm_min=1e-5, norm_max=5e-3, unit="$/m", lower_is_better=True),
|
||||||
MetricBound("environmental_impact", weight=0.10, norm_min=0, norm_max=5e-4, unit="kg/m", lower_is_better=True),
|
MetricBound("cargo_capacity_kg", weight=0.2353, norm_min=1, norm_max=500, unit="kg"),
|
||||||
|
MetricBound("environmental_impact", weight=0.1177, norm_min=0, norm_max=5e-4, unit="kg/m", lower_is_better=True),
|
||||||
],
|
],
|
||||||
constraints=[DomainConstraint("medium", ["ground", "air"])],
|
constraints=[DomainConstraint("medium", ["ground", "air"])],
|
||||||
)
|
)
|
||||||
@@ -833,11 +875,11 @@ def load_transport_seed(repo) -> dict:
|
|||||||
counts["domains"] += 1
|
counts["domains"] += 1
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
pass
|
pass
|
||||||
# Backfill metric units and lower_is_better on existing DBs.
|
# Sync domain_metric_weights to exactly match this domain's current
|
||||||
for mb in domain.metric_bounds:
|
# metric_bounds on existing DBs -- upserts weight/norm_min/norm_max/
|
||||||
repo.ensure_metric(mb.metric_name, unit=mb.unit)
|
# unit for current metrics and removes any that were dropped (e.g.
|
||||||
if mb.lower_is_better:
|
# safety/availability no longer scored).
|
||||||
repo.backfill_lower_is_better(domain.name, mb.metric_name)
|
repo.sync_domain_metric_weights(domain)
|
||||||
# Backfill domain constraints
|
# Backfill domain constraints
|
||||||
repo.replace_domain_constraints(domain)
|
repo.replace_domain_constraints(domain)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from physcom.db.repository import Repository
|
from physcom.db.repository import Repository
|
||||||
from physcom.models.entity import Entity, Dependency
|
from physcom.models.entity import Entity, Dependency
|
||||||
from physcom.models.domain import Domain, DomainConstraint, MetricBound
|
from physcom.models.domain import Domain, DomainConstraint, FreeVariable, MetricBound, MetricFormula
|
||||||
from physcom.models.combination import Combination
|
from physcom.models.combination import Combination
|
||||||
|
|
||||||
|
|
||||||
@@ -70,11 +70,27 @@ def export_snapshot(repo: Repository) -> dict:
|
|||||||
"key": dc.key,
|
"key": dc.key,
|
||||||
"allowed_values": dc.allowed_values,
|
"allowed_values": dc.allowed_values,
|
||||||
})
|
})
|
||||||
|
fvs = []
|
||||||
|
for fv in d.free_variables:
|
||||||
|
fvs.append({
|
||||||
|
"name": fv.name,
|
||||||
|
"sort_order": fv.sort_order,
|
||||||
|
"floor_formula": fv.floor_formula,
|
||||||
|
"ceiling_formula": fv.ceiling_formula,
|
||||||
|
})
|
||||||
|
mfs = []
|
||||||
|
for mf in d.metric_formulas:
|
||||||
|
mfs.append({
|
||||||
|
"metric_name": mf.metric_name,
|
||||||
|
"formula": mf.formula,
|
||||||
|
})
|
||||||
domain_list.append({
|
domain_list.append({
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
"description": d.description,
|
"description": d.description,
|
||||||
"metric_bounds": mbs,
|
"metric_bounds": mbs,
|
||||||
"constraints": dcs,
|
"constraints": dcs,
|
||||||
|
"free_variables": fvs,
|
||||||
|
"metric_formulas": mfs,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Export combinations
|
# Export combinations
|
||||||
@@ -207,11 +223,26 @@ def import_snapshot(repo: Repository, data: dict, *, clear: bool = False) -> dic
|
|||||||
)
|
)
|
||||||
for dc in d_data.get("constraints", [])
|
for dc in d_data.get("constraints", [])
|
||||||
]
|
]
|
||||||
|
fvs = [
|
||||||
|
FreeVariable(
|
||||||
|
name=fv["name"],
|
||||||
|
floor_formula=fv["floor_formula"],
|
||||||
|
ceiling_formula=fv["ceiling_formula"],
|
||||||
|
sort_order=fv.get("sort_order", 0),
|
||||||
|
)
|
||||||
|
for fv in d_data.get("free_variables", [])
|
||||||
|
]
|
||||||
|
mfs = [
|
||||||
|
MetricFormula(metric_name=mf["metric_name"], formula=mf["formula"])
|
||||||
|
for mf in d_data.get("metric_formulas", [])
|
||||||
|
]
|
||||||
domain = Domain(
|
domain = Domain(
|
||||||
name=d_data["name"],
|
name=d_data["name"],
|
||||||
description=d_data.get("description", ""),
|
description=d_data.get("description", ""),
|
||||||
metric_bounds=mbs,
|
metric_bounds=mbs,
|
||||||
constraints=dcs,
|
constraints=dcs,
|
||||||
|
free_variables=fvs,
|
||||||
|
metric_formulas=mfs,
|
||||||
)
|
)
|
||||||
repo.add_domain(domain)
|
repo.add_domain(domain)
|
||||||
counts["domains"] += 1
|
counts["domains"] += 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from physcom.models.domain import Domain, MetricBound
|
from physcom.models.domain import Domain, FreeVariable, MetricBound, MetricFormula
|
||||||
from physcom_web.app import get_repo
|
from physcom_web.app import get_repo
|
||||||
|
|
||||||
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
||||||
@@ -117,3 +117,96 @@ def metric_delete(domain_id: int, metric_id: int):
|
|||||||
flash("Metric removed.", "success")
|
flash("Metric removed.", "success")
|
||||||
domain = repo.get_domain_by_id(domain_id)
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
return render_template("domains/_metrics_table.html", domain=domain)
|
return render_template("domains/_metrics_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Free variable CRUD (HTMX partials) ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/free-vars/add", methods=["POST"])
|
||||||
|
def free_var_add(domain_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
name = request.form["name"].strip()
|
||||||
|
floor_formula = request.form.get("floor_formula", "").strip()
|
||||||
|
ceiling_formula = request.form.get("ceiling_formula", "").strip()
|
||||||
|
try:
|
||||||
|
sort_order = int(request.form.get("sort_order", "0"))
|
||||||
|
except ValueError:
|
||||||
|
sort_order = 0
|
||||||
|
if not name or not floor_formula or not ceiling_formula:
|
||||||
|
flash("Name, floor formula, and ceiling formula are required.", "error")
|
||||||
|
else:
|
||||||
|
fv = FreeVariable(
|
||||||
|
name=name, floor_formula=floor_formula, ceiling_formula=ceiling_formula,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
repo.add_free_variable(domain_id, fv)
|
||||||
|
flash(f"Free variable '{name}' added.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_free_vars_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/free-vars/<int:fv_id>/edit", methods=["POST"])
|
||||||
|
def free_var_edit(domain_id: int, fv_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
try:
|
||||||
|
sort_order = int(request.form.get("sort_order", "0"))
|
||||||
|
except ValueError:
|
||||||
|
sort_order = 0
|
||||||
|
fv = FreeVariable(
|
||||||
|
name=request.form["name"].strip(),
|
||||||
|
floor_formula=request.form.get("floor_formula", "").strip(),
|
||||||
|
ceiling_formula=request.form.get("ceiling_formula", "").strip(),
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
repo.update_free_variable(fv_id, fv)
|
||||||
|
flash("Free variable updated.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_free_vars_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/free-vars/<int:fv_id>/delete", methods=["POST"])
|
||||||
|
def free_var_delete(domain_id: int, fv_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
repo.delete_free_variable(fv_id)
|
||||||
|
flash("Free variable removed.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_free_vars_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Metric formula CRUD (HTMX partials) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/formulas/add", methods=["POST"])
|
||||||
|
def formula_add(domain_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
metric_name = request.form["metric_name"].strip()
|
||||||
|
formula = request.form.get("formula", "").strip()
|
||||||
|
if not metric_name or not formula:
|
||||||
|
flash("Metric name and formula are required.", "error")
|
||||||
|
else:
|
||||||
|
repo.add_metric_formula(domain_id, MetricFormula(metric_name=metric_name, formula=formula))
|
||||||
|
flash(f"Formula for '{metric_name}' added.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_formulas_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/formulas/<int:formula_id>/edit", methods=["POST"])
|
||||||
|
def formula_edit(domain_id: int, formula_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
mf = MetricFormula(
|
||||||
|
metric_name=request.form["metric_name"].strip(),
|
||||||
|
formula=request.form.get("formula", "").strip(),
|
||||||
|
)
|
||||||
|
repo.update_metric_formula(formula_id, mf)
|
||||||
|
flash("Formula updated.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_formulas_table.html", domain=domain)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:domain_id>/formulas/<int:formula_id>/delete", methods=["POST"])
|
||||||
|
def formula_delete(domain_id: int, formula_id: int):
|
||||||
|
repo = get_repo()
|
||||||
|
repo.delete_metric_formula(formula_id)
|
||||||
|
flash("Formula removed.", "success")
|
||||||
|
domain = repo.get_domain_by_id(domain_id)
|
||||||
|
return render_template("domains/_formulas_table.html", domain=domain)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ def _run_pipeline_in_background(
|
|||||||
passes: list[int],
|
passes: list[int],
|
||||||
threshold: float,
|
threshold: float,
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
llm_provider: str | None = None,
|
||||||
|
llm_model: str | None = None,
|
||||||
|
llm_host: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the pipeline in a background thread with its own DB connection."""
|
"""Run the pipeline in a background thread with its own DB connection."""
|
||||||
from physcom.db.schema import init_db
|
from physcom.db.schema import init_db
|
||||||
@@ -29,6 +32,8 @@ def _run_pipeline_in_background(
|
|||||||
from physcom.engine.scorer import Scorer
|
from physcom.engine.scorer import Scorer
|
||||||
from physcom.engine.pipeline import Pipeline
|
from physcom.engine.pipeline import Pipeline
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
repo = None
|
||||||
try:
|
try:
|
||||||
conn = init_db(db_path)
|
conn = init_db(db_path)
|
||||||
repo = Repository(conn)
|
repo = Repository(conn)
|
||||||
@@ -45,7 +50,8 @@ def _run_pipeline_in_background(
|
|||||||
from physcom.llm.registry import build_llm_provider
|
from physcom.llm.registry import build_llm_provider
|
||||||
resolver = ConstraintResolver()
|
resolver = ConstraintResolver()
|
||||||
scorer = Scorer(domain)
|
scorer = Scorer(domain)
|
||||||
pipeline = Pipeline(repo, resolver, scorer, llm=build_llm_provider())
|
llm = build_llm_provider(provider=llm_provider, model=llm_model, host=llm_host)
|
||||||
|
pipeline = Pipeline(repo, resolver, scorer, llm=llm)
|
||||||
|
|
||||||
pipeline.run(
|
pipeline.run(
|
||||||
domain, dim_list,
|
domain, dim_list,
|
||||||
@@ -54,6 +60,7 @@ def _run_pipeline_in_background(
|
|||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if repo is not None:
|
||||||
try:
|
try:
|
||||||
repo.update_pipeline_run(
|
repo.update_pipeline_run(
|
||||||
run_id, status="failed",
|
run_id, status="failed",
|
||||||
@@ -61,7 +68,15 @@ def _run_pipeline_in_background(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
# Couldn't even open the DB to record the failure (bad
|
||||||
|
# PHYSCOM_DB path, locked/corrupt file) -- the pipeline_runs
|
||||||
|
# row will stay "pending" forever with no way to write an
|
||||||
|
# error_message to it, so at least don't let that swallow the
|
||||||
|
# real cause silently. Server logs are the only trace left.
|
||||||
|
print(f"pipeline run {run_id} failed before DB was reachable: {exc!r}")
|
||||||
finally:
|
finally:
|
||||||
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -108,11 +123,17 @@ def pipeline_run():
|
|||||||
flash("Select at least one dimension.", "error")
|
flash("Select at least one dimension.", "error")
|
||||||
return redirect(url_for("pipeline.pipeline_form"))
|
return redirect(url_for("pipeline.pipeline_form"))
|
||||||
|
|
||||||
|
llm_provider = request.form.get("llm_provider", "").strip() or None
|
||||||
|
llm_model = request.form.get("llm_model", "").strip() or None
|
||||||
|
llm_host = request.form.get("llm_host", "").strip() or None
|
||||||
|
|
||||||
# Create pipeline_run record
|
# Create pipeline_run record
|
||||||
config = {
|
config = {
|
||||||
"passes": passes,
|
"passes": passes,
|
||||||
"threshold": threshold,
|
"threshold": threshold,
|
||||||
"dimensions": dim_list,
|
"dimensions": dim_list,
|
||||||
|
"llm_provider": llm_provider,
|
||||||
|
"llm_model": llm_model,
|
||||||
}
|
}
|
||||||
run_id = repo.create_pipeline_run(domain.id, config)
|
run_id = repo.create_pipeline_run(domain.id, config)
|
||||||
|
|
||||||
@@ -123,7 +144,8 @@ def pipeline_run():
|
|||||||
# Start background thread
|
# Start background thread
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=_run_pipeline_in_background,
|
target=_run_pipeline_in_background,
|
||||||
args=(db_path, domain_name, dim_list, passes, threshold, run_id),
|
args=(db_path, domain_name, dim_list, passes, threshold, run_id,
|
||||||
|
llm_provider, llm_model, llm_host),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
@@ -4,11 +4,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
from physcom.engine.constraint_resolver import ConstraintResolver
|
||||||
|
from physcom.engine.pipeline import Pipeline
|
||||||
|
from physcom.engine.scorer import Scorer
|
||||||
from physcom_web.app import get_repo
|
from physcom_web.app import get_repo
|
||||||
|
|
||||||
bp = Blueprint("results", __name__, url_prefix="/results")
|
bp = Blueprint("results", __name__, url_prefix="/results")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_evaluate(repo, domain, combo, platform_mass=None, actuator_mass=None, storage_mass=None):
|
||||||
|
"""Purely exploratory -- never writes to the DB. Returns None if this
|
||||||
|
combo has no free mass allocation to explore (see
|
||||||
|
Pipeline.evaluate_allocation's docstring)."""
|
||||||
|
pipeline = Pipeline(repo, ConstraintResolver(), Scorer(domain))
|
||||||
|
return pipeline.evaluate_allocation(
|
||||||
|
combo, domain,
|
||||||
|
platform_mass=platform_mass, actuator_mass=actuator_mass, storage_mass=storage_mass,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def results_index():
|
def results_index():
|
||||||
repo = get_repo()
|
repo = get_repo()
|
||||||
@@ -25,9 +39,11 @@ def results_domain(domain_name: str):
|
|||||||
return redirect(url_for("results.results_index"))
|
return redirect(url_for("results.results_index"))
|
||||||
|
|
||||||
status_filter = request.args.get("status")
|
status_filter = request.args.get("status")
|
||||||
results = repo.get_all_results(domain_name, status=status_filter)
|
rating_filter = request.args.get("rating")
|
||||||
|
results = repo.get_all_results(domain_name, status=status_filter, rating=rating_filter)
|
||||||
# Domain-scoped status counts (only combos that have results in this domain)
|
# Domain-scoped status counts (only combos that have results in this domain)
|
||||||
statuses = repo.count_combinations_by_status(domain_name=domain_name)
|
statuses = repo.count_combinations_by_status(domain_name=domain_name)
|
||||||
|
ratings = repo.count_results_by_rating(domain_name)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"results/list.html",
|
"results/list.html",
|
||||||
@@ -35,7 +51,9 @@ def results_domain(domain_name: str):
|
|||||||
domain=domain,
|
domain=domain,
|
||||||
results=results,
|
results=results,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
|
rating_filter=rating_filter,
|
||||||
statuses=statuses,
|
statuses=statuses,
|
||||||
|
ratings=ratings,
|
||||||
total_results=sum(statuses.values()),
|
total_results=sum(statuses.values()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +76,7 @@ def result_detail(domain_name: str, combo_id: int):
|
|||||||
flash("No results for this combination in this domain.", "error")
|
flash("No results for this combination in this domain.", "error")
|
||||||
return redirect(url_for("results.results_domain", domain_name=domain_name))
|
return redirect(url_for("results.results_domain", domain_name=domain_name))
|
||||||
scores = repo.get_combination_scores(combo_id, domain.id)
|
scores = repo.get_combination_scores(combo_id, domain.id)
|
||||||
|
explore_result = _run_evaluate(repo, domain, combo)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"results/detail.html",
|
"results/detail.html",
|
||||||
@@ -65,6 +84,40 @@ def result_detail(domain_name: str, combo_id: int):
|
|||||||
combo=combo,
|
combo=combo,
|
||||||
result=result,
|
result=result,
|
||||||
scores=scores,
|
scores=scores,
|
||||||
|
explore_result=explore_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<domain_name>/<int:combo_id>/explore", methods=["POST"])
|
||||||
|
def explore(domain_name: str, combo_id: int):
|
||||||
|
"""Live, purely exploratory re-evaluation for an explicit platform/
|
||||||
|
actuator/storage mass choice -- never touches stored data. Returns an
|
||||||
|
HTMX partial."""
|
||||||
|
repo = get_repo()
|
||||||
|
domain = repo.get_domain(domain_name)
|
||||||
|
combo = repo.get_combination(combo_id) if domain else None
|
||||||
|
if not domain or not combo:
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
def _mass(field: str) -> float | None:
|
||||||
|
raw = request.form.get(field)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
explore_result = _run_evaluate(
|
||||||
|
repo, domain, combo,
|
||||||
|
platform_mass=_mass("platform_mass"),
|
||||||
|
actuator_mass=_mass("actuator_mass"),
|
||||||
|
storage_mass=_mass("storage_mass"),
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"results/_explore_result.html",
|
||||||
|
domain=domain,
|
||||||
|
explore_result=explore_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +154,7 @@ def submit_review(domain_name: str, combo_id: int):
|
|||||||
novelty_flag=novelty_flag,
|
novelty_flag=novelty_flag,
|
||||||
llm_review=existing.get("llm_review") if existing else None,
|
llm_review=existing.get("llm_review") if existing else None,
|
||||||
human_notes=human_notes,
|
human_notes=human_notes,
|
||||||
|
qualitative_rating=existing.get("qualitative_rating") if existing else None,
|
||||||
)
|
)
|
||||||
repo.update_combination_status(combo_id, "reviewed")
|
repo.update_combination_status(combo_id, "reviewed")
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ table.compact th, table.compact td { padding: 0.25rem 0.4rem; font-size: 0.83rem
|
|||||||
.badge-llm_reviewed { background: rgba(107,163,160,0.12); color: var(--accent-teal); border-color: rgba(107,163,160,0.25); }
|
.badge-llm_reviewed { background: rgba(107,163,160,0.12); color: var(--accent-teal); border-color: rgba(107,163,160,0.25); }
|
||||||
.badge-reviewed { background: rgba(155,142,196,0.12); color: var(--accent-violet); border-color: rgba(155,142,196,0.25); }
|
.badge-reviewed { background: rgba(155,142,196,0.12); color: var(--accent-violet); border-color: rgba(155,142,196,0.25); }
|
||||||
.badge-pending { background: rgba(184,147,92,0.12); color: var(--accent-amber); border-color: rgba(184,147,92,0.25); }
|
.badge-pending { background: rgba(184,147,92,0.12); color: var(--accent-amber); border-color: rgba(184,147,92,0.25); }
|
||||||
|
.badge-rating-low { background: rgba(184,92,92,0.12); color: var(--accent-red); border-color: rgba(184,92,92,0.25); }
|
||||||
|
.badge-rating-medium { background: rgba(184,147,92,0.12); color: var(--accent-amber); border-color: rgba(184,147,92,0.25); }
|
||||||
|
.badge-rating-high { background: rgba(122,171,138,0.12); color: var(--accent-green); border-color: rgba(122,171,138,0.25); }
|
||||||
|
|
||||||
/* ── Buttons ─────────────────────────────────────────────── */
|
/* ── Buttons ─────────────────────────────────────────────── */
|
||||||
.btn {
|
.btn {
|
||||||
@@ -461,6 +464,52 @@ dd { font-size: 0.9rem; color: var(--text-primary); }
|
|||||||
margin-left: 0.3rem;
|
margin-left: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mass allocation bar (optimizer) ───────────────────────── */
|
||||||
|
.mass-bar-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.mass-bar-seg { height: 100%; }
|
||||||
|
.mass-bar-platform { background: var(--accent-blue); }
|
||||||
|
.mass-bar-actuator { background: var(--accent-gold); }
|
||||||
|
.mass-bar-storage { background: var(--accent-teal); }
|
||||||
|
.mass-bar-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mass-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.optimize-summary { margin-bottom: 0.25rem; }
|
||||||
|
.optimize-score { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
|
||||||
|
/* ── Importance sliders (optimizer) ────────────────────────── */
|
||||||
|
.weight-slider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr 48px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.weight-slider-row label { font-size: 0.85rem; color: var(--text-muted); }
|
||||||
|
.weight-slider-row output { font-size: 0.85rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.weight-slider-row input[type="range"] { width: 100%; }
|
||||||
|
|
||||||
/* ── Select dropdown dark styling ────────────────────────── */
|
/* ── Select dropdown dark styling ────────────────────────── */
|
||||||
select option {
|
select option {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
|
|||||||
56
src/physcom_web/templates/domains/_formulas_table.html
Normal file
56
src/physcom_web/templates/domains/_formulas_table.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<table id="formulas-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Formula</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mf in domain.metric_formulas %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mf.metric_name }}</td>
|
||||||
|
<td><code>{{ mf.formula }}</code></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
onclick="this.closest('tr').nextElementSibling.style.display='table-row'; this.closest('tr').style.display='none'">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.formula_delete', domain_id=domain.id, formula_id=mf.id) }}"
|
||||||
|
hx-target="#formulas-section" hx-swap="innerHTML"
|
||||||
|
class="inline-form">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Del</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="edit-row" style="display:none">
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.formula_edit', domain_id=domain.id, formula_id=mf.id) }}"
|
||||||
|
hx-target="#formulas-section" hx-swap="innerHTML">
|
||||||
|
<td><input name="metric_name" value="{{ mf.metric_name }}" required></td>
|
||||||
|
<td><input name="formula" value="{{ mf.formula }}" required></td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
onclick="this.closest('tr').style.display='none'; this.closest('tr').previousElementSibling.style.display=''">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Add Formula</h3>
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.formula_add', domain_id=domain.id) }}"
|
||||||
|
hx-target="#formulas-section" hx-swap="innerHTML"
|
||||||
|
class="dep-add-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<input name="metric_name" placeholder="metric name" required>
|
||||||
|
<input name="formula" placeholder='formula, e.g. draw_weight_chosen * 2' required>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
64
src/physcom_web/templates/domains/_free_vars_table.html
Normal file
64
src/physcom_web/templates/domains/_free_vars_table.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<table id="free-vars-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Floor formula</th>
|
||||||
|
<th>Ceiling formula</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for fv in domain.free_variables %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ fv.name }}</td>
|
||||||
|
<td>{{ fv.sort_order }}</td>
|
||||||
|
<td><code>{{ fv.floor_formula }}</code></td>
|
||||||
|
<td><code>{{ fv.ceiling_formula }}</code></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
onclick="this.closest('tr').nextElementSibling.style.display='table-row'; this.closest('tr').style.display='none'">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.free_var_delete', domain_id=domain.id, fv_id=fv.id) }}"
|
||||||
|
hx-target="#free-vars-section" hx-swap="innerHTML"
|
||||||
|
class="inline-form">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Del</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="edit-row" style="display:none">
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.free_var_edit', domain_id=domain.id, fv_id=fv.id) }}"
|
||||||
|
hx-target="#free-vars-section" hx-swap="innerHTML">
|
||||||
|
<td><input name="name" value="{{ fv.name }}" required></td>
|
||||||
|
<td><input name="sort_order" type="number" step="1" value="{{ fv.sort_order }}"></td>
|
||||||
|
<td><input name="floor_formula" value="{{ fv.floor_formula }}" required></td>
|
||||||
|
<td><input name="ceiling_formula" value="{{ fv.ceiling_formula }}" required></td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
onclick="this.closest('tr').style.display='none'; this.closest('tr').previousElementSibling.style.display=''">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Add Free Variable</h3>
|
||||||
|
<form method="post"
|
||||||
|
hx-post="{{ url_for('domains.free_var_add', domain_id=domain.id) }}"
|
||||||
|
hx-target="#free-vars-section" hx-swap="innerHTML"
|
||||||
|
class="dep-add-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<input name="name" placeholder="name, e.g. draw_weight_chosen" required>
|
||||||
|
<input name="sort_order" type="number" step="1" placeholder="order" value="0">
|
||||||
|
<input name="floor_formula" placeholder='floor formula, e.g. dep("draw_weight", "range_min")' required>
|
||||||
|
<input name="ceiling_formula" placeholder='ceiling formula, e.g. dep("draw_weight", "range_max")' required>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -33,4 +33,18 @@
|
|||||||
<div id="metrics-section">
|
<div id="metrics-section">
|
||||||
{% include "domains/_metrics_table.html" %}
|
{% include "domains/_metrics_table.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>Free Variables</h2>
|
||||||
|
<p class="hint">Quantities pass 2's estimator searches to maximize this domain's composite score. Leave empty for domains where nothing needs sizing.</p>
|
||||||
|
|
||||||
|
<div id="free-vars-section">
|
||||||
|
{% include "domains/_free_vars_table.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Metric Formulas</h2>
|
||||||
|
<p class="hint">How each metric's raw value is computed from declared entity properties (via <code>dep(key, constraint_type="provides")</code>) and any free variable above. If this domain declares any formulas, pass 2 uses them instead of the built-in vehicle physics model.</p>
|
||||||
|
|
||||||
|
<div id="formulas-section">
|
||||||
|
{% include "domains/_formulas_table.html" %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -50,12 +50,13 @@
|
|||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<h3>Physics Estimation</h3>
|
<h3>Physics Estimation</h3>
|
||||||
<p>
|
<p>
|
||||||
Surviving combinations get raw metric estimates — speed, cost,
|
Surviving combinations get raw metric estimates — power
|
||||||
safety, range — via heuristic stubs or an LLM provider that
|
density, cost, range — from a deterministic physics engine
|
||||||
reasons about the physical properties of each pairing.
|
that sizes each combination from its own declared attributes, not
|
||||||
|
a guess.
|
||||||
</p>
|
</p>
|
||||||
<div class="step-example">
|
<div class="step-example">
|
||||||
Bicycle + Human Pedalling → speed: 20 km/h, cost: $0.01/km
|
Bicycle + Human Muscle → power density: 4.4 W/kg, range: 500km
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,8 +73,8 @@
|
|||||||
Combinations are ranked within their domain.
|
Combinations are ranked within their domain.
|
||||||
</p>
|
</p>
|
||||||
<div class="step-example">
|
<div class="step-example">
|
||||||
Domain <code>urban_commuting</code> weights: speed 25%, cost 25%,
|
Domain <code>urban_commuting</code> weights: power density 42%,
|
||||||
safety 25%, availability 15%, range 10%
|
cost 42%, range 17%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,9 +86,11 @@
|
|||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<h3>LLM Review</h3>
|
<h3>LLM Review</h3>
|
||||||
<p>
|
<p>
|
||||||
Top-scoring combinations are sent to a language model for plausibility
|
Top-scoring combinations are sent to a language model for a
|
||||||
and novelty assessment — catching physically valid but practically
|
plausibility verdict plus a holistic LOW/MEDIUM/HIGH rating —
|
||||||
absurd pairings.
|
weighing safety and accessibility as qualitative judgment calls
|
||||||
|
alongside the physics scores, catching physically valid but
|
||||||
|
practically absurd pairings.
|
||||||
</p>
|
</p>
|
||||||
<div class="step-example">
|
<div class="step-example">
|
||||||
"Train + Solar Sail: structurally valid constraints, but solar radiation
|
"Train + Solar Sail: structurally valid constraints, but solar radiation
|
||||||
@@ -163,14 +166,15 @@
|
|||||||
<div class="card concept-card">
|
<div class="card concept-card">
|
||||||
<h3>Metrics</h3>
|
<h3>Metrics</h3>
|
||||||
<p>
|
<p>
|
||||||
Quantitative axes like speed, cost, safety, and range. Each metric
|
Quantitative physics axes like power density, cost, and range. Each
|
||||||
has a domain-specific weight and normalization range. Some are
|
metric has a domain-specific weight and normalization range. Some
|
||||||
inverted — lower cost is better.
|
are inverted — lower cost is better. Safety and accessibility
|
||||||
|
are judgment calls, not physics quantities — they're weighed
|
||||||
|
qualitatively in the LLM review pass instead of scored here.
|
||||||
</p>
|
</p>
|
||||||
<div class="concept-examples">
|
<div class="concept-examples">
|
||||||
<span class="badge">speed</span>
|
<span class="badge">power_density</span>
|
||||||
<span class="badge">cost_efficiency</span>
|
<span class="badge">cost_efficiency</span>
|
||||||
<span class="badge">safety</span>
|
|
||||||
<span class="badge">range_fuel</span>
|
<span class="badge">range_fuel</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>LLM Provider</legend>
|
||||||
|
<p class="form-hint">Used for Pass 2 estimation and Pass 4 review. Leave on "server default" to use whatever LLM_PROVIDER is configured in the server environment (or the physics stub if none).</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<select name="llm_provider" id="llm_provider">
|
||||||
|
<option value="">— server default —</option>
|
||||||
|
<option value="stub">Stub (fast, no LLM)</option>
|
||||||
|
<option value="ollama">Ollama (local)</option>
|
||||||
|
<option value="gemini">Gemini (cloud, requires server-side GEMINI_API_KEY)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="llm_model">Model</label>
|
||||||
|
<p class="form-hint">Leave blank to use the provider's default model.</p>
|
||||||
|
<input type="text" name="llm_model" id="llm_model" placeholder="e.g. qwen2.5:7b or gemini-2.0-flash">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="llm_host">Ollama host</label>
|
||||||
|
<p class="form-hint">Only used when Ollama is selected. Leave blank for http://localhost:11434.</p>
|
||||||
|
<input type="text" name="llm_host" id="llm_host" placeholder="http://localhost:11434">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="threshold">Score Threshold</label>
|
<label for="threshold">Score Threshold</label>
|
||||||
<p class="form-hint">Minimum composite score (0–1) for a combination to pass scoring. Lower values keep more results; higher values are more selective.</p>
|
<p class="form-hint">Minimum composite score (0–1) for a combination to pass scoring. Lower values keep more results; higher values are more selective.</p>
|
||||||
|
|||||||
56
src/physcom_web/templates/results/_explore_result.html
Normal file
56
src/physcom_web/templates/results/_explore_result.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{% if explore_result is none %}
|
||||||
|
<p class="empty">No free mass allocation to explore for this combination — its
|
||||||
|
platform has no declared mass ceiling to bound the sliders.</p>
|
||||||
|
{% else %}
|
||||||
|
{% set r = explore_result %}
|
||||||
|
<div class="optimize-summary">
|
||||||
|
<div class="optimize-score">
|
||||||
|
<span class="score-cell" style="font-size:1.4rem">{{ "%.4f"|format(r.composite_score) }}</span>
|
||||||
|
<span class="subtitle">composite score at this build</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if r.exceeds_platform_envelope %}
|
||||||
|
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
|
||||||
|
⚠ total mass {{ "%.1f"|format(r.total_mass) }}kg exceeds this platform's declared ceiling
|
||||||
|
({{ "%.1f"|format(r.platform_max) }}kg) — not a build this platform category could carry
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.insufficient_structure %}
|
||||||
|
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
|
||||||
|
⚠ platform mass {{ "%.1f"|format(r.platform_mass) }}kg is too little structure to carry
|
||||||
|
{{ "%.1f"|format(r.actuator_mass + r.storage_mass) }}kg of actuator+storage
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mass-bar-container" title="platform {{ '%.1f'|format(r.platform_mass) }}kg / actuator {{ '%.1f'|format(r.actuator_mass) }}kg / storage {{ '%.1f'|format(r.storage_mass) }}kg">
|
||||||
|
{% set total = r.total_mass %}
|
||||||
|
{% if total > 0 %}
|
||||||
|
<div class="mass-bar-seg mass-bar-platform" style="width: {{ (r.platform_mass / total * 100)|round(1) }}%"></div>
|
||||||
|
<div class="mass-bar-seg mass-bar-actuator" style="width: {{ (r.actuator_mass / total * 100)|round(1) }}%"></div>
|
||||||
|
<div class="mass-bar-seg mass-bar-storage" style="width: {{ (r.storage_mass / total * 100)|round(1) }}%"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mass-bar-legend">
|
||||||
|
<span><span class="mass-swatch mass-bar-platform"></span>platform {{ "%.1f"|format(r.platform_mass) }}kg</span>
|
||||||
|
<span><span class="mass-swatch mass-bar-actuator"></span>actuator {{ "%.1f"|format(r.actuator_mass) }}kg</span>
|
||||||
|
<span><span class="mass-swatch mass-bar-storage"></span>storage {{ "%.1f"|format(r.storage_mass) }}kg</span>
|
||||||
|
<span class="subtitle">{{ "%.1f"|format(r.total_mass) }}kg total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="compact" style="margin-top:0.75rem">
|
||||||
|
<thead><tr><th>Metric</th><th>Raw Value</th><th>Normalized</th><th>Weight</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mb in domain.metric_bounds %}
|
||||||
|
{% set val = r.raw_metrics.get(mb.metric_name) %}
|
||||||
|
{% set n = r.normalized_scores.get(mb.metric_name) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mb.metric_name }}</td>
|
||||||
|
<td class="score-cell">{{ val|qty(mb.unit) if val is not none else '—' }}</td>
|
||||||
|
<td class="score-cell">{{ "%.4f"|format(n) if n is not none else '—' }}</td>
|
||||||
|
<td>{{ "%.0f%%"|format(mb.weight * 100) }}{{ ' ↓' if mb.lower_is_better else '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
{% if result %}
|
{% if result %}
|
||||||
<dt>Composite Score</dt><dd class="score-cell">{{ "%.4f"|format(result.composite_score) }}</dd>
|
<dt>Composite Score</dt><dd class="score-cell">{{ "%.4f"|format(result.composite_score) }}</dd>
|
||||||
<dt>Pass Reached</dt><dd>{{ result.pass_reached }}</dd>
|
<dt>Pass Reached</dt><dd>{{ result.pass_reached }}</dd>
|
||||||
|
{% if result.qualitative_rating %}
|
||||||
|
<dt>Rating</dt><dd><span class="badge badge-rating-{{ result.qualitative_rating|lower }}">{{ result.qualitative_rating }}</span></dd>
|
||||||
|
{% endif %}
|
||||||
{% if result.novelty_flag %}
|
{% if result.novelty_flag %}
|
||||||
<dt>Novelty</dt><dd>{{ result.novelty_flag }}</dd>
|
<dt>Novelty</dt><dd>{{ result.novelty_flag }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -102,11 +105,16 @@
|
|||||||
{%- elif s.raw_value >= mb.norm_max -%}
|
{%- elif s.raw_value >= mb.norm_max -%}
|
||||||
<span class="badge badge-{{ 'p1_fail' if mb.lower_is_better else 'valid' }}">at/above max{{ ' (worst)' if mb.lower_is_better else '' }}</span>
|
<span class="badge badge-{{ 'p1_fail' if mb.lower_is_better else 'valid' }}">at/above max{{ ' (worst)' if mb.lower_is_better else '' }}</span>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
{% set pct = ((s.raw_value - mb.norm_min) / (mb.norm_max - mb.norm_min) * 100) | int %}
|
{% set raw_pct = (s.raw_value - mb.norm_min) / (mb.norm_max - mb.norm_min) * 100 %}
|
||||||
|
{# For lower_is_better metrics, raw_pct alone measures distance from norm_min,
|
||||||
|
not quality -- a value near norm_min (excellent, cost near its floor) would
|
||||||
|
otherwise render as a near-empty bar. Invert so the bar and percentage always
|
||||||
|
mean "how good", matching the normalized score's own higher-is-better convention. #}
|
||||||
|
{% set pct = ((100 - raw_pct) if mb.lower_is_better else raw_pct) | int %}
|
||||||
<div class="metric-bar-container">
|
<div class="metric-bar-container">
|
||||||
<div class="metric-bar" style="width: {{ pct }}%"></div>
|
<div class="metric-bar" style="width: {{ pct }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="metric-bar-label">~{{ pct }}%{{ ' ↓' if mb.lower_is_better else '' }}</span>
|
<span class="metric-bar-label">~{{ pct }}%{{ ' (lower is better)' if mb.lower_is_better else '' }}</span>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
—
|
—
|
||||||
@@ -121,6 +129,53 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scores %}
|
||||||
|
<h2>Explore: Scale the Build</h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
Purely exploratory — nothing here is saved. Drag a slider to pick a
|
||||||
|
platform weight class, motor size, or battery size directly, and see how
|
||||||
|
power density, range, and the resulting score respond. Sliders open on
|
||||||
|
the saved build above, which is already the score-optimized allocation
|
||||||
|
for this domain (subject to the platform's physical performance floor),
|
||||||
|
so the starting point is the best build already found, not an arbitrary
|
||||||
|
or merely functional one.
|
||||||
|
</p>
|
||||||
|
<div class="card">
|
||||||
|
{% if explore_result is not none %}
|
||||||
|
{% set r = explore_result %}
|
||||||
|
<form id="explore-form"
|
||||||
|
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}"
|
||||||
|
hx-trigger="input changed delay:200ms"
|
||||||
|
hx-target="#explore-result" hx-swap="innerHTML">
|
||||||
|
<div class="weight-slider-row">
|
||||||
|
<label for="platform_mass">platform (weight class)</label>
|
||||||
|
<input type="range" min="{{ r.platform_min }}" max="{{ r.platform_max }}" step="0.1"
|
||||||
|
id="platform_mass" name="platform_mass" value="{{ r.platform_mass }}"
|
||||||
|
oninput="document.getElementById('out_platform_mass').textContent = (+this.value).toFixed(1) + 'kg'">
|
||||||
|
<output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output>
|
||||||
|
</div>
|
||||||
|
<div class="weight-slider-row">
|
||||||
|
<label for="actuator_mass">actuator (motor/collector size, or operator count/size for muscle power)</label>
|
||||||
|
<input type="range" min="{{ r.actuator_min }}" max="{{ r.actuator_slider_max }}" step="0.1"
|
||||||
|
id="actuator_mass" name="actuator_mass" value="{{ r.actuator_mass }}"
|
||||||
|
oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'">
|
||||||
|
<output id="out_actuator_mass">{{ "%.1f"|format(r.actuator_mass) }}kg</output>
|
||||||
|
</div>
|
||||||
|
<div class="weight-slider-row">
|
||||||
|
<label for="storage_mass">storage (battery/tank size)</label>
|
||||||
|
<input type="range" min="{{ r.storage_min }}" max="{{ r.storage_slider_max }}" step="0.1"
|
||||||
|
id="storage_mass" name="storage_mass" value="{{ r.storage_mass }}"
|
||||||
|
oninput="document.getElementById('out_storage_mass').textContent = (+this.value).toFixed(1) + 'kg'">
|
||||||
|
<output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div id="explore-result">
|
||||||
|
{% include "results/_explore_result.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h2>Human Review</h2>
|
<h2>Human Review</h2>
|
||||||
<div id="review-section">
|
<div id="review-section">
|
||||||
{% include "results/_review_form.html" %}
|
{% include "results/_review_form.html" %}
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
|
|
||||||
{% if statuses %}
|
{% if statuses %}
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<span>Filter:</span>
|
<span>Status:</span>
|
||||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name) }}"
|
<a href="{{ url_for('results.results_domain', domain_name=domain.name, rating=rating_filter) }}"
|
||||||
class="btn btn-sm {{ '' if status_filter else 'btn-primary' }}">All ({{ total_results }})</a>
|
class="btn btn-sm {{ '' if status_filter else 'btn-primary' }}">All ({{ total_results }})</a>
|
||||||
{% for s, cnt in statuses.items() %}
|
{% for s, cnt in statuses.items() %}
|
||||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=s) }}"
|
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=s, rating=rating_filter) }}"
|
||||||
class="btn btn-sm {{ 'btn-primary' if status_filter == s else '' }}">
|
class="btn btn-sm {{ 'btn-primary' if status_filter == s else '' }}">
|
||||||
{{ s }} ({{ cnt }})
|
{{ s }} ({{ cnt }})
|
||||||
</a>
|
</a>
|
||||||
@@ -38,9 +38,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if ratings %}
|
||||||
|
<div class="filter-row">
|
||||||
|
<span>Rating:</span>
|
||||||
|
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=status_filter) }}"
|
||||||
|
class="btn btn-sm {{ '' if not rating_filter else 'btn-primary' }}">All</a>
|
||||||
|
{% for rt in ['HIGH', 'MEDIUM', 'LOW'] %}
|
||||||
|
{% if rt in ratings %}
|
||||||
|
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=status_filter, rating=rt) }}"
|
||||||
|
class="btn btn-sm {{ 'btn-primary' if rating_filter == rt else '' }}">
|
||||||
|
{{ rt }} ({{ ratings[rt] }})
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not results %}
|
{% if not results %}
|
||||||
{% if status_filter %}
|
{% if status_filter or rating_filter %}
|
||||||
<p class="empty">No results with status "{{ status_filter }}" in this domain.</p>
|
<p class="empty">No results matching that filter in this domain.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">No results for this domain yet. <a href="{{ url_for('pipeline.pipeline_form') }}">Run the pipeline</a> first.</p>
|
<p class="empty">No results for this domain yet. <a href="{{ url_for('pipeline.pipeline_form') }}">Run the pipeline</a> first.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -52,6 +68,7 @@
|
|||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
<th>Entities</th>
|
<th>Entities</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Rating</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -69,6 +86,13 @@
|
|||||||
<span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span>
|
<span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{%- if r.qualitative_rating -%}
|
||||||
|
<span class="badge badge-rating-{{ r.qualitative_rating|lower }}">{{ r.qualitative_rating }}</span>
|
||||||
|
{%- else -%}
|
||||||
|
—
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
<td class="block-reason-cell">
|
<td class="block-reason-cell">
|
||||||
{%- if r.domain_block_reason -%}
|
{%- if r.domain_block_reason -%}
|
||||||
{{ r.domain_block_reason }}
|
{{ r.domain_block_reason }}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ def road_vehicle():
|
|||||||
description="Generic wheeled road vehicle",
|
description="Generic wheeled road vehicle",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "mass", "36000", "kg", "range_max"),
|
Dependency("physical", "mass", "36000", "kg", "range_max"),
|
||||||
Dependency("physical", "mass", "50", "kg", "range_min"),
|
Dependency("physical", "mass", "50", "kg", "range_min"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
@@ -51,7 +53,9 @@ def bicycle():
|
|||||||
description="Two-wheeled human-scale vehicle",
|
description="Two-wheeled human-scale vehicle",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
|
Dependency("environment", "ground_surface", "true", None, "provides"),
|
||||||
Dependency("environment", "gravity", "true", None, "requires"),
|
Dependency("environment", "gravity", "true", None, "requires"),
|
||||||
|
Dependency("environment", "gravity", "true", None, "provides"),
|
||||||
Dependency("physical", "mass", "30", "kg", "range_max"),
|
Dependency("physical", "mass", "30", "kg", "range_max"),
|
||||||
Dependency("environment", "medium", "ground", None, "requires"),
|
Dependency("environment", "medium", "ground", None, "requires"),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -160,3 +160,231 @@ def test_domain_constraint_allows_matching_medium(bicycle, human_pedalling, food
|
|||||||
constraints = [DomainConstraint("medium", ["ground", "air"])]
|
constraints = [DomainConstraint("medium", ["ground", "air"])]
|
||||||
result = resolver.check_domain_constraints(combo, constraints)
|
result = resolver.check_domain_constraints(combo, constraints)
|
||||||
assert result.status == "valid"
|
assert result.status == "valid"
|
||||||
|
|
||||||
|
|
||||||
|
def _rotorcraft():
|
||||||
|
return Entity(
|
||||||
|
name="Rotorcraft", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "footprint", "20", "m²", "range_max"),
|
||||||
|
Dependency("physical", "footprint", "0.5", "m²", "range_min"),
|
||||||
|
Dependency("physical", "mass", "5000", "kg", "range_max"),
|
||||||
|
Dependency("physical", "mass", "1", "kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _spaceship_with_footprint():
|
||||||
|
return Entity(
|
||||||
|
name="Spaceship", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "footprint", "500", "m²", "range_max"),
|
||||||
|
Dependency("physical", "footprint", "10", "m²", "range_min"),
|
||||||
|
Dependency("physical", "mass", "5000", "kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nuclear_thermal_drive_with_footprint():
|
||||||
|
return Entity(
|
||||||
|
name="Nuclear Thermal Drive", dimension="actuator",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "footprint", "20", "m²", "range_min"),
|
||||||
|
Dependency("physical", "mass", "1500", "kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nuclear_fuel_with_footprint():
|
||||||
|
return Entity(
|
||||||
|
name="Nuclear Fuel", dimension="energy_storage",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "footprint", "5", "m²", "range_min"),
|
||||||
|
Dependency("physical", "mass", "500", "kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_footprint_aggregation_blocks_reactor_on_rotorcraft():
|
||||||
|
"""P1: individual footprint floors each fit under the ceiling (20, 5 <= 20)
|
||||||
|
but their sum (25.5) doesn't — must block even though no single component
|
||||||
|
exceeds the ceiling on its own."""
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
combo = Combination(entities=[
|
||||||
|
_rotorcraft(), _nuclear_thermal_drive_with_footprint(), _nuclear_fuel_with_footprint(),
|
||||||
|
])
|
||||||
|
result = resolver.resolve(combo)
|
||||||
|
assert result.status == "p1_fail"
|
||||||
|
assert any("combined footprint" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_footprint_aggregation_still_passes_spaceship():
|
||||||
|
"""Same reactor + fuel, but a platform with enough footprint budget (500 m²)
|
||||||
|
must still pass — aggregation shouldn't over-block combos with real headroom."""
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
combo = Combination(entities=[
|
||||||
|
_spaceship_with_footprint(), _nuclear_thermal_drive_with_footprint(), _nuclear_fuel_with_footprint(),
|
||||||
|
])
|
||||||
|
result = resolver.resolve(combo)
|
||||||
|
assert result.status != "p1_fail"
|
||||||
|
assert not any("footprint" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mass_aggregation_within_tolerance_warns_not_blocks():
|
||||||
|
"""Sum only slightly over the ceiling (65 vs 60, +8.3%) is a data-calibration
|
||||||
|
signal, not a hard physical impossibility — should warn, not block."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Light Personal Vehicle", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "60", "kg", "range_max"),
|
||||||
|
Dependency("physical", "mass", "5", "kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
actuator = Entity(
|
||||||
|
name="Piston Engine", dimension="actuator",
|
||||||
|
dependencies=[Dependency("physical", "mass", "45", "kg", "range_min")],
|
||||||
|
)
|
||||||
|
storage = Entity(
|
||||||
|
name="Compressed Natural Gas", dimension="energy_storage",
|
||||||
|
dependencies=[Dependency("physical", "mass", "15", "kg", "range_min")],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[platform, actuator, storage]))
|
||||||
|
assert result.status == "conditional"
|
||||||
|
assert any("combined mass" in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weak_secondary_provider_does_not_block_satisfied_requirement():
|
||||||
|
"""P3: a strong provider (nuclear fuel) already satisfies the requirement;
|
||||||
|
a weak secondary provider (solar panel) in the same combo must not
|
||||||
|
retroactively block it — a real backup power source shouldn't break a
|
||||||
|
vehicle that already has enough primary power."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Spaceship", dimension="platform",
|
||||||
|
dependencies=[Dependency("physical", "energy_density", "7200000", "J/kg", "range_min")],
|
||||||
|
)
|
||||||
|
nuclear_fuel = Entity(
|
||||||
|
name="Nuclear Fuel", dimension="energy_storage",
|
||||||
|
dependencies=[Dependency("physical", "energy_density", "1800000000", "J/kg", "provides")],
|
||||||
|
)
|
||||||
|
solar_panel = Entity(
|
||||||
|
name="Solar Photovoltaic Panel", dimension="energy_storage",
|
||||||
|
dependencies=[Dependency("physical", "energy_density", "180000", "J/kg", "provides")],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[platform, nuclear_fuel, solar_panel]))
|
||||||
|
assert result.status != "p1_fail"
|
||||||
|
assert not any("energy_density" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrecognized_mutex_value_fails_closed():
|
||||||
|
"""P4: a value not in any registered mutex set (e.g. a new 'medium' typed
|
||||||
|
into the admin UI) must conflict with a recognized value on the same key,
|
||||||
|
not silently pass."""
|
||||||
|
a = Entity(
|
||||||
|
name="A", dimension="platform",
|
||||||
|
dependencies=[Dependency("environment", "medium", "underground", None, "requires")],
|
||||||
|
)
|
||||||
|
b = Entity(
|
||||||
|
name="B", dimension="actuator",
|
||||||
|
dependencies=[Dependency("environment", "medium", "space", None, "requires")],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[a, b]))
|
||||||
|
assert result.status == "p1_fail"
|
||||||
|
assert any("mutually exclusive" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_propulsion_viability_blocks_weak_actuator_regardless_of_scale():
|
||||||
|
"""G4: specific_thrust below min_effective_accel can never be fixed by
|
||||||
|
adding more actuator mass — must block unconditionally (Case 1)."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Rotorcraft", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "5000", "kg", "range_max"),
|
||||||
|
Dependency("physical", "min_effective_accel", "10", "m/s²", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
actuator = Entity(
|
||||||
|
name="Ion Drive", dimension="actuator",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "8", "kg", "range_min"),
|
||||||
|
Dependency("force", "specific_thrust", "0.01", "N/kg", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[platform, actuator]))
|
||||||
|
assert result.status == "p1_fail"
|
||||||
|
assert any("regardless of scale" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_propulsion_viability_blocks_when_required_mass_exceeds_ceiling():
|
||||||
|
"""G4 Case 2: specific_thrust clears min_effective_accel, but the mass
|
||||||
|
needed to hit that thrust doesn't fit the vehicle's mass budget."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Test Platform", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "50", "kg", "range_max"),
|
||||||
|
Dependency("physical", "mass", "10", "kg", "range_min"),
|
||||||
|
Dependency("physical", "min_effective_accel", "5", "m/s²", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
actuator = Entity(
|
||||||
|
name="Weak Reaction Drive", dimension="actuator",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "1", "kg", "range_min"),
|
||||||
|
Dependency("force", "specific_thrust", "6", "N/kg", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
storage = Entity(
|
||||||
|
name="Fuel", dimension="energy_storage",
|
||||||
|
dependencies=[Dependency("physical", "mass", "1", "kg", "range_min")],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[platform, actuator, storage]))
|
||||||
|
assert result.status == "p1_fail"
|
||||||
|
assert any("would need >=" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_propulsion_viability_passes_with_enough_budget():
|
||||||
|
"""Same shape as above but with a generous mass ceiling — must pass."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Test Platform", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "5000", "kg", "range_max"),
|
||||||
|
Dependency("physical", "mass", "10", "kg", "range_min"),
|
||||||
|
Dependency("physical", "min_effective_accel", "5", "m/s²", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
actuator = Entity(
|
||||||
|
name="Weak Reaction Drive", dimension="actuator",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "mass", "1", "kg", "range_min"),
|
||||||
|
Dependency("force", "specific_thrust", "6", "N/kg", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
storage = Entity(
|
||||||
|
name="Fuel", dimension="energy_storage",
|
||||||
|
dependencies=[Dependency("physical", "mass", "1", "kg", "range_min")],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[platform, actuator, storage]))
|
||||||
|
assert not any("specific thrust" in v or "would need" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_propulsion_viability_skips_when_undeclared(bicycle, human_pedalling, food_calories):
|
||||||
|
"""Platforms/actuators that never declare min_effective_accel or
|
||||||
|
specific_thrust (most of the catalog, for now) must be unaffected."""
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[bicycle, human_pedalling, food_calories]))
|
||||||
|
assert not any("specific thrust" in v or "would need" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agreement_key_reaches_valid_status(bicycle, human_pedalling, food_calories):
|
||||||
|
"""P2: medium/atmosphere are agreement keys, not supply/demand — a combo
|
||||||
|
with no other issues should reach 'valid', not get stuck at 'conditional'
|
||||||
|
forever because nothing 'provides' medium=ground."""
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
result = resolver.resolve(Combination(entities=[bicycle, human_pedalling, food_calories]))
|
||||||
|
assert result.status == "valid"
|
||||||
|
assert not any("medium" in w for w in result.warnings)
|
||||||
|
|||||||
108
tests/test_formula.py
Normal file
108
tests/test_formula.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests for the safe formula evaluator."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from physcom.engine.formula import FormulaError, compile_formula, evaluate_formula
|
||||||
|
|
||||||
|
|
||||||
|
class TestArithmetic:
|
||||||
|
def test_constant(self):
|
||||||
|
assert evaluate_formula(compile_formula("42"), {}) == 42.0
|
||||||
|
|
||||||
|
def test_basic_ops(self):
|
||||||
|
assert evaluate_formula(compile_formula("2 + 3 * 4"), {}) == 14.0
|
||||||
|
assert evaluate_formula(compile_formula("(2 + 3) * 4"), {}) == 20.0
|
||||||
|
assert evaluate_formula(compile_formula("10 / 4"), {}) == 2.5
|
||||||
|
assert evaluate_formula(compile_formula("2 ** 3"), {}) == 8.0
|
||||||
|
|
||||||
|
def test_unary_minus(self):
|
||||||
|
assert evaluate_formula(compile_formula("-5 + 2"), {}) == -3.0
|
||||||
|
|
||||||
|
def test_variable_lookup(self):
|
||||||
|
result = evaluate_formula(compile_formula("mass * 2"), {"mass": 3.0})
|
||||||
|
assert result == 6.0
|
||||||
|
|
||||||
|
def test_unknown_variable_raises(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
evaluate_formula(compile_formula("unknown_var"), {})
|
||||||
|
|
||||||
|
def test_division_by_zero_raises_formula_error(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
evaluate_formula(compile_formula("1 / 0"), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestFunctions:
|
||||||
|
def test_default_math_functions(self):
|
||||||
|
assert evaluate_formula(compile_formula("sqrt(16)"), {}) == 4.0
|
||||||
|
assert evaluate_formula(compile_formula("max(1, 2, 3)"), {}) == 3.0
|
||||||
|
assert evaluate_formula(compile_formula("min(1, 2, 3)"), {}) == 1.0
|
||||||
|
assert evaluate_formula(compile_formula("abs(-5)"), {}) == 5.0
|
||||||
|
assert evaluate_formula(compile_formula("exp(0)"), {}) == 1.0
|
||||||
|
assert math.isclose(evaluate_formula(compile_formula("log(exp(1))"), {}), 1.0)
|
||||||
|
|
||||||
|
def test_custom_injected_function(self):
|
||||||
|
formula = compile_formula('dep("power_density", "provides")')
|
||||||
|
result = evaluate_formula(
|
||||||
|
formula, {}, functions={"dep": lambda key, constraint_type: 99.0}
|
||||||
|
)
|
||||||
|
assert result == 99.0
|
||||||
|
|
||||||
|
def test_unknown_function_raises(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
evaluate_formula(compile_formula("unknown_fn(1)"), {})
|
||||||
|
|
||||||
|
def test_string_constant_passthrough_to_function(self):
|
||||||
|
formula = compile_formula('dep("mass")')
|
||||||
|
result = evaluate_formula(formula, {}, functions={"dep": lambda key: len(key)})
|
||||||
|
assert result == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecurity:
|
||||||
|
@pytest.mark.parametrize("source", [
|
||||||
|
"__import__('os').system('echo hi')",
|
||||||
|
"().__class__",
|
||||||
|
"[1, 2, 3]",
|
||||||
|
"{1: 2}",
|
||||||
|
"{1, 2}",
|
||||||
|
"(x for x in [1])",
|
||||||
|
"lambda: 1",
|
||||||
|
"1 if True else 0",
|
||||||
|
"1 == 1",
|
||||||
|
"x.__class__",
|
||||||
|
"x[0]",
|
||||||
|
"(lambda: 1)()",
|
||||||
|
"1; 2",
|
||||||
|
])
|
||||||
|
def test_disallowed_constructs_rejected(self, source):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
compile_formula(source)
|
||||||
|
|
||||||
|
def test_unregistered_function_name_never_executes(self):
|
||||||
|
"""exec/eval/__import__ etc. parse as ordinary Call nodes -- the
|
||||||
|
actual guarantee is that no function name is callable unless it's
|
||||||
|
explicitly in DEFAULT_FUNCTIONS or caller-supplied, checked at
|
||||||
|
evaluate time, not that the bare name is rejected at compile time."""
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
evaluate_formula(compile_formula("exec('1')"), {})
|
||||||
|
|
||||||
|
def test_dunder_name_rejected(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
compile_formula("__builtins__")
|
||||||
|
|
||||||
|
def test_indirect_call_rejected(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
compile_formula("(a + b)(1)")
|
||||||
|
|
||||||
|
def test_invalid_syntax_raises_formula_error(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
compile_formula("2 +")
|
||||||
|
|
||||||
|
def test_boolean_constant_rejected(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
compile_formula("True")
|
||||||
|
|
||||||
|
def test_large_exponent_overflows_cleanly_not_hangs(self):
|
||||||
|
with pytest.raises(FormulaError):
|
||||||
|
evaluate_formula(compile_formula("9 ** 9 ** 9 ** 9"), {})
|
||||||
32
tests/test_llm_parsing.py
Normal file
32
tests/test_llm_parsing.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Tests for shared LLM response-parsing logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from physcom.llm.parsing import parse_metric_json, parse_verdict
|
||||||
|
from physcom.models.domain import MetricBound
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds():
|
||||||
|
return [
|
||||||
|
MetricBound("power_density", weight=0.5, norm_min=1, norm_max=2000, unit="W/kg"),
|
||||||
|
MetricBound("safety", weight=0.5, norm_min=0.0, norm_max=1.0, unit="0-1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_metric_json_strips_fences():
|
||||||
|
text = '```json\n{"power_density": 500.0, "safety": 0.7}\n```'
|
||||||
|
result = parse_metric_json(text, _bounds())
|
||||||
|
assert result == {"power_density": 500.0, "safety": 0.7}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_metric_json_falls_back_to_range_midpoint_on_invalid():
|
||||||
|
result = parse_metric_json("not json", _bounds())
|
||||||
|
assert result == {"power_density": 1000.5, "safety": 0.5}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_verdict_plausible():
|
||||||
|
assert parse_verdict("blah blah\nVERDICT: PLAUSIBLE") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_verdict_implausible():
|
||||||
|
assert parse_verdict("blah blah\nVERDICT: IMPLAUSIBLE") is False
|
||||||
@@ -1,36 +1,10 @@
|
|||||||
"""Tests for the Ollama provider's parsing logic and registry wiring."""
|
"""Tests for the Ollama provider's registry wiring."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from physcom.llm.providers.ollama import OllamaLLMProvider
|
from physcom.llm.providers.ollama import OllamaLLMProvider
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def provider():
|
|
||||||
return OllamaLLMProvider()
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_json_strips_fences(provider):
|
|
||||||
text = '```json\n{"power_density": 500.0, "safety": 0.7}\n```'
|
|
||||||
result = provider._parse_json(text, ["power_density", "safety"])
|
|
||||||
assert result == {"power_density": 500.0, "safety": 0.7}
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_json_falls_back_on_invalid(provider):
|
|
||||||
result = provider._parse_json("not json", ["power_density", "safety"])
|
|
||||||
assert result == {"power_density": 0.5, "safety": 0.5}
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_verdict_plausible(provider):
|
|
||||||
assert provider._parse_verdict("blah blah\nVERDICT: PLAUSIBLE") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_verdict_implausible(provider):
|
|
||||||
assert provider._parse_verdict("blah blah\nVERDICT: IMPLAUSIBLE") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_builds_ollama_provider(monkeypatch):
|
def test_registry_builds_ollama_provider(monkeypatch):
|
||||||
from physcom.llm.registry import build_llm_provider
|
from physcom.llm.registry import build_llm_provider
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ def test_blocked_combos_not_scored(seeded_repo):
|
|||||||
score_threshold=0.0, passes=[1, 2, 3, 5],
|
score_threshold=0.0, passes=[1, 2, 3, 5],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Estimated count should be less than total (blocked ones filtered)
|
# Estimated count should be less than total (blocked ones filtered).
|
||||||
|
# Not necessarily equal to pass1_valid + pass1_conditional: a combo can
|
||||||
|
# pass pass 1's entity-declared-floor checks but still turn out
|
||||||
|
# structurally infeasible once pass 2 solves the domain-specific
|
||||||
|
# actuator/storage requirement (e.g. an engine too big to fit its own
|
||||||
|
# platform's declared mass ceiling) -- that's a legitimate per-domain
|
||||||
|
# block, not a bug (see Pipeline._decide_masses' `feasible` return).
|
||||||
assert result.pass2_estimated < result.total_generated
|
assert result.pass2_estimated < result.total_generated
|
||||||
assert result.pass2_estimated == result.pass1_valid + result.pass1_conditional
|
assert result.pass2_estimated <= result.pass1_valid + result.pass1_conditional
|
||||||
|
|||||||
@@ -335,28 +335,34 @@ def test_p3_fail_below_threshold(seeded_repo):
|
|||||||
|
|
||||||
|
|
||||||
def test_p4_fail_implausible(seeded_repo):
|
def test_p4_fail_implausible(seeded_repo):
|
||||||
"""Combos deemed implausible by LLM should get p4_fail status."""
|
"""Combos deemed implausible by LLM should get p4_fail status.
|
||||||
|
|
||||||
|
Pass 2 is estimator-only now (never calls the LLM), so there's no way
|
||||||
|
to force every combo's raw estimates toward a controlled low/high value
|
||||||
|
the way MockLLMProvider's default_estimates used to. Force the pass-4
|
||||||
|
verdict directly instead -- what's under test here is pipeline.py's
|
||||||
|
wiring of review_plausibility's return value to status/counters, not
|
||||||
|
MockLLMProvider's avg-based heuristic.
|
||||||
|
"""
|
||||||
from physcom.llm.providers.mock import MockLLMProvider
|
from physcom.llm.providers.mock import MockLLMProvider
|
||||||
|
|
||||||
|
class AlwaysImplausibleLLM(MockLLMProvider):
|
||||||
|
def review_plausibility(self, description, raw_metrics, normalized_scores, domain):
|
||||||
|
return ("Always implausible for testing.", False)
|
||||||
|
|
||||||
repo = seeded_repo
|
repo = seeded_repo
|
||||||
domain = repo.get_domain("urban_commuting")
|
domain = repo.get_domain("urban_commuting")
|
||||||
|
|
||||||
resolver = ConstraintResolver()
|
resolver = ConstraintResolver()
|
||||||
scorer = Scorer(domain)
|
scorer = Scorer(domain)
|
||||||
# Low estimates → normalized scores avg <= 0.5 → MockLLMProvider returns (text, False)
|
|
||||||
# Use threshold=0.0 so no combo gets p3_fail and all reach pass 4
|
# Use threshold=0.0 so no combo gets p3_fail and all reach pass 4
|
||||||
mock_llm = MockLLMProvider(default_estimates={
|
pipeline = Pipeline(repo, resolver, scorer, llm=AlwaysImplausibleLLM())
|
||||||
"power_density": 0.1, "cost_efficiency": 0.1, "safety": 0.1,
|
|
||||||
"availability": 0.1, "range_fuel": 0.1,
|
|
||||||
})
|
|
||||||
pipeline = Pipeline(repo, resolver, scorer, llm=mock_llm)
|
|
||||||
|
|
||||||
result = pipeline.run(
|
result = pipeline.run(
|
||||||
domain, ["platform", "actuator", "energy_storage"],
|
domain, ["platform", "actuator", "energy_storage"],
|
||||||
score_threshold=0.0, passes=[1, 2, 3, 4],
|
score_threshold=0.0, passes=[1, 2, 3, 4],
|
||||||
)
|
)
|
||||||
|
|
||||||
# With low normalized scores (avg <= 0.5), reviewed combos should be p4_fail
|
|
||||||
assert result.pass4_failed > 0
|
assert result.pass4_failed > 0
|
||||||
assert result.pass4_reviewed == 0
|
assert result.pass4_reviewed == 0
|
||||||
|
|
||||||
@@ -367,20 +373,23 @@ def test_p4_fail_implausible(seeded_repo):
|
|||||||
|
|
||||||
|
|
||||||
def test_p4_pass_plausible(seeded_repo):
|
def test_p4_pass_plausible(seeded_repo):
|
||||||
"""Combos deemed plausible by LLM should get llm_reviewed status."""
|
"""Combos deemed plausible by LLM should get llm_reviewed status.
|
||||||
|
|
||||||
|
See test_p4_fail_implausible on why the verdict is forced directly
|
||||||
|
rather than via controlled pass-2 estimates.
|
||||||
|
"""
|
||||||
from physcom.llm.providers.mock import MockLLMProvider
|
from physcom.llm.providers.mock import MockLLMProvider
|
||||||
|
|
||||||
|
class AlwaysPlausibleLLM(MockLLMProvider):
|
||||||
|
def review_plausibility(self, description, raw_metrics, normalized_scores, domain):
|
||||||
|
return ("Always plausible for testing.", True)
|
||||||
|
|
||||||
repo = seeded_repo
|
repo = seeded_repo
|
||||||
domain = repo.get_domain("urban_commuting")
|
domain = repo.get_domain("urban_commuting")
|
||||||
|
|
||||||
resolver = ConstraintResolver()
|
resolver = ConstraintResolver()
|
||||||
scorer = Scorer(domain)
|
scorer = Scorer(domain)
|
||||||
# High estimates → avg > 0.5 → MockLLMProvider returns (text, True)
|
pipeline = Pipeline(repo, resolver, scorer, llm=AlwaysPlausibleLLM())
|
||||||
mock_llm = MockLLMProvider(default_estimates={
|
|
||||||
"power_density": 500.0, "cost_efficiency": 5e-4, "safety": 0.6,
|
|
||||||
"availability": 0.7, "range_fuel": 200000.0,
|
|
||||||
})
|
|
||||||
pipeline = Pipeline(repo, resolver, scorer, llm=mock_llm)
|
|
||||||
|
|
||||||
result = pipeline.run(
|
result = pipeline.run(
|
||||||
domain, ["platform", "actuator", "energy_storage"],
|
domain, ["platform", "actuator", "energy_storage"],
|
||||||
|
|||||||
113
tests/test_pipeline_formulas.py
Normal file
113
tests/test_pipeline_formulas.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""End-to-end test that pass 2 can estimate a non-transport domain entirely
|
||||||
|
from domain-authored formulas, without touching the platform/actuator/
|
||||||
|
energy_storage physics model in Pipeline._estimate_physics."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from physcom.engine.constraint_resolver import ConstraintResolver
|
||||||
|
from physcom.engine.scorer import Scorer
|
||||||
|
from physcom.engine.pipeline import Pipeline
|
||||||
|
from physcom.models.domain import Domain, FreeVariable, MetricBound, MetricFormula
|
||||||
|
from physcom.models.entity import Dependency, Entity
|
||||||
|
|
||||||
|
|
||||||
|
def _build_archery_domain(repo):
|
||||||
|
repo.add_entity(Entity(
|
||||||
|
name="Recurve",
|
||||||
|
dimension="bow",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "draw_weight", "20", None, "range_min"),
|
||||||
|
Dependency("physical", "draw_weight", "50", None, "range_max"),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
repo.add_entity(Entity(
|
||||||
|
name="Carbon",
|
||||||
|
dimension="arrow",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "arrow_mass", "0.02", None, "provides"),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
return repo.add_domain(Domain(
|
||||||
|
name="archery_test",
|
||||||
|
metric_bounds=[
|
||||||
|
MetricBound("drawback_force", weight=0.6, norm_min=0, norm_max=100),
|
||||||
|
MetricBound("range", weight=0.4, norm_min=0, norm_max=300),
|
||||||
|
],
|
||||||
|
free_variables=[
|
||||||
|
FreeVariable(
|
||||||
|
name="draw_weight_chosen",
|
||||||
|
floor_formula='dep("draw_weight", "range_min")',
|
||||||
|
ceiling_formula='dep("draw_weight", "range_max")',
|
||||||
|
sort_order=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
metric_formulas=[
|
||||||
|
MetricFormula(metric_name="drawback_force", formula="draw_weight_chosen * 2"),
|
||||||
|
MetricFormula(
|
||||||
|
metric_name="range",
|
||||||
|
formula='draw_weight_chosen * 5 / dep("arrow_mass")',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_formula_domain_scores_without_platform_actuator_shape(repo):
|
||||||
|
domain = _build_archery_domain(repo)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
scorer = Scorer(domain)
|
||||||
|
pipeline = Pipeline(repo, resolver, scorer)
|
||||||
|
|
||||||
|
result = pipeline.run(
|
||||||
|
domain, ["bow", "arrow"], score_threshold=0.01, passes=[1, 2, 3, 5],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total_generated == 1
|
||||||
|
assert result.pass1_failed == 0
|
||||||
|
assert result.pass2_estimated == 1
|
||||||
|
assert result.pass3_above_threshold == 1
|
||||||
|
|
||||||
|
combos = repo.list_combinations()
|
||||||
|
assert len(combos) == 1
|
||||||
|
combo = combos[0]
|
||||||
|
scores = {
|
||||||
|
s["metric_name"]: s["raw_value"]
|
||||||
|
for s in repo.get_combination_scores(combo.id, domain.id)
|
||||||
|
}
|
||||||
|
# Both metrics increase monotonically with draw_weight_chosen and nothing
|
||||||
|
# trades off against it, so the optimizer should push to the declared
|
||||||
|
# ceiling (50) -- confirms _search_free_variables is actually searching,
|
||||||
|
# not just evaluating at the floor.
|
||||||
|
assert scores["drawback_force"] == pytest.approx(100.0, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_formula_domain_zero_free_variables_direct_evaluation(repo):
|
||||||
|
"""A domain with metric_formulas but no free_variables should evaluate
|
||||||
|
each formula once directly -- no search loop at all."""
|
||||||
|
repo.add_entity(Entity(
|
||||||
|
name="Recurve",
|
||||||
|
dimension="bow",
|
||||||
|
dependencies=[Dependency("physical", "draw_weight", "30", None, "provides")],
|
||||||
|
))
|
||||||
|
repo.add_entity(Entity(name="Carbon", dimension="arrow"))
|
||||||
|
domain = repo.add_domain(Domain(
|
||||||
|
name="archery_direct_test",
|
||||||
|
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=100)],
|
||||||
|
metric_formulas=[
|
||||||
|
MetricFormula(metric_name="drawback_force", formula='dep("draw_weight") * 2'),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
scorer = Scorer(domain)
|
||||||
|
pipeline = Pipeline(repo, resolver, scorer)
|
||||||
|
|
||||||
|
result = pipeline.run(
|
||||||
|
domain, ["bow", "arrow"], score_threshold=0.01, passes=[1, 2, 3, 5],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pass2_estimated == 1
|
||||||
|
combos = repo.list_combinations()
|
||||||
|
scores = {
|
||||||
|
s["metric_name"]: s["raw_value"]
|
||||||
|
for s in repo.get_combination_scores(combos[0].id, domain.id)
|
||||||
|
}
|
||||||
|
assert scores["drawback_force"] == 60.0
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for the database repository."""
|
"""Tests for the database repository."""
|
||||||
|
|
||||||
from physcom.models.entity import Entity, Dependency
|
from physcom.models.entity import Entity, Dependency
|
||||||
from physcom.models.domain import Domain, MetricBound
|
from physcom.models.domain import Domain, FreeVariable, MetricBound, MetricFormula
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_dimension(repo):
|
def test_ensure_dimension(repo):
|
||||||
@@ -63,6 +63,62 @@ def test_add_and_get_domain(repo):
|
|||||||
assert loaded.metric_bounds[0].metric_name == "speed"
|
assert loaded.metric_bounds[0].metric_name == "speed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_domain_with_free_variables_and_formulas(repo):
|
||||||
|
domain = Domain(
|
||||||
|
name="archery_test",
|
||||||
|
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=500)],
|
||||||
|
free_variables=[
|
||||||
|
FreeVariable(
|
||||||
|
name="draw_weight",
|
||||||
|
floor_formula='dep("draw_weight", "range_min")',
|
||||||
|
ceiling_formula='dep("draw_weight", "range_max")',
|
||||||
|
sort_order=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
metric_formulas=[
|
||||||
|
MetricFormula(metric_name="drawback_force", formula="draw_weight * 1.5"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
saved = repo.add_domain(domain)
|
||||||
|
assert saved.id is not None
|
||||||
|
|
||||||
|
loaded = repo.get_domain("archery_test")
|
||||||
|
assert loaded is not None
|
||||||
|
assert len(loaded.free_variables) == 1
|
||||||
|
assert loaded.free_variables[0].name == "draw_weight"
|
||||||
|
assert loaded.free_variables[0].id is not None
|
||||||
|
assert len(loaded.metric_formulas) == 1
|
||||||
|
assert loaded.metric_formulas[0].formula == "draw_weight * 1.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_variable_and_formula_crud(repo):
|
||||||
|
domain = repo.add_domain(Domain(name="crud_test"))
|
||||||
|
|
||||||
|
fv = repo.add_free_variable(
|
||||||
|
domain.id,
|
||||||
|
FreeVariable(name="x", floor_formula="0", ceiling_formula="100", sort_order=0),
|
||||||
|
)
|
||||||
|
mf = repo.add_metric_formula(
|
||||||
|
domain.id, MetricFormula(metric_name="m", formula="x * 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.update_free_variable(
|
||||||
|
fv.id, FreeVariable(name="x", floor_formula="1", ceiling_formula="200", sort_order=0)
|
||||||
|
)
|
||||||
|
repo.update_metric_formula(mf.id, MetricFormula(metric_name="m", formula="x * 3"))
|
||||||
|
|
||||||
|
loaded = repo.get_domain_by_id(domain.id)
|
||||||
|
assert loaded.free_variables[0].floor_formula == "1"
|
||||||
|
assert loaded.free_variables[0].ceiling_formula == "200"
|
||||||
|
assert loaded.metric_formulas[0].formula == "x * 3"
|
||||||
|
|
||||||
|
repo.delete_free_variable(fv.id)
|
||||||
|
repo.delete_metric_formula(mf.id)
|
||||||
|
loaded = repo.get_domain_by_id(domain.id)
|
||||||
|
assert loaded.free_variables == []
|
||||||
|
assert loaded.metric_formulas == []
|
||||||
|
|
||||||
|
|
||||||
def test_combination_save_and_dedup(repo):
|
def test_combination_save_and_dedup(repo):
|
||||||
e1 = repo.add_entity(Entity(name="A", dimension="platform"))
|
e1 = repo.add_entity(Entity(name="A", dimension="platform"))
|
||||||
e2 = repo.add_entity(Entity(name="B", dimension="actuator"))
|
e2 = repo.add_entity(Entity(name="B", dimension="actuator"))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from physcom.db.schema import init_db
|
from physcom.db.schema import init_db
|
||||||
from physcom.db.repository import Repository
|
from physcom.db.repository import Repository
|
||||||
from physcom.models.entity import Entity, Dependency
|
from physcom.models.entity import Entity, Dependency
|
||||||
from physcom.models.domain import Domain, DomainConstraint, MetricBound
|
from physcom.models.domain import Domain, DomainConstraint, FreeVariable, MetricBound, MetricFormula
|
||||||
from physcom.models.combination import Combination
|
from physcom.models.combination import Combination
|
||||||
from physcom.snapshot import export_snapshot, import_snapshot
|
from physcom.snapshot import export_snapshot, import_snapshot
|
||||||
|
|
||||||
@@ -169,6 +169,46 @@ def test_import_with_combinations(seeded_repo, tmp_path):
|
|||||||
assert len(fresh_combos) == len(data["combinations"])
|
assert len(fresh_combos) == len(data["combinations"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_import_roundtrip_free_variables_and_formulas(repo, tmp_path):
|
||||||
|
domain = Domain(
|
||||||
|
name="archery_snapshot_test",
|
||||||
|
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=100)],
|
||||||
|
free_variables=[
|
||||||
|
FreeVariable(
|
||||||
|
name="draw_weight_chosen",
|
||||||
|
floor_formula='dep("draw_weight", "range_min")',
|
||||||
|
ceiling_formula='dep("draw_weight", "range_max")',
|
||||||
|
sort_order=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
metric_formulas=[
|
||||||
|
MetricFormula(metric_name="drawback_force", formula="draw_weight_chosen * 2"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
repo.add_domain(domain)
|
||||||
|
|
||||||
|
data = export_snapshot(repo)
|
||||||
|
exported = next(d for d in data["domains"] if d["name"] == "archery_snapshot_test")
|
||||||
|
assert exported["free_variables"] == [{
|
||||||
|
"name": "draw_weight_chosen", "sort_order": 0,
|
||||||
|
"floor_formula": 'dep("draw_weight", "range_min")',
|
||||||
|
"ceiling_formula": 'dep("draw_weight", "range_max")',
|
||||||
|
}]
|
||||||
|
assert exported["metric_formulas"] == [
|
||||||
|
{"metric_name": "drawback_force", "formula": "draw_weight_chosen * 2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = init_db(tmp_path / "fresh.db")
|
||||||
|
fresh = Repository(conn)
|
||||||
|
import_snapshot(fresh, data, clear=True)
|
||||||
|
|
||||||
|
loaded = fresh.get_domain("archery_snapshot_test")
|
||||||
|
assert len(loaded.free_variables) == 1
|
||||||
|
assert loaded.free_variables[0].floor_formula == 'dep("draw_weight", "range_min")'
|
||||||
|
assert len(loaded.metric_formulas) == 1
|
||||||
|
assert loaded.metric_formulas[0].formula == "draw_weight_chosen * 2"
|
||||||
|
|
||||||
|
|
||||||
def test_import_merge_skips_existing_domain(repo):
|
def test_import_merge_skips_existing_domain(repo):
|
||||||
"""Merge import skips domains that already exist."""
|
"""Merge import skips domains that already exist."""
|
||||||
domain = Domain(
|
domain = Domain(
|
||||||
|
|||||||
Reference in New Issue
Block a user