c551bf0340
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
233 lines
14 KiB
Markdown
233 lines
14 KiB
Markdown
# 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.
|