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
+52
View File
@@ -0,0 +1,52 @@
-- matchup-axis-holdout.sql — per-axis proof, RUN WHEN n IS ADEQUATE.
--
-- Filtered to MATCHUP-CARRYING rows only. Including untouched rows would
-- dilute the comparison with rows where challenger === champion BY
-- CONSTRUCTION, biasing toward a false positive (the trap opportunity_drift
-- established).
--
-- MATCHUP'S OWN CONTRIBUTION IS KEPT VISIBLE, not just the combined challenger:
-- arch-v1 composes archetype + environment + opportunity + matchup into ONE
-- p_win_challenger, so a combined-only view cannot tell which axis earned the
-- movement. `matchup_nudge` is pulled out of the adjustments array so the axis
-- can be judged on its own terms and, if it is the one dragging, shelved alone.
--
-- BOTH reliability AND resolution must improve for the axis to promote.
with rows_ as (
select
l.game_date, l.id,
l.p_win::numeric champ,
l.p_win_challenger::numeric chal,
(l.outcome='hit')::int won,
(select (a->>'nudge')::numeric
from jsonb_array_elements(l.challenger_adjustments) a
where a->>'axis' = 'matchup' limit 1) matchup_nudge,
(select a->>'tier'
from jsonb_array_elements(l.challenger_adjustments) a
where a->>'axis' = 'matchup' limit 1) matchup_tier
from public.ledger_entries l
where l.sport='mlb' and l.user_id is null
and l.outcome in ('hit','miss')
and l.p_win is not null and l.p_win_challenger is not null
and l.challenger_adjustments::text like '%matchup%'
),
split as (
select *, case when ntile(2) over (order by game_date, id) = 1 then 'train' else 'holdout' end split
from rows_
),
b_champ as (select split, width_bucket(champ,0,1,10) bkt, count(*) n, avg(champ) pred, avg(won::numeric) actual from split group by 1,2),
b_chal as (select split, width_bucket(chal ,0,1,10) bkt, count(*) n, avg(chal ) pred, avg(won::numeric) actual from split group by 1,2)
select
s.split,
count(*) n,
count(distinct s.matchup_tier) tiers,
round(avg(abs(s.matchup_nudge))::numeric,4) mean_abs_matchup_nudge,
round((select sum(n*abs(pred-actual))/nullif(sum(n),0) from b_champ c where c.split=s.split),4) reliability_champion,
round((select sum(n*abs(pred-actual))/nullif(sum(n),0) from b_chal c where c.split=s.split),4) reliability_challenger,
round(corr(s.champ, s.won::numeric)::numeric,4) resolution_champion,
round(corr(s.chal , s.won::numeric)::numeric,4) resolution_challenger,
-- does the matchup nudge ITSELF point the right way?
round(corr(s.matchup_nudge, s.won::numeric)::numeric,4) matchup_nudge_vs_outcome,
round(avg(s.won::numeric),4) base_rate
from split s group by s.split order by s.split desc;
+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 = {
+86
View File
@@ -0,0 +1,86 @@
'use strict';
/**
* MATCHUP (platoon) axis — 2026-08-02.
*
* It was wired and firing on 0/634 prod rows. Three separate absences kept it
* silent: oppPitcherByTeam 0, handById 0, bats 0/120. These lock the joins that
* fixed each, and the honest-absent behaviour when any one is missing.
*/
const env = require('../../src/services/environmentContext');
const { adjust } = require('../../src/services/challengerProjection');
const GAME = {
teams: {
home: { team: { name: 'Philadelphia Phillies' }, probablePitcher: { id: 111 } },
away: { team: { name: 'New York Mets' }, probablePitcher: { id: 222 } },
},
};
const SCHED = { dates: [{ games: [GAME] }] };
const PEOPLE = { people: [{ id: 111, pitchHand: { code: 'L' } }, { id: 222, pitchHand: { code: 'R' } }] };
const PLAYERS = { people: [{ id: 900, batSide: { code: 'L' } }, { id: 901, batSide: { code: 'S' } }] };
const deps = (over = {}) => ({
origin: '',
schedule: { games: [] },
pitchers: { games: [] }, // self-origin route returns nothing, as in prod
probableSchedule: SCHED,
people: PEOPLE,
seasonPlayers: PLAYERS,
fetchJson: async () => { throw new Error('no network in tests'); },
...over,
});
describe('buildContext — the three joins that were absent', () => {
it('falls back to the statsapi probable-pitcher hydrate when the self-origin route is empty', async () => {
const ctx = await env.buildContext('mlb', deps());
expect(ctx._internals.oppPitcherByTeam.get('PHI')).toBe(222); // opposing = away SP
expect(ctx._internals.oppPitcherByTeam.get('NYM')).toBe(111);
expect(ctx.stats.opp_declared).toBe(2);
});
it('resolves pitcher handedness for the declared starters', async () => {
const ctx = await env.buildContext('mlb', deps());
expect(ctx._internals.handById.get(222)).toBe('R');
expect(ctx.stats.pitchers_with_hand).toBe(2);
});
it('joins BATTER handedness off the cached season player list', async () => {
const ctx = await env.buildContext('mlb', deps());
expect(ctx._internals.batsById.get(900)).toBe('L');
expect(ctx._internals.batsById.get(901)).toBe('S'); // switch-hitters preserved
expect(ctx.stats.batters_with_hand).toBe(2);
});
it('ABSTAINS — no starter, no hand, or no batter hand produces NO matchup', async () => {
const noSp = await env.buildContext('mlb', deps({ probableSchedule: { dates: [] } }));
const r1 = await noSp.contextFor({ stat_type: 'hits', team: 'PHI', playerId: 900 });
expect(r1.matchup).toBeNull();
const ctx = await env.buildContext('mlb', deps());
// known team + known starter, but the batter's hand is unknown
const r2 = await ctx.contextFor({ stat_type: 'hits', team: 'PHI', playerId: 999 });
expect(r2.matchup).toBeNull();
});
});
describe('the fallback ladder is LABELLED, and never invents a higher rung', () => {
it('a firing matchup carries its tier through to the challenger adjustment', () => {
const r = adjust({
pWin: 0.55, direction: 'over', statType: 'hits',
matchup: { multiplier: 0.95, label: 'PLATOON', tier: 'batter_own_split', batter_hand: 'L', pitcher_hand: 'R' },
});
const m = r.adjustments.find((a) => a.axis === 'matchup');
expect(m.tier).toBe('batter_own_split');
expect(r.p_win_challenger).toBeLessThan(0.55);
});
it('a neutral multiplier produces NO adjustment — never a fabricated zero-nudge row', () => {
const r = adjust({
pWin: 0.55, direction: 'over', statType: 'hits',
matchup: { multiplier: 1, label: 'PLATOON', tier: 'batter_own_split' },
});
expect(r.adjustments.find((a) => a.axis === 'matchup')).toBeUndefined();
});
});