READ-ONLY. src/ and web/ untouched. Inventoried every forward-model component against the real objective -- a forward matchup projection, not an edge number. The finding is that all of it already exists and is already loaded in production, and 100% of it sits DOWNSTREAM of the grade in challenger columns nothing serves. The served p_win reads three features and a game log; it has never seen a pitcher. Inputs are HAVE, not missing: statcast_aggregates carries 1,354 rows (750 pitchers, 604 batters) with exit velo, launch angle, barrel, hard-hit, whiff, chase, pitch mix, GB/FB, arm angle, and handedness complete on every row. Real gaps are team defense and catcher/umpire. So Stage A is a plumbing-and- modelling job, not a data-acquisition job. Found along the way: the Statcast nightly refresh is unreachable code. tick() returns for any hour not in HOURS_UTC (14,19,22,1,3) and the refresh block then tests h === 11, which that guard can never admit. The mechanism data has been frozen at its 2026-07-21 backfill for 13 days, and the block's own failure alert sits in the same dead branch -- the identical silently-guarded- out shape as the settlement outage. Design shows the counter: every factor label the SIGNAL BREAKDOWN renders is a restatement of recent frequency (l5_hot_vs_line, l20_over_line, back_to_back, home_game) plus several structurally-NBA labels (referees, coach pace, starters out) inside a baseball product. Not one names a pitcher, pitch type, handedness or park. The card's forward-read slots already exist and go unfilled -- the surface needs feeding, not redesigning. On what changes: the prior measurements were outcome-accuracy, not edge, so the metric was right and the question was narrow. proj-v1.1 and hits-v1 stay correctly refuted as DISTRIBUTION swaps on thin inputs -- neither tested a matchup-fed projection. arch-v1 is a market-relative nudge by construction and is the one component genuinely measured on the wrong axis. AT CEILING is provisional: measured only against features the champion already reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
14 KiB
REALITY ASSESSMENT — what exists vs the FORWARD PROJECTION objective
Read-only inventory, 2026-08-03. Nothing built, nothing changed.
THE HEADLINE: every component the forward model needs already exists and is already loaded in production — Statcast batter contact quality, pitcher stuff and handedness, park, weather, platoon, arsenal classification, archetypes. All of it sits DOWNSTREAM of the grade, feeding challenger columns that are never served. The thing the user actually sees is a hit-rate counter that has never seen a pitcher.
The distance to Stage A is not data acquisition. It is re-plumbing — plus one broken cron and one unreachable code branch found along the way.
0. First, a correction — because it changes what needs re-checking
The order says "we have measured EDGE for five sessions — the WRONG test." That is not what the measurements were, and the distinction matters.
Resolution as measured = corr(forecast, outcome) — do higher forecasts hit
more often. That is outcome accuracy at the listed line. It never referenced the
market. The market-relative objects are separate and few: proj_book_implied,
fair_prob, CLV/dCLV, and arch-v1, which is by construction a "market-relative
p_win nudge."
So the metric was right. What was wrong was the QUESTION. Every session asked "does challenger X out-rank champion Y?" and never "can a forward matchup read be produced at all, and does it call listed lines better than base rate?" Those have different answers, because:
- Resolution parity ≠ product parity. A frequency counter and a forward model could both resolve ~0.20 on hits, and only one of them can say why.
- Ranking is not the same as accuracy above base rate, which is what "accurate listed-line calls" means. That was only measured once, at the very end of the last session (the calibration table), and it failed: +3.5pt global over-prediction.
That correction narrows what has to be re-checked (§4) rather than voiding it.
1. Component inventory — SPINE / NUDGE / DISCONNECTED (STEP 1)
The served grade is gradeSlateService → analyzeViaEngine1 → probabilityEstimator.
p_win reads exactly three features: opp_rank_stat, home_away,
l10_stddev/l20_avg. Everything below is measured against that path.
| component | exists? | what it computes today | role in the SERVED grade |
|---|---|---|---|
| base + recency frequency | yes | frequency of (stat > THIS line), 0.6/0.4 with last-5 | THE SPINE — ~100% of resolution (measured, specs/champion-input-diagnosis.md) |
| archetypeService (41 archetypes) | yes | full feature-scoring classifier, primary/secondary/blend | DISCONNECTED. Attached at snapshot time for display only; analyzeViaEngine1 explicitly does not set it |
| matchupRead (arsenal vs hitter) | yes | sinker/breaking mix, whiff & hard-hit tendency → multiplier | DISCONNECTED from the grade. Only inside proj-v1.1, a challenger measured worse |
park (parkBase, parkFactors) |
yes | per-venue factors, exposure-relative baseline | NUDGE, in challengers only (arch-v1 env axis, proj-v1.1) |
weather (environmentContext) |
yes | temp / wind / precip → multiplier | NUDGE, in challengers only |
platoon (platoonSplits, mlbContext) |
yes | handedness advantage | DISCONNECTED — mlbContext has ZERO consumers. Dead code |
| opportunity_drift | yes | recent AB ÷ season AB/game | NUDGE (arch-v1 opportunity axis). The one feature with repeated residual signal — and the axis extracts none of it |
| statcastAggregateService | yes | 1,354 rows of real mechanism data | DISCONNECTED from grading. Required only by snapshotScheduler (writer) and routes/internal |
| contact quality (contact-v1) | yes | season contact profile | NUDGE (challenger column, n=1,055, inconclusive) |
| pitcher stuff / tendencies | yes | pitch mix, velo, break, arm angle, GB/FB, whiff | DISCONNECTED. Loaded per snapshot as arsenalById, used only by proj-v1.1 |
Not one forward-model component is in the spine. The spine is the counter.
2. Baseball input coverage — the real distance (STEP 2)
statcast_aggregates: 1,354 rows (750 pitchers, 604 batters), real Baseball
Savant, free and unlimited.
| input the forward model needs | status | evidence |
|---|---|---|
| hitter exit velo (avg/max) | HAVE | 600/604 batters |
| hitter launch angle | HAVE | 600/604 |
| hitter barrel% / hard-hit% | HAVE | 600/604 |
| hitter whiff / chase / K / BB | HAVE | 602–603/604 |
| hitter batted-ball type (GB/FB/LD) | PARTIAL | gb_pct/fb_pct are 0/604 for batters (populated for pitchers only). The batted-ball feed carries fb_ld_pct/gb_pct_bb — they land in the metrics JSONB, not the typed columns |
| handedness (bats / throws) | HAVE | 1,354/1,354 — both sides, complete |
| pitcher pitch mix + velo + break | HAVE | pitch_mix 729/750 |
| pitcher GB/FB/LD | HAVE | 713/750 |
| pitcher whiff / chase / barrel & hard-hit allowed | HAVE | 713/750 |
| pitcher arm angle | HAVE | 713/750 |
| park factors | HAVE | parkBase / parkFactors, per venue |
| weather (temp/wind/precip) | HAVE | wx_forecast on ledger rows; 5,855 feature rows |
| probable pitcher for tonight | HAVE | probablePitchers → mlbStatsAdapter.getScheduleWithPitchers |
| lineup slot / opportunity | PARTIAL | opportunity_drift + ab_per_game are proxies; depthChartService exists but lineup_role_profiles / player_role_profiles tables are 0 rows |
| team defense (OAA / DRS / range) | MISSING | only opp_rank_stat, a coarse 0..1 team rank |
| catcher framing, umpire zone | MISSING | the ref_* features are NBA officiating, not MLB umpires |
| batter-vs-pitcher history | PARTIAL | getBatterVsPitcher exists in the adapter; not in the grade path |
Verdict on STEP 2: the inputs are overwhelmingly HAVE. The forward model is not blocked on data acquisition. Two real gaps (team defense, catcher/umpire) and two partials (batter batted-ball columns, lineup slot) — none of which block a first forward model.
But the mechanism data is 13 days stale, and here is why
statcast_aggregates.updated_at max = 2026-07-21. Today is 2026-08-03.
The nightly refresh is unreachable code. In src/snapshotScheduler.js:
152 const tick = async () => {
158 if (!HOURS_UTC.includes(h)) return; // HOURS_UTC = 14,19,22,1,3
...
323 const statcastHour = Number(process.env.STATCAST_HOUR_UTC || 11);
324 if (h === statcastHour && process.env.STATCAST !== '0') {
Line 324 is only reachable when h ∈ {14,19,22,1,3}; it tests h === 11. That
branch can never execute at the default. The data has sat at its Session-68
backfill ever since. Its failure alert (line 330) is inside the same dead branch,
so it has never been able to warn either — the same silently guarded-out shape
as the settlement outage. (Caveat: if STATCAST_HOUR_UTC were set in prod to one
of the cron hours it would run; the 13-day staleness says it is not.)
3. Does the design reflect the forward read? (STEP 3)
The card has the slots. The engine never fills the matchup ones.
GradeResultCard already supports archetypeBlend (with a literal "Why this
grade:" caption), propDNA (reliable/volatile), statContext, vyndrIntel
(form/usage/matchup/rest), signals, killConditions. The surface was
designed for a scouting report.
What actually reaches it — the complete factor-label vocabulary the "SIGNAL BREAKDOWN · N FACTORS" section renders:
l5_hot_vs_line l5_cold_vs_line l20_over_line l20_under_line
l20_contradicts_over/under l5_under_friendly consistency_boom_bust
back_to_back rested_2plus heavy_workload_7d home_game
top_opponent_defense weak_opponent_defense away_vs_top5_defense
ref_foul_high/low ref_pace_adjustment coach_pace_delta
opp_2_starters_out opp_3plus_starters_out rookie/veteran_in_playoffs
Every one is a restatement of the counter — he's been hot against this number, he's rested, he's home. Not one names a pitcher, a pitch type, a handedness edge, a park, or a batted-ball profile. Several are structurally NBA (referees, coach pace, starters out, playoffs) inside a baseball product.
Design verdict: the product communicates the COUNTER, with the counter's own inputs relabelled as "signals." The "why" is currently "because he's been doing it," which is the one thing a forward projection is supposed to replace. The good news is that the surface does not need redesigning — it needs feeding.
4. What prior work changes (STEP 4)
KEEP — serves the forward model regardless of objective:
| work | why it survives |
|---|---|
| Settlement fix (S77) | the measurement floor. Without it nothing is testable at all |
| Clamp + calibration diagnosis (S78) | 20.6% of props pinned to a constant, +3.5pt over-prediction. A forward model inherits this bug unless it is fixed — it is in the output stage, not the model |
| Champion decomposition (S78) | tells us precisely what the counter contributes (~all of it from base+recency) — that is the baseline any forward model must beat |
| Takeability / book-identity work | market-structure hygiene, objective-independent |
| Ledger + retention infrastructure | the proving ground |
opportunity_drift residual finding |
a genuine forward input, wrongly implemented |
RE-CHECK — verdicts that answered the narrow question:
| verdict | why it needs re-checking |
|---|---|
| arch-v1 "inconclusive" | it is explicitly a market-relative nudge — the one component genuinely measured on the wrong axis. Its axes (env/matchup/opportunity) should be re-tested as forward inputs to a projection, not as nudges to a market-relative probability |
| proj-v1.1 "reliably worse" | valid as measured, but it was a distribution swap on the same thin inputs (season rate + bounded multiplier). It does not establish that a matchup-fed projection is worse — that was never built |
| hits-v1 "refuted" | same. It changed the family, not the information. The refutation stands for that model and says nothing about a stuff-vs-profile read |
| "AT CEILING" on runs/walks | provisional. The ceiling was measured against features the champion already reads. It cannot rule out inputs never wired in |
| Archetype "unmeasurable" | unchanged — 2 of 41 labels have testable n. Still the honest answer |
Nothing gets thrown away. Two verdicts (proj-v1.1, hits-v1) are correctly scoped to what they tested and simply do not answer the forward question.
5. STAGED DISTANCE TO PRODUCT (STEP 5)
Stage A — a forward baseball model that beats the counter on listed-line accuracy
BUILT (more than expected): all five Statcast feeds ingested and typed;
handedness complete on both sides; arsenal classification (matchupRead) written
and unit-tested; park + weather + platoon multipliers written; probable pitchers
resolved per game; archetype classifier complete; the settled ledger that proves
or kills it; a champion baseline decomposed to the number.
NEEDED:
- Fix the Statcast cron (one guard) — a forward model on 13-day-old mechanism data is not a forward model.
- The actual new thing: a projection whose SPINE is
hitter profile × pitcher stuff × park/conditions, with frequency demoted to a prior. Every input exists; none is assembled this way. This is the real build. - Wire it into the grade path (
analyzeViaEngine1), not a challenger column — though it should ride as a challenger first to be proven. - Fill the two partials: batter GB/FB into typed columns, lineup slot.
Honest distance: this is the one genuinely substantial build, and it is a plumbing-and-modelling job, not a data job. Main risk is not feasibility but sample: 1,741 settled rows, ~578 on the biggest stat, is thin for validating a richer model per stat. Expect the verdict to be n-blocked for weeks after it ships — which is an argument for shipping it soon, not for waiting.
Stage B — calibrated + productized (the scouting-report surface)
BUILT: the entire card (archetypeBlend + "Why this grade", propDNA,
statContext, vyndrIntel.matchup, signals, kill conditions), tier gating, the
row grammar, the honesty layer.
NEEDED: (a) the clamp/calibration fix — already diagnosed, no new data;
(b) matchup-derived factor labels to replace the l5_hot_vs_line vocabulary;
(c) archetype attached at grade time (currently refused there on I/O-cost grounds
that a forward model changes). Distance: short once Stage A exists — this is
mostly deleting the counter's vocabulary and passing through what Stage A already
computes.
Stage C — same method per sport
BUILT: the per-sport doctrine, the sport-agnostic Bayesian scaffolding, WNBA settling end-to-end.
NEEDED: a mechanism feed per sport equivalent to Statcast. This does not
exist for NBA/WNBA in our stack — espnStatsAdapter is box scores, and the
Python nba_api service is offline in production. Soccer is quota-blocked on
odds-api (478/500). Distance: materially further than A or B, and gated on data
we do not currently have. Baseball is the only sport where the forward model is
buildable today — which is an argument for making it excellent in one sport
first, exactly as the per-sport doctrine says.
6. The one-sentence answer to "how far"
Stage A is one real build away and zero data acquisitions away; Stage B is mostly wiring plus an already-diagnosed calibration fix; Stage C is blocked on per-sport mechanism data that does not exist in the stack yet. The reason five sessions of challengers failed is now legible and it is not the modelling — it is that every one of them was attached downstream of the grade, nudging a counter instead of replacing it.