9ebd77b68e
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
87 lines
3.6 KiB
JavaScript
87 lines
3.6 KiB
JavaScript
'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();
|
|
});
|
|
});
|