Files
physicalCombinatorics/tests/test_scorer.py

115 lines
4.6 KiB
Python

"""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="actuator"),
])
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):
"""A zero on a higher-is-better metric should drive composite to 0."""
scorer = Scorer(urban_domain)
combo = Combination(entities=[])
combo.id = 1
raw = {"speed": 60.0, "cost_efficiency": 0.5, "safety": 0.0,
"availability": 0.8, "range_fuel": 400}
result = scorer.score_combination(combo, raw)
assert result.composite_score == 0.0
def test_lower_is_better_inverts_score(self, urban_domain):
"""cost_efficiency is lower_is_better: low raw value should score high."""
scorer = Scorer(urban_domain)
combo = Combination(entities=[])
combo.id = 1
# cost_efficiency: norm_min=0.01, norm_max=2.0, lower_is_better=True
# A low cost (0.02) should get a HIGH normalized score (near 1.0)
# A high cost (1.9) should get a LOW normalized score (near 0.0)
raw_cheap = {"speed": 60.0, "cost_efficiency": 0.02, "safety": 0.7,
"availability": 0.8, "range_fuel": 400}
raw_expensive = {"speed": 60.0, "cost_efficiency": 1.9, "safety": 0.7,
"availability": 0.8, "range_fuel": 400}
result_cheap = scorer.score_combination(combo, raw_cheap)
result_expensive = scorer.score_combination(combo, raw_expensive)
# Find the cost_efficiency score in each
cost_cheap = next(s for s in result_cheap.scores if s.metric_name == "cost_efficiency")
cost_expensive = next(s for s in result_expensive.scores if s.metric_name == "cost_efficiency")
assert cost_cheap.normalized_score > cost_expensive.normalized_score
assert cost_cheap.normalized_score > 0.9 # near the best
assert cost_expensive.normalized_score < 0.1 # near the worst