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

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