Records the verification that matters: firing measured on a real prod snapshot rather than inferred. environment 248/293 (84.6%) -- also its FIRST confirmed ledger write, which the previous session could only infer -- and matchup 243/293 (82.9%) on tier batter_own_split. Both were 0/634. Collinearity guard passed at n=243: r = -0.003 vs the projection, +0.074 vs p_win, +0.003 vs line, -0.068 vs environment, -0.150 vs opportunity. The axis is not re-encoding recent form. The nudge distribution is also the right SHAPE -- mean +0.0007, 123 positive / 120 negative -- a balanced two-sided signal; a one-sided distribution would have suggested a sign or baseline error. Plan reconciled in place: arch-v1 condition axes marked firing, three challengers listed with coverage and their own holdout queries, and the next-order pointer moved to connecting the still-dormant layers (similarity, Bayesian, distribution ladder) with archetype_x_archetype as the named alternative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
5.5 KiB
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).