# MATCHUP / PLATOON AXIS — BUILT, FIRING ON PROD, VERDICT PENDING **Date:** 2026-08-02 · ledger-only · champion `p_win`, ranking, calibration, the armed invariant and the two accruing verdicts **untouched**. **Gates:** 4,093 tests / 328 suites green · `next build` exit 0 · **firing verified on a real prod snapshot** · collinearity guard passed. --- ## THE HEADLINE: TWO DEAD AXES ARE NOW LIVE Verified on an induced prod snapshot (293 grades), not on tests: | axis | tier | rows | % of slate | mean \|nudge\| | |---|---|---:|---:|---:| | **environment** | `env` | **248** | **84.6%** | 0.0574 | | **matchup** | **`batter_own_split`** | **243** | **82.9%** | 0.0152 | | opportunity | `opportunity` | 88 | 30.0% | 0.1419 | | *(no axis at all)* | — | 32 | 10.9% | — | **Both were 0/634 before.** This is also the **first confirmed ledger write of the environment axis**, which the previous session could only infer. --- ## WHAT WAS ACTUALLY BROKEN — three separate absences The axis was already wired. It fired on nothing because **every one of its three required joins was empty**: | input | was | fix | |---|---:|---| | `oppPitcherByTeam` | **0** | the self-origin `/api/schedule/mlb/pitchers` route returned nothing in prod → added the **statsapi probable-pitcher hydrate** as a fallback, mirroring the one the schedule step already uses. **29/30 team-sides**, one free request | | `handById` | **0** | follows from the above — the batched people call now has ids to ask about | | `bats` | **0/120** | batter hand rode **only** on statcast aggregate rows, which don't cover the slate. The season player list we **already fetch and cache** carries `batSide` on **1342/1342** — a join, not a fetch | Switch-hitters (`S`, 78 of 1342) are preserved as-is; `platoonSplits` decides what to do with them, not the join. --- ## THE FALLBACK LADDER — and a deliberate deviation **Shipped: `batter_own_split`** — the hitter's OWN vs-L/vs-R line, regressed toward **his own** overall rate. Labelled on every adjustment. ### `league_generic` is deliberately NOT implemented `platoonSplits` already handles thin evidence by regressing toward the hitter's own rate — its own doc-comment argues that *a hitter with no evidence of a split correctly gets no adjustment*. A league split applied to that hitter models the **league, not the player** — the exact doctrine breach the order names in the same step it asks for the tier. **Adding it would have produced more firing rows and a weaker signal.** Coverage is already 82.9% without it. ### `archetype_x_archetype` is scoped, not built It needs the opposing starter classified per game — real work, and its own order. The tier vocabulary is in place for it. **Neither absent tier is faked.** No adjustment ever claims a rung it did not use. --- ## COLLINEARITY GUARD — PASSED (n=243) The ledger doesn't store `l5`/`l20`, so the guard uses the quantities they **drive** and that are stored: | correlation | r | |---|---:| | matchup nudge vs **projection** (`model_value`) | **−0.003** | | matchup nudge vs **`p_win`** | **+0.074** | | matchup nudge vs **line** | **+0.003** | | matchup nudge vs environment nudge | −0.068 | | matchup nudge vs opportunity nudge | −0.150 | **Orthogonal to recent form and to every other axis.** It is not re-encoding something the projection already knows. **And the shape is right:** mean nudge **+0.0007**, sd 0.0202, **123 positive / 120 negative**. A balanced two-sided signal with no systematic tilt — which is what a platoon read should look like. A one-sided distribution would have suggested a sign or baseline error. The mean |nudge| of **0.0152** is appropriately small: a platoon split is a real but modest effect, and it cannot outvote a measured archetype trait (0.11–0.22). --- ## HOLDOUT — COMMITTED, VERDICT n-BLOCKED `scripts/matchup-axis-holdout.sql`, filtered to **matchup-carrying rows only** — including untouched rows would dilute with rows where challenger ≡ champion **by construction**, biasing toward a false positive. **It keeps matchup's OWN nudge visible**, not just the combined challenger. arch-v1 now composes **four** axes into one `p_win_challenger`, so a combined-only view cannot tell which axis earned the movement — or which one is dragging. The query pulls `matchup_nudge` out of the adjustments array and correlates it with the outcome directly, so the axis can be **shelved alone**. **Three challengers now accrue in parallel** on one harness: | axis | coverage | verdict | |---|---:|---| | `opportunity` | 30.0% | n-blocked — needs settled rows | | `environment` | 84.6% | n-blocked — first writes confirmed today | | `matchup` | 82.9% | n-blocked — first writes today | Each promotes **only** on its own axis-filtered holdout, and **only** if **reliability AND resolution** both improve. --- ## WHAT IS UNTOUCHED Champion `p_win`, the live grade path, ranking, calibration, the armed S59 invariant, and the two previously-accruing verdicts. All four axes write only to `p_win_challenger` / `challenger_adjustments`. ## TAGS **VERIFIED:** matchup fires 243/293 (82.9%) and environment 248/293 (84.6%) on a real prod snapshot · three joins repaired (SP 29/30, pitcher hand, batter hand 1342/1342) · collinearity r ≈ 0 against projection, p_win, line and both other axes · nudge distribution balanced 123/120. **NOT BUILT, BY DECISION:** `league_generic` (models the league, not the player). **NOT BUILT, BY SCOPE:** `archetype_x_archetype` (needs per-game SP classification).