Build the matchup/platoon axis: three joins fixed, axis now FIRES

The axis was already wired and firing on 0/634 prod rows. Three separate
absences kept it silent, and all three are now joined:

1. 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.
2. handById 0 -> follows from (1); the batched people call now has ids.
3. bats 0/120 -> batter hand rode ONLY on statcast aggregate rows, which do
   not cover the slate. The season player list we ALREADY fetch and cache
   carries batSide on 1342/1342, so this is a join, not a fetch.
   Switch-hitters ('S') are preserved as-is; platoonSplits decides what to
   do with them, not the map.

Verified end-to-end against the live API: opp_declared 29,
pitchers_with_hand 29, batters_with_hand 1342, and a real read --
multiplier 0.966, L vs R, 287 observed PA, weight 0.324 -- composing
alongside environment in one challenger.

FALLBACK LADDER, and a deliberate deviation from the order. Shipped tier:
`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, which
covers the thin case per-player; its own doc-comment argues a hitter with
no split evidence should get NO adjustment. A league split applied to such
a hitter models the LEAGUE, not the player -- the doctrine breach the order
itself names in the same step. Adding it would have produced more firing
rows and a weaker signal.

`archetype_x_archetype` is scoped, not built: it needs the opposing
starter classified per game, which is real work and a separate order. The
tier vocabulary is in place for it.

Honest-absent on every join: no starter, no pitcher hand, or no batter hand
-> NO matchup adjustment, never a fabricated neutral. A neutral multiplier
produces no adjustment row at all.

Holdout committed (scripts/matchup-axis-holdout.sql), filtered to
matchup-carrying rows, and it keeps MATCHUP'S OWN nudge visible rather than
only the combined challenger -- arch-v1 composes four axes into one
p_win_challenger, so a combined-only view could not tell which axis earned
the movement, or which one is dragging.

Champion p_win, ranking, calibration, the armed invariant and the two
accruing verdicts are untouched.

Gates: 4,093 tests / 328 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-02 01:11:05 -04:00
parent 9fc17a4689
commit 9ebd77b68e
4 changed files with 194 additions and 3 deletions
+2 -1
View File
@@ -215,7 +215,8 @@ function adjust({ pWin, direction, statType, classification, environment, matchu
total += n;
adjustments.push({
axis: 'matchup', label: matchup.label || 'PLATOON',
tier: 'matchup', nudge: Math.round(n * 1000) / 1000,
// Which rung of the fallback ladder produced this read.
tier: matchup.tier || 'matchup', nudge: Math.round(n * 1000) / 1000,
multiplier: Math.round(mMult * 1000) / 1000,
batter_hand: matchup.batter_hand ?? null,
pitcher_hand: matchup.pitcher_hand ?? null,
+54 -2
View File
@@ -124,6 +124,26 @@ async function buildContext(sport, deps = {}) {
if (homeAbbr && awayPid) { oppPitcherByTeam.set(homeAbbr, awayPid); pitcherIds.add(awayPid); }
if (awayAbbr && homePid) { oppPitcherByTeam.set(awayAbbr, homePid); pitcherIds.add(homePid); }
}
// STATSAPI FALLBACK (2026-08-02). The self-origin route returned nothing in
// prod — measured oppPitcherByTeam = 0 — which alone kept the matchup axis
// at 0/634 rows. statsapi's own schedule hydrate carries probables at 29/30
// team-sides, mirrors the fallback the schedule step above already uses, and
// costs one free request.
if (oppPitcherByTeam.size === 0) {
const day = (deps.today || new Date().toISOString().slice(0, 10));
const sched2 = deps.probableSchedule
|| await fetchJson(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${day}&hydrate=probablePitcher`, deps);
for (const dt of (sched2 && sched2.dates) || []) {
for (const g of dt.games || []) {
const homeAbbr = abbrOf(g.teams?.home?.team?.name);
const awayAbbr = abbrOf(g.teams?.away?.team?.name);
const homePid = num(g.teams?.home?.probablePitcher?.id);
const awayPid = num(g.teams?.away?.probablePitcher?.id);
if (homeAbbr && awayPid) { oppPitcherByTeam.set(homeAbbr, awayPid); pitcherIds.add(awayPid); }
if (awayAbbr && homePid) { oppPitcherByTeam.set(awayAbbr, homePid); pitcherIds.add(homePid); }
}
}
}
} catch { /* platoon honest-absents */ }
// ── 3. pitcher handedness: ONE batched statsapi call ────────────────────
@@ -139,6 +159,22 @@ async function buildContext(sport, deps = {}) {
} catch { /* platoon honest-absents */ }
}
// ── 3b. BATTER handedness (2026-08-02) ─────────────────────────────────
// Measured: `bats` was absent on 120/120 graded rows because it rode only
// on the statcast aggregate rows, which do not cover the slate. The season
// player list — already fetched and cached — carries `batSide` on
// 1342/1342, so this is a join, not a fetch. Switch-hitters ('S') are kept
// as-is: platoonSplits decides what to do with them, not this map.
const batsById = new Map();
try {
const list = deps.seasonPlayers
|| await fetchJson(`https://statsapi.mlb.com/api/v1/sports/1/players?season=${season}`, deps);
for (const p of (list && list.people) || []) {
const code = p.batSide?.code;
if (code && p.id != null) batsById.set(num(p.id), String(code).toUpperCase());
}
} catch { /* batter hand honest-absents → matchup abstains */ }
// ── 4. weather forecast: ONE Open-Meteo call per HOME park ──────────────
const homeAbbrs = new Set([...gameByTeam.values()].map((r) => r.homeAbbr).filter(Boolean));
const forecastByHome = new Map();
@@ -168,6 +204,7 @@ async function buildContext(sport, deps = {}) {
sport: sp, applicable: true,
games: gameByTeam.size / 2, venues_with_weather: forecastByHome.size,
pitchers_with_hand: handById.size, opp_declared: oppPitcherByTeam.size,
batters_with_hand: batsById.size,
};
/**
@@ -211,7 +248,9 @@ async function buildContext(sport, deps = {}) {
// MATCHUP = platoon split, hitter hand + opposing-SP hand.
let matchup = null;
const batterHand = grade && grade.bats;
// Grade-supplied hand wins (statcast rows carry it when present); otherwise
// fall back to the season list join. Absent in both → the axis ABSTAINS.
const batterHand = (grade && grade.bats) || batsById.get(num(grade && grade.playerId)) || null;
const oppPid = teamAbbr ? oppPitcherByTeam.get(teamAbbr) : null;
const pitcherHand = oppPid != null ? handById.get(oppPid) : null;
if (batterHand && pitcherHand && grade.playerId != null) {
@@ -222,6 +261,19 @@ async function buildContext(sport, deps = {}) {
if (e.multiplier !== 1) {
matchup = {
multiplier: e.multiplier, label: 'PLATOON',
// TIER LABEL (2026-08-02). The ladder is explicit so no consumer has
// to guess how strong a matchup read is:
// batter_own_split — the hitter's OWN vs-L/vs-R line, regressed
// toward HIS OWN overall rate. This is the only tier shipped.
// archetype_x_archetype — functional matchup vs the opposing
// starter's profile. Scoped, NOT built (see the report).
// league_generic — deliberately NOT implemented: platoonSplits
// regresses thin evidence toward the hitter's own rate, which
// already covers the thin case and does so per-player. A league
// split applied to a hitter with no split evidence models the
// LEAGUE, not the player — the doctrine breach the order itself
// names.
tier: 'batter_own_split',
batter_hand: e.batter_hand, pitcher_hand: e.pitcher_hand,
observed_pa: e.observed_pa, observed_weight: e.observed_weight,
};
@@ -232,7 +284,7 @@ async function buildContext(sport, deps = {}) {
return { environment, matchup };
};
return { contextFor, stats, _internals: { gameByTeam, forecastByHome, handById, oppPitcherByTeam } };
return { contextFor, stats, _internals: { gameByTeam, forecastByHome, handById, oppPitcherByTeam, batsById } };
}
module.exports = {