| name | heater-appb-live-data-honesty |
| description | Use when building or fixing any data-bearing App B surface — fallbacks, projections, empty/degraded states, "today"/week-window logic — or when live shows 0 / nan / 100% / wrong-date values that look like code bugs. Also before adding any fillna(0), default value, or synthetic placeholder to data a user will see as advice. |
Live-Data Honesty — never render degraded data as confident advice
The 2026-07-01 live-accuracy audit's core finding: the SYNC layer was accurate; the DECISION layer took degraded inputs and rendered them as confident numbers. Six root causes, weeks of remediation. These rules exist so that class never re-ships.
The rules
- Honest-empty over fabricated. A failed load returns empty + a reason, never a widened/synthetic result. Examples to copy:
research_service returns an empty FA board on roster-load failure (NOT all-players); calibration grid abstains (best=None) on degenerate data; draft refuses a broken pool; TradeFinderResponse.reason explains ok/team_not_resolved/no_totals so empty can't be silent again (WS4: two independent bugs hid behind one quiet [] for weeks — emoji team-name key miss + all_team_totals=None).
- Missing is NaN/None → "—", never 0.
fillna(0) fabricates: xwoba_delta fillna(0) manufactured a −0.36 "cold" signal for players with no Statcast data (fixed b01841c — emit NaN, make every consumer NaN-safe; note float(x or 0) does NOT neutralize NaN). Sentinels map to None at the boundary: adp=999 → None (player_rankings.py:103-106); an unknown home/away renders a bare opponent, not a guessed "vs".
- One methodology per number. Win probability =
api/services/outcome_probs.seeded_outcome_probs ONLY (Team hero, Matchup, Bubba). The audit found FOUR competing matchup scores; the worst, cats-won/12 = "66.7%", was a fabricated statistic. Same seed key → same number on every page.
- Dates are ET, always. Server is UTC; MLB/Yahoo days are Eastern. After ~8 PM ET,
datetime.now(UTC) calls today "tomorrow" — unlocking finished games and emptying boards (this single skew caused 5 distinct live symptoms). Route ALL date-of-day logic through src/et_today.py (today_et(), today_et_str(), days_ahead_et()); tests patch src.et_today._utcnow (the :19 patch point), NEVER datetime.now — patching the wrong clock makes date tests flake in evening ET and in CI.
- Know the platform degradations before "fixing" data:
- Railway's datacenter IP gets 403 from FanGraphs → projections come from the Marcel fallback (
src/marcel_bootstrap.py, log: "Marcel fallback: wrote N projection rows"). Related scar: Marcel once routed volume (ip/pa) through the counting-rate path → every pitcher's ROS counting collapsed to ~0 (closers with 0 SV); fixed by _weighted_volume_projection (marcel.py:239-280, regress to 90 IP/450 PA, never 0; sv rescale-exempt via _VOLUME_INDEPENDENT_COUNTING marcel.py:87).
- Yahoo token + Statcast xwOBA arrive via relays from the owner's residential PC (
src/token_relay.py, src/statcast_relay.py) — env-gated, dormant when unset; token staleness ≈ relay task dead, not code. The Statcast relay is a free-beta stopgap, commercial-use-prohibited: must be disabled before monetization.
- Degrade with an escape route:
lineup_service._ip_pace falls back to YTD IP when projected ip ≤ 0 (~506-569) — otherwise the projection gap rendered a false "0 IP DANGER".
- Surface freshness/caveats rather than hiding them (freshness fields,
proxy_caveat, min-max value floors) — transparency beats polish.
Litmus test before shipping a surface
"If the upstream source were empty/stale/403 right now, what would this render?" If the answer is a confident number, add the honest-empty path and a greppable logger.warning.
When NOT to apply
Neutral defaults INSIDE engine math (documented design choices like punt-weight 0.05, ROS floor 0.20×) are not fabrication — see heater-appb-known-non-bugs before "fixing" them. Mock mode (NEXT_PUBLIC_HEATER_LIVE unset) intentionally shows demo data.