| name | heater-appb-engine-composition |
| description | Use when an App B feature needs Python engine behavior — composing src/ engines from api/services/*, changing scores/projections/valuations for the API, an engine returning empty/zero/wrong-looking data at the service seam — or when tempted to edit src/ engine code to serve an API need. |
Composing the UNCHANGED src/ Engines
The Python engines are shared with the live Streamlit app and must stay byte-identical for it. App B adds behavior in three sanctioned places:
- The service seam (
api/services/*_service.py) — compose, filter, map, bound existing engine calls. This is where 90% of "engine changes" actually belong.
- A NEW standalone
src/ module — when real new math is needed (pattern: src/points_scoring.py, src/player_game_windows.py, src/research_signals.py, src/et_today.py — pure, engine-untouched, own TDD tests).
- A refactor guarded by a frozen-reference equivalence test — only when an engine function must move/split. Existing proofs:
tests/api/test_ai_providers_streaming.py:79 (chat() must return the byte-identical pre-streaming dict) and tests/test_draft_auto_pick_extraction.py:63 (extracted auto_pick_opponents reproduces the frozen pick sequence under the same RNG seed). Copy that shape: capture the old behavior as a fixture FIRST, then refactor against it.
Additive engine parameters (a new optional kwarg defaulting to today's behavior, e.g. is_over_cap(cap_usd=None)) are acceptable — prove the default path unchanged.
Seam gotchas — unit conventions the engines will not warn you about
| Trap | Reality |
|---|
| Key casing | LeagueConfig.sgp_denominators keys are UPPERCASE; streaming.py expects lowercase (stream_analyzer._lower_keys bridges). h2h_engine.ALL_CATEGORIES is lowercase while every API caller keys totals UPPERCASE — unnormalized input silently degrades the copula to a 0.5 coin-flip (fixed in api/services/outcome_probs.py:37-45; use seeded_outcome_probs, never call h2h_engine directly for win-prob). ctx.category_gaps keys are lowercase vs UPPERCASE best_category — .upper() normalize (team lever bug: 0 pickups on Railway only). |
| Percent vs fraction | team_strength k_pct is PERCENT (22.0); compute_pitcher_matchup_score wants FRACTION (0.22). score_stream_candidate divides at the boundary — new callers must too. |
| Engine dict keys | FA move dicts emit add_id/drop_id (NOT add_player_id); LineupOptimizerPipeline runs via .optimize() reading result["lineup"] (.run() doesn't exist — the endpoint shipped broken); the pipeline FLATTENS urgency_weights to the {cat: weight} dict itself (pipeline.py:~514); statsapi.schedule emits home_name/away_name full names, not home_team abbrs — canonicalize via team_name_to_abbr+canonicalize_team. |
| Roster columns | selected_position = assigned slot; roster_slot = ELIGIBLE positions ("2B,3B,SS"). Using roster_slot as the slot mislabeled lineups and routed SP,RP swingmen into the hitter table (matchup_service fix at ~1356-1367). |
| Cost | build_optimizer_context ≈ 2-4s. Build ONCE per request and share (team_service._build_ctx hoist — lever+ops had been paying it twice). Note weeks_remaining is omitted by all callers — keep cross-page consistency unless a plan says otherwise. |
| Determinism | Paired MC = same seed both arms; API sims use fixed seeds (playoff n_sims=2000); page-stable win-prob comes from seeded_outcome_probs' CRC32(seed_key) RNG. |
Failure story — why fake-service tests can't validate a seam
Optimizer daily mode shipped with green synthetic tests while BOTH of these were broken on real data: _matchup_str read home_team/away_team (statsapi emits home_name) so matchup was always ""; _daily_meta read result["urgency_weights"]["urgency"] (the pipeline flattens it) so urgency was always {} (plan 2026-06-20 optimizer-daily-slice2; caught only by code review WITH engine context). Rule: when a service consumes an engine dict, open the engine source at the producing line and mirror its real shape; then smoke once against the real engine.
When NOT to apply
Genuine engine math bugs that also affect the Streamlit app: fix in src/ IS correct then — with TDD + both reviewers, knowing both apps consume it (e.g. the Marcel volume-collapse fix lived in src/marcel.py only). Formatting/display concerns: service mappers, never engines.