Files
vyndr/specs/forward-model-reality-assessment.md
T
builtbykev c551bf0340 Reality assessment: the forward model exists, wired to the wrong side of the pipe
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
2026-08-03 00:53:56 -04:00

14 KiB
Raw Blame History

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 602603/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 probablePitchersmlbStatsAdapter.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:

  1. Fix the Statcast cron (one guard) — a forward model on 13-day-old mechanism data is not a forward model.
  2. 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.
  3. Wire it into the grade path (analyzeViaEngine1), not a challenger column — though it should ride as a challenger first to be proven.
  4. 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 stackespnStatsAdapter 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.