# 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:** 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 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.