Add Flask web UI, Docker Compose, core engine + tests
- physcom core: CLI, 5-pass pipeline, SQLite repo, 37 tests - physcom_web: Flask app with HTMX for entity/domain/pipeline/results CRUD - Docker Compose: web + cli services sharing a named volume for the DB - Clean up local settings to use wildcard permissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
data/
|
||||
.git/
|
||||
.claude/
|
||||
.venv/
|
||||
*.md
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -174,3 +174,6 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Project data
|
||||
data/
|
||||
|
||||
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -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"]
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -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:
|
||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@@ -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"]
|
||||
1
src/physcom/__init__.py
Normal file
1
src/physcom/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Physical Combinatorics — innovation via attribute mixing."""
|
||||
4
src/physcom/__main__.py
Normal file
4
src/physcom/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Allow `python -m physcom` to work."""
|
||||
from physcom.cli import main
|
||||
|
||||
main()
|
||||
268
src/physcom/cli.py
Normal file
268
src/physcom/cli.py
Normal file
@@ -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()
|
||||
0
src/physcom/db/__init__.py
Normal file
0
src/physcom/db/__init__.py
Normal file
414
src/physcom/db/repository.py
Normal file
414
src/physcom/db/repository.py
Normal file
@@ -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
|
||||
111
src/physcom/db/schema.py
Normal file
111
src/physcom/db/schema.py
Normal file
@@ -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
|
||||
0
src/physcom/engine/__init__.py
Normal file
0
src/physcom/engine/__init__.py
Normal file
33
src/physcom/engine/combinator.py
Normal file
33
src/physcom/engine/combinator.py
Normal file
@@ -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
|
||||
180
src/physcom/engine/constraint_resolver.py
Normal file
180
src/physcom/engine/constraint_resolver.py
Normal file
@@ -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"
|
||||
)
|
||||
246
src/physcom/engine/pipeline.py
Normal file
246
src/physcom/engine/pipeline.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Multi-pass pipeline orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from physcom.db.repository import Repository
|
||||
from physcom.engine.combinator import generate_combinations
|
||||
from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult
|
||||
from physcom.engine.scorer import Scorer
|
||||
from physcom.llm.base import LLMProvider
|
||||
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
||||
from physcom.models.combination import Combination, ScoredResult
|
||||
from physcom.models.domain import Domain
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
"""Summary of a pipeline run."""
|
||||
|
||||
total_generated: int = 0
|
||||
pass1_valid: int = 0
|
||||
pass1_blocked: int = 0
|
||||
pass1_conditional: int = 0
|
||||
pass2_estimated: int = 0
|
||||
pass3_above_threshold: int = 0
|
||||
pass4_reviewed: int = 0
|
||||
pass5_human_reviewed: int = 0
|
||||
top_results: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _describe_combination(combo: Combination) -> str:
|
||||
"""Build a natural-language description of a combination."""
|
||||
parts = [f"{e.dimension}: {e.name}" for e in combo.entities]
|
||||
descriptions = [e.description for e in combo.entities if e.description]
|
||||
header = " + ".join(parts)
|
||||
detail = "; ".join(descriptions)
|
||||
return f"{header}. {detail}"
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Orchestrates the multi-pass viability pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Repository,
|
||||
resolver: ConstraintResolver,
|
||||
scorer: Scorer,
|
||||
llm: LLMProvider | None = None,
|
||||
) -> None:
|
||||
self.repo = repo
|
||||
self.resolver = resolver
|
||||
self.scorer = scorer
|
||||
self.llm = llm
|
||||
|
||||
def run(
|
||||
self,
|
||||
domain: Domain,
|
||||
dimensions: list[str],
|
||||
score_threshold: float = 0.1,
|
||||
passes: list[int] | None = None,
|
||||
) -> PipelineResult:
|
||||
if passes is None:
|
||||
passes = [1, 2, 3, 4, 5]
|
||||
|
||||
result = PipelineResult()
|
||||
|
||||
# Generate all combinations
|
||||
combos = generate_combinations(self.repo, dimensions)
|
||||
result.total_generated = len(combos)
|
||||
|
||||
# Save all combinations to DB
|
||||
for combo in combos:
|
||||
self.repo.save_combination(combo)
|
||||
|
||||
# ── Pass 1: Constraint Resolution ───────────────────────
|
||||
valid_combos: list[Combination] = []
|
||||
if 1 in passes:
|
||||
valid_combos = self._pass1_constraints(combos, result)
|
||||
else:
|
||||
valid_combos = combos
|
||||
|
||||
# ── Pass 2: Physics Estimation ──────────────────────────
|
||||
estimated: list[tuple[Combination, dict[str, float]]] = []
|
||||
if 2 in passes:
|
||||
estimated = self._pass2_estimation(valid_combos, domain, result)
|
||||
else:
|
||||
# Skip estimation, use zeros
|
||||
estimated = [(c, {}) for c in valid_combos]
|
||||
|
||||
# ── Pass 3: Scoring & Ranking ───────────────────────────
|
||||
scored: list[tuple[Combination, ScoredResult]] = []
|
||||
if 3 in passes:
|
||||
scored = self._pass3_scoring(estimated, domain, score_threshold, result)
|
||||
|
||||
# ── Pass 4: LLM Review ──────────────────────────────────
|
||||
if 4 in passes and self.llm:
|
||||
self._pass4_llm_review(scored, domain, result)
|
||||
|
||||
# ── Save results after scoring ─────────────────────────
|
||||
if 3 in passes:
|
||||
max_pass = max(p for p in passes if p <= 5)
|
||||
for combo, sr in scored:
|
||||
self.repo.save_result(
|
||||
combo.id, domain.id, sr.composite_score,
|
||||
pass_reached=max_pass,
|
||||
novelty_flag=sr.novelty_flag,
|
||||
llm_review=sr.llm_review,
|
||||
)
|
||||
self.repo.update_combination_status(combo.id, "scored")
|
||||
|
||||
# Collect top results
|
||||
result.top_results = self.repo.get_top_results(domain.name, limit=20)
|
||||
return result
|
||||
|
||||
def _pass1_constraints(
|
||||
self, combos: list[Combination], result: PipelineResult
|
||||
) -> list[Combination]:
|
||||
valid = []
|
||||
for combo in combos:
|
||||
cr: ConstraintResult = self.resolver.resolve(combo)
|
||||
if cr.status == "blocked":
|
||||
combo.status = "blocked"
|
||||
combo.block_reason = "; ".join(cr.violations)
|
||||
self.repo.update_combination_status(
|
||||
combo.id, "blocked", combo.block_reason
|
||||
)
|
||||
result.pass1_blocked += 1
|
||||
elif cr.status == "conditional":
|
||||
combo.status = "valid"
|
||||
self.repo.update_combination_status(combo.id, "valid")
|
||||
valid.append(combo)
|
||||
result.pass1_conditional += 1
|
||||
else:
|
||||
combo.status = "valid"
|
||||
self.repo.update_combination_status(combo.id, "valid")
|
||||
valid.append(combo)
|
||||
result.pass1_valid += 1
|
||||
return valid
|
||||
|
||||
def _pass2_estimation(
|
||||
self,
|
||||
combos: list[Combination],
|
||||
domain: Domain,
|
||||
result: PipelineResult,
|
||||
) -> list[tuple[Combination, dict[str, float]]]:
|
||||
metric_names = [mb.metric_name for mb in domain.metric_bounds]
|
||||
estimated = []
|
||||
|
||||
for combo in combos:
|
||||
description = _describe_combination(combo)
|
||||
if self.llm:
|
||||
raw_metrics = self.llm.estimate_physics(description, metric_names)
|
||||
else:
|
||||
# Stub estimation: derive from dependencies where possible
|
||||
raw_metrics = self._stub_estimate(combo, metric_names)
|
||||
estimated.append((combo, raw_metrics))
|
||||
result.pass2_estimated += 1
|
||||
|
||||
return estimated
|
||||
|
||||
def _pass3_scoring(
|
||||
self,
|
||||
estimated: list[tuple[Combination, dict[str, float]]],
|
||||
domain: Domain,
|
||||
threshold: float,
|
||||
result: PipelineResult,
|
||||
) -> list[tuple[Combination, ScoredResult]]:
|
||||
scored = []
|
||||
for combo, raw_metrics in estimated:
|
||||
sr = self.scorer.score_combination(combo, raw_metrics)
|
||||
if sr.composite_score >= threshold:
|
||||
scored.append((combo, sr))
|
||||
result.pass3_above_threshold += 1
|
||||
# Persist per-metric scores
|
||||
score_dicts = []
|
||||
bounds_by_name = {mb.metric_name: mb for mb in domain.metric_bounds}
|
||||
for s in sr.scores:
|
||||
mb = bounds_by_name.get(s.metric_name)
|
||||
if mb and mb.metric_id:
|
||||
score_dicts.append({
|
||||
"metric_id": mb.metric_id,
|
||||
"raw_value": s.raw_value,
|
||||
"normalized_score": s.normalized_score,
|
||||
"estimation_method": s.estimation_method,
|
||||
"confidence": s.confidence,
|
||||
})
|
||||
if score_dicts:
|
||||
self.repo.save_scores(combo.id, domain.id, score_dicts)
|
||||
|
||||
# Sort by composite score descending
|
||||
scored.sort(key=lambda x: x[1].composite_score, reverse=True)
|
||||
return scored
|
||||
|
||||
def _pass4_llm_review(
|
||||
self,
|
||||
scored: list[tuple[Combination, ScoredResult]],
|
||||
domain: Domain,
|
||||
result: PipelineResult,
|
||||
) -> None:
|
||||
for combo, sr in scored:
|
||||
description = _describe_combination(combo)
|
||||
score_dict = {s.metric_name: s.normalized_score for s in sr.scores}
|
||||
review = self.llm.review_plausibility(description, score_dict)
|
||||
sr.llm_review = review
|
||||
result.pass4_reviewed += 1
|
||||
|
||||
def _stub_estimate(
|
||||
self, combo: Combination, metric_names: list[str]
|
||||
) -> dict[str, float]:
|
||||
"""Simple heuristic estimation from dependency data."""
|
||||
raw: dict[str, float] = {m: 0.0 for m in metric_names}
|
||||
|
||||
# Extract force output from power source
|
||||
force_watts = 0.0
|
||||
mass_kg = 100.0 # default
|
||||
for entity in combo.entities:
|
||||
for dep in entity.dependencies:
|
||||
if dep.key == "force_output_watts" and dep.constraint_type == "provides":
|
||||
force_watts = max(force_watts, float(dep.value))
|
||||
if dep.key == "min_mass_kg" and dep.constraint_type == "range_min":
|
||||
mass_kg = max(mass_kg, float(dep.value))
|
||||
|
||||
# Rough speed estimate: F=ma -> v proportional to power/mass
|
||||
if "speed" in raw and mass_kg > 0:
|
||||
# Very rough: speed ~ power / (mass * drag_coeff)
|
||||
raw["speed"] = min(force_watts / mass_kg * 0.5, 300000)
|
||||
|
||||
if "cost_efficiency" in raw:
|
||||
# Lower force = cheaper per km (roughly)
|
||||
raw["cost_efficiency"] = max(0.01, 2.0 - force_watts / 100000)
|
||||
|
||||
if "safety" in raw:
|
||||
raw["safety"] = 0.5 # default mid-range
|
||||
|
||||
if "availability" in raw:
|
||||
raw["availability"] = 0.5
|
||||
|
||||
if "range_fuel" in raw:
|
||||
# More power = more range (very rough)
|
||||
raw["range_fuel"] = min(force_watts * 0.01, 1e10)
|
||||
|
||||
if "range_degradation" in raw:
|
||||
raw["range_degradation"] = 365 # 1 year default
|
||||
|
||||
return raw
|
||||
89
src/physcom/engine/scorer.py
Normal file
89
src/physcom/engine/scorer.py
Normal file
@@ -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,
|
||||
)
|
||||
0
src/physcom/llm/__init__.py
Normal file
0
src/physcom/llm/__init__.py
Normal file
25
src/physcom/llm/base.py
Normal file
25
src/physcom/llm/base.py
Normal file
@@ -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."""
|
||||
...
|
||||
37
src/physcom/llm/prompts.py
Normal file
37
src/physcom/llm/prompts.py
Normal file
@@ -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.
|
||||
"""
|
||||
0
src/physcom/llm/providers/__init__.py
Normal file
0
src/physcom/llm/providers/__init__.py
Normal file
28
src/physcom/llm/providers/mock.py
Normal file
28
src/physcom/llm/providers/mock.py
Normal file
@@ -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."
|
||||
9
src/physcom/models/__init__.py
Normal file
9
src/physcom/models/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
45
src/physcom/models/combination.py
Normal file
45
src/physcom/models/combination.py
Normal file
@@ -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
|
||||
26
src/physcom/models/domain.py
Normal file
26
src/physcom/models/domain.py
Normal file
@@ -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
|
||||
37
src/physcom/models/entity.py
Normal file
37
src/physcom/models/entity.py
Normal file
@@ -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
|
||||
0
src/physcom/seed/__init__.py
Normal file
0
src/physcom/seed/__init__.py
Normal file
286
src/physcom/seed/transport_example.py
Normal file
286
src/physcom/seed/transport_example.py
Normal file
@@ -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
|
||||
1
src/physcom_web/__init__.py
Normal file
1
src/physcom_web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""PhysCom Web — Flask UI for Physical Combinatorics."""
|
||||
4
src/physcom_web/__main__.py
Normal file
4
src/physcom_web/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Allow `python -m physcom_web` to start the dev server."""
|
||||
from physcom_web.app import run
|
||||
|
||||
run()
|
||||
60
src/physcom_web/app.py
Normal file
60
src/physcom_web/app.py
Normal file
@@ -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")))
|
||||
0
src/physcom_web/routes/__init__.py
Normal file
0
src/physcom_web/routes/__init__.py
Normal file
16
src/physcom_web/routes/domains.py
Normal file
16
src/physcom_web/routes/domains.py
Normal file
@@ -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)
|
||||
128
src/physcom_web/routes/entities.py
Normal file
128
src/physcom_web/routes/entities.py
Normal file
@@ -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("/<int:entity_id>")
|
||||
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("/<int:entity_id>/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("/<int:entity_id>/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("/<int:entity_id>/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("/<int:entity_id>/deps/<int:dep_id>/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("/<int:entity_id>/deps/<int:dep_id>/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)
|
||||
56
src/physcom_web/routes/pipeline.py
Normal file
56
src/physcom_web/routes/pipeline.py
Normal file
@@ -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))
|
||||
95
src/physcom_web/routes/results.py
Normal file
95
src/physcom_web/routes/results.py
Normal file
@@ -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("/<domain_name>")
|
||||
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("/<domain_name>/<int:combo_id>")
|
||||
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("/<domain_name>/<int:combo_id>/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))
|
||||
156
src/physcom_web/static/style.css
Normal file
156
src/physcom_web/static/style.css
Normal file
@@ -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; }
|
||||
35
src/physcom_web/templates/base.html
Normal file
35
src/physcom_web/templates/base.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}PhysCom{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{{ url_for('entities.entity_list') }}" class="nav-brand">PhysCom</a>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('entities.entity_list') }}">Entities</a></li>
|
||||
<li><a href="{{ url_for('domains.domain_list') }}">Domains</a></li>
|
||||
<li><a href="{{ url_for('pipeline.pipeline_form') }}">Pipeline</a></li>
|
||||
<li><a href="{{ url_for('results.results_index') }}">Results</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-container">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
34
src/physcom_web/templates/domains/list.html
Normal file
34
src/physcom_web/templates/domains/list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Domains — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Domains</h1>
|
||||
|
||||
{% if not domains %}
|
||||
<p class="empty">No domains found. Seed data via CLI first.</p>
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for d in domains %}
|
||||
<div class="card">
|
||||
<h2>{{ d.name }}</h2>
|
||||
<p>{{ d.description }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Weight</th><th>Min</th><th>Max</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mb in d.metric_bounds %}
|
||||
<tr>
|
||||
<td>{{ mb.metric_name }}</td>
|
||||
<td>{{ mb.weight }}</td>
|
||||
<td>{{ mb.norm_min }}</td>
|
||||
<td>{{ mb.norm_max }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
80
src/physcom_web/templates/entities/_dep_table.html
Normal file
80
src/physcom_web/templates/entities/_dep_table.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<table id="dep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Unit</th>
|
||||
<th>Type</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dep-table-body">
|
||||
{% for dep in entity.dependencies %}
|
||||
<tr>
|
||||
<td>{{ dep.category }}</td>
|
||||
<td>{{ dep.key }}</td>
|
||||
<td>{{ dep.value }}</td>
|
||||
<td>{{ dep.unit or '—' }}</td>
|
||||
<td><span class="badge badge-{{ dep.constraint_type }}">{{ dep.constraint_type }}</span></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm"
|
||||
onclick="this.closest('tr').querySelector('.edit-row').style.display='table-row'; this.closest('tr').style.display='none'">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('entities.dep_delete', entity_id=entity.id, dep_id=dep.id) }}"
|
||||
hx-target="#dep-section" hx-swap="innerHTML"
|
||||
class="inline-form">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Del</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="edit-row" style="display:none">
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('entities.dep_edit', entity_id=entity.id, dep_id=dep.id) }}"
|
||||
hx-target="#dep-section" hx-swap="innerHTML">
|
||||
<td><input name="category" value="{{ dep.category }}" required></td>
|
||||
<td><input name="key" value="{{ dep.key }}" required></td>
|
||||
<td><input name="value" value="{{ dep.value }}" required></td>
|
||||
<td><input name="unit" value="{{ dep.unit or '' }}"></td>
|
||||
<td>
|
||||
<select name="constraint_type">
|
||||
{% for ct in ['requires', 'provides', 'range_min', 'range_max', 'excludes'] %}
|
||||
<option value="{{ ct }}" {{ 'selected' if dep.constraint_type == ct }}>{{ ct }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
onclick="this.closest('tr').style.display='none'; this.closest('tr').previousElementSibling.style.display=''">
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Add Dependency</h3>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('entities.dep_add', entity_id=entity.id) }}"
|
||||
hx-target="#dep-section" hx-swap="innerHTML"
|
||||
class="dep-add-form">
|
||||
<div class="form-row">
|
||||
<input name="category" placeholder="category" required>
|
||||
<input name="key" placeholder="key" required>
|
||||
<input name="value" placeholder="value" required>
|
||||
<input name="unit" placeholder="unit">
|
||||
<select name="constraint_type">
|
||||
<option value="requires">requires</option>
|
||||
<option value="provides">provides</option>
|
||||
<option value="range_min">range_min</option>
|
||||
<option value="range_max">range_max</option>
|
||||
<option value="excludes">excludes</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
28
src/physcom_web/templates/entities/detail.html
Normal file
28
src/physcom_web/templates/entities/detail.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ entity.name }} — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ entity.name }}</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('entities.entity_edit', entity_id=entity.id) }}" class="btn">Edit</a>
|
||||
<form method="post" action="{{ url_for('entities.entity_delete', entity_id=entity.id) }}" class="inline-form"
|
||||
onsubmit="return confirm('Delete this entity?')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl>
|
||||
<dt>Dimension</dt><dd>{{ entity.dimension }}</dd>
|
||||
<dt>Description</dt><dd>{{ entity.description or '—' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2>Dependencies</h2>
|
||||
|
||||
<div id="dep-section">
|
||||
{% include "entities/_dep_table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
38
src/physcom_web/templates/entities/form.html
Normal file
38
src/physcom_web/templates/entities/form.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if entity else 'Add' }} Entity — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ 'Edit' if entity else 'New' }} Entity</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="dimension">Dimension</label>
|
||||
{% if entity %}
|
||||
<input type="text" id="dimension" name="dimension" value="{{ entity.dimension }}" readonly>
|
||||
{% else %}
|
||||
<input type="text" id="dimension" name="dimension" list="dim-list"
|
||||
value="{{ request.form.get('dimension', '') }}" required placeholder="e.g. platform">
|
||||
<datalist id="dim-list">
|
||||
{% for d in dimensions %}
|
||||
<option value="{{ d.name }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name"
|
||||
value="{{ entity.name if entity else request.form.get('name', '') }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3">{{ entity.description if entity else request.form.get('description', '') }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save' if entity else 'Create' }}</button>
|
||||
<a href="{{ url_for('entities.entity_detail', entity_id=entity.id) if entity else url_for('entities.entity_list') }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
src/physcom_web/templates/entities/list.html
Normal file
43
src/physcom_web/templates/entities/list.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Entities — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Entities</h1>
|
||||
<a href="{{ url_for('entities.entity_new') }}" class="btn btn-primary">+ Add Entity</a>
|
||||
</div>
|
||||
|
||||
{% if not grouped %}
|
||||
<p class="empty">No entities found. <a href="{{ url_for('entities.entity_new') }}">Add one</a> or seed data via CLI.</p>
|
||||
{% else %}
|
||||
{% for dimension, entities in grouped.items() %}
|
||||
<div class="card">
|
||||
<h2>{{ dimension }}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Deps</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entities %}
|
||||
<tr>
|
||||
<td>{{ e.id }}</td>
|
||||
<td><a href="{{ url_for('entities.entity_detail', entity_id=e.id) }}">{{ e.name }}</a></td>
|
||||
<td>{{ e.description }}</td>
|
||||
<td>{{ e.dependencies|length }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('entities.entity_edit', entity_id=e.id) }}" class="btn btn-sm">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
60
src/physcom_web/templates/pipeline/run.html
Normal file
60
src/physcom_web/templates/pipeline/run.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Run Pipeline — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Run Pipeline</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_run') }}">
|
||||
<div class="form-group">
|
||||
<label for="domain">Domain</label>
|
||||
<select name="domain" id="domain" required>
|
||||
<option value="">— select —</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.name }}">{{ d.name }} — {{ d.description }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Passes</legend>
|
||||
<div class="checkbox-row">
|
||||
{% for p in [1, 2, 3, 4, 5] %}
|
||||
<label>
|
||||
<input type="checkbox" name="passes" value="{{ p }}"
|
||||
{{ 'checked' if p <= 3 }}>
|
||||
Pass {{ p }}
|
||||
{% if p == 1 %}(Constraints)
|
||||
{% elif p == 2 %}(Estimation)
|
||||
{% elif p == 3 %}(Scoring)
|
||||
{% elif p == 4 %}(LLM Review)
|
||||
{% elif p == 5 %}(Human Review)
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="threshold">Score Threshold</label>
|
||||
<input type="number" name="threshold" id="threshold" value="0.1" step="0.01" min="0" max="1">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Dimensions</legend>
|
||||
<div class="checkbox-row">
|
||||
{% for d in dimensions %}
|
||||
<label>
|
||||
<input type="checkbox" name="dimensions" value="{{ d.name }}" checked>
|
||||
{{ d.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Run Pipeline</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
7
src/physcom_web/templates/results/_review_done.html
Normal file
7
src/physcom_web/templates/results/_review_done.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="card">
|
||||
<p class="flash flash-success">Review saved.</p>
|
||||
<dl>
|
||||
{% if novelty_flag %}<dt>Novelty</dt><dd>{{ novelty_flag }}</dd>{% endif %}
|
||||
{% if human_notes %}<dt>Notes</dt><dd>{{ human_notes }}</dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
22
src/physcom_web/templates/results/_review_form.html
Normal file
22
src/physcom_web/templates/results/_review_form.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="card">
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('results.submit_review', domain_name=domain.name, combo_id=combo.id) }}"
|
||||
hx-target="#review-section" hx-swap="innerHTML">
|
||||
<div class="form-group">
|
||||
<label for="novelty_flag">Novelty Flag</label>
|
||||
<select name="novelty_flag" id="novelty_flag">
|
||||
<option value="">— none —</option>
|
||||
<option value="novel" {{ 'selected' if result and result.novelty_flag == 'novel' }}>novel</option>
|
||||
<option value="exists" {{ 'selected' if result and result.novelty_flag == 'exists' }}>exists</option>
|
||||
<option value="researched" {{ 'selected' if result and result.novelty_flag == 'researched' }}>researched</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="human_notes">Notes</label>
|
||||
<textarea name="human_notes" id="human_notes" rows="3">{{ result.human_notes if result and result.human_notes else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Review</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
82
src/physcom_web/templates/results/detail.html
Normal file
82
src/physcom_web/templates/results/detail.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Combination #{{ combo.id }} — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Combination #{{ combo.id }}</h1>
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name) }}" class="btn">Back to list</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl>
|
||||
<dt>Domain</dt><dd>{{ domain.name }}</dd>
|
||||
<dt>Status</dt><dd><span class="badge badge-{{ combo.status }}">{{ combo.status }}</span></dd>
|
||||
{% if combo.block_reason %}
|
||||
<dt>Block Reason</dt><dd>{{ combo.block_reason }}</dd>
|
||||
{% endif %}
|
||||
{% if result %}
|
||||
<dt>Composite Score</dt><dd class="score-cell">{{ "%.4f"|format(result.composite_score) }}</dd>
|
||||
<dt>Pass Reached</dt><dd>{{ result.pass_reached }}</dd>
|
||||
{% if result.novelty_flag %}
|
||||
<dt>Novelty</dt><dd>{{ result.novelty_flag }}</dd>
|
||||
{% endif %}
|
||||
{% if result.llm_review %}
|
||||
<dt>LLM Review</dt><dd>{{ result.llm_review }}</dd>
|
||||
{% endif %}
|
||||
{% if result.human_notes %}
|
||||
<dt>Human Notes</dt><dd>{{ result.human_notes }}</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2>Entities</h2>
|
||||
<div class="card-grid">
|
||||
{% for e in combo.entities %}
|
||||
<div class="card">
|
||||
<h3><a href="{{ url_for('entities.entity_detail', entity_id=e.id) }}">{{ e.name }}</a></h3>
|
||||
<p class="subtitle">{{ e.dimension }}</p>
|
||||
<p>{{ e.description }}</p>
|
||||
<table class="compact">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
{% for dep in e.dependencies %}
|
||||
<tr>
|
||||
<td>{{ dep.key }}</td>
|
||||
<td>{{ dep.value }}{{ ' ' + dep.unit if dep.unit else '' }}</td>
|
||||
<td><span class="badge badge-{{ dep.constraint_type }}">{{ dep.constraint_type }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if scores %}
|
||||
<h2>Per-Metric Scores</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Raw Value</th><th>Normalized</th><th>Method</th><th>Confidence</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in scores %}
|
||||
<tr>
|
||||
<td>{{ s.metric_name }}</td>
|
||||
<td>{{ "%.2f"|format(s.raw_value) if s.raw_value is not none else '—' }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(s.normalized_score) if s.normalized_score is not none else '—' }}</td>
|
||||
<td>{{ s.estimation_method or '—' }}</td>
|
||||
<td>{{ "%.2f"|format(s.confidence) if s.confidence is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Human Review</h2>
|
||||
<div id="review-section">
|
||||
{% include "results/_review_form.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
src/physcom_web/templates/results/list.html
Normal file
69
src/physcom_web/templates/results/list.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Results — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Results</h1>
|
||||
|
||||
<div class="form-row" style="margin-bottom:1rem">
|
||||
{% for d in domains %}
|
||||
<a href="{{ url_for('results.results_domain', domain_name=d.name) }}"
|
||||
class="btn {{ 'btn-primary' if domain and domain.name == d.name else '' }}">
|
||||
{{ d.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if domain and results is not none %}
|
||||
<div class="card">
|
||||
<h2>{{ domain.name }} <span class="subtitle">{{ domain.description }}</span></h2>
|
||||
|
||||
{% if statuses %}
|
||||
<div class="filter-row">
|
||||
<span>Filter:</span>
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name) }}"
|
||||
class="btn btn-sm {{ '' if status_filter else 'btn-primary' }}">All</a>
|
||||
{% for s, cnt in statuses.items() %}
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=s) }}"
|
||||
class="btn btn-sm {{ 'btn-primary' if status_filter == s else '' }}">
|
||||
{{ s }} ({{ cnt }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not results %}
|
||||
<p class="empty">No results yet. <a href="{{ url_for('pipeline.pipeline_form') }}">Run the pipeline</a> first.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score</th>
|
||||
<th>Entities</th>
|
||||
<th>Status</th>
|
||||
<th>Novelty</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(r.composite_score) }}</td>
|
||||
<td>{{ r.combination.entities|map(attribute='name')|join(' + ') }}</td>
|
||||
<td><span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span></td>
|
||||
<td>{{ r.novelty_flag or '—' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('results.result_detail', domain_name=domain.name, combo_id=r.combination.id) }}"
|
||||
class="btn btn-sm">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif not domain %}
|
||||
<p class="empty">Select a domain above to view results.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
158
tests/conftest.py
Normal file
158
tests/conftest.py
Normal file
@@ -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),
|
||||
],
|
||||
)
|
||||
26
tests/test_combinator.py
Normal file
26
tests/test_combinator.py
Normal file
@@ -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"])
|
||||
121
tests/test_constraint_resolver.py
Normal file
121
tests/test_constraint_resolver.py
Normal file
@@ -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
|
||||
72
tests/test_pipeline.py
Normal file
72
tests/test_pipeline.py
Normal file
@@ -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
|
||||
88
tests/test_repository.py
Normal file
88
tests/test_repository.py
Normal file
@@ -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
|
||||
90
tests/test_scorer.py
Normal file
90
tests/test_scorer.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user