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:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user