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