| name | heater-appb-math-feature-dev |
| description | Use when designing or implementing a new scoring, valuation, probability, projection, or optimization algorithm for a HEATER decision feature (trade grades, win-prob, lineup value, FA/streaming scores, draft math) — before writing the code. Also when extending the value-engine / trade-math programs or unsure where new math should live. |
Developing Decision-Math Features — the lifecycle that has shipped
HEATER's math earns trust through a fixed lifecycle. Shipped exemplars to copy: src/points_scoring.py (13 TDD tests), src/research_signals.py, src/player_game_windows.py, src/winprob_proxy/category.py, src/engine/projections/conformal.py (E10a), src/engine/output/dominance.py (E4).
Where math lives
A NEW standalone src/ module — pure functions, no UI, no service imports, its own tests — wired into App B later via a service (heater-appb-endpoint-slice). Never inline algorithm math in api/services/ (services map/compose/bound) and never in routers (AST-guarded). Shared engines stay byte-identical (heater-appb-engine-composition).
The lifecycle
- Spec with the owner (superpowers:brainstorming) — slice choices are owner calls (e.g. Roto-breadth vs points-VORP-depth), and every plan defines the abstention behavior up front.
- Derive before coding. Write the math down; for any non-trivial derivation (distributional approximations, interval coverage, estimator bias) run it through the math-olympiad skill's adversarial verification —
src/winprob_proxy/category.py's moment-matched Skellam/Welch per-category P(win/tie/loss) shipped "math-olympiad-derived+verified". Self-checking your own derivation misses what an adversarial pass catches.
- TDD (superpowers:test-driven-development): failing tests first, including inverse categories, zero denominators, empty inputs, and OFF-nominal parameters. Failure story: the Marcel age-adjustment was applied TWICE (age² over-suppression breaking rate preservation) and survived every subagent test because all of them pinned age at peak — the fix's guard asserts K/IP is age-invariant. Test the parameter you're tempted to hold constant.
- Constants →
CONSTANTS_REGISTRY (src/optimizer/constants_registry.py) with value/citation/bounds/sensitivity (guard: test_constants_registry_coverage.py). Consumers read the registry at call time so calibrators take effect without restart (guard: test_sigmoid_calibrator_patches_registry.py). Never a bare magic threshold in the module.
- Calibrate/validate before it drives decisions — heater-appb-calibration-backtest. New math ships behind a validation gate (pattern:
src/player_model/validation.py; research slice 2 calibrated weights BEFORE slice 3 exposed the API).
- Dual review (code-reviewer with full engine context + silent-failure-hunter), then wire via the endpoint slice.
Domain invariants are load-bearing
Read heater-appb-fantasy-math-invariants before writing a line. The canonical scar: _compute_base_value once did value += (stat/denom) * weight — wrong SIGN for inverse stats, so an unknown pitcher with default era=9.0/whip=2.0 scored +38.8 (should be −38.8) and phantom minor-leaguers outranked real hitters (FA PR #99; fix = delegate to SGPCalculator.marginal_sgp; guard test_compute_base_value_inverse_stats.py). Reinventing category math instead of using the canon is how that class returns.
Normalization needs a designed range
Leaders' 0-100 value originally normalized over a single-z window → the whole top of the board washed to 100 (saturation, caught in review; fixed to the sum-of-z range [-10,10]). When mapping scores to display scales, pick the range from the statistic's real distribution and test the extremes — also don't emit "0-100" scores that can go negative (max(abs) start_score clamp, WS3).
Missing data = abstain
Every decision-facing number defines its degraded behavior in the spec: None/"—"/empty + reason, never argmax-of-noise (heater-appb-live-data-honesty; the calibration grid returns best=None on degenerate data).
When NOT to apply
Exploratory scratch analysis is free-form — the lifecycle starts when output will face a user. Pure display/formatting is not math. Fixing a bug inside an existing engine formula: TDD + invariants still apply, but no new module — fix in place with both reviewers.