From 8dfe3607b180afff957b8201b88c80d7daba263f Mon Sep 17 00:00:00 2001 From: Andrew Simonson Date: Thu, 19 Feb 2026 11:13:00 -0600 Subject: [PATCH] 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//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 --- .gitignore | 1 + src/physcom/db/repository.py | 237 ++++++++++++++++-- src/physcom/engine/constraint_resolver.py | 35 +++ src/physcom/engine/pipeline.py | 70 ++++-- src/physcom/llm/base.py | 8 +- src/physcom/llm/providers/gemini.py | 10 +- src/physcom/seed/transport_example.py | 10 + src/physcom_web/app.py | 28 ++- src/physcom_web/routes/domains.py | 102 +++++++- src/physcom_web/routes/pipeline.py | 2 +- src/physcom_web/routes/results.py | 13 + src/physcom_web/static/style.css | 2 + .../templates/domains/_metrics_table.html | 68 +++++ src/physcom_web/templates/domains/detail.html | 36 +++ src/physcom_web/templates/domains/form.html | 24 ++ src/physcom_web/templates/domains/list.html | 9 +- .../templates/pipeline/_run_status.html | 10 +- src/physcom_web/templates/pipeline/run.html | 2 +- src/physcom_web/templates/results/list.html | 11 +- tests/test_constraint_resolver.py | 64 +++++ 20 files changed, 675 insertions(+), 67 deletions(-) create mode 100644 src/physcom_web/templates/domains/_metrics_table.html create mode 100644 src/physcom_web/templates/domains/detail.html create mode 100644 src/physcom_web/templates/domains/form.html diff --git a/.gitignore b/.gitignore index ae73f95..a5bfdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,4 @@ cython_debug/ # Project data data/ +.aider* diff --git a/src/physcom/db/repository.py b/src/physcom/db/repository.py index 4f50bae..f23a243 100644 --- a/src/physcom/db/repository.py +++ b/src/physcom/db/repository.py @@ -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) diff --git a/src/physcom/engine/constraint_resolver.py b/src/physcom/engine/constraint_resolver.py index 493d15a..77fa929 100644 --- a/src/physcom/engine/constraint_resolver.py +++ b/src/physcom/engine/constraint_resolver.py @@ -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: diff --git a/src/physcom/engine/pipeline.py b/src/physcom/engine/pipeline.py index d3580ad..3a4b431 100644 --- a/src/physcom/engine/pipeline.py +++ b/src/physcom/engine/pipeline.py @@ -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]: diff --git a/src/physcom/llm/base.py b/src/physcom/llm/base.py index d8efb4e..2762134 100644 --- a/src/physcom/llm/base.py +++ b/src/physcom/llm/base.py @@ -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.""" diff --git a/src/physcom/llm/providers/gemini.py b/src/physcom/llm/providers/gemini.py index 81748f6..2b46575 100644 --- a/src/physcom/llm/providers/gemini.py +++ b/src/physcom/llm/providers/gemini.py @@ -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() diff --git a/src/physcom/seed/transport_example.py b/src/physcom/seed/transport_example.py index 75bb9cc..aea3ccf 100644 --- a/src/physcom/seed/transport_example.py +++ b/src/physcom/seed/transport_example.py @@ -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"), ], ), ] diff --git a/src/physcom_web/app.py b/src/physcom_web/app.py index 3a80562..b56fce3 100644 --- a/src/physcom_web/app.py +++ b/src/physcom_web/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import secrets from pathlib import Path from flask import Flask, g @@ -12,6 +13,29 @@ from physcom.db.repository import Repository DEFAULT_DB = Path("data/physcom.db") +_ENV_FILE = Path(".env") + + +def _load_or_generate_secret_key() -> str: + """Return FLASK_SECRET_KEY from env, .env file, or auto-generate and persist it.""" + key = os.environ.get("FLASK_SECRET_KEY", "").strip() + if key: + return key + + # Try to load from .env + if _ENV_FILE.exists(): + for line in _ENV_FILE.read_text().splitlines(): + line = line.strip() + if line.startswith("FLASK_SECRET_KEY="): + key = line[len("FLASK_SECRET_KEY="):].strip() + if key: + return key + + # Generate, persist, and return + key = secrets.token_hex(32) + with _ENV_FILE.open("a") as f: + f.write(f"FLASK_SECRET_KEY={key}\n") + return key def get_repo() -> Repository: @@ -31,7 +55,7 @@ def close_db(exc: BaseException | None = None) -> None: def create_app() -> Flask: app = Flask(__name__) - app.secret_key = os.environ.get("FLASK_SECRET_KEY", "physcom-dev-key") + app.secret_key = _load_or_generate_secret_key() app.teardown_appcontext(close_db) @@ -57,4 +81,4 @@ def create_app() -> Flask: def run() -> None: """Entry point for `physcom-web` script.""" app = create_app() - app.run(debug=True, port=int(os.environ.get("PORT", "5000"))) + app.run(host="0.0.0.0", debug=True, port=int(os.environ.get("PORT", "5000"))) diff --git a/src/physcom_web/routes/domains.py b/src/physcom_web/routes/domains.py index 3aeb218..6f8b326 100644 --- a/src/physcom_web/routes/domains.py +++ b/src/physcom_web/routes/domains.py @@ -1,9 +1,10 @@ -"""Domain listing routes.""" +"""Domain CRUD routes.""" from __future__ import annotations -from flask import Blueprint, render_template +from flask import Blueprint, flash, redirect, render_template, request, url_for +from physcom.models.domain import Domain, MetricBound from physcom_web.app import get_repo bp = Blueprint("domains", __name__, url_prefix="/domains") @@ -14,3 +15,100 @@ def domain_list(): repo = get_repo() domains = repo.list_domains() return render_template("domains/list.html", domains=domains) + + +@bp.route("/new", methods=["GET", "POST"]) +def domain_new(): + repo = get_repo() + if request.method == "POST": + name = request.form["name"].strip() + description = request.form.get("description", "").strip() + if not name: + flash("Name is required.", "error") + return render_template("domains/form.html") + domain = repo.add_domain(Domain(name=name, description=description)) + flash(f"Domain '{name}' created.", "success") + return redirect(url_for("domains.domain_detail", domain_id=domain.id)) + return render_template("domains/form.html") + + +@bp.route("/", methods=["GET", "POST"]) +def domain_detail(domain_id: int): + repo = get_repo() + domain = repo.get_domain_by_id(domain_id) + if not domain: + flash("Domain not found.", "error") + return redirect(url_for("domains.domain_list")) + if request.method == "POST": + name = request.form["name"].strip() + description = request.form.get("description", "").strip() + if not name: + flash("Name is required.", "error") + else: + repo.update_domain(domain_id, name, description) + flash(f"Domain '{name}' updated.", "success") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/detail.html", domain=domain) + + +@bp.route("//delete", methods=["POST"]) +def domain_delete(domain_id: int): + repo = get_repo() + domain = repo.get_domain_by_id(domain_id) + if domain: + repo.delete_domain(domain_id) + flash(f"Domain '{domain.name}' deleted.", "success") + return redirect(url_for("domains.domain_list")) + + +# ── Metric bound CRUD (HTMX partials) ──────────────────────── + + +@bp.route("//metrics/add", methods=["POST"]) +def metric_add(domain_id: int): + repo = get_repo() + metric_name = request.form["metric_name"].strip() + unit = request.form.get("unit", "").strip() + try: + weight = float(request.form.get("weight", "1.0")) + norm_min = float(request.form.get("norm_min", "0.0")) + norm_max = float(request.form.get("norm_max", "1.0")) + except ValueError: + flash("Weight, norm_min, and norm_max must be numbers.", "error") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/_metrics_table.html", domain=domain) + if not metric_name: + flash("Metric name is required.", "error") + else: + mb = MetricBound(metric_name=metric_name, weight=weight, norm_min=norm_min, norm_max=norm_max, unit=unit) + repo.add_metric_bound(domain_id, mb) + flash("Metric added.", "success") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/_metrics_table.html", domain=domain) + + +@bp.route("//metrics//edit", methods=["POST"]) +def metric_edit(domain_id: int, metric_id: int): + repo = get_repo() + try: + weight = float(request.form.get("weight", "1.0")) + norm_min = float(request.form.get("norm_min", "0.0")) + norm_max = float(request.form.get("norm_max", "1.0")) + except ValueError: + flash("Weight, norm_min, and norm_max must be numbers.", "error") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/_metrics_table.html", domain=domain) + unit = request.form.get("unit", "").strip() + repo.update_metric_bound(domain_id, metric_id, weight, norm_min, norm_max, unit) + flash("Metric updated.", "success") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/_metrics_table.html", domain=domain) + + +@bp.route("//metrics//delete", methods=["POST"]) +def metric_delete(domain_id: int, metric_id: int): + repo = get_repo() + repo.delete_metric_bound(domain_id, metric_id) + flash("Metric removed.", "success") + domain = repo.get_domain_by_id(domain_id) + return render_template("domains/_metrics_table.html", domain=domain) diff --git a/src/physcom_web/routes/pipeline.py b/src/physcom_web/routes/pipeline.py index 5d02408..23d175e 100644 --- a/src/physcom_web/routes/pipeline.py +++ b/src/physcom_web/routes/pipeline.py @@ -151,7 +151,7 @@ def run_cancel(run_id: int): """Set a running pipeline to cancelled. The pipeline checks this flag.""" repo = get_repo() run = repo.get_pipeline_run(run_id) - if run and run["status"] == "running": + if run and run["status"] in ("running", "rate_limited"): repo.update_pipeline_run(run_id, status="cancelled") flash(f"Run #{run_id} cancellation requested.", "info") return redirect(url_for("pipeline.pipeline_form")) diff --git a/src/physcom_web/routes/results.py b/src/physcom_web/routes/results.py index e87629d..a8e141f 100644 --- a/src/physcom_web/routes/results.py +++ b/src/physcom_web/routes/results.py @@ -65,6 +65,18 @@ def result_detail(domain_name: str, combo_id: int): ) +@bp.route("//reset", methods=["POST"]) +def reset_results(domain_name: str): + repo = get_repo() + domain = repo.get_domain(domain_name) + if not domain: + flash(f"Domain '{domain_name}' not found.", "error") + return redirect(url_for("results.results_index")) + count = repo.reset_domain_results(domain_name) + flash(f"Reset {count} result rows for '{domain_name}'. Ready to re-run.", "success") + return redirect(url_for("results.results_domain", domain_name=domain_name)) + + @bp.route("///review", methods=["POST"]) def submit_review(domain_name: str, combo_id: int): repo = get_repo() @@ -84,6 +96,7 @@ def submit_review(domain_name: str, combo_id: int): combo_id, domain.id, composite_score, pass_reached=5, novelty_flag=novelty_flag, + llm_review=existing.get("llm_review") if existing else None, human_notes=human_notes, ) repo.update_combination_status(combo_id, "reviewed") diff --git a/src/physcom_web/static/style.css b/src/physcom_web/static/style.css index d83c473..9aa71be 100644 --- a/src/physcom_web/static/style.css +++ b/src/physcom_web/static/style.css @@ -81,6 +81,7 @@ table.compact th, table.compact td { padding: 0.25rem 0.4rem; font-size: 0.85rem .badge-valid { background: #dcfce7; color: #166534; } .badge-blocked { background: #fee2e2; color: #991b1b; } .badge-scored { background: #dbeafe; color: #1e40af; } +.badge-llm_reviewed { background: #e0f2fe; color: #0369a1; } .badge-reviewed { background: #f3e8ff; color: #6b21a8; } .badge-pending { background: #fef3c7; color: #92400e; } @@ -171,6 +172,7 @@ dd { font-size: 0.9rem; } .badge-completed { background: #dcfce7; color: #166534; } .badge-failed { background: #fee2e2; color: #991b1b; } .badge-cancelled { background: #fef3c7; color: #92400e; } +.badge-rate_limited { background: #ffedd5; color: #9a3412; } .run-status { padding: 0.25rem 0; } .run-status-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } diff --git a/src/physcom_web/templates/domains/_metrics_table.html b/src/physcom_web/templates/domains/_metrics_table.html new file mode 100644 index 0000000..f6f33cb --- /dev/null +++ b/src/physcom_web/templates/domains/_metrics_table.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + {% for mb in domain.metric_bounds %} + + + + + + + + + + + + + + + + + + + {% endfor %} + +
MetricUnitWeightNorm MinNorm Max
{{ mb.metric_name }}{{ mb.unit or '—' }}{{ mb.weight }}{{ mb.norm_min }}{{ mb.norm_max }} + +
+ +
+
+ +

Add Metric

+
+
+ + + + + + +
+
diff --git a/src/physcom_web/templates/domains/detail.html b/src/physcom_web/templates/domains/detail.html new file mode 100644 index 0000000..35810b4 --- /dev/null +++ b/src/physcom_web/templates/domains/detail.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}{{ domain.name }} — PhysCom{% endblock %} + +{% block content %} + + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +

Metrics

+ +
+ {% include "domains/_metrics_table.html" %} +
+{% endblock %} diff --git a/src/physcom_web/templates/domains/form.html b/src/physcom_web/templates/domains/form.html new file mode 100644 index 0000000..09e1bad --- /dev/null +++ b/src/physcom_web/templates/domains/form.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}New Domain — PhysCom{% endblock %} + +{% block content %} +

New Domain

+ +
+
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+{% endblock %} diff --git a/src/physcom_web/templates/domains/list.html b/src/physcom_web/templates/domains/list.html index b69f2cc..ceba19a 100644 --- a/src/physcom_web/templates/domains/list.html +++ b/src/physcom_web/templates/domains/list.html @@ -2,14 +2,17 @@ {% block title %}Domains — PhysCom{% endblock %} {% block content %} -

Domains

+ {% if not domains %} -

No domains found. Seed data via CLI first.

+

No domains found. Create one or seed data via CLI.

{% else %} {% for d in domains %}
-

{{ d.name }}

+

{{ d.name }}

{{ d.description }}

diff --git a/src/physcom_web/templates/pipeline/_run_status.html b/src/physcom_web/templates/pipeline/_run_status.html index 08fb13f..b7d8d28 100644 --- a/src/physcom_web/templates/pipeline/_run_status.html +++ b/src/physcom_web/templates/pipeline/_run_status.html @@ -1,6 +1,6 @@ {# HTMX partial: live status for a single pipeline run #}
{{ run.error_message }}
{% endif %} + {% if run.status == 'rate_limited' %} +
+ Rate limited — waiting for quota to refresh, then resuming automatically. +
+ {% endif %} +
- {% if run.status == 'running' %} + {% if run.status in ('running', 'rate_limited') %}
diff --git a/src/physcom_web/templates/pipeline/run.html b/src/physcom_web/templates/pipeline/run.html index b84b76f..4544876 100644 --- a/src/physcom_web/templates/pipeline/run.html +++ b/src/physcom_web/templates/pipeline/run.html @@ -74,7 +74,7 @@
-{% set active_runs = runs | selectattr('status', 'in', ['pending', 'running']) | list %} +{% set active_runs = runs | selectattr('status', 'in', ['pending', 'running', 'rate_limited']) | list %} {% if active_runs %}

Active Runs

{% for run in active_runs %} diff --git a/src/physcom_web/templates/results/list.html b/src/physcom_web/templates/results/list.html index d7883ff..aacf4c2 100644 --- a/src/physcom_web/templates/results/list.html +++ b/src/physcom_web/templates/results/list.html @@ -15,7 +15,14 @@ {% if domain and results is not none %}
-

{{ domain.name }} {{ domain.description }}

+ {% if statuses %}
@@ -53,7 +60,7 @@ {% for r in results %}
- +
{{ loop.index }}{{ "%.4f"|format(r.composite_score) if r.composite_score else '—' }}{{ "%.4f"|format(r.composite_score) if r.composite_score is not none else '—' }} {{ r.combination.entities|map(attribute='name')|join(' + ') }} {{ r.combination.status }} diff --git a/tests/test_constraint_resolver.py b/tests/test_constraint_resolver.py index abbd489..0daca05 100644 --- a/tests/test_constraint_resolver.py +++ b/tests/test_constraint_resolver.py @@ -119,3 +119,67 @@ def test_hydrogen_bicycle_valid(bicycle, hydrogen_engine): # This is actually a borderline case — let's just verify no hard physics blocks range_blocks = [v for v in result.violations if "mutually exclusive" in v or "atmosphere" in v] assert len(range_blocks) == 0 + + +def test_energy_density_deficit_blocks(): + """A platform needing 2000 Wh/kg paired with a 200 Wh/kg battery → blocked.""" + platform = Entity( + name="Spaceship", dimension="platform", + dependencies=[ + Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"), + ], + ) + power = Entity( + name="Battery", dimension="power_source", + dependencies=[ + Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"), + ], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[platform, power]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("energy density deficit" in v for v in result.violations) + + +def test_energy_density_under_density_warning(): + """A platform needing 400 Wh/kg paired with a 200 Wh/kg battery → conditional.""" + platform = Entity( + name="Airplane", dimension="platform", + dependencies=[ + Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "range_min"), + ], + ) + power = Entity( + name="Battery", dimension="power_source", + dependencies=[ + Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"), + ], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[platform, power]) + result = resolver.resolve(combo) + assert result.status != "blocked" + assert any("under-density" in w for w in result.warnings) + + +def test_energy_density_no_constraint_if_no_provider(): + """A platform with energy density requirement but no declared provider → no violation.""" + platform = Entity( + name="Spaceship", dimension="platform", + dependencies=[ + Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"), + ], + ) + # Solar Sail-style: no energy_density_wh_kg declared + power = Entity( + name="Solar Sail", dimension="power_source", + dependencies=[ + Dependency("force", "force_output_watts", "1", "watts", "provides"), + ], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[platform, power]) + result = resolver.resolve(combo) + density_violations = [v for v in result.violations if "energy density" in v] + assert len(density_violations) == 0