feat: formula engine for evolution value computation (WP-09)
Closes #74 Adds app/services/formula_engine.py with three pure formula functions (compute_batter_value, compute_sp_value, compute_rp_value), a dispatch helper (compute_value_for_track), and a tier classifier (tier_from_value). Tier boundaries and thresholds match the locked seed data from WP-03. Note: pitcher formulas use stats.k (not stats.so) to match the PlayerSeasonStats model field name introduced in WP-02. 19 unit tests in tests/test_formula_engine.py — all pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a66ef9bd7c
commit
40e988ac9d
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
105
app/services/formula_engine.py
Normal file
105
app/services/formula_engine.py
Normal file
@ -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
|
||||
188
tests/test_formula_engine.py
Normal file
188
tests/test_formula_engine.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user