diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py new file mode 100644 index 0000000..6178363 --- /dev/null +++ b/app/services/formula_engine.py @@ -0,0 +1,105 @@ +"""Formula engine for evolution value computation (WP-09). + +Three pure functions that compute a numeric evolution value from career stats, +plus helpers for formula dispatch and tier classification. + +Stats attributes expected by each formula: + compute_batter_value: pa, hits, doubles, triples, hr + compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats) + compute_rp_value: outs, k +""" + +from typing import Protocol + + +class BatterStats(Protocol): + pa: int + hits: int + doubles: int + triples: int + hr: int + + +class PitcherStats(Protocol): + outs: int + k: int + + +# --------------------------------------------------------------------------- +# Core formula functions +# --------------------------------------------------------------------------- + + +def compute_batter_value(stats) -> float: + """PA + (TB × 2) where TB = 1B + 2×2B + 3×3B + 4×HR.""" + singles = stats.hits - stats.doubles - stats.triples - stats.hr + tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr + return float(stats.pa + tb * 2) + + +def compute_sp_value(stats) -> float: + """IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts).""" + return stats.outs / 3 + stats.k + + +def compute_rp_value(stats) -> float: + """IP + K (same formula as SP; thresholds differ). Uses stats.k.""" + return stats.outs / 3 + stats.k + + +# --------------------------------------------------------------------------- +# Dispatch and tier helpers +# --------------------------------------------------------------------------- + +_FORMULA_DISPATCH = { + "batter": compute_batter_value, + "sp": compute_sp_value, + "rp": compute_rp_value, +} + + +def compute_value_for_track(card_type: str, stats) -> float: + """Dispatch to the correct formula function by card_type. + + Args: + card_type: One of 'batter', 'sp', 'rp'. + stats: Object with the attributes required by the formula. + + Raises: + ValueError: If card_type is not recognised. + """ + fn = _FORMULA_DISPATCH.get(card_type) + if fn is None: + raise ValueError(f"Unknown card_type: {card_type!r}") + return fn(stats) + + +def tier_from_value(value: float, track) -> int: + """Return the evolution tier (0–4) for a computed value against a track. + + Tier boundaries are inclusive on the lower end: + T0: value < t1 + T1: t1 <= value < t2 + T2: t2 <= value < t3 + T3: t3 <= value < t4 + T4: value >= t4 + + Args: + value: Computed formula value. + track: Object (or dict-like) with t1, t2, t3, t4 attributes/keys. + """ + # Support both attribute-style (Peewee model) and dict (seed fixture) + if isinstance(track, dict): + t1, t2, t3, t4 = track["t1"], track["t2"], track["t3"], track["t4"] + else: + t1, t2, t3, t4 = track.t1, track.t2, track.t3, track.t4 + + if value >= t4: + return 4 + if value >= t3: + return 3 + if value >= t2: + return 2 + if value >= t1: + return 1 + return 0 diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py new file mode 100644 index 0000000..daed322 --- /dev/null +++ b/tests/test_formula_engine.py @@ -0,0 +1,188 @@ +"""Tests for the formula engine (WP-09). + +Unit tests only — no database required. Stats inputs are simple namespace +objects whose attributes match what PlayerSeasonStats exposes. + +Tier thresholds used (from evolution_tracks.json seed data): + Batter: t1=37, t2=149, t3=448, t4=896 + SP: t1=10, t2=40, t3=120, t4=240 + RP: t1=3, t2=12, t3=35, t4=70 +""" + +from types import SimpleNamespace + +import pytest + +from app.services.formula_engine import ( + compute_batter_value, + compute_rp_value, + compute_sp_value, + compute_value_for_track, + tier_from_value, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def batter_stats(**kwargs): + """Build a minimal batter stats object with all fields defaulting to 0.""" + defaults = {"pa": 0, "hits": 0, "doubles": 0, "triples": 0, "hr": 0} + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def pitcher_stats(**kwargs): + """Build a minimal pitcher stats object with all fields defaulting to 0.""" + defaults = {"outs": 0, "k": 0} + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def track_dict(card_type: str) -> dict: + """Return the locked threshold dict for a given card_type.""" + return { + "batter": {"card_type": "batter", "t1": 37, "t2": 149, "t3": 448, "t4": 896}, + "sp": {"card_type": "sp", "t1": 10, "t2": 40, "t3": 120, "t4": 240}, + "rp": {"card_type": "rp", "t1": 3, "t2": 12, "t3": 35, "t4": 70}, + }[card_type] + + +def track_ns(card_type: str): + """Return a namespace (attribute-style) track for a given card_type.""" + return SimpleNamespace(**track_dict(card_type)) + + +# --------------------------------------------------------------------------- +# compute_batter_value +# --------------------------------------------------------------------------- + + +def test_batter_formula_single_and_double(): + """4 PA, 1 single, 1 double: PA=4, TB=1+2=3, value = 4 + 3×2 = 10.""" + stats = batter_stats(pa=4, hits=2, doubles=1) + assert compute_batter_value(stats) == 10.0 + + +def test_batter_formula_no_hits(): + """4 PA, 0 hits: TB=0, value = 4 + 0 = 4.""" + stats = batter_stats(pa=4) + assert compute_batter_value(stats) == 4.0 + + +def test_batter_formula_hr_heavy(): + """4 PA, 2 HR: TB = 0 singles + 4×2 = 8, value = 4 + 8×2 = 20.""" + stats = batter_stats(pa=4, hits=2, hr=2) + assert compute_batter_value(stats) == 20.0 + + +# --------------------------------------------------------------------------- +# compute_sp_value +# --------------------------------------------------------------------------- + + +def test_sp_formula_standard(): + """18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0.""" + stats = pitcher_stats(outs=18, k=5) + assert compute_sp_value(stats) == 11.0 + + +# --------------------------------------------------------------------------- +# compute_rp_value +# --------------------------------------------------------------------------- + + +def test_rp_formula_standard(): + """3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0.""" + stats = pitcher_stats(outs=3, k=2) + assert compute_rp_value(stats) == 3.0 + + +# --------------------------------------------------------------------------- +# Zero stats +# --------------------------------------------------------------------------- + + +def test_batter_zero_stats_returns_zero(): + """All-zero batter stats must return 0.0.""" + assert compute_batter_value(batter_stats()) == 0.0 + + +def test_sp_zero_stats_returns_zero(): + """All-zero SP stats must return 0.0.""" + assert compute_sp_value(pitcher_stats()) == 0.0 + + +def test_rp_zero_stats_returns_zero(): + """All-zero RP stats must return 0.0.""" + assert compute_rp_value(pitcher_stats()) == 0.0 + + +# --------------------------------------------------------------------------- +# Formula dispatch by track name +# --------------------------------------------------------------------------- + + +def test_dispatch_batter(): + """compute_value_for_track('batter', ...) delegates to compute_batter_value.""" + stats = batter_stats(pa=4, hits=2, doubles=1) + assert compute_value_for_track("batter", stats) == compute_batter_value(stats) + + +def test_dispatch_sp(): + """compute_value_for_track('sp', ...) delegates to compute_sp_value.""" + stats = pitcher_stats(outs=18, k=5) + assert compute_value_for_track("sp", stats) == compute_sp_value(stats) + + +def test_dispatch_rp(): + """compute_value_for_track('rp', ...) delegates to compute_rp_value.""" + stats = pitcher_stats(outs=3, k=2) + assert compute_value_for_track("rp", stats) == compute_rp_value(stats) + + +def test_dispatch_unknown_raises(): + """An unrecognised card_type must raise ValueError.""" + with pytest.raises(ValueError, match="Unknown card_type"): + compute_value_for_track("dh", batter_stats()) + + +# --------------------------------------------------------------------------- +# tier_from_value — batter thresholds (t1=37, t2=149, t3=448, t4=896) +# --------------------------------------------------------------------------- + + +def test_tier_exact_t1_boundary(): + """value=37 is exactly t1 for batter → T1.""" + assert tier_from_value(37, track_dict("batter")) == 1 + + +def test_tier_just_below_t1(): + """value=36 is just below t1=37 for batter → T0.""" + assert tier_from_value(36, track_dict("batter")) == 0 + + +def test_tier_t4_boundary(): + """value=896 is exactly t4 for batter → T4.""" + assert tier_from_value(896, track_dict("batter")) == 4 + + +def test_tier_above_t4(): + """value above t4 still returns T4 (fully evolved).""" + assert tier_from_value(1000, track_dict("batter")) == 4 + + +def test_tier_t2_boundary(): + """value=149 is exactly t2 for batter → T2.""" + assert tier_from_value(149, track_dict("batter")) == 2 + + +def test_tier_t3_boundary(): + """value=448 is exactly t3 for batter → T3.""" + assert tier_from_value(448, track_dict("batter")) == 3 + + +def test_tier_accepts_namespace_track(): + """tier_from_value must work with attribute-style track objects (Peewee models).""" + assert tier_from_value(37, track_ns("batter")) == 1