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:
Simonson, Andrew
2026-02-18 13:59:53 -06:00
parent 6e0f82835a
commit 8118a62242
54 changed files with 3505 additions and 1 deletions

158
tests/conftest.py Normal file
View 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
View 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"])

View 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
View 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
View 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
View 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