| name | heater-appb-fantasy-math-invariants |
| description | Use when implementing or reviewing any fantasy-baseball category math in HEATER — stat aggregation, SGP/VORP, win probability, Monte Carlo sims, projections, urgency/scarcity weighting — or when symptoms appear like 1-IP relievers dominating lineups, phantom pitchers ranked high, inverse categories (L/ERA/WHIP) moving the wrong way, or probabilities that don't behave. |
Fantasy-Baseball Math Invariants (H2H Categories) — the domain physics
12-team Yahoo H2H: hitting R/HR/RBI/SB/AVG/OBP; pitching W/L/SV/K/ERA/WHIP; inverse = {L, ERA, WHIP} (lower wins). Category lists ALWAYS derive from LeagueConfig (src/valuation.py) — hardcoded lists fail structural guards.
Aggregation & weighting (the top bug source)
- Rate stats aggregate weighted, never averaged: AVG=Σh/Σab; OBP=Σ(h+bb+hbp)/Σ(ab+bb+hbp+sf); ERA=Σer·9/Σip; WHIP=Σ(bb_allowed+h_allowed)/Σip. Zero-denominator → "—"/None (a walk-only ab=0 game once 500'd the player card until the guards were TDD-pinned).
- Volume-weight rate impacts: LP weights ERA/WHIP by IP or 1-IP relievers dominate starters; a per-unit WHIP impact was once scaled 9× (the "WHIP 9→1" live-accuracy fix). AVG/OBP marginals blend by AB/PA — omitting that once priced a .215 hitter at market_price +54.84 to the league's best-AVG teams (Bug C 2026-05-23; guard
test_opponent_market_value_scale.py).
- L is an inverse COUNTING stat: sign-flips in SGP, but NO IP-weighting in LP. Inverse sets must be
{L, ERA, WHIP} — guards reject {ERA, WHIP}.
- Weekly scaling: counting ÷ 26 (
WEEKS_IN_SEASON=26.0, guard-pinned); rates unchanged. Weeks/dates are ET (src/et_today.py).
Canonical calculators — never reinvent
- SGP only via
SGPCalculator.totals_sgp / marginal_sgp — consolidates 7 prior local reinventions; marginal_sgp owns inverse signs + rate-volume blending. The scar: an inline value += (stat/denom)*weight scored a default-bad unknown pitcher +38.8 instead of −38.8 (PR #99).
- Win probability only via
api/services/outcome_probs.seeded_outcome_probs — wraps the Gaussian-copula engine with a CRC32-seeded RNG (same matchup = same number on Team/Matchup/Bubba) and normalizes the UPPERCASE→lowercase key mismatch that otherwise silently degrades the copula to a 0.5 coin-flip. Never derive win% as cats-won/12 — that fabricated the "66.7%" incident.
- Scarcity via
compute_positional_scarcity_factor — applied SYMMETRICALLY to adds and drops (PR #96).
- Urgency: sigmoid k-values read from
CONSTANTS_REGISTRY at call time.
Simulation discipline
- Paired MC: identical seed for before/after arms — always. True antithetic variates = negate the uniforms (u→1−u, z→−z; ~25% variance reduction,
trade_simulator.py). Identical rosters must produce exact-zero deltas.
- Convergence is checked, not assumed (
test_mc_convergence_wired.py); API sims fix seeds for determinism (playoff n_sims=2000).
- Grade authority: Phase-1 weighted SGP owns grade/verdict/confidence; MC overlays rename to
mc_* so result.update() can't clobber (guard test_phase1_is_grade_authority.py — pins the live "grade=B with surplus −1.58" regression).
Distributions & uncertainty
- Gaussian is fine for rates; low-count cats (SB/SV) are right-skewed → Skellam for weekly H2H counting-cat win-prob (
_category_win_prob_skellam, guard test_skellam_win_prob.py).
- Per-category posteriors:
src/player_model/ heteroscedastic {mean, σ², τ²} with NB/beta-binomial margins + availability survival — calibrated on real game logs (406 players/3837 weeks), the σ/τ seeds fixed a real ERA/WHIP under-confidence.
- Under-sampled rosters carry MORE variance: Kalman shrinkage pulls favorable win-probs toward 0.5 (
test_kalman_variance_wiring.py).
- Intervals: split-conformal (
src/engine/projections/conformal.py, E10a) — distribution-free coverage; prefer it over ad-hoc ±bands.
- Dominance: before presenting option A over B, the stochastic-dominance screen (
src/engine/output/dominance.py, E4) must not show A dominated — a point-estimate win can hide a dominated distribution.
Structure gates that ride along
Punt detection needs BOTH gainable==0 AND rank≥10; punted cats weight 0.05× (not 0 — divide-by-zero safety); trades count toward the 10/week cap; can't-drop restricts DROPS not trades; IL players keep _il_weight_from_status-weighted projections (zeroing them made dropping an IL ace look free — the Crochet/Kirk incident, PR #90).
When NOT to apply
Points/Roto formats have their own standalone engines (src/points_scoring.py, Roto valuation) — don't force H2H machinery on them. Display rounding/formatting is a service-mapper concern.