Files
vyndr/specs/matchup-axis.md
builtbykev ec815b0e37 Matchup axis report + plan reconciled: three challengers now accruing
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
2026-08-02 01:15:51 -04:00

134 lines
5.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.110.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).