From 8118a62242b2bab6bb66adb41bdaad1ece4a01da Mon Sep 17 00:00:00 2001 From: "Simonson, Andrew" Date: Wed, 18 Feb 2026 13:59:53 -0600 Subject: [PATCH] Add Flask web UI, Docker Compose, core engine + tests - physcom core: CLI, 5-pass pipeline, SQLite repo, 37 tests - physcom_web: Flask app with HTMX for entity/domain/pipeline/results CRUD - Docker Compose: web + cli services sharing a named volume for the DB - Clean up local settings to use wildcard permissions Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 7 +- .dockerignore | 10 + .gitignore | 3 + Dockerfile | 14 + docker-compose.yml | 34 ++ pyproject.toml | 30 ++ src/physcom/__init__.py | 1 + src/physcom/__main__.py | 4 + src/physcom/cli.py | 268 ++++++++++++ src/physcom/db/__init__.py | 0 src/physcom/db/repository.py | 414 ++++++++++++++++++ src/physcom/db/schema.py | 111 +++++ src/physcom/engine/__init__.py | 0 src/physcom/engine/combinator.py | 33 ++ src/physcom/engine/constraint_resolver.py | 180 ++++++++ src/physcom/engine/pipeline.py | 246 +++++++++++ src/physcom/engine/scorer.py | 89 ++++ src/physcom/llm/__init__.py | 0 src/physcom/llm/base.py | 25 ++ src/physcom/llm/prompts.py | 37 ++ src/physcom/llm/providers/__init__.py | 0 src/physcom/llm/providers/mock.py | 28 ++ src/physcom/models/__init__.py | 9 + src/physcom/models/combination.py | 45 ++ src/physcom/models/domain.py | 26 ++ src/physcom/models/entity.py | 37 ++ src/physcom/seed/__init__.py | 0 src/physcom/seed/transport_example.py | 286 ++++++++++++ src/physcom_web/__init__.py | 1 + src/physcom_web/__main__.py | 4 + src/physcom_web/app.py | 60 +++ src/physcom_web/routes/__init__.py | 0 src/physcom_web/routes/domains.py | 16 + src/physcom_web/routes/entities.py | 128 ++++++ src/physcom_web/routes/pipeline.py | 56 +++ src/physcom_web/routes/results.py | 95 ++++ src/physcom_web/static/style.css | 156 +++++++ src/physcom_web/templates/base.html | 35 ++ src/physcom_web/templates/domains/list.html | 34 ++ .../templates/entities/_dep_table.html | 80 ++++ .../templates/entities/detail.html | 28 ++ src/physcom_web/templates/entities/form.html | 38 ++ src/physcom_web/templates/entities/list.html | 43 ++ src/physcom_web/templates/pipeline/run.html | 60 +++ .../templates/results/_review_done.html | 7 + .../templates/results/_review_form.html | 22 + src/physcom_web/templates/results/detail.html | 82 ++++ src/physcom_web/templates/results/list.html | 69 +++ tests/conftest.py | 158 +++++++ tests/test_combinator.py | 26 ++ tests/test_constraint_resolver.py | 121 +++++ tests/test_pipeline.py | 72 +++ tests/test_repository.py | 88 ++++ tests/test_scorer.py | 90 ++++ 54 files changed, 3505 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/physcom/__init__.py create mode 100644 src/physcom/__main__.py create mode 100644 src/physcom/cli.py create mode 100644 src/physcom/db/__init__.py create mode 100644 src/physcom/db/repository.py create mode 100644 src/physcom/db/schema.py create mode 100644 src/physcom/engine/__init__.py create mode 100644 src/physcom/engine/combinator.py create mode 100644 src/physcom/engine/constraint_resolver.py create mode 100644 src/physcom/engine/pipeline.py create mode 100644 src/physcom/engine/scorer.py create mode 100644 src/physcom/llm/__init__.py create mode 100644 src/physcom/llm/base.py create mode 100644 src/physcom/llm/prompts.py create mode 100644 src/physcom/llm/providers/__init__.py create mode 100644 src/physcom/llm/providers/mock.py create mode 100644 src/physcom/models/__init__.py create mode 100644 src/physcom/models/combination.py create mode 100644 src/physcom/models/domain.py create mode 100644 src/physcom/models/entity.py create mode 100644 src/physcom/seed/__init__.py create mode 100644 src/physcom/seed/transport_example.py create mode 100644 src/physcom_web/__init__.py create mode 100644 src/physcom_web/__main__.py create mode 100644 src/physcom_web/app.py create mode 100644 src/physcom_web/routes/__init__.py create mode 100644 src/physcom_web/routes/domains.py create mode 100644 src/physcom_web/routes/entities.py create mode 100644 src/physcom_web/routes/pipeline.py create mode 100644 src/physcom_web/routes/results.py create mode 100644 src/physcom_web/static/style.css create mode 100644 src/physcom_web/templates/base.html create mode 100644 src/physcom_web/templates/domains/list.html create mode 100644 src/physcom_web/templates/entities/_dep_table.html create mode 100644 src/physcom_web/templates/entities/detail.html create mode 100644 src/physcom_web/templates/entities/form.html create mode 100644 src/physcom_web/templates/entities/list.html create mode 100644 src/physcom_web/templates/pipeline/run.html create mode 100644 src/physcom_web/templates/results/_review_done.html create mode 100644 src/physcom_web/templates/results/_review_form.html create mode 100644 src/physcom_web/templates/results/detail.html create mode 100644 src/physcom_web/templates/results/list.html create mode 100644 tests/conftest.py create mode 100644 tests/test_combinator.py create mode 100644 tests/test_constraint_resolver.py create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_repository.py create mode 100644 tests/test_scorer.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 78ec196..07f16a9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,12 @@ { "permissions": { "allow": [ - "Bash(git -C \"/c/Users/simonan/OneDrive - Ecolab/Documents/Github/physicalCombinatorics\" log --oneline --all)" + "Bash(git *)", + "Bash(pip install *)", + "Bash(python -m pytest *)", + "Bash(python -m physcom *)", + "Bash(python -m physcom_web *)", + "Bash(python -c *)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7c3b477 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +data/ +.git/ +.claude/ +.venv/ +*.md diff --git a/.gitignore b/.gitignore index 36b13f1..ae73f95 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ cython_debug/ # PyPI configuration file .pypirc +# Project data +data/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d42b91d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim AS base + +WORKDIR /app + +COPY pyproject.toml . +COPY src/ src/ + +RUN pip install --no-cache-dir ".[web]" + +VOLUME /app/data +ENV PHYSCOM_DB=/app/data/physcom.db + +EXPOSE 5000 +CMD ["python", "-m", "physcom_web"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e1ec4e7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + web: + build: . + ports: + - "5000:5000" + volumes: + - physcom-data:/app/data + environment: + - PHYSCOM_DB=/app/data/physcom.db + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-physcom-dev-key} + restart: unless-stopped + + cli: + build: . + volumes: + - physcom-data:/app/data + environment: + - PHYSCOM_DB=/app/data/physcom.db + entrypoint: ["python", "-m", "physcom"] + profiles: [cli] + + # Future: replace SQLite with a dedicated DB service + # db: + # image: postgres:16 + # volumes: + # - pgdata:/var/lib/postgresql/data + # environment: + # - POSTGRES_DB=physcom + # - POSTGRES_USER=physcom + # - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + +volumes: + physcom-data: + # pgdata: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..75854ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "physcom" +version = "0.1.0" +description = "Physical Combinatorics — innovation via attribute mixing" +requires-python = ">=3.11" +dependencies = [ + "click>=8.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] +web = [ + "flask>=3.0", +] + +[project.scripts] +physcom = "physcom.cli:main" +physcom-web = "physcom_web.app:run" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/physcom/__init__.py b/src/physcom/__init__.py new file mode 100644 index 0000000..f3cc708 --- /dev/null +++ b/src/physcom/__init__.py @@ -0,0 +1 @@ +"""Physical Combinatorics — innovation via attribute mixing.""" diff --git a/src/physcom/__main__.py b/src/physcom/__main__.py new file mode 100644 index 0000000..8aa1298 --- /dev/null +++ b/src/physcom/__main__.py @@ -0,0 +1,4 @@ +"""Allow `python -m physcom` to work.""" +from physcom.cli import main + +main() diff --git a/src/physcom/cli.py b/src/physcom/cli.py new file mode 100644 index 0000000..5bf0bcc --- /dev/null +++ b/src/physcom/cli.py @@ -0,0 +1,268 @@ +"""CLI entry point for Physical Combinatorics.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click + +from physcom.db.schema import init_db +from physcom.db.repository import Repository + +DEFAULT_DB = Path("data/physcom.db") + + +def _get_repo(db_path: str | None = None) -> Repository: + path = Path(db_path) if db_path else DEFAULT_DB + conn = init_db(path) + return Repository(conn) + + +@click.group() +@click.option("--db", default=None, help="Path to SQLite database") +@click.pass_context +def main(ctx, db): + """Physical Combinatorics — innovation via attribute mixing.""" + ctx.ensure_object(dict) + ctx.obj["db"] = db + + +@main.command() +@click.pass_context +def init(ctx): + """Create database and initialize schema.""" + repo = _get_repo(ctx.obj["db"]) + click.echo(f"Database initialized at {repo.conn.execute('PRAGMA database_list').fetchone()[2]}") + + +@main.command() +@click.argument("seed_name") +@click.pass_context +def seed(ctx, seed_name): + """Load seed data (e.g., 'transport').""" + repo = _get_repo(ctx.obj["db"]) + if seed_name == "transport": + from physcom.seed.transport_example import load_transport_seed + counts = load_transport_seed(repo) + click.echo(f"Loaded transport seed: {counts}") + else: + click.echo(f"Unknown seed: {seed_name}. Available: transport") + + +@main.group() +def entity(): + """Manage entities.""" + pass + + +@entity.command("list") +@click.option("--dimension", "-d", default=None, help="Filter by dimension") +@click.pass_context +def entity_list(ctx, dimension): + """List entities.""" + repo = _get_repo(ctx.obj["db"]) + entities = repo.list_entities(dimension=dimension) + if not entities: + click.echo("No entities found.") + return + current_dim = None + for e in entities: + if e.dimension != current_dim: + current_dim = e.dimension + click.echo(f"\n[{current_dim}]") + dep_count = len(e.dependencies) + click.echo(f" {e.id:3d}. {e.name} ({dep_count} deps)") + + +@entity.command("add") +@click.argument("dimension") +@click.argument("name") +@click.option("--description", "-d", default="", help="Entity description") +@click.pass_context +def entity_add(ctx, dimension, name, description): + """Add an entity to a dimension.""" + from physcom.models.entity import Entity + repo = _get_repo(ctx.obj["db"]) + entity = Entity(name=name, dimension=dimension, description=description) + repo.add_entity(entity) + click.echo(f"Added entity '{name}' to dimension '{dimension}' (id={entity.id})") + + +@main.group() +def domain(): + """Manage domains.""" + pass + + +@domain.command("list") +@click.pass_context +def domain_list(ctx): + """List domains.""" + repo = _get_repo(ctx.obj["db"]) + domains = repo.list_domains() + if not domains: + click.echo("No domains found.") + return + for d in domains: + metric_info = ", ".join( + f"{mb.metric_name}(w={mb.weight})" for mb in d.metric_bounds + ) + click.echo(f" {d.name}: {d.description}") + click.echo(f" metrics: {metric_info}") + + +@main.command() +@click.argument("domain_name") +@click.option("--passes", "-p", default="1,2,3", help="Comma-separated pass numbers to run") +@click.option("--threshold", "-t", default=0.1, type=float, help="Score threshold for pass 3") +@click.option("--dimensions", "-d", default="platform,power_source", + help="Comma-separated dimension names") +@click.pass_context +def run(ctx, domain_name, passes, threshold, dimensions): + """Run the viability pipeline for a domain.""" + repo = _get_repo(ctx.obj["db"]) + domain = repo.get_domain(domain_name) + if not domain: + click.echo(f"Domain '{domain_name}' not found.") + return + + from physcom.engine.constraint_resolver import ConstraintResolver + from physcom.engine.scorer import Scorer + from physcom.engine.pipeline import Pipeline + + resolver = ConstraintResolver() + scorer = Scorer(domain) + pass_list = [int(p.strip()) for p in passes.split(",")] + dim_list = [d.strip() for d in dimensions.split(",")] + + pipeline = Pipeline(repo, resolver, scorer, llm=None) + click.echo(f"Running pipeline for '{domain_name}' (passes={pass_list}, threshold={threshold})") + click.echo(f"Dimensions: {dim_list}") + + result = pipeline.run(domain, dim_list, score_threshold=threshold, passes=pass_list) + + click.echo(f"\nResults:") + click.echo(f" Total combinations: {result.total_generated}") + click.echo(f" Pass 1 — valid: {result.pass1_valid}, " + f"conditional: {result.pass1_conditional}, " + f"blocked: {result.pass1_blocked}") + if 2 in pass_list: + click.echo(f" Pass 2 — estimated: {result.pass2_estimated}") + if 3 in pass_list: + click.echo(f" Pass 3 — above threshold: {result.pass3_above_threshold}") + if 4 in pass_list: + click.echo(f" Pass 4 — LLM reviewed: {result.pass4_reviewed}") + + +@main.command() +@click.argument("domain_name") +@click.option("--top", "-n", default=10, type=int, help="Number of top results to show") +@click.pass_context +def results(ctx, domain_name, top): + """View top-N results for a domain.""" + repo = _get_repo(ctx.obj["db"]) + top_results = repo.get_top_results(domain_name, limit=top) + if not top_results: + click.echo(f"No results for domain '{domain_name}'.") + return + + click.echo(f"\nTop {len(top_results)} results for '{domain_name}':\n") + for i, r in enumerate(top_results, 1): + combo = r["combination"] + entity_names = " + ".join(e.name for e in combo.entities) + click.echo(f" {i:2d}. [{r['composite_score']:.4f}] {entity_names}") + if r["novelty_flag"]: + click.echo(f" novelty: {r['novelty_flag']}") + if r["llm_review"]: + click.echo(f" review: {r['llm_review'][:100]}") + if r["human_notes"]: + click.echo(f" notes: {r['human_notes'][:100]}") + + +@main.command() +@click.argument("combination_id", type=int) +@click.pass_context +def review(ctx, combination_id): + """Interactive human review of a combination.""" + repo = _get_repo(ctx.obj["db"]) + combo = repo.get_combination(combination_id) + if not combo: + click.echo(f"Combination {combination_id} not found.") + return + + entity_names = " + ".join(e.name for e in combo.entities) + click.echo(f"\nCombination #{combo.id}: {entity_names}") + click.echo(f"Status: {combo.status}") + if combo.block_reason: + click.echo(f"Block reason: {combo.block_reason}") + + click.echo("\nEntities:") + for e in combo.entities: + click.echo(f" [{e.dimension}] {e.name}: {e.description}") + for dep in e.dependencies: + click.echo(f" {dep.constraint_type}: {dep.key}={dep.value}" + f"{f' ({dep.unit})' if dep.unit else ''}") + + novelty = click.prompt( + "\nNovelty flag (novel/exists/researched/skip)", default="skip" + ) + notes = click.prompt("Human notes (or empty)", default="") + + if novelty != "skip" or notes: + # Get all domains this combo has results for + rows = repo.conn.execute( + "SELECT domain_id, composite_score FROM combination_results WHERE combination_id = ?", + (combo.id,), + ).fetchall() + for row in rows: + repo.save_result( + combo.id, row["domain_id"], row["composite_score"], + pass_reached=5, + novelty_flag=novelty if novelty != "skip" else None, + human_notes=notes or None, + ) + repo.update_combination_status(combo.id, "reviewed") + click.echo("Review saved.") + + +@main.command() +@click.argument("domain_name") +@click.option("--format", "fmt", default="md", help="Export format (md)") +@click.option("--top", "-n", default=20, type=int, help="Number of results to export") +@click.option("--output", "-o", default=None, help="Output file path") +@click.pass_context +def export(ctx, domain_name, fmt, top, output): + """Export results to a report.""" + repo = _get_repo(ctx.obj["db"]) + top_results = repo.get_top_results(domain_name, limit=top) + if not top_results: + click.echo(f"No results for domain '{domain_name}'.") + return + + if fmt == "md": + lines = [f"# {domain_name} — Top {len(top_results)} Concepts\n"] + for i, r in enumerate(top_results, 1): + combo = r["combination"] + entity_names = " + ".join(e.name for e in combo.entities) + lines.append(f"## {i}. {entity_names} (score: {r['composite_score']:.4f})") + if r["novelty_flag"]: + lines.append(f"**Novelty:** {r['novelty_flag']}") + if r["llm_review"]: + lines.append(f"\n{r['llm_review']}") + if r["human_notes"]: + lines.append(f"\n*Notes:* {r['human_notes']}") + lines.append("") + + content = "\n".join(lines) + if output: + Path(output).write_text(content) + click.echo(f"Exported to {output}") + else: + click.echo(content) + else: + click.echo(f"Unsupported format: {fmt}") + + +if __name__ == "__main__": + main() diff --git a/src/physcom/db/__init__.py b/src/physcom/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom/db/repository.py b/src/physcom/db/repository.py new file mode 100644 index 0000000..9bc80af --- /dev/null +++ b/src/physcom/db/repository.py @@ -0,0 +1,414 @@ +"""CRUD operations for all entities.""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from typing import Sequence + +from physcom.models.entity import Dependency, Entity +from physcom.models.domain import Domain, MetricBound +from physcom.models.combination import Combination + + +class Repository: + """Thin data-access layer over the SQLite database.""" + + def __init__(self, conn: sqlite3.Connection) -> None: + self.conn = conn + self.conn.row_factory = sqlite3.Row + + # ── Dimensions ────────────────────────────────────────────── + + def ensure_dimension(self, name: str, description: str = "") -> int: + """Insert dimension if it doesn't exist, return its id.""" + cur = self.conn.execute( + "INSERT OR IGNORE INTO dimensions (name, description) VALUES (?, ?)", + (name, description), + ) + if cur.lastrowid and cur.rowcount: + self.conn.commit() + return cur.lastrowid + row = self.conn.execute( + "SELECT id FROM dimensions WHERE name = ?", (name,) + ).fetchone() + return row["id"] + + def list_dimensions(self) -> list[dict]: + rows = self.conn.execute("SELECT * FROM dimensions ORDER BY name").fetchall() + return [dict(r) for r in rows] + + # ── Entities ──────────────────────────────────────────────── + + def add_entity(self, entity: Entity) -> Entity: + """Persist an Entity (and its dependencies). Returns it with id set.""" + dim_id = self.ensure_dimension(entity.dimension) + cur = self.conn.execute( + "INSERT INTO entities (dimension_id, name, description) VALUES (?, ?, ?)", + (dim_id, entity.name, entity.description), + ) + entity.id = cur.lastrowid + entity.dimension_id = dim_id + for dep in entity.dependencies: + dep_cur = self.conn.execute( + """INSERT INTO dependencies + (entity_id, category, key, value, unit, constraint_type) + VALUES (?, ?, ?, ?, ?, ?)""", + (entity.id, dep.category, dep.key, dep.value, dep.unit, dep.constraint_type), + ) + dep.id = dep_cur.lastrowid + self.conn.commit() + return entity + + def get_entity(self, entity_id: int) -> Entity | None: + row = self.conn.execute( + """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 = ?""", + (entity_id,), + ).fetchone() + if not row: + return None + deps = self._load_dependencies(row["id"]) + return Entity( + id=row["id"], + name=row["name"], + description=row["description"] or "", + dimension=row["dimension"], + dimension_id=row["dimension_id"], + dependencies=deps, + ) + + def list_entities(self, dimension: str | None = None) -> list[Entity]: + if dimension: + rows = self.conn.execute( + """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 d.name = ? ORDER BY e.name""", + (dimension,), + ).fetchall() + else: + rows = self.conn.execute( + """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 + ORDER BY d.name, e.name""" + ).fetchall() + entities = [] + for r in rows: + deps = self._load_dependencies(r["id"]) + entities.append(Entity( + id=r["id"], name=r["name"], description=r["description"] or "", + dimension=r["dimension"], dimension_id=r["dimension_id"], + dependencies=deps, + )) + return entities + + def _load_dependencies(self, entity_id: int) -> list[Dependency]: + rows = self.conn.execute( + "SELECT * FROM dependencies WHERE entity_id = ?", (entity_id,) + ).fetchall() + return [ + Dependency( + id=r["id"], category=r["category"], key=r["key"], + value=r["value"], unit=r["unit"], constraint_type=r["constraint_type"], + ) + for r in rows + ] + + def update_entity(self, entity_id: int, name: str, description: str) -> None: + self.conn.execute( + "UPDATE entities SET name = ?, description = ? WHERE id = ?", + (name, description, entity_id), + ) + self.conn.commit() + + def delete_entity(self, entity_id: int) -> None: + 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() + + def add_dependency(self, entity_id: int, dep: Dependency) -> Dependency: + cur = self.conn.execute( + """INSERT INTO dependencies + (entity_id, category, key, value, unit, constraint_type) + VALUES (?, ?, ?, ?, ?, ?)""", + (entity_id, dep.category, dep.key, dep.value, dep.unit, dep.constraint_type), + ) + dep.id = cur.lastrowid + self.conn.commit() + return dep + + def update_dependency(self, dep_id: int, dep: Dependency) -> None: + self.conn.execute( + """UPDATE dependencies + SET category = ?, key = ?, value = ?, unit = ?, constraint_type = ? + WHERE id = ?""", + (dep.category, dep.key, dep.value, dep.unit, dep.constraint_type, dep_id), + ) + self.conn.commit() + + def delete_dependency(self, dep_id: int) -> None: + self.conn.execute("DELETE FROM dependencies WHERE id = ?", (dep_id,)) + self.conn.commit() + + def get_dependency(self, dep_id: int) -> Dependency | None: + row = self.conn.execute( + "SELECT * FROM dependencies WHERE id = ?", (dep_id,) + ).fetchone() + if not row: + return None + return Dependency( + id=row["id"], category=row["category"], key=row["key"], + value=row["value"], unit=row["unit"], constraint_type=row["constraint_type"], + ) + + # ── Domains & Metrics ─────────────────────────────────────── + + def ensure_metric(self, name: str, unit: str = "", description: str = "") -> int: + self.conn.execute( + "INSERT OR IGNORE INTO metrics (name, unit, description) VALUES (?, ?, ?)", + (name, unit, description), + ) + row = self.conn.execute("SELECT id FROM metrics WHERE name = ?", (name,)).fetchone() + self.conn.commit() + return row["id"] + + def add_domain(self, domain: Domain) -> Domain: + cur = self.conn.execute( + "INSERT INTO domains (name, description) VALUES (?, ?)", + (domain.name, domain.description), + ) + domain.id = cur.lastrowid + for mb in domain.metric_bounds: + metric_id = self.ensure_metric(mb.metric_name) + mb.metric_id = metric_id + self.conn.execute( + """INSERT 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 domain + + def get_domain(self, name: str) -> Domain | None: + row = self.conn.execute("SELECT * FROM domains WHERE name = ?", (name,)).fetchone() + if not row: + return None + weights = self.conn.execute( + """SELECT m.name, 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"], + ) + for w in weights + ], + ) + + def list_domains(self) -> list[Domain]: + rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall() + return [self.get_domain(r["name"]) for r in rows] + + # ── Combinations ──────────────────────────────────────────── + + @staticmethod + def compute_hash(entity_ids: Sequence[int]) -> str: + key = ",".join(str(eid) for eid in sorted(entity_ids)) + return hashlib.sha256(key.encode()).hexdigest()[:16] + + def save_combination(self, combination: Combination) -> Combination: + entity_ids = [e.id for e in combination.entities] + combination.hash = self.compute_hash(entity_ids) + + existing = self.conn.execute( + "SELECT id FROM combinations WHERE hash = ?", (combination.hash,) + ).fetchone() + if existing: + combination.id = existing["id"] + return combination + + cur = self.conn.execute( + "INSERT INTO combinations (hash, status, block_reason) VALUES (?, ?, ?)", + (combination.hash, combination.status, combination.block_reason), + ) + combination.id = cur.lastrowid + for eid in entity_ids: + self.conn.execute( + "INSERT INTO combination_entities (combination_id, entity_id) VALUES (?, ?)", + (combination.id, eid), + ) + self.conn.commit() + return combination + + def update_combination_status( + self, combo_id: int, status: str, block_reason: str | None = None + ) -> None: + self.conn.execute( + "UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?", + (status, block_reason, combo_id), + ) + self.conn.commit() + + def get_combination(self, combo_id: int) -> Combination | None: + row = self.conn.execute("SELECT * FROM combinations WHERE id = ?", (combo_id,)).fetchone() + if not row: + return None + entity_rows = self.conn.execute( + "SELECT entity_id FROM combination_entities WHERE combination_id = ?", + (combo_id,), + ).fetchall() + entities = [self.get_entity(er["entity_id"]) for er in entity_rows] + return Combination( + id=row["id"], hash=row["hash"], status=row["status"], + block_reason=row["block_reason"], entities=entities, + ) + + def list_combinations(self, status: str | None = None) -> list[Combination]: + if status: + rows = self.conn.execute( + "SELECT id FROM combinations WHERE status = ? ORDER BY id", (status,) + ).fetchall() + else: + rows = self.conn.execute("SELECT id FROM combinations ORDER BY id").fetchall() + return [self.get_combination(r["id"]) for r in rows] + + # ── Scores & Results ──────────────────────────────────────── + + def save_scores( + self, + combo_id: int, + domain_id: int, + scores: list[dict], + ) -> None: + """Save per-metric scores. Each dict: metric_id, raw_value, normalized_score, estimation_method, confidence.""" + for s in scores: + self.conn.execute( + """INSERT OR REPLACE INTO combination_scores + (combination_id, domain_id, metric_id, raw_value, normalized_score, + estimation_method, confidence) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (combo_id, domain_id, s["metric_id"], s["raw_value"], + s["normalized_score"], s["estimation_method"], s["confidence"]), + ) + self.conn.commit() + + def save_result( + self, + combo_id: int, + domain_id: int, + composite_score: float, + pass_reached: int, + novelty_flag: str | None = None, + llm_review: str | None = None, + human_notes: str | None = None, + ) -> None: + self.conn.execute( + """INSERT OR REPLACE INTO combination_results + (combination_id, domain_id, composite_score, novelty_flag, + llm_review, human_notes, pass_reached) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (combo_id, domain_id, composite_score, novelty_flag, + llm_review, human_notes, pass_reached), + ) + self.conn.commit() + + def get_combination_scores(self, combo_id: int, domain_id: int) -> list[dict]: + """Return per-metric scores for a combination in a domain.""" + rows = self.conn.execute( + """SELECT cs.*, m.name as metric_name + FROM combination_scores cs + JOIN metrics m ON cs.metric_id = m.id + WHERE cs.combination_id = ? AND cs.domain_id = ?""", + (combo_id, domain_id), + ).fetchall() + return [dict(r) for r in rows] + + def count_combinations_by_status(self) -> dict[str, int]: + rows = self.conn.execute( + "SELECT status, COUNT(*) as cnt FROM combinations GROUP BY status" + ).fetchall() + return {r["status"]: r["cnt"] for r in rows} + + def get_result(self, combo_id: int, domain_id: int) -> dict | None: + """Return a single combination_result row.""" + row = 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 = ? AND cr.domain_id = ?""", + (combo_id, domain_id), + ).fetchone() + return dict(row) if row else None + + def get_all_results(self, domain_name: str, status: str | None = None) -> list[dict]: + """Return all results for a domain, optionally filtered by combo status.""" + if status: + rows = self.conn.execute( + """SELECT cr.*, c.hash, c.status as combo_status, d.name as domain_name + FROM combination_results cr + JOIN combinations c ON cr.combination_id = c.id + JOIN domains d ON cr.domain_id = d.id + WHERE d.name = ? AND c.status = ? + ORDER BY cr.composite_score DESC""", + (domain_name, status), + ).fetchall() + else: + rows = self.conn.execute( + """SELECT cr.*, c.hash, c.status as combo_status, d.name as domain_name + FROM combination_results cr + JOIN combinations c ON cr.combination_id = c.id + JOIN domains d ON cr.domain_id = d.id + WHERE d.name = ? + ORDER BY cr.composite_score DESC""", + (domain_name,), + ).fetchall() + results = [] + for r in rows: + combo = self.get_combination(r["combination_id"]) + results.append({ + "combination": combo, + "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 + + 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.""" + rows = self.conn.execute( + """SELECT cr.*, c.hash, c.status, d.name as domain_name + FROM combination_results cr + JOIN combinations c ON cr.combination_id = c.id + JOIN domains d ON cr.domain_id = d.id + WHERE d.name = ? + ORDER BY cr.composite_score DESC + LIMIT ?""", + (domain_name, limit), + ).fetchall() + results = [] + for r in rows: + combo = self.get_combination(r["combination_id"]) + results.append({ + "combination": combo, + "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 diff --git a/src/physcom/db/schema.py b/src/physcom/db/schema.py new file mode 100644 index 0000000..fd39128 --- /dev/null +++ b/src/physcom/db/schema.py @@ -0,0 +1,111 @@ +"""DDL, table creation, and schema initialization.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +DDL = """ +CREATE TABLE IF NOT EXISTS dimensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE IF NOT EXISTS entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dimension_id INTEGER NOT NULL REFERENCES dimensions(id), + name TEXT NOT NULL, + description TEXT, + UNIQUE(dimension_id, name) +); + +CREATE TABLE IF NOT EXISTS dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER NOT NULL REFERENCES entities(id), + category TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + unit TEXT, + constraint_type TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + unit TEXT, + description TEXT +); + +CREATE TABLE IF NOT EXISTS domain_metric_weights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id), + metric_id INTEGER NOT NULL REFERENCES metrics(id), + weight REAL NOT NULL, + norm_min REAL, + norm_max REAL, + UNIQUE(domain_id, metric_id) +); + +CREATE TABLE IF NOT EXISTS combinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + block_reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS combination_entities ( + combination_id INTEGER NOT NULL REFERENCES combinations(id), + entity_id INTEGER NOT NULL REFERENCES entities(id), + PRIMARY KEY (combination_id, entity_id) +); + +CREATE TABLE IF NOT EXISTS combination_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + combination_id INTEGER NOT NULL REFERENCES combinations(id), + domain_id INTEGER NOT NULL REFERENCES domains(id), + metric_id INTEGER NOT NULL REFERENCES metrics(id), + raw_value REAL, + normalized_score REAL, + estimation_method TEXT, + confidence REAL, + UNIQUE(combination_id, domain_id, metric_id) +); + +CREATE TABLE IF NOT EXISTS combination_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + combination_id INTEGER NOT NULL REFERENCES combinations(id), + domain_id INTEGER NOT NULL REFERENCES domains(id), + composite_score REAL, + novelty_flag TEXT, + llm_review TEXT, + human_notes TEXT, + pass_reached INTEGER, + UNIQUE(combination_id, domain_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_combo_status ON combinations(status); +CREATE INDEX IF NOT EXISTS idx_scores_combo_domain ON combination_scores(combination_id, domain_id); +CREATE INDEX IF NOT EXISTS idx_results_domain_score ON combination_results(domain_id, composite_score DESC); +""" + + +def init_db(db_path: str | Path) -> sqlite3.Connection: + """Create/open the database and ensure all tables exist.""" + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.executescript(DDL) + conn.commit() + return conn diff --git a/src/physcom/engine/__init__.py b/src/physcom/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom/engine/combinator.py b/src/physcom/engine/combinator.py new file mode 100644 index 0000000..2a6bd61 --- /dev/null +++ b/src/physcom/engine/combinator.py @@ -0,0 +1,33 @@ +"""Cartesian product generator across dimensions.""" + +from __future__ import annotations + +import itertools + +from physcom.models.entity import Entity +from physcom.models.combination import Combination +from physcom.db.repository import Repository + + +def generate_combinations( + repo: Repository, + dimensions: list[str], +) -> list[Combination]: + """Generate all Cartesian-product combinations across the given dimensions. + + Each combination contains exactly one entity per dimension. + Deduplication is handled by the repository via hash. + """ + entity_groups: list[list[Entity]] = [] + for dim in dimensions: + entities = repo.list_entities(dimension=dim) + if not entities: + raise ValueError(f"No entities found for dimension '{dim}'") + entity_groups.append(entities) + + combinations = [] + for entity_tuple in itertools.product(*entity_groups): + combo = Combination(entities=list(entity_tuple)) + combinations.append(combo) + + return combinations diff --git a/src/physcom/engine/constraint_resolver.py b/src/physcom/engine/constraint_resolver.py new file mode 100644 index 0000000..493d15a --- /dev/null +++ b/src/physcom/engine/constraint_resolver.py @@ -0,0 +1,180 @@ +"""Dependency contradiction detection engine.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from physcom.models.combination import Combination +from physcom.models.entity import Dependency + + +# Mutual exclusion registry: for a given key, which value-sets contradict. +# Values within the same set are compatible; values in different sets contradict. +MUTEX_VALUES: dict[str, list[set[str]]] = { + "atmosphere": [{"vacuum", "vacuum_or_thin"}, {"dense", "standard"}], + "medium": [{"ground"}, {"water"}, {"air"}, {"space"}], +} + + +@dataclass +class ConstraintResult: + """Outcome of constraint resolution for a combination.""" + + status: str = "valid" # valid, blocked, conditional + violations: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +class ConstraintResolver: + """Checks a Combination's entities for dependency contradictions.""" + + def __init__(self, mutex_registry: dict[str, list[set[str]]] | None = None) -> None: + self.mutex = mutex_registry or MUTEX_VALUES + + def resolve(self, combination: Combination) -> ConstraintResult: + result = ConstraintResult() + all_deps: list[tuple[str, Dependency]] = [] + for entity in combination.entities: + for dep in entity.dependencies: + all_deps.append((entity.name, dep)) + + self._check_requires_vs_excludes(all_deps, result) + self._check_mutual_exclusion(all_deps, result) + self._check_range_incompatibility(all_deps, result) + self._check_force_scale(combination, result) + self._check_unmet_requirements(all_deps, result) + + if result.violations: + result.status = "blocked" + elif result.warnings: + result.status = "conditional" + + return result + + def _check_requires_vs_excludes( + self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult + ) -> None: + """Rule 1: If A requires key=X and B excludes key=X → BLOCKED.""" + requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"] + excludes = [(name, d) for name, d in all_deps if d.constraint_type == "excludes"] + + for req_name, req in requires: + for exc_name, exc in excludes: + if req_name == exc_name: + continue + if req.key == exc.key and req.value == exc.value: + result.violations.append( + f"{req_name} requires {req.key}={req.value} " + f"but {exc_name} excludes it" + ) + + def _check_mutual_exclusion( + self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult + ) -> None: + """Rule 2: If A requires key=X and B requires key=Y where X,Y are mutex → BLOCKED.""" + requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"] + + for i, (name_a, dep_a) in enumerate(requires): + for name_b, dep_b in requires[i + 1:]: + if name_a == name_b: + continue + if dep_a.key != dep_b.key: + continue + if dep_a.value == dep_b.value: + continue + # Check if values are in different mutex sets + if dep_a.key in self.mutex: + set_a = self._find_mutex_set(dep_a.key, dep_a.value) + set_b = self._find_mutex_set(dep_b.key, dep_b.value) + if set_a is not None and set_b is not None and set_a is not set_b: + result.violations.append( + f"{name_a} requires {dep_a.key}={dep_a.value} " + f"but {name_b} requires {dep_b.key}={dep_b.value} " + f"(mutually exclusive)" + ) + + def _find_mutex_set(self, key: str, value: str) -> set[str] | None: + """Find which mutex set a value belongs to, or None.""" + for value_set in self.mutex.get(key, []): + if value in value_set: + return value_set + return None + + def _check_range_incompatibility( + self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult + ) -> None: + """Rule 3: If A range_min > B range_max for the same key → BLOCKED.""" + range_mins: dict[str, list[tuple[str, float]]] = {} + range_maxs: dict[str, list[tuple[str, float]]] = {} + + for name, dep in all_deps: + if dep.constraint_type == "range_min": + range_mins.setdefault(dep.key, []).append((name, float(dep.value))) + elif dep.constraint_type == "range_max": + range_maxs.setdefault(dep.key, []).append((name, float(dep.value))) + + for key in set(range_mins) & set(range_maxs): + for min_name, min_val in range_mins[key]: + for max_name, max_val in range_maxs[key]: + if min_name == max_name: + continue + if min_val > max_val: + result.violations.append( + f"{min_name} requires {key} >= {min_val} " + f"but {max_name} limits {key} <= {max_val}" + ) + + def _check_force_scale( + self, combination: Combination, result: ConstraintResult + ) -> None: + """Rule 4: If power source output << platform requirement → warn/block.""" + force_provided: list[tuple[str, float]] = [] + force_required: list[tuple[str, float]] = [] + + for entity in combination.entities: + for dep in entity.dependencies: + if dep.key == "force_output_watts" and dep.constraint_type == "provides": + force_provided.append((entity.name, float(dep.value))) + elif dep.key == "force_required_watts" and dep.constraint_type == "range_min": + force_required.append((entity.name, float(dep.value))) + + for req_name, req_watts in force_required: + for prov_name, prov_watts in force_provided: + if prov_watts < req_watts * 0.01: + # Off by more than 100x — hard block + result.violations.append( + f"{prov_name} provides {prov_watts}W but " + f"{req_name} requires {req_watts}W " + f"(force deficit > 100x)" + ) + elif prov_watts < req_watts: + # Under-powered but not impossibly so — warn + result.warnings.append( + f"{prov_name} provides {prov_watts}W but " + f"{req_name} requires {req_watts}W " + f"(under-powered)" + ) + + def _check_unmet_requirements( + self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult + ) -> None: + """Rule 5: Required condition not provided by any entity → conditional.""" + provides = {(d.key, d.value) for _, d in all_deps if d.constraint_type == "provides"} + # Ambient conditions that don't need to be explicitly provided + ambient = { + ("ground_surface", "true"), + ("gravity", "true"), + ("star_proximity", "true"), + } + + for name, dep in all_deps: + if dep.constraint_type != "requires": + continue + if dep.category == "infrastructure": + continue # Infrastructure is external, not checked here + key_val = (dep.key, dep.value) + if key_val not in provides and key_val not in ambient: + result.warnings.append( + f"{name} requires {dep.key}={dep.value} " + f"but no entity in this combination provides it" + ) diff --git a/src/physcom/engine/pipeline.py b/src/physcom/engine/pipeline.py new file mode 100644 index 0000000..f231345 --- /dev/null +++ b/src/physcom/engine/pipeline.py @@ -0,0 +1,246 @@ +"""Multi-pass pipeline orchestrator.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from physcom.db.repository import Repository +from physcom.engine.combinator import generate_combinations +from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult +from physcom.engine.scorer import Scorer +from physcom.llm.base import LLMProvider +from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT +from physcom.models.combination import Combination, ScoredResult +from physcom.models.domain import Domain + + +@dataclass +class PipelineResult: + """Summary of a pipeline run.""" + + total_generated: int = 0 + pass1_valid: int = 0 + pass1_blocked: int = 0 + pass1_conditional: int = 0 + pass2_estimated: int = 0 + pass3_above_threshold: int = 0 + pass4_reviewed: int = 0 + pass5_human_reviewed: int = 0 + top_results: list[dict] = field(default_factory=list) + + +def _describe_combination(combo: Combination) -> str: + """Build a natural-language description of a combination.""" + parts = [f"{e.dimension}: {e.name}" for e in combo.entities] + descriptions = [e.description for e in combo.entities if e.description] + header = " + ".join(parts) + detail = "; ".join(descriptions) + return f"{header}. {detail}" + + +class Pipeline: + """Orchestrates the multi-pass viability pipeline.""" + + def __init__( + self, + repo: Repository, + resolver: ConstraintResolver, + scorer: Scorer, + llm: LLMProvider | None = None, + ) -> None: + self.repo = repo + self.resolver = resolver + self.scorer = scorer + self.llm = llm + + def run( + self, + domain: Domain, + dimensions: list[str], + score_threshold: float = 0.1, + passes: list[int] | None = None, + ) -> PipelineResult: + if passes is None: + passes = [1, 2, 3, 4, 5] + + result = PipelineResult() + + # Generate all combinations + combos = generate_combinations(self.repo, dimensions) + result.total_generated = len(combos) + + # Save all combinations to DB + for combo in combos: + self.repo.save_combination(combo) + + # ── Pass 1: Constraint Resolution ─────────────────────── + valid_combos: list[Combination] = [] + if 1 in passes: + valid_combos = self._pass1_constraints(combos, result) + else: + valid_combos = combos + + # ── Pass 2: Physics Estimation ────────────────────────── + estimated: list[tuple[Combination, dict[str, float]]] = [] + if 2 in passes: + estimated = self._pass2_estimation(valid_combos, domain, result) + else: + # Skip estimation, use zeros + estimated = [(c, {}) for c in valid_combos] + + # ── Pass 3: Scoring & Ranking ─────────────────────────── + scored: list[tuple[Combination, ScoredResult]] = [] + if 3 in passes: + scored = self._pass3_scoring(estimated, domain, score_threshold, result) + + # ── Pass 4: LLM Review ────────────────────────────────── + if 4 in passes and self.llm: + self._pass4_llm_review(scored, domain, result) + + # ── Save results after scoring ───────────────────────── + if 3 in passes: + max_pass = max(p for p in passes if p <= 5) + for combo, sr in scored: + self.repo.save_result( + combo.id, domain.id, sr.composite_score, + pass_reached=max_pass, + novelty_flag=sr.novelty_flag, + llm_review=sr.llm_review, + ) + self.repo.update_combination_status(combo.id, "scored") + + # Collect top results + result.top_results = self.repo.get_top_results(domain.name, limit=20) + return result + + def _pass1_constraints( + self, combos: list[Combination], result: PipelineResult + ) -> list[Combination]: + valid = [] + for combo in combos: + cr: ConstraintResult = self.resolver.resolve(combo) + if cr.status == "blocked": + combo.status = "blocked" + combo.block_reason = "; ".join(cr.violations) + self.repo.update_combination_status( + combo.id, "blocked", combo.block_reason + ) + result.pass1_blocked += 1 + elif cr.status == "conditional": + combo.status = "valid" + self.repo.update_combination_status(combo.id, "valid") + valid.append(combo) + result.pass1_conditional += 1 + else: + combo.status = "valid" + self.repo.update_combination_status(combo.id, "valid") + valid.append(combo) + result.pass1_valid += 1 + return valid + + def _pass2_estimation( + self, + combos: list[Combination], + domain: Domain, + result: PipelineResult, + ) -> list[tuple[Combination, dict[str, float]]]: + metric_names = [mb.metric_name for mb in domain.metric_bounds] + estimated = [] + + for combo in combos: + description = _describe_combination(combo) + if self.llm: + raw_metrics = self.llm.estimate_physics(description, metric_names) + else: + # Stub estimation: derive from dependencies where possible + raw_metrics = self._stub_estimate(combo, metric_names) + estimated.append((combo, raw_metrics)) + result.pass2_estimated += 1 + + return estimated + + def _pass3_scoring( + self, + estimated: list[tuple[Combination, dict[str, float]]], + domain: Domain, + threshold: float, + result: PipelineResult, + ) -> list[tuple[Combination, ScoredResult]]: + scored = [] + for combo, raw_metrics in estimated: + sr = self.scorer.score_combination(combo, raw_metrics) + if sr.composite_score >= threshold: + scored.append((combo, sr)) + result.pass3_above_threshold += 1 + # Persist per-metric scores + score_dicts = [] + bounds_by_name = {mb.metric_name: mb for mb in domain.metric_bounds} + for s in sr.scores: + mb = bounds_by_name.get(s.metric_name) + if mb and mb.metric_id: + score_dicts.append({ + "metric_id": mb.metric_id, + "raw_value": s.raw_value, + "normalized_score": s.normalized_score, + "estimation_method": s.estimation_method, + "confidence": s.confidence, + }) + if score_dicts: + self.repo.save_scores(combo.id, domain.id, score_dicts) + + # Sort by composite score descending + scored.sort(key=lambda x: x[1].composite_score, reverse=True) + return scored + + def _pass4_llm_review( + self, + scored: list[tuple[Combination, ScoredResult]], + domain: Domain, + result: PipelineResult, + ) -> None: + for combo, sr in scored: + description = _describe_combination(combo) + score_dict = {s.metric_name: s.normalized_score for s in sr.scores} + review = self.llm.review_plausibility(description, score_dict) + sr.llm_review = review + result.pass4_reviewed += 1 + + def _stub_estimate( + self, combo: Combination, metric_names: list[str] + ) -> dict[str, float]: + """Simple heuristic estimation from dependency data.""" + raw: dict[str, float] = {m: 0.0 for m in metric_names} + + # Extract force output from power source + force_watts = 0.0 + mass_kg = 100.0 # default + for entity in combo.entities: + for dep in entity.dependencies: + if dep.key == "force_output_watts" and dep.constraint_type == "provides": + force_watts = max(force_watts, float(dep.value)) + if dep.key == "min_mass_kg" and dep.constraint_type == "range_min": + mass_kg = max(mass_kg, float(dep.value)) + + # Rough speed estimate: F=ma -> v proportional to power/mass + if "speed" in raw and mass_kg > 0: + # Very rough: speed ~ power / (mass * drag_coeff) + raw["speed"] = min(force_watts / mass_kg * 0.5, 300000) + + if "cost_efficiency" in raw: + # Lower force = cheaper per km (roughly) + raw["cost_efficiency"] = max(0.01, 2.0 - force_watts / 100000) + + if "safety" in raw: + raw["safety"] = 0.5 # default mid-range + + if "availability" in raw: + raw["availability"] = 0.5 + + if "range_fuel" in raw: + # More power = more range (very rough) + raw["range_fuel"] = min(force_watts * 0.01, 1e10) + + if "range_degradation" in raw: + raw["range_degradation"] = 365 # 1 year default + + return raw diff --git a/src/physcom/engine/scorer.py b/src/physcom/engine/scorer.py new file mode 100644 index 0000000..d9449ad --- /dev/null +++ b/src/physcom/engine/scorer.py @@ -0,0 +1,89 @@ +"""Multiplicative logarithmic scoring engine.""" + +from __future__ import annotations + +import math + +from physcom.models.domain import Domain, MetricBound +from physcom.models.combination import Combination, Score, ScoredResult + + +def normalize(raw_value: float, norm_min: float, norm_max: float) -> float: + """Log-normalize a raw value to 0-1 within domain bounds. + + Values at or below norm_min -> 0.0 + Values at or above norm_max -> 1.0 + Values between are log-interpolated. + """ + if norm_max <= norm_min: + return 0.0 + if raw_value <= norm_min: + return 0.0 + if raw_value >= norm_max: + return 1.0 + log_min = math.log1p(norm_min) + log_max = math.log1p(norm_max) + log_val = math.log1p(raw_value) + return (log_val - log_min) / (log_max - log_min) + + +def composite_score(scores: list[float], weights: list[float]) -> float: + """Weighted geometric mean. Any score near 0 drives the result to 0. + + score = product(s_i ^ w_i) for all metrics i + """ + if not scores or not weights: + return 0.0 + result = 1.0 + for score, weight in zip(scores, weights): + if score <= 0.0: + return 0.0 + result *= score ** weight + return result + + +class Scorer: + """Scores combinations against a domain's metric bounds.""" + + def __init__(self, domain: Domain) -> None: + self.domain = domain + self._bounds_by_name: dict[str, MetricBound] = { + mb.metric_name: mb for mb in domain.metric_bounds + } + + def score_combination( + self, + combination: Combination, + raw_metrics: dict[str, float], + estimation_method: str = "physics_calc", + confidence: float = 1.0, + ) -> ScoredResult: + """Score a combination given raw metric values. + + raw_metrics maps metric_name -> raw estimated value. + """ + scores: list[Score] = [] + norm_scores: list[float] = [] + weights: list[float] = [] + + for mb in self.domain.metric_bounds: + raw = raw_metrics.get(mb.metric_name, 0.0) + normed = normalize(raw, mb.norm_min, mb.norm_max) + scores.append(Score( + metric_name=mb.metric_name, + raw_value=raw, + normalized_score=normed, + estimation_method=estimation_method, + confidence=confidence, + )) + norm_scores.append(normed) + weights.append(mb.weight) + + comp = composite_score(norm_scores, weights) + + return ScoredResult( + combination_id=combination.id, + domain_name=self.domain.name, + scores=scores, + composite_score=comp, + ) diff --git a/src/physcom/llm/__init__.py b/src/physcom/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom/llm/base.py b/src/physcom/llm/base.py new file mode 100644 index 0000000..495301d --- /dev/null +++ b/src/physcom/llm/base.py @@ -0,0 +1,25 @@ +"""Abstract LLM interface — provider-agnostic.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class LLMProvider(ABC): + """Abstract LLM interface for physics estimation and plausibility review.""" + + @abstractmethod + def estimate_physics( + self, combination_description: str, metrics: list[str] + ) -> dict[str, float]: + """Given a natural-language description of a combination, + estimate raw metric values. Returns {metric_name: estimated_value}.""" + ... + + @abstractmethod + def review_plausibility( + self, combination_description: str, scores: dict[str, float] + ) -> str: + """Given a combination and its scores, return a natural-language + plausibility and novelty assessment.""" + ... diff --git a/src/physcom/llm/prompts.py b/src/physcom/llm/prompts.py new file mode 100644 index 0000000..68e679f --- /dev/null +++ b/src/physcom/llm/prompts.py @@ -0,0 +1,37 @@ +"""Prompt templates for LLM-assisted passes.""" + +PHYSICS_ESTIMATION_PROMPT = """\ +You are a physics estimation assistant. Given the following transportation concept, \ +estimate the requested metrics using order-of-magnitude physics reasoning. + +## Concept +{description} + +## Metrics to estimate +{metrics} + +## Instructions +- Use real-world physics to estimate each metric. +- If the concept is implausible, still provide your best estimate. +- Return ONLY valid JSON mapping metric names to numeric values. +- Example: {{"speed": 45.0, "cost_efficiency": 0.15, "safety": 0.7}} +""" + +PLAUSIBILITY_REVIEW_PROMPT = """\ +You are reviewing a novel transportation concept for social and practical viability. + +## Concept +{description} + +## Metric Scores +{scores} + +## Instructions +Review this concept for: +1. Social viability — would people actually use this? +2. Practical barriers — what engineering or regulatory obstacles exist? +3. Novelty — does anything similar already exist? +4. Overall plausibility — is this a genuinely interesting innovation or nonsense? + +Provide a concise 2-4 sentence assessment. +""" diff --git a/src/physcom/llm/providers/__init__.py b/src/physcom/llm/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom/llm/providers/mock.py b/src/physcom/llm/providers/mock.py new file mode 100644 index 0000000..50a2f26 --- /dev/null +++ b/src/physcom/llm/providers/mock.py @@ -0,0 +1,28 @@ +"""Mock LLM provider for testing.""" + +from __future__ import annotations + +from physcom.llm.base import LLMProvider + + +class MockLLMProvider(LLMProvider): + """Returns deterministic stub responses for testing.""" + + def __init__(self, default_estimates: dict[str, float] | None = None) -> None: + self._defaults = default_estimates or {} + + def estimate_physics( + self, combination_description: str, metrics: list[str] + ) -> dict[str, float]: + result = {} + for metric in metrics: + result[metric] = self._defaults.get(metric, 0.5) + return result + + def review_plausibility( + self, combination_description: str, scores: dict[str, float] + ) -> str: + avg = sum(scores.values()) / max(len(scores), 1) + if avg > 0.5: + return "This concept appears plausible and worth further investigation." + return "This concept has significant feasibility challenges." diff --git a/src/physcom/models/__init__.py b/src/physcom/models/__init__.py new file mode 100644 index 0000000..bb8a421 --- /dev/null +++ b/src/physcom/models/__init__.py @@ -0,0 +1,9 @@ +from physcom.models.entity import Entity, Dependency +from physcom.models.domain import Domain, MetricBound +from physcom.models.combination import Combination, Score, ScoredResult + +__all__ = [ + "Entity", "Dependency", + "Domain", "MetricBound", + "Combination", "Score", "ScoredResult", +] diff --git a/src/physcom/models/combination.py b/src/physcom/models/combination.py new file mode 100644 index 0000000..dd04e15 --- /dev/null +++ b/src/physcom/models/combination.py @@ -0,0 +1,45 @@ +"""Combination and scoring dataclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from physcom.models.entity import Entity + + +@dataclass +class Score: + """A single metric score for a combination in a domain.""" + + metric_name: str + raw_value: float + normalized_score: float + estimation_method: str = "physics_calc" + confidence: float = 1.0 + + +@dataclass +class ScoredResult: + """Final composite result for a combination in a domain.""" + + combination_id: int | None + domain_name: str + scores: list[Score] = field(default_factory=list) + composite_score: float = 0.0 + novelty_flag: str | None = None + llm_review: str | None = None + human_notes: str | None = None + pass_reached: int = 0 + + +@dataclass +class Combination: + """A generated combination of entities (one per dimension).""" + + entities: list[Entity] = field(default_factory=list) + status: str = "pending" # pending → valid/blocked → scored → reviewed + block_reason: str | None = None + hash: str | None = None + id: int | None = None diff --git a/src/physcom/models/domain.py b/src/physcom/models/domain.py new file mode 100644 index 0000000..9946684 --- /dev/null +++ b/src/physcom/models/domain.py @@ -0,0 +1,26 @@ +"""Domain and MetricBound dataclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class MetricBound: + """Per-metric weight and normalization bounds within a domain.""" + + metric_name: str + weight: float # 0.0–1.0 + norm_min: float # Below this → score 0 + norm_max: float # Above this → score 1 + metric_id: int | None = None + + +@dataclass +class Domain: + """A context frame that defines what 'good' means (e.g., urban_commuting).""" + + name: str + description: str = "" + metric_bounds: list[MetricBound] = field(default_factory=list) + id: int | None = None diff --git a/src/physcom/models/entity.py b/src/physcom/models/entity.py new file mode 100644 index 0000000..59b3fd6 --- /dev/null +++ b/src/physcom/models/entity.py @@ -0,0 +1,37 @@ +"""Entity and Dependency dataclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Dependency: + """A single dependency/constraint on an entity. + + Constraint types: + requires — this entity needs this condition to function + provides — this entity supplies this condition + range_min — numeric lower bound + range_max — numeric upper bound + excludes — this entity cannot coexist with this condition + """ + + category: str # environment, force, material, physical, infrastructure + key: str + value: str + unit: str | None = None + constraint_type: str = "requires" # requires, provides, range_min, range_max, excludes + id: int | None = None + + +@dataclass +class Entity: + """An attribute within a dimension (e.g., 'Bicycle' in 'platform').""" + + name: str + dimension: str + description: str = "" + dependencies: list[Dependency] = field(default_factory=list) + id: int | None = None + dimension_id: int | None = None diff --git a/src/physcom/seed/__init__.py b/src/physcom/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom/seed/transport_example.py b/src/physcom/seed/transport_example.py new file mode 100644 index 0000000..7fe397f --- /dev/null +++ b/src/physcom/seed/transport_example.py @@ -0,0 +1,286 @@ +"""Seed data for the transport example from the README.""" + +from __future__ import annotations + +from physcom.models.entity import Entity, Dependency +from physcom.models.domain import Domain, MetricBound + + +# ── Platforms ─────────────────────────────────────────────────── + +PLATFORMS: list[Entity] = [ + Entity( + name="Car", + dimension="platform", + description="Four-wheeled enclosed road vehicle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "3000", "kg", "range_max"), + Dependency("physical", "mass_kg", "800", "kg", "range_min"), + Dependency("force", "force_required_watts", "15000", "watts", "range_min"), + Dependency("infrastructure", "road_network", "true", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Airplane", + dimension="platform", + description="Fixed-wing aircraft for atmospheric flight", + dependencies=[ + Dependency("environment", "atmosphere", "standard", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "500", "kg", "range_min"), + Dependency("force", "force_required_watts", "100000", "watts", "range_min"), + Dependency("infrastructure", "runway", "true", None, "requires"), + Dependency("environment", "medium", "air", None, "requires"), + ], + ), + Entity( + name="Train", + dimension="platform", + description="Rail-guided vehicle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "10000", "kg", "range_min"), + Dependency("force", "force_required_watts", "500000", "watts", "range_min"), + Dependency("infrastructure", "rail_network", "true", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Bicycle", + dimension="platform", + description="Two-wheeled human-scale vehicle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "30", "kg", "range_max"), + Dependency("force", "force_required_watts", "75", "watts", "range_min"), + Dependency("infrastructure", "road_network", "true", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Walking", + dimension="platform", + description="Bipedal locomotion", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "150", "kg", "range_max"), + Dependency("force", "force_required_watts", "75", "watts", "range_min"), + Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Wheelchair", + dimension="platform", + description="Wheeled chair for seated mobility", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "200", "kg", "range_max"), + Dependency("force", "force_required_watts", "50", "watts", "range_min"), + Dependency("infrastructure", "road_network", "true", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Scooter", + dimension="platform", + description="Small two-wheeled standing vehicle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "50", "kg", "range_max"), + Dependency("force", "force_required_watts", "200", "watts", "range_min"), + Dependency("infrastructure", "road_network", "true", None, "requires"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ), + Entity( + name="Spaceship", + dimension="platform", + description="Vehicle designed for space travel", + dependencies=[ + Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"), + Dependency("physical", "mass_kg", "5000", "kg", "range_min"), + Dependency("force", "force_required_watts", "1000000", "watts", "range_min"), + Dependency("infrastructure", "launch_facility", "true", None, "requires"), + Dependency("environment", "medium", "space", None, "requires"), + ], + ), + Entity( + name="Teleporter", + dimension="platform", + description="Hypothetical matter transmission device", + dependencies=[ + Dependency("physical", "mass_kg", "0", "kg", "range_min"), + Dependency("force", "force_required_watts", "1000000000", "watts", "range_min"), + Dependency("infrastructure", "teleport_network", "true", None, "requires"), + ], + ), +] + + +# ── Power Sources ─────────────────────────────────────────────── + +POWER_SOURCES: list[Entity] = [ + Entity( + name="Internal Combustion Engine", + dimension="power_source", + description="Gas/petrol-powered engine", + dependencies=[ + Dependency("force", "force_output_watts", "100000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "gas_station", None, "requires"), + Dependency("environment", "atmosphere", "standard", None, "requires"), + Dependency("physical", "mass_kg", "50", "kg", "range_min"), + Dependency("force", "thrust_profile", "high_continuous", None, "provides"), + ], + ), + Entity( + name="Lithium Ion Battery", + dimension="power_source", + description="Rechargeable electric battery pack", + dependencies=[ + Dependency("force", "force_output_watts", "50000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "charging_station", None, "requires"), + Dependency("physical", "mass_kg", "10", "kg", "range_min"), + Dependency("force", "thrust_profile", "moderate_continuous", None, "provides"), + ], + ), + Entity( + name="Hydrogen Combustion Engine", + dimension="power_source", + description="Hydrogen fuel cell or combustion engine", + dependencies=[ + Dependency("force", "force_output_watts", "80000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "hydrogen_station", None, "requires"), + Dependency("physical", "mass_kg", "30", "kg", "range_min"), + Dependency("force", "thrust_profile", "high_continuous", None, "provides"), + ], + ), + Entity( + name="Human Pedalling", + dimension="power_source", + description="Human-powered via pedalling mechanism", + dependencies=[ + Dependency("force", "force_output_watts", "75", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"), + Dependency("physical", "mass_kg", "0", "kg", "range_min"), + Dependency("force", "thrust_profile", "low_continuous", None, "provides"), + ], + ), + Entity( + name="Modular Nuclear Reactor", + dimension="power_source", + description="Small modular nuclear fission reactor", + dependencies=[ + Dependency("force", "force_output_watts", "50000000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "nuclear_fuel", None, "requires"), + Dependency("physical", "mass_kg", "2000", "kg", "range_min"), + Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"), + Dependency("material", "radiation_shielding", "true", None, "requires"), + ], + ), + Entity( + name="Coal Steam Engine", + dimension="power_source", + description="Coal-fired boiler with steam locomotion", + dependencies=[ + Dependency("force", "force_output_watts", "200000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "coal_supply", None, "requires"), + Dependency("environment", "atmosphere", "standard", None, "requires"), + Dependency("physical", "mass_kg", "500", "kg", "range_min"), + Dependency("force", "thrust_profile", "high_continuous", None, "provides"), + ], + ), + Entity( + name="Solar Sail", + dimension="power_source", + description="Propulsion via radiation pressure from a star", + dependencies=[ + Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"), + Dependency("environment", "star_proximity", "true", None, "requires"), + Dependency("physical", "surface_area_m2", "100", "m^2", "range_min"), + Dependency("force", "force_output_watts", "1", "watts", "provides"), + Dependency("force", "thrust_profile", "continuous_low", None, "provides"), + Dependency("environment", "medium", "space", None, "requires"), + ], + ), + Entity( + name="Cannonfire Recoil", + dimension="power_source", + description="Propulsion via sequential cannon blasts", + dependencies=[ + Dependency("force", "force_output_watts", "500000", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "ammunition", None, "requires"), + Dependency("physical", "mass_kg", "100", "kg", "range_min"), + Dependency("force", "thrust_profile", "high_burst", None, "provides"), + ], + ), + Entity( + name="Pushed by a Friend", + dimension="power_source", + description="External human pushing the vehicle", + dependencies=[ + Dependency("force", "force_output_watts", "50", "watts", "provides"), + Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"), + Dependency("physical", "mass_kg", "0", "kg", "range_min"), + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("force", "thrust_profile", "low_continuous", None, "provides"), + ], + ), +] + + +# ── Domains ───────────────────────────────────────────────────── + +URBAN_COMMUTING = Domain( + name="urban_commuting", + description="Daily travel within a city, 1-50km range", + metric_bounds=[ + MetricBound("speed", weight=0.25, norm_min=5, norm_max=120), + MetricBound("cost_efficiency", weight=0.25, norm_min=0.01, norm_max=2.0), + MetricBound("safety", weight=0.25, norm_min=0.0, norm_max=1.0), + MetricBound("availability", weight=0.15, norm_min=0.0, norm_max=1.0), + MetricBound("range_fuel", weight=0.10, norm_min=5, norm_max=500), + ], +) + +INTERPLANETARY = Domain( + name="interplanetary_travel", + description="Travel between planets within a solar system", + metric_bounds=[ + MetricBound("speed", weight=0.30, norm_min=1000, norm_max=300000), + MetricBound("range_fuel", weight=0.30, norm_min=1e6, norm_max=1e10), + MetricBound("safety", weight=0.20, norm_min=0.0, norm_max=1.0), + MetricBound("cost_efficiency", weight=0.10, norm_min=1e3, norm_max=1e9), + MetricBound("range_degradation", weight=0.10, norm_min=100, norm_max=36500), + ], +) + + +def load_transport_seed(repo) -> dict: + """Load all transport seed data into the repository. Returns counts.""" + from physcom.db.repository import Repository + repo: Repository + + counts = {"platforms": 0, "power_sources": 0, "domains": 0} + + for entity in PLATFORMS: + repo.add_entity(entity) + counts["platforms"] += 1 + + for entity in POWER_SOURCES: + repo.add_entity(entity) + counts["power_sources"] += 1 + + repo.add_domain(URBAN_COMMUTING) + repo.add_domain(INTERPLANETARY) + counts["domains"] = 2 + + return counts diff --git a/src/physcom_web/__init__.py b/src/physcom_web/__init__.py new file mode 100644 index 0000000..be5cd48 --- /dev/null +++ b/src/physcom_web/__init__.py @@ -0,0 +1 @@ +"""PhysCom Web — Flask UI for Physical Combinatorics.""" diff --git a/src/physcom_web/__main__.py b/src/physcom_web/__main__.py new file mode 100644 index 0000000..4207286 --- /dev/null +++ b/src/physcom_web/__main__.py @@ -0,0 +1,4 @@ +"""Allow `python -m physcom_web` to start the dev server.""" +from physcom_web.app import run + +run() diff --git a/src/physcom_web/app.py b/src/physcom_web/app.py new file mode 100644 index 0000000..3a80562 --- /dev/null +++ b/src/physcom_web/app.py @@ -0,0 +1,60 @@ +"""Flask application factory and DB setup.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from flask import Flask, g + +from physcom.db.schema import init_db +from physcom.db.repository import Repository + + +DEFAULT_DB = Path("data/physcom.db") + + +def get_repo() -> Repository: + """Return a Repository scoped to the current request.""" + if "repo" not in g: + db_path = Path(os.environ.get("PHYSCOM_DB", str(DEFAULT_DB))) + conn = init_db(db_path) + g.repo = Repository(conn) + return g.repo + + +def close_db(exc: BaseException | None = None) -> None: + repo: Repository | None = g.pop("repo", None) + if repo is not None: + repo.conn.close() + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = os.environ.get("FLASK_SECRET_KEY", "physcom-dev-key") + + app.teardown_appcontext(close_db) + + # Register blueprints + from physcom_web.routes.entities import bp as entities_bp + from physcom_web.routes.domains import bp as domains_bp + from physcom_web.routes.pipeline import bp as pipeline_bp + from physcom_web.routes.results import bp as results_bp + + app.register_blueprint(entities_bp) + app.register_blueprint(domains_bp) + app.register_blueprint(pipeline_bp) + app.register_blueprint(results_bp) + + @app.route("/") + def index(): + from flask import redirect, url_for + return redirect(url_for("entities.entity_list")) + + return app + + +def run() -> None: + """Entry point for `physcom-web` script.""" + app = create_app() + app.run(debug=True, port=int(os.environ.get("PORT", "5000"))) diff --git a/src/physcom_web/routes/__init__.py b/src/physcom_web/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/physcom_web/routes/domains.py b/src/physcom_web/routes/domains.py new file mode 100644 index 0000000..3aeb218 --- /dev/null +++ b/src/physcom_web/routes/domains.py @@ -0,0 +1,16 @@ +"""Domain listing routes.""" + +from __future__ import annotations + +from flask import Blueprint, render_template + +from physcom_web.app import get_repo + +bp = Blueprint("domains", __name__, url_prefix="/domains") + + +@bp.route("/") +def domain_list(): + repo = get_repo() + domains = repo.list_domains() + return render_template("domains/list.html", domains=domains) diff --git a/src/physcom_web/routes/entities.py b/src/physcom_web/routes/entities.py new file mode 100644 index 0000000..1151720 --- /dev/null +++ b/src/physcom_web/routes/entities.py @@ -0,0 +1,128 @@ +"""Entity + dependency CRUD routes.""" + +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from physcom.models.entity import Dependency, Entity +from physcom_web.app import get_repo + +bp = Blueprint("entities", __name__, url_prefix="/entities") + + +@bp.route("/") +def entity_list(): + repo = get_repo() + entities = repo.list_entities() + # Group by dimension + grouped: dict[str, list[Entity]] = {} + for e in entities: + grouped.setdefault(e.dimension, []).append(e) + return render_template("entities/list.html", grouped=grouped) + + +@bp.route("/new", methods=["GET", "POST"]) +def entity_new(): + repo = get_repo() + if request.method == "POST": + dimension = request.form["dimension"].strip() + name = request.form["name"].strip() + description = request.form.get("description", "").strip() + if not dimension or not name: + flash("Dimension and name are required.", "error") + return render_template("entities/form.html", entity=None, + dimensions=repo.list_dimensions()) + entity = Entity(name=name, dimension=dimension, description=description) + repo.add_entity(entity) + flash(f"Entity '{name}' added to '{dimension}'.", "success") + return redirect(url_for("entities.entity_detail", entity_id=entity.id)) + return render_template("entities/form.html", entity=None, + dimensions=repo.list_dimensions()) + + +@bp.route("/") +def entity_detail(entity_id: int): + repo = get_repo() + entity = repo.get_entity(entity_id) + if not entity: + flash("Entity not found.", "error") + return redirect(url_for("entities.entity_list")) + return render_template("entities/detail.html", entity=entity) + + +@bp.route("//edit", methods=["GET", "POST"]) +def entity_edit(entity_id: int): + repo = get_repo() + entity = repo.get_entity(entity_id) + if not entity: + flash("Entity not found.", "error") + return redirect(url_for("entities.entity_list")) + 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("entities/form.html", entity=entity, + dimensions=repo.list_dimensions()) + repo.update_entity(entity_id, name, description) + flash(f"Entity '{name}' updated.", "success") + return redirect(url_for("entities.entity_detail", entity_id=entity_id)) + return render_template("entities/form.html", entity=entity, + dimensions=repo.list_dimensions()) + + +@bp.route("//delete", methods=["POST"]) +def entity_delete(entity_id: int): + repo = get_repo() + entity = repo.get_entity(entity_id) + if entity: + repo.delete_entity(entity_id) + flash(f"Entity '{entity.name}' deleted.", "success") + return redirect(url_for("entities.entity_list")) + + +# ── Dependency CRUD (HTMX partials) ───────────────────────── + + +@bp.route("//deps/add", methods=["POST"]) +def dep_add(entity_id: int): + repo = get_repo() + dep = Dependency( + category=request.form["category"].strip(), + key=request.form["key"].strip(), + value=request.form["value"].strip(), + unit=request.form.get("unit", "").strip() or None, + constraint_type=request.form.get("constraint_type", "requires").strip(), + ) + if not dep.category or not dep.key or not dep.value: + flash("Category, key, and value are required.", "error") + else: + repo.add_dependency(entity_id, dep) + flash("Dependency added.", "success") + entity = repo.get_entity(entity_id) + return render_template("entities/_dep_table.html", entity=entity) + + +@bp.route("//deps//edit", methods=["POST"]) +def dep_edit(entity_id: int, dep_id: int): + repo = get_repo() + dep = Dependency( + category=request.form["category"].strip(), + key=request.form["key"].strip(), + value=request.form["value"].strip(), + unit=request.form.get("unit", "").strip() or None, + constraint_type=request.form.get("constraint_type", "requires").strip(), + ) + repo.update_dependency(dep_id, dep) + flash("Dependency updated.", "success") + entity = repo.get_entity(entity_id) + return render_template("entities/_dep_table.html", entity=entity) + + +@bp.route("//deps//delete", methods=["POST"]) +def dep_delete(entity_id: int, dep_id: int): + repo = get_repo() + repo.delete_dependency(dep_id) + flash("Dependency deleted.", "success") + entity = repo.get_entity(entity_id) + return render_template("entities/_dep_table.html", entity=entity) diff --git a/src/physcom_web/routes/pipeline.py b/src/physcom_web/routes/pipeline.py new file mode 100644 index 0000000..303b68d --- /dev/null +++ b/src/physcom_web/routes/pipeline.py @@ -0,0 +1,56 @@ +"""Pipeline run routes.""" + +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from physcom_web.app import get_repo + +bp = Blueprint("pipeline", __name__, url_prefix="/pipeline") + + +@bp.route("/") +def pipeline_form(): + repo = get_repo() + domains = repo.list_domains() + dimensions = repo.list_dimensions() + return render_template("pipeline/run.html", domains=domains, dimensions=dimensions) + + +@bp.route("/run", methods=["POST"]) +def pipeline_run(): + repo = get_repo() + + domain_name = request.form["domain"] + domain = repo.get_domain(domain_name) + if not domain: + flash(f"Domain '{domain_name}' not found.", "error") + return redirect(url_for("pipeline.pipeline_form")) + + passes = [int(p) for p in request.form.getlist("passes")] + if not passes: + passes = [1, 2, 3] + + threshold = float(request.form.get("threshold", 0.1)) + dim_list = request.form.getlist("dimensions") + if not dim_list: + flash("Select at least one dimension.", "error") + return redirect(url_for("pipeline.pipeline_form")) + + from physcom.engine.constraint_resolver import ConstraintResolver + from physcom.engine.scorer import Scorer + from physcom.engine.pipeline import Pipeline + + resolver = ConstraintResolver() + scorer = Scorer(domain) + pipeline = Pipeline(repo, resolver, scorer, llm=None) + + result = pipeline.run(domain, dim_list, score_threshold=threshold, passes=passes) + + flash( + f"Pipeline complete: {result.total_generated} combos generated, " + f"{result.pass1_valid} valid, {result.pass1_blocked} blocked, " + f"{result.pass3_above_threshold} above threshold.", + "success", + ) + return redirect(url_for("results.results_domain", domain_name=domain_name)) diff --git a/src/physcom_web/routes/results.py b/src/physcom_web/routes/results.py new file mode 100644 index 0000000..11dee21 --- /dev/null +++ b/src/physcom_web/routes/results.py @@ -0,0 +1,95 @@ +"""Results browse + review routes.""" + +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from physcom_web.app import get_repo + +bp = Blueprint("results", __name__, url_prefix="/results") + + +@bp.route("/") +def results_index(): + repo = get_repo() + domains = repo.list_domains() + return render_template("results/list.html", domains=domains, results=None, domain=None) + + +@bp.route("/") +def results_domain(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")) + + status_filter = request.args.get("status") + results = repo.get_all_results(domain_name, status=status_filter) + statuses = repo.count_combinations_by_status() + + return render_template( + "results/list.html", + domains=repo.list_domains(), + domain=domain, + results=results, + status_filter=status_filter, + statuses=statuses, + ) + + +@bp.route("//") +def result_detail(domain_name: str, combo_id: int): + 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")) + + combo = repo.get_combination(combo_id) + if not combo: + flash("Combination not found.", "error") + return redirect(url_for("results.results_domain", domain_name=domain_name)) + + result = repo.get_result(combo_id, domain.id) + scores = repo.get_combination_scores(combo_id, domain.id) + + return render_template( + "results/detail.html", + domain=domain, + combo=combo, + result=result, + scores=scores, + ) + + +@bp.route("///review", methods=["POST"]) +def submit_review(domain_name: str, combo_id: int): + 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")) + + novelty_flag = request.form.get("novelty_flag", "").strip() or None + human_notes = request.form.get("human_notes", "").strip() or None + + # Get existing result to preserve composite_score + existing = repo.get_result(combo_id, domain.id) + composite_score = existing["composite_score"] if existing else 0.0 + + repo.save_result( + combo_id, domain.id, composite_score, + pass_reached=5, + novelty_flag=novelty_flag, + human_notes=human_notes, + ) + repo.update_combination_status(combo_id, "reviewed") + + # Return HTMX partial or redirect + if request.headers.get("HX-Request"): + return render_template("results/_review_done.html", + novelty_flag=novelty_flag, human_notes=human_notes) + flash("Review saved.", "success") + return redirect(url_for("results.result_detail", + domain_name=domain_name, combo_id=combo_id)) diff --git a/src/physcom_web/static/style.css b/src/physcom_web/static/style.css new file mode 100644 index 0000000..b3d2a85 --- /dev/null +++ b/src/physcom_web/static/style.css @@ -0,0 +1,156 @@ +/* ── Reset & Base ─────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.5; + color: #1a1a2e; + background: #f5f5f7; +} + +a { color: #2563eb; text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Nav ─────────────────────────────────────────────────── */ +nav { + background: #1a1a2e; + color: #fff; + display: flex; + align-items: center; + padding: 0.5rem 1.5rem; + gap: 2rem; +} +nav .nav-brand { color: #fff; font-weight: 700; font-size: 1.1rem; } +nav ul { list-style: none; display: flex; gap: 1.25rem; } +nav a { color: #c4c4d4; } +nav a:hover { color: #fff; text-decoration: none; } + +/* ── Main ────────────────────────────────────────────────── */ +main { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; } + +/* ── Page header ─────────────────────────────────────────── */ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +h1 { font-size: 1.5rem; margin-bottom: 0.75rem; } +h2 { font-size: 1.2rem; margin: 1rem 0 0.5rem; } +h3 { font-size: 1rem; margin-bottom: 0.25rem; } +.subtitle { font-weight: 400; color: #666; font-size: 0.9rem; } + +/* ── Cards ───────────────────────────────────────────────── */ +.card { + background: #fff; + border: 1px solid #e2e2e8; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} + +/* ── Tables ──────────────────────────────────────────────── */ +table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +th, td { padding: 0.4rem 0.6rem; text-align: left; border-bottom: 1px solid #eee; } +th { font-weight: 600; color: #555; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; } +table.compact th, table.compact td { padding: 0.25rem 0.4rem; font-size: 0.85rem; } + +/* ── Score cells ─────────────────────────────────────────── */ +.score-cell { font-family: monospace; font-weight: 600; } + +/* ── Badges ──────────────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + background: #e2e2e8; + color: #333; +} +.badge-requires { background: #dbeafe; color: #1e40af; } +.badge-provides { background: #dcfce7; color: #166534; } +.badge-range_min, .badge-range_max { background: #fef3c7; color: #92400e; } +.badge-excludes { background: #fee2e2; color: #991b1b; } +.badge-valid { background: #dcfce7; color: #166534; } +.badge-blocked { background: #fee2e2; color: #991b1b; } +.badge-scored { background: #dbeafe; color: #1e40af; } +.badge-reviewed { background: #f3e8ff; color: #6b21a8; } +.badge-pending { background: #fef3c7; color: #92400e; } + +/* ── Buttons ─────────────────────────────────────────────── */ +.btn { + display: inline-block; + padding: 0.4rem 0.85rem; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + color: #374151; + font-size: 0.85rem; + cursor: pointer; + text-decoration: none; +} +.btn:hover { background: #f3f4f6; text-decoration: none; } +.btn-primary { background: #2563eb; color: #fff; border-color: #2563eb; } +.btn-primary:hover { background: #1d4ed8; } +.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } +.btn-danger:hover { background: #b91c1c; } +.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; } + +/* ── Forms ───────────────────────────────────────────────── */ +.form-group { margin-bottom: 0.75rem; } +.form-group label { display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.25rem; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 0.4rem 0.6rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.9rem; +} +.form-actions { margin-top: 1rem; display: flex; gap: 0.5rem; } +.form-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.form-row input, .form-row select { width: auto; flex: 1; min-width: 80px; } + +fieldset { border: 1px solid #e2e2e8; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; } +legend { font-weight: 600; font-size: 0.85rem; padding: 0 0.3rem; } +.checkbox-row { display: flex; gap: 1rem; flex-wrap: wrap; } +.checkbox-row label { display: flex; align-items: center; gap: 0.3rem; font-size: 0.9rem; } + +/* ── Inline form (for delete buttons) ────────────────────── */ +.inline-form { display: inline; } + +/* ── Flash messages ──────────────────────────────────────── */ +.flash-container { margin-bottom: 1rem; } +.flash { + padding: 0.6rem 1rem; + border-radius: 6px; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} +.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; } +.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; } +.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; } + +/* ── Filter row ──────────────────────────────────────────── */ +.filter-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } +.filter-row span { font-weight: 600; font-size: 0.85rem; color: #555; } + +/* ── DL styling ──────────────────────────────────────────── */ +dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; } +dt { font-weight: 600; font-size: 0.85rem; color: #555; } +dd { font-size: 0.9rem; } + +/* ── Empty state ─────────────────────────────────────────── */ +.empty { color: #666; padding: 2rem 0; text-align: center; } + +/* ── Actions column ──────────────────────────────────────── */ +.actions { white-space: nowrap; display: flex; gap: 0.25rem; } + +/* ── Dep add form ────────────────────────────────────────── */ +.dep-add-form { margin-top: 0.75rem; } diff --git a/src/physcom_web/templates/base.html b/src/physcom_web/templates/base.html new file mode 100644 index 0000000..d496598 --- /dev/null +++ b/src/physcom_web/templates/base.html @@ -0,0 +1,35 @@ + + + + + + {% block title %}PhysCom{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/src/physcom_web/templates/domains/list.html b/src/physcom_web/templates/domains/list.html new file mode 100644 index 0000000..e70edfd --- /dev/null +++ b/src/physcom_web/templates/domains/list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Domains — PhysCom{% endblock %} + +{% block content %} +

Domains

+ +{% if not domains %} +

No domains found. Seed data via CLI first.

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

{{ d.name }}

+

{{ d.description }}

+ + + + + + {% for mb in d.metric_bounds %} + + + + + + + {% endfor %} + +
MetricWeightMinMax
{{ mb.metric_name }}{{ mb.weight }}{{ mb.norm_min }}{{ mb.norm_max }}
+
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/src/physcom_web/templates/entities/_dep_table.html b/src/physcom_web/templates/entities/_dep_table.html new file mode 100644 index 0000000..4bb3560 --- /dev/null +++ b/src/physcom_web/templates/entities/_dep_table.html @@ -0,0 +1,80 @@ + + + + + + + + + + + + + {% for dep in entity.dependencies %} + + + + + + + + + + + + + + + + + + + {% endfor %} + +
CategoryKeyValueUnitType
{{ dep.category }}{{ dep.key }}{{ dep.value }}{{ dep.unit or '—' }}{{ dep.constraint_type }} + +
+ +
+
+ +

Add Dependency

+
+
+ + + + + + +
+
diff --git a/src/physcom_web/templates/entities/detail.html b/src/physcom_web/templates/entities/detail.html new file mode 100644 index 0000000..cc0a80d --- /dev/null +++ b/src/physcom_web/templates/entities/detail.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ entity.name }} — PhysCom{% endblock %} + +{% block content %} + + +
+
+
Dimension
{{ entity.dimension }}
+
Description
{{ entity.description or '—' }}
+
+
+ +

Dependencies

+ +
+ {% include "entities/_dep_table.html" %} +
+{% endblock %} diff --git a/src/physcom_web/templates/entities/form.html b/src/physcom_web/templates/entities/form.html new file mode 100644 index 0000000..db31f7b --- /dev/null +++ b/src/physcom_web/templates/entities/form.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if entity else 'Add' }} Entity — PhysCom{% endblock %} + +{% block content %} +

{{ 'Edit' if entity else 'New' }} Entity

+ +
+
+
+ + {% if entity %} + + {% else %} + + + {% for d in dimensions %} + + {% endif %} +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+{% endblock %} diff --git a/src/physcom_web/templates/entities/list.html b/src/physcom_web/templates/entities/list.html new file mode 100644 index 0000000..057fa8b --- /dev/null +++ b/src/physcom_web/templates/entities/list.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Entities — PhysCom{% endblock %} + +{% block content %} + + +{% if not grouped %} +

No entities found. Add one or seed data via CLI.

+{% else %} + {% for dimension, entities in grouped.items() %} +
+

{{ dimension }}

+ + + + + + + + + + + + {% for e in entities %} + + + + + + + + {% endfor %} + +
IDNameDescriptionDeps
{{ e.id }}{{ e.name }}{{ e.description }}{{ e.dependencies|length }} + Edit +
+
+ {% endfor %} +{% endif %} +{% endblock %} diff --git a/src/physcom_web/templates/pipeline/run.html b/src/physcom_web/templates/pipeline/run.html new file mode 100644 index 0000000..1b218f7 --- /dev/null +++ b/src/physcom_web/templates/pipeline/run.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Run Pipeline — PhysCom{% endblock %} + +{% block content %} +

Run Pipeline

+ +
+
+
+ + +
+ +
+ Passes +
+ {% for p in [1, 2, 3, 4, 5] %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ Dimensions +
+ {% for d in dimensions %} + + {% endfor %} +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/src/physcom_web/templates/results/_review_done.html b/src/physcom_web/templates/results/_review_done.html new file mode 100644 index 0000000..08c0dcb --- /dev/null +++ b/src/physcom_web/templates/results/_review_done.html @@ -0,0 +1,7 @@ +
+

Review saved.

+
+ {% if novelty_flag %}
Novelty
{{ novelty_flag }}
{% endif %} + {% if human_notes %}
Notes
{{ human_notes }}
{% endif %} +
+
diff --git a/src/physcom_web/templates/results/_review_form.html b/src/physcom_web/templates/results/_review_form.html new file mode 100644 index 0000000..a044934 --- /dev/null +++ b/src/physcom_web/templates/results/_review_form.html @@ -0,0 +1,22 @@ +
+
+
+ + +
+
+ + +
+
+ +
+
+
diff --git a/src/physcom_web/templates/results/detail.html b/src/physcom_web/templates/results/detail.html new file mode 100644 index 0000000..4e9a14a --- /dev/null +++ b/src/physcom_web/templates/results/detail.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block title %}Combination #{{ combo.id }} — PhysCom{% endblock %} + +{% block content %} + + +
+
+
Domain
{{ domain.name }}
+
Status
{{ combo.status }}
+ {% if combo.block_reason %} +
Block Reason
{{ combo.block_reason }}
+ {% endif %} + {% if result %} +
Composite Score
{{ "%.4f"|format(result.composite_score) }}
+
Pass Reached
{{ result.pass_reached }}
+ {% if result.novelty_flag %} +
Novelty
{{ result.novelty_flag }}
+ {% endif %} + {% if result.llm_review %} +
LLM Review
{{ result.llm_review }}
+ {% endif %} + {% if result.human_notes %} +
Human Notes
{{ result.human_notes }}
+ {% endif %} + {% endif %} +
+
+ +

Entities

+
+ {% for e in combo.entities %} +
+

{{ e.name }}

+

{{ e.dimension }}

+

{{ e.description }}

+ + + + {% for dep in e.dependencies %} + + + + + + {% endfor %} + +
KeyValueType
{{ dep.key }}{{ dep.value }}{{ ' ' + dep.unit if dep.unit else '' }}{{ dep.constraint_type }}
+
+ {% endfor %} +
+ +{% if scores %} +

Per-Metric Scores

+
+ + + + + + {% for s in scores %} + + + + + + + + {% endfor %} + +
MetricRaw ValueNormalizedMethodConfidence
{{ s.metric_name }}{{ "%.2f"|format(s.raw_value) if s.raw_value is not none else '—' }}{{ "%.4f"|format(s.normalized_score) if s.normalized_score is not none else '—' }}{{ s.estimation_method or '—' }}{{ "%.2f"|format(s.confidence) if s.confidence is not none else '—' }}
+
+{% endif %} + +

Human Review

+
+ {% include "results/_review_form.html" %} +
+{% endblock %} diff --git a/src/physcom_web/templates/results/list.html b/src/physcom_web/templates/results/list.html new file mode 100644 index 0000000..5a0ba68 --- /dev/null +++ b/src/physcom_web/templates/results/list.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Results — PhysCom{% endblock %} + +{% block content %} +

Results

+ +
+ {% for d in domains %} + + {{ d.name }} + + {% endfor %} +
+ +{% if domain and results is not none %} +
+

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

+ + {% if statuses %} +
+ Filter: + All + {% for s, cnt in statuses.items() %} + + {{ s }} ({{ cnt }}) + + {% endfor %} +
+ {% endif %} + + {% if not results %} +

No results yet. Run the pipeline first.

+ {% else %} + + + + + + + + + + + + + {% for r in results %} + + + + + + + + + {% endfor %} + +
#ScoreEntitiesStatusNovelty
{{ loop.index }}{{ "%.4f"|format(r.composite_score) }}{{ r.combination.entities|map(attribute='name')|join(' + ') }}{{ r.combination.status }}{{ r.novelty_flag or '—' }} + View +
+ {% endif %} +
+{% elif not domain %} +

Select a domain above to view results.

+{% endif %} +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8703118 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,158 @@ +"""Shared fixtures for tests.""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from physcom.db.schema import init_db +from physcom.db.repository import Repository +from physcom.models.entity import Entity, Dependency +from physcom.models.domain import Domain, MetricBound +from physcom.models.combination import Combination + + +@pytest.fixture +def repo(tmp_path): + """Fresh in-memory repository for each test.""" + db_path = tmp_path / "test.db" + conn = init_db(db_path) + return Repository(conn) + + +@pytest.fixture +def seeded_repo(repo): + """Repository pre-loaded with transport seed data.""" + from physcom.seed.transport_example import load_transport_seed + load_transport_seed(repo) + return repo + + +@pytest.fixture +def walking(): + return Entity( + name="Walking", + dimension="platform", + description="Bipedal locomotion", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "150", "kg", "range_max"), + Dependency("force", "force_required_watts", "75", "watts", "range_min"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ) + + +@pytest.fixture +def bicycle(): + return Entity( + name="Bicycle", + dimension="platform", + description="Two-wheeled human-scale vehicle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + Dependency("environment", "gravity", "true", None, "requires"), + Dependency("physical", "mass_kg", "30", "kg", "range_max"), + Dependency("force", "force_required_watts", "75", "watts", "range_min"), + Dependency("environment", "medium", "ground", None, "requires"), + ], + ) + + +@pytest.fixture +def spaceship(): + return Entity( + name="Spaceship", + dimension="platform", + description="Vehicle designed for space travel", + dependencies=[ + Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"), + Dependency("physical", "mass_kg", "5000", "kg", "range_min"), + Dependency("force", "force_required_watts", "1000000", "watts", "range_min"), + Dependency("environment", "medium", "space", None, "requires"), + ], + ) + + +@pytest.fixture +def solar_sail(): + return Entity( + name="Solar Sail", + dimension="power_source", + description="Propulsion via radiation pressure", + dependencies=[ + Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"), + Dependency("force", "force_output_watts", "1", "watts", "provides"), + Dependency("environment", "medium", "space", None, "requires"), + ], + ) + + +@pytest.fixture +def human_pedalling(): + return Entity( + name="Human Pedalling", + dimension="power_source", + description="Human-powered via pedalling", + dependencies=[ + Dependency("force", "force_output_watts", "75", "watts", "provides"), + Dependency("physical", "mass_kg", "0", "kg", "range_min"), + ], + ) + + +@pytest.fixture +def nuclear_reactor(): + return Entity( + name="Modular Nuclear Reactor", + dimension="power_source", + description="Small modular nuclear fission reactor", + dependencies=[ + Dependency("force", "force_output_watts", "50000000", "watts", "provides"), + Dependency("physical", "mass_kg", "2000", "kg", "range_min"), + ], + ) + + +@pytest.fixture +def hydrogen_engine(): + return Entity( + name="Hydrogen Combustion Engine", + dimension="power_source", + description="Hydrogen fuel cell", + dependencies=[ + Dependency("force", "force_output_watts", "80000", "watts", "provides"), + Dependency("physical", "mass_kg", "30", "kg", "range_min"), + ], + ) + + +@pytest.fixture +def ice_engine(): + return Entity( + name="Internal Combustion Engine", + dimension="power_source", + description="Gas-powered engine", + dependencies=[ + Dependency("force", "force_output_watts", "100000", "watts", "provides"), + Dependency("environment", "atmosphere", "standard", None, "requires"), + Dependency("physical", "mass_kg", "50", "kg", "range_min"), + ], + ) + + +@pytest.fixture +def urban_domain(): + return Domain( + name="urban_commuting", + description="Daily city travel", + metric_bounds=[ + MetricBound("speed", weight=0.25, norm_min=5, norm_max=120), + MetricBound("cost_efficiency", weight=0.25, norm_min=0.01, norm_max=2.0), + MetricBound("safety", weight=0.25, norm_min=0.0, norm_max=1.0), + MetricBound("availability", weight=0.15, norm_min=0.0, norm_max=1.0), + MetricBound("range_fuel", weight=0.10, norm_min=5, norm_max=500), + ], + ) diff --git a/tests/test_combinator.py b/tests/test_combinator.py new file mode 100644 index 0000000..660532a --- /dev/null +++ b/tests/test_combinator.py @@ -0,0 +1,26 @@ +"""Tests for the Cartesian product combinator.""" + +import pytest + +from physcom.engine.combinator import generate_combinations +from physcom.models.entity import Entity + + +def test_generates_cartesian_product(seeded_repo): + combos = generate_combinations(seeded_repo, ["platform", "power_source"]) + # 9 platforms x 9 power sources = 81 + assert len(combos) == 81 + + +def test_each_combo_has_one_per_dimension(seeded_repo): + combos = generate_combinations(seeded_repo, ["platform", "power_source"]) + for combo in combos: + dims = [e.dimension for e in combo.entities] + assert "platform" in dims + assert "power_source" in dims + assert len(combo.entities) == 2 + + +def test_missing_dimension_raises(seeded_repo): + with pytest.raises(ValueError, match="No entities found"): + generate_combinations(seeded_repo, ["platform", "nonexistent"]) diff --git a/tests/test_constraint_resolver.py b/tests/test_constraint_resolver.py new file mode 100644 index 0000000..abbd489 --- /dev/null +++ b/tests/test_constraint_resolver.py @@ -0,0 +1,121 @@ +"""Tests for the constraint resolver engine.""" + +from physcom.engine.constraint_resolver import ConstraintResolver +from physcom.models.combination import Combination +from physcom.models.entity import Entity, Dependency + + +def test_compatible_ground_combo(bicycle, human_pedalling): + """Bicycle + Human Pedalling should be valid.""" + resolver = ConstraintResolver() + combo = Combination(entities=[bicycle, human_pedalling]) + result = resolver.resolve(combo) + assert result.status != "blocked", f"Unexpected block: {result.violations}" + + +def test_solar_sail_blocks_with_walking(walking, solar_sail): + """Walking (ground) + Solar Sail (space) should be blocked by medium mutex.""" + resolver = ConstraintResolver() + combo = Combination(entities=[walking, solar_sail]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("mutually exclusive" in v for v in result.violations) + + +def test_spaceship_compatible_with_solar_sail(spaceship, solar_sail): + """Spaceship + Solar Sail both need space/vacuum — should not conflict.""" + resolver = ConstraintResolver() + combo = Combination(entities=[spaceship, solar_sail]) + result = resolver.resolve(combo) + # Should not be blocked by atmosphere or medium + medium_blocks = [v for v in result.violations if "mutually exclusive" in v] + assert len(medium_blocks) == 0 + + +def test_nuclear_reactor_blocks_with_bicycle(bicycle, nuclear_reactor): + """Nuclear reactor min_mass=2000kg vs bicycle max_mass=30kg → range incompatibility.""" + resolver = ConstraintResolver() + combo = Combination(entities=[bicycle, nuclear_reactor]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("mass" in v.lower() for v in result.violations) + + +def test_force_scale_mismatch_blocks(): + """A platform needing 1MW and a power source providing 1W → force deficit block.""" + platform = Entity( + name="HeavyPlatform", dimension="platform", + dependencies=[ + Dependency("force", "force_required_watts", "1000000", "watts", "range_min"), + ], + ) + power = Entity( + name="TinyPower", dimension="power_source", + dependencies=[ + Dependency("force", "force_output_watts", "1", "watts", "provides"), + ], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[platform, power]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("force deficit" in v for v in result.violations) + + +def test_force_under_powered_warning(): + """Power source slightly below requirement → warning, not block.""" + platform = Entity( + name="MedPlatform", dimension="platform", + dependencies=[ + Dependency("force", "force_required_watts", "1000", "watts", "range_min"), + ], + ) + power = Entity( + name="WeakPower", dimension="power_source", + dependencies=[ + Dependency("force", "force_output_watts", "500", "watts", "provides"), + ], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[platform, power]) + result = resolver.resolve(combo) + # Under-powered but within 100x → warning, not block + assert result.status != "blocked" + assert any("under-powered" in w for w in result.warnings) + + +def test_requires_vs_excludes(): + """Direct requires/excludes contradiction.""" + a = Entity( + name="A", dimension="platform", + dependencies=[Dependency("environment", "oxygen", "true", None, "requires")], + ) + b = Entity( + name="B", dimension="power_source", + dependencies=[Dependency("environment", "oxygen", "true", None, "excludes")], + ) + resolver = ConstraintResolver() + combo = Combination(entities=[a, b]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("excludes" in v for v in result.violations) + + +def test_ice_engine_blocks_with_spaceship(spaceship, ice_engine): + """ICE requires standard atmosphere, spaceship requires vacuum_or_thin → mutex.""" + resolver = ConstraintResolver() + combo = Combination(entities=[spaceship, ice_engine]) + result = resolver.resolve(combo) + assert result.status == "blocked" + assert any("atmosphere" in v for v in result.violations) + + +def test_hydrogen_bicycle_valid(bicycle, hydrogen_engine): + """Hydrogen bike — the README's example of a plausible novel concept.""" + resolver = ConstraintResolver() + combo = Combination(entities=[bicycle, hydrogen_engine]) + result = resolver.resolve(combo) + # Should pass constraints (mass range is compatible: h2 min 30kg, bike max 30kg) + # 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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..d785821 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,72 @@ +"""Tests for the multi-pass pipeline.""" + +from physcom.engine.constraint_resolver import ConstraintResolver +from physcom.engine.scorer import Scorer +from physcom.engine.pipeline import Pipeline +from physcom.llm.providers.mock import MockLLMProvider + + +def test_pass1_filters_impossible_combos(seeded_repo): + """Pass 1 should block known-impossible combinations (e.g., solar sail + walking).""" + domain = seeded_repo.get_domain("urban_commuting") + resolver = ConstraintResolver() + scorer = Scorer(domain) + pipeline = Pipeline(seeded_repo, resolver, scorer) + + result = pipeline.run(domain, ["platform", "power_source"], passes=[1]) + + assert result.total_generated == 81 + assert result.pass1_blocked > 0 + assert result.pass1_valid + result.pass1_conditional + result.pass1_blocked == 81 + + +def test_pass123_produces_scored_results(seeded_repo): + """Passes 1-3 should produce a scored shortlist.""" + domain = seeded_repo.get_domain("urban_commuting") + resolver = ConstraintResolver() + scorer = Scorer(domain) + pipeline = Pipeline(seeded_repo, resolver, scorer) + + result = pipeline.run( + domain, ["platform", "power_source"], + score_threshold=0.01, passes=[1, 2, 3, 5], + ) + + assert result.pass2_estimated > 0 + assert result.pass3_above_threshold > 0 + + +def test_pass4_with_mock_llm(seeded_repo): + """Pass 4 should annotate with LLM review.""" + domain = seeded_repo.get_domain("urban_commuting") + resolver = ConstraintResolver() + scorer = Scorer(domain) + mock_llm = MockLLMProvider(default_estimates={ + "speed": 50.0, "cost_efficiency": 0.5, "safety": 0.6, + "availability": 0.7, "range_fuel": 200.0, + }) + pipeline = Pipeline(seeded_repo, resolver, scorer, llm=mock_llm) + + result = pipeline.run( + domain, ["platform", "power_source"], + score_threshold=0.01, passes=[1, 2, 3, 4, 5], + ) + + assert result.pass4_reviewed > 0 + + +def test_blocked_combos_not_scored(seeded_repo): + """Blocked combinations should not make it to scoring.""" + domain = seeded_repo.get_domain("urban_commuting") + resolver = ConstraintResolver() + scorer = Scorer(domain) + pipeline = Pipeline(seeded_repo, resolver, scorer) + + result = pipeline.run( + domain, ["platform", "power_source"], + score_threshold=0.0, passes=[1, 2, 3, 5], + ) + + # Estimated count should be less than total (blocked ones filtered) + assert result.pass2_estimated < result.total_generated + assert result.pass2_estimated == result.pass1_valid + result.pass1_conditional diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..c85b950 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,88 @@ +"""Tests for the database repository.""" + +from physcom.models.entity import Entity, Dependency +from physcom.models.domain import Domain, MetricBound + + +def test_ensure_dimension(repo): + dim_id = repo.ensure_dimension("platform", "Vehicle platforms") + assert dim_id > 0 + # Idempotent + dim_id2 = repo.ensure_dimension("platform", "Vehicle platforms") + assert dim_id == dim_id2 + + +def test_add_and_get_entity(repo): + entity = Entity( + name="TestBike", + dimension="platform", + description="A test bicycle", + dependencies=[ + Dependency("environment", "ground_surface", "true", None, "requires"), + ], + ) + saved = repo.add_entity(entity) + assert saved.id is not None + + loaded = repo.get_entity(saved.id) + assert loaded is not None + assert loaded.name == "TestBike" + assert loaded.dimension == "platform" + assert len(loaded.dependencies) == 1 + assert loaded.dependencies[0].key == "ground_surface" + + +def test_list_entities_by_dimension(repo): + repo.add_entity(Entity(name="A", dimension="platform")) + repo.add_entity(Entity(name="B", dimension="platform")) + repo.add_entity(Entity(name="C", dimension="power_source")) + + platforms = repo.list_entities(dimension="platform") + assert len(platforms) == 2 + + all_entities = repo.list_entities() + assert len(all_entities) == 3 + + +def test_add_and_get_domain(repo): + domain = Domain( + name="test_domain", + description="A test domain", + metric_bounds=[ + MetricBound("speed", weight=0.5, norm_min=0, norm_max=100), + MetricBound("safety", weight=0.5, norm_min=0, norm_max=1), + ], + ) + saved = repo.add_domain(domain) + assert saved.id is not None + + loaded = repo.get_domain("test_domain") + assert loaded is not None + assert loaded.name == "test_domain" + assert len(loaded.metric_bounds) == 2 + assert loaded.metric_bounds[0].metric_name == "speed" + + +def test_combination_save_and_dedup(repo): + e1 = repo.add_entity(Entity(name="A", dimension="platform")) + e2 = repo.add_entity(Entity(name="B", dimension="power_source")) + + from physcom.models.combination import Combination + combo = Combination(entities=[e1, e2]) + saved = repo.save_combination(combo) + assert saved.id is not None + + # Same entities, same hash → should not create duplicate + combo2 = Combination(entities=[e1, e2]) + saved2 = repo.save_combination(combo2) + assert saved2.id == saved.id + + +def test_seed_loads(seeded_repo): + platforms = seeded_repo.list_entities(dimension="platform") + power_sources = seeded_repo.list_entities(dimension="power_source") + assert len(platforms) == 9 + assert len(power_sources) == 9 + + domains = seeded_repo.list_domains() + assert len(domains) == 2 diff --git a/tests/test_scorer.py b/tests/test_scorer.py new file mode 100644 index 0000000..0565f10 --- /dev/null +++ b/tests/test_scorer.py @@ -0,0 +1,90 @@ +"""Tests for the scoring engine.""" + +import math + +import pytest + +from physcom.engine.scorer import normalize, composite_score, Scorer +from physcom.models.combination import Combination +from physcom.models.entity import Entity + + +class TestNormalize: + def test_below_min_is_zero(self): + assert normalize(3.0, 5.0, 120.0) == 0.0 + + def test_at_min_is_zero(self): + assert normalize(5.0, 5.0, 120.0) == 0.0 + + def test_above_max_is_one(self): + assert normalize(200.0, 5.0, 120.0) == 1.0 + + def test_at_max_is_one(self): + assert normalize(120.0, 5.0, 120.0) == 1.0 + + def test_mid_range_is_between_zero_and_one(self): + result = normalize(50.0, 5.0, 120.0) + assert 0.0 < result < 1.0 + + def test_monotonically_increasing(self): + """Higher raw values should give higher normalized scores.""" + scores = [normalize(v, 5.0, 120.0) for v in [10, 30, 60, 90, 110]] + for a, b in zip(scores, scores[1:]): + assert a < b + + def test_degenerate_bounds(self): + """norm_min >= norm_max should return 0.""" + assert normalize(50.0, 100.0, 50.0) == 0.0 + + def test_zero_min(self): + """norm_min=0 should work (log1p(0)=0).""" + result = normalize(0.5, 0.0, 1.0) + assert 0.0 < result < 1.0 + + +class TestCompositeScore: + def test_all_ones(self): + assert composite_score([1.0, 1.0, 1.0], [0.33, 0.33, 0.34]) == pytest.approx(1.0, abs=0.01) + + def test_single_zero_kills(self): + """Any score of 0 should make composite 0.""" + assert composite_score([0.0, 1.0, 1.0], [0.33, 0.33, 0.34]) == 0.0 + + def test_empty(self): + assert composite_score([], []) == 0.0 + + def test_low_score_dominates(self): + """A single low score should dramatically reduce the composite.""" + high = composite_score([0.9, 0.9, 0.9], [0.33, 0.33, 0.34]) + mixed = composite_score([0.9, 0.9, 0.1], [0.33, 0.33, 0.34]) + assert mixed < high * 0.5 + + def test_weighted_geometric_mean(self): + """Manual verification of weighted geometric mean.""" + # s = 0.5^0.5 * 0.8^0.5 = sqrt(0.5) * sqrt(0.8) + expected = (0.5 ** 0.5) * (0.8 ** 0.5) + assert composite_score([0.5, 0.8], [0.5, 0.5]) == pytest.approx(expected) + + +class TestScorer: + def test_scorer_produces_valid_result(self, urban_domain): + scorer = Scorer(urban_domain) + combo = Combination(entities=[ + Entity(name="Car", dimension="platform"), + Entity(name="ICE", dimension="power_source"), + ]) + combo.id = 1 + raw = {"speed": 60.0, "cost_efficiency": 0.5, "safety": 0.7, + "availability": 0.8, "range_fuel": 400} + result = scorer.score_combination(combo, raw) + assert 0.0 < result.composite_score <= 1.0 + assert len(result.scores) == 5 + + def test_scorer_zero_metric_kills_score(self, urban_domain): + scorer = Scorer(urban_domain) + combo = Combination(entities=[]) + combo.id = 1 + raw = {"speed": 60.0, "cost_efficiency": 0.0, "safety": 0.7, + "availability": 0.8, "range_fuel": 400} + result = scorer.score_combination(combo, raw) + assert result.composite_score == 0.0