Add domain CRUD, energy density constraint, LLM status, reset results, score display fixes

Domain management:
- Add domain list/detail/form templates and full CRUD routes (domains.py)
- Add metric bound add/edit/delete via HTMX partials (_metrics_table.html)

Energy density constraint (Rule 6 in ConstraintResolver):
- Hard-block combos where power source provides <25% of platform's required Wh/kg
- Warn (conditional) when under-density but within 4x
- Solar Sail exempt (no stored energy); Airplane requires 400 Wh/kg, Spaceship 2000 Wh/kg
- Add energy_density_wh_kg provides to all 8 stored-energy power sources in seed data
- 3 new constraint resolver tests

LLM-complete status:
- Pipeline Pass 4 now sets combo status to llm_reviewed after successful LLM review
- update_combination_status guards against downgrading: scored won't overwrite
  llm_reviewed or reviewed; llm_reviewed won't overwrite reviewed
- Add badge-llm_reviewed CSS style (light blue)

Reset results:
- Repository.reset_domain_results() deletes combination_results, combination_scores,
  and pipeline_runs for a domain; pipeline re-evaluates on next run
- POST /results/<domain>/reset route with flash confirmation
- "Reset results" danger button with JS confirm dialog in results list

Fix composite score 0 displaying as --- (Jinja2 falsy 0.0 bug):
- Change `if r.composite_score` to `if r.composite_score is not none`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 11:13:00 -06:00
parent ee885b2390
commit 8dfe3607b1
20 changed files with 675 additions and 67 deletions

View File

@@ -125,8 +125,20 @@ class Repository:
self.conn.commit()
def delete_entity(self, entity_id: int) -> None:
combo_ids = [
r["combination_id"]
for r in self.conn.execute(
"SELECT combination_id FROM combination_entities WHERE entity_id = ?",
(entity_id,),
).fetchall()
]
if combo_ids:
ph = ",".join("?" * len(combo_ids))
self.conn.execute(f"DELETE FROM combination_results WHERE combination_id IN ({ph})", combo_ids)
self.conn.execute(f"DELETE FROM combination_scores WHERE combination_id IN ({ph})", combo_ids)
self.conn.execute(f"DELETE FROM combination_entities WHERE combination_id IN ({ph})", combo_ids)
self.conn.execute(f"DELETE FROM combinations WHERE id IN ({ph})", combo_ids)
self.conn.execute("DELETE FROM dependencies WHERE entity_id = ?", (entity_id,))
self.conn.execute("DELETE FROM combination_entities WHERE entity_id = ?", (entity_id,))
self.conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,))
self.conn.commit()
@@ -228,6 +240,99 @@ class Repository:
rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall()
return [self.get_domain(r["name"]) for r in rows]
def get_domain_by_id(self, domain_id: int) -> Domain | None:
row = self.conn.execute("SELECT * FROM domains WHERE id = ?", (domain_id,)).fetchone()
if not row:
return None
weights = self.conn.execute(
"""SELECT m.name, m.unit, dmw.weight, dmw.norm_min, dmw.norm_max, dmw.metric_id
FROM domain_metric_weights dmw
JOIN metrics m ON dmw.metric_id = m.id
WHERE dmw.domain_id = ?""",
(row["id"],),
).fetchall()
return Domain(
id=row["id"],
name=row["name"],
description=row["description"] or "",
metric_bounds=[
MetricBound(
metric_name=w["name"], weight=w["weight"],
norm_min=w["norm_min"], norm_max=w["norm_max"],
metric_id=w["metric_id"], unit=w["unit"] or "",
)
for w in weights
],
)
def update_domain(self, domain_id: int, name: str, description: str) -> None:
self.conn.execute(
"UPDATE domains SET name = ?, description = ? WHERE id = ?",
(name, description, domain_id),
)
self.conn.commit()
def add_metric_bound(self, domain_id: int, mb: MetricBound) -> MetricBound:
metric_id = self.ensure_metric(mb.metric_name, mb.unit)
mb.metric_id = metric_id
self.conn.execute(
"""INSERT OR REPLACE INTO domain_metric_weights
(domain_id, metric_id, weight, norm_min, norm_max)
VALUES (?, ?, ?, ?, ?)""",
(domain_id, metric_id, mb.weight, mb.norm_min, mb.norm_max),
)
self.conn.commit()
return mb
def update_metric_bound(
self, domain_id: int, metric_id: int, weight: float, norm_min: float, norm_max: float, unit: str
) -> None:
self.conn.execute(
"""UPDATE domain_metric_weights
SET weight = ?, norm_min = ?, norm_max = ?
WHERE domain_id = ? AND metric_id = ?""",
(weight, norm_min, norm_max, domain_id, metric_id),
)
if unit:
self.conn.execute(
"UPDATE metrics SET unit = ? WHERE id = ?",
(unit, metric_id),
)
self.conn.commit()
def delete_metric_bound(self, domain_id: int, metric_id: int) -> None:
self.conn.execute(
"DELETE FROM domain_metric_weights WHERE domain_id = ? AND metric_id = ?",
(domain_id, metric_id),
)
self.conn.commit()
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 combination_results 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 domains WHERE id = ?", (domain_id,))
self.conn.commit()
def reset_domain_results(self, domain_name: str) -> int:
"""Delete all pipeline results for a domain so it can be re-run from scratch.
Returns the number of result rows deleted.
"""
domain = self.get_domain(domain_name)
if not domain:
return 0
count = self.conn.execute(
"SELECT COUNT(*) FROM combination_results WHERE domain_id = ?",
(domain.id,),
).fetchone()[0]
self.conn.execute("DELETE FROM combination_scores WHERE domain_id = ?", (domain.id,))
self.conn.execute("DELETE FROM combination_results WHERE domain_id = ?", (domain.id,))
self.conn.execute("DELETE FROM pipeline_runs WHERE domain_id = ?", (domain.id,))
self.conn.commit()
return count
# ── Combinations ────────────────────────────────────────────
@staticmethod
@@ -265,13 +370,17 @@ class Repository:
def update_combination_status(
self, combo_id: int, status: str, block_reason: str | None = None
) -> None:
# Don't downgrade 'reviewed' to 'scored' — preserve human review state
if status == "scored":
# Don't downgrade from higher pass states — preserves human/LLM review data
if status in ("scored", "llm_reviewed"):
row = self.conn.execute(
"SELECT status FROM combinations WHERE id = ?", (combo_id,)
).fetchone()
if row and row["status"] == "reviewed":
return
if row:
cur = row["status"]
if status == "scored" and cur in ("llm_reviewed", "reviewed"):
return
if status == "llm_reviewed" and cur == "reviewed":
return
self.conn.execute(
"UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?",
(status, block_reason, combo_id),
@@ -292,6 +401,60 @@ class Repository:
block_reason=row["block_reason"], entities=entities,
)
def _bulk_load_combinations(self, combo_ids: list[int]) -> dict[int, Combination]:
"""Load multiple Combinations in O(4) queries instead of O(N*M)."""
if not combo_ids:
return {}
ph = ",".join("?" * len(combo_ids))
combo_rows = self.conn.execute(
f"SELECT * FROM combinations WHERE id IN ({ph})", combo_ids
).fetchall()
combos: dict[int, Combination] = {
r["id"]: Combination(
id=r["id"], hash=r["hash"], status=r["status"],
block_reason=r["block_reason"], entities=[],
)
for r in combo_rows
}
ce_rows = self.conn.execute(
f"SELECT combination_id, entity_id FROM combination_entities WHERE combination_id IN ({ph})",
combo_ids,
).fetchall()
combo_to_eids: dict[int, list[int]] = {}
for r in ce_rows:
combo_to_eids.setdefault(r["combination_id"], []).append(r["entity_id"])
entity_ids = list({r["entity_id"] for r in ce_rows})
if entity_ids:
eph = ",".join("?" * len(entity_ids))
entity_rows = self.conn.execute(
f"""SELECT e.id, e.name, e.description, d.name as dimension, e.dimension_id
FROM entities e JOIN dimensions d ON e.dimension_id = d.id
WHERE e.id IN ({eph})""",
entity_ids,
).fetchall()
dep_rows = self.conn.execute(
f"SELECT * FROM dependencies WHERE entity_id IN ({eph})", entity_ids
).fetchall()
deps_by_entity: dict[int, list[Dependency]] = {}
for r in dep_rows:
deps_by_entity.setdefault(r["entity_id"], []).append(Dependency(
id=r["id"], category=r["category"], key=r["key"],
value=r["value"], unit=r["unit"], constraint_type=r["constraint_type"],
))
entities_by_id: dict[int, Entity] = {
r["id"]: Entity(
id=r["id"], name=r["name"], description=r["description"] or "",
dimension=r["dimension"], dimension_id=r["dimension_id"],
dependencies=deps_by_entity.get(r["id"], []),
)
for r in entity_rows
}
for cid, eids in combo_to_eids.items():
if cid in combos:
combos[cid].entities = [entities_by_id[eid] for eid in eids if eid in entities_by_id]
return combos
def list_combinations(self, status: str | None = None) -> list[Combination]:
if status:
rows = self.conn.execute(
@@ -299,7 +462,9 @@ class Repository:
).fetchall()
else:
rows = self.conn.execute("SELECT id FROM combinations ORDER BY id").fetchall()
return [self.get_combination(r["id"]) for r in rows]
ids = [r["id"] for r in rows]
combos = self._bulk_load_combinations(ids)
return [combos[i] for i in ids if i in combos]
# ── Scores & Results ────────────────────────────────────────
@@ -385,9 +550,13 @@ class Repository:
).fetchone()
if not row or row["total"] == 0:
return None
# Also count blocked combos (they have no results but exist)
blocked = self.conn.execute(
"SELECT COUNT(*) as cnt FROM combinations WHERE status = 'blocked'"
"""SELECT COUNT(*) as cnt
FROM combinations c
JOIN combination_results cr ON cr.combination_id = c.id
JOIN domains d ON cr.domain_id = d.id
WHERE c.status = 'blocked' AND d.name = ?""",
(domain_name,),
).fetchone()
return {
"total_results": row["total"],
@@ -422,19 +591,20 @@ class Repository:
params.append(status)
query += " ORDER BY cr.composite_score DESC"
rows = self.conn.execute(query, params).fetchall()
results = []
for r in rows:
combo = self.get_combination(r["combination_id"])
results.append({
"combination": combo,
combo_ids = [r["combination_id"] for r in rows]
combos = self._bulk_load_combinations(combo_ids)
return [
{
"combination": combos.get(r["combination_id"]),
"composite_score": r["composite_score"],
"novelty_flag": r["novelty_flag"],
"llm_review": r["llm_review"],
"human_notes": r["human_notes"],
"pass_reached": r["pass_reached"],
"domain_id": r["domain_id"],
})
return results
}
for r in rows
]
def get_top_results(self, domain_name: str, limit: int = 10) -> list[dict]:
"""Return top-N results for a domain, ordered by composite_score DESC."""
@@ -448,18 +618,30 @@ class Repository:
LIMIT ?""",
(domain_name, limit),
).fetchall()
results = []
for r in rows:
combo = self.get_combination(r["combination_id"])
results.append({
"combination": combo,
combo_ids = [r["combination_id"] for r in rows]
combos = self._bulk_load_combinations(combo_ids)
return [
{
"combination": combos.get(r["combination_id"]),
"composite_score": r["composite_score"],
"novelty_flag": r["novelty_flag"],
"llm_review": r["llm_review"],
"human_notes": r["human_notes"],
"pass_reached": r["pass_reached"],
})
return results
}
for r in rows
]
def get_results_for_combination(self, combo_id: int) -> list[dict]:
"""Return all domain results for a combination."""
rows = self.conn.execute(
"""SELECT cr.*, d.name as domain_name
FROM combination_results cr
JOIN domains d ON cr.domain_id = d.id
WHERE cr.combination_id = ?""",
(combo_id,),
).fetchall()
return [dict(r) for r in rows]
# ── Pipeline Runs ────────────────────────────────────────
@@ -473,10 +655,19 @@ class Repository:
self.conn.commit()
return cur.lastrowid
_PIPELINE_RUN_UPDATABLE = frozenset({
"status", "total_combos", "combos_pass1", "combos_pass2",
"combos_pass3", "combos_pass4", "current_pass",
"error_message", "started_at", "completed_at",
})
def update_pipeline_run(self, run_id: int, **fields) -> None:
"""Update arbitrary fields on a pipeline_run."""
"""Update fields on a pipeline_run. Only allowlisted column names are accepted."""
if not fields:
return
invalid = set(fields) - self._PIPELINE_RUN_UPDATABLE
if invalid:
raise ValueError(f"Invalid pipeline_run fields: {invalid}")
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values())
values.append(run_id)

View File

@@ -42,6 +42,7 @@ class ConstraintResolver:
self._check_mutual_exclusion(all_deps, result)
self._check_range_incompatibility(all_deps, result)
self._check_force_scale(combination, result)
self._check_energy_density(combination, result)
self._check_unmet_requirements(all_deps, result)
if result.violations:
@@ -155,6 +156,40 @@ class ConstraintResolver:
f"(under-powered)"
)
def _check_energy_density(
self, combination: Combination, result: ConstraintResult
) -> None:
"""Rule 6: If power source energy density << platform minimum → warn/block.
Uses a 25% threshold: below 25% of required → hard block (> 4x deficit).
"""
density_provided: list[tuple[str, float]] = []
density_required: list[tuple[str, float]] = []
for entity in combination.entities:
for dep in entity.dependencies:
if dep.key == "energy_density_wh_kg" and dep.constraint_type == "provides":
density_provided.append((entity.name, float(dep.value)))
elif dep.key == "energy_density_wh_kg" and dep.constraint_type == "range_min":
density_required.append((entity.name, float(dep.value)))
for req_name, req_density in density_required:
if not density_provided:
continue # No stored energy source in this combo — skip check
for prov_name, prov_density in density_provided:
if prov_density < req_density * 0.25:
result.violations.append(
f"{prov_name} provides {prov_density:.0f} Wh/kg but "
f"{req_name} requires {req_density:.0f} Wh/kg "
f"(energy density deficit > 4x)"
)
elif prov_density < req_density:
result.warnings.append(
f"{prov_name} provides {prov_density:.0f} Wh/kg but "
f"{req_name} requires {req_density:.0f} Wh/kg "
f"(under-density)"
)
def _check_unmet_requirements(
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
) -> None:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -293,22 +294,36 @@ class Pipeline:
for s in db_scores
if s["normalized_score"] is not None
}
review = self.llm.review_plausibility(
description, score_dict
)
self.repo.save_result(
combo.id,
domain.id,
cur_result["composite_score"],
pass_reached=4,
novelty_flag=cur_result.get("novelty_flag"),
llm_review=review,
human_notes=cur_result.get("human_notes"),
)
result.pass4_reviewed += 1
self._update_run_counters(
run_id, result, current_pass=4
)
review: str | None = None
try:
review = self.llm.review_plausibility(
description, score_dict
)
except LLMRateLimitError as exc:
self._wait_for_rate_limit(run_id, exc.retry_after)
try:
review = self.llm.review_plausibility(
description, score_dict
)
except LLMRateLimitError:
pass # still limited; skip, retry next run
if review is not None:
self.repo.save_result(
combo.id,
domain.id,
cur_result["composite_score"],
pass_reached=4,
novelty_flag=cur_result.get("novelty_flag"),
llm_review=review,
human_notes=cur_result.get("human_notes"),
)
self.repo.update_combination_status(
combo.id, "llm_reviewed"
)
result.pass4_reviewed += 1
self._update_run_counters(
run_id, result, current_pass=4
)
except CancelledError:
if run_id is not None:
@@ -319,17 +334,6 @@ class Pipeline:
)
result.top_results = self.repo.get_top_results(domain.name, limit=20)
return result
except LLMRateLimitError:
# Rate limit hit — save progress and let the user re-run to continue.
# Already-reviewed combos are persisted; resumability skips them next time.
if run_id is not None:
self.repo.update_pipeline_run(
run_id,
status="completed",
completed_at=datetime.now(timezone.utc).isoformat(),
)
result.top_results = self.repo.get_top_results(domain.name, limit=20)
return result
# Mark run as completed
if run_id is not None:
@@ -342,6 +346,18 @@ class Pipeline:
result.top_results = self.repo.get_top_results(domain.name, limit=20)
return result
def _wait_for_rate_limit(self, run_id: int | None, retry_after: int) -> None:
"""Mark run rate_limited, sleep with cancel checks, then resume."""
if run_id is not None:
self.repo.update_pipeline_run(run_id, status="rate_limited")
waited = 0
while waited < retry_after:
time.sleep(5)
waited += 5
self._check_cancelled(run_id)
if run_id is not None:
self.repo.update_pipeline_run(run_id, status="running")
def _stub_estimate(
self, combo: Combination, metric_names: list[str]
) -> dict[str, float]:

View File

@@ -8,10 +8,14 @@ from abc import ABC, abstractmethod
class LLMRateLimitError(Exception):
"""Raised by a provider when the API rate limit is exceeded.
The pipeline catches this to stop gracefully and let the user re-run
to continue from where it left off.
The pipeline catches this, waits retry_after seconds (checking for
cancellation), then retries. retry_after defaults to 65s if unknown.
"""
def __init__(self, message: str, retry_after: int = 65) -> None:
super().__init__(message)
self.retry_after = retry_after
class LLMProvider(ABC):
"""Abstract LLM interface for physics estimation and plausibility review."""

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import re
import math
from physcom.llm.base import LLMProvider, LLMRateLimitError
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
@@ -35,7 +36,7 @@ class GeminiLLMProvider(LLMProvider):
)
except Exception as exc:
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
raise LLMRateLimitError(str(exc)) from exc
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
raise
return self._parse_json(response.text, metrics)
@@ -53,10 +54,15 @@ class GeminiLLMProvider(LLMProvider):
)
except Exception as exc:
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
raise LLMRateLimitError(str(exc)) from exc
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
raise
return response.text.strip()
def _parse_retry_after(self, exc: Exception) -> int:
"""Extract retry delay from the error message, with a safe default."""
m = re.search(r"retry in (\d+(?:\.\d+)?)", str(exc))
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()

View File

@@ -34,6 +34,7 @@ PLATFORMS: list[Entity] = [
Dependency("force", "force_required_watts", "100000", "watts", "range_min"),
Dependency("infrastructure", "runway", "true", None, "requires"),
Dependency("environment", "medium", "air", None, "requires"),
Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "range_min"),
],
),
Entity(
@@ -111,6 +112,7 @@ PLATFORMS: list[Entity] = [
Dependency("force", "force_required_watts", "1000000", "watts", "range_min"),
Dependency("infrastructure", "launch_facility", "true", None, "requires"),
Dependency("environment", "medium", "space", None, "requires"),
Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"),
],
),
Entity(
@@ -139,6 +141,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("environment", "atmosphere", "standard", None, "requires"),
Dependency("physical", "mass_kg", "50", "kg", "range_min"),
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "1500", "Wh/kg", "provides"),
],
),
Entity(
@@ -150,6 +153,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("infrastructure", "fuel_infrastructure", "charging_station", None, "requires"),
Dependency("physical", "mass_kg", "10", "kg", "range_min"),
Dependency("force", "thrust_profile", "moderate_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
],
),
Entity(
@@ -161,6 +165,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("infrastructure", "fuel_infrastructure", "hydrogen_station", None, "requires"),
Dependency("physical", "mass_kg", "30", "kg", "range_min"),
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "600", "Wh/kg", "provides"),
],
),
Entity(
@@ -172,6 +177,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"),
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
],
),
Entity(
@@ -184,6 +190,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("physical", "mass_kg", "2000", "kg", "range_min"),
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
Dependency("material", "radiation_shielding", "true", None, "requires"),
Dependency("physical", "energy_density_wh_kg", "500000", "Wh/kg", "provides"),
],
),
Entity(
@@ -196,6 +203,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("environment", "atmosphere", "standard", None, "requires"),
Dependency("physical", "mass_kg", "500", "kg", "range_min"),
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "provides"),
],
),
Entity(
@@ -220,6 +228,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("infrastructure", "fuel_infrastructure", "ammunition", None, "requires"),
Dependency("physical", "mass_kg", "100", "kg", "range_min"),
Dependency("force", "thrust_profile", "high_burst", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
],
),
Entity(
@@ -232,6 +241,7 @@ POWER_SOURCES: list[Entity] = [
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
Dependency("environment", "ground_surface", "true", None, "requires"),
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
],
),
]