08276c0880
RBI is power TIMES opportunity. The same swing drives in one run or three depending on who is on base, and a hitter batting with the bases empty cannot drive anyone in however hard he hits it. Every context-free model of RBI here has failed, and the failure kept being read as 'skill inputs don't work for RBI' when the truth was that we were modelling half the stat. Both halves are free from statsapi.mlb.com, which we already call for game logs, schedules and probable pitchers. No new provider, no key, no quota. RUNG 1, batting order: schedule?hydrate=lineups returns homePlayers and awayPlayers as ORDERED arrays of nine, and the order IS the batting order -- index 0 is the leadoff hitter. That single fact gives CATALYST its identity and supplies lineup-position context for every context-dependent stat. RUNG 2 turned out cheap, which the cheapest-first rule did not expect. It looked like it would need play-by-play reconstruction across a season; statsapi serves situational splits directly, so 'how often does this hitter bat with runners to drive in' is ONE call per player rather than one per game. Measured on a real hitter: 87 plate appearances with runners in scoring position producing 25 RBI, against 302 with the bases empty producing 17. That ratio is the opportunity half of the stat and it is the thing no amount of exit velocity can tell you. Both tables are dated in the primary key. statcast_aggregates was built upsert-in-place and that silently made every backtest leak the games it was predicting; a lineup is worse still, because it is a PRE-GAME fact that changes by the hour, so an in-place table would overwrite what we knew at grade time with what turned out to be true. Absent stays absent throughout: no lineup posted is an empty slate rather than a guessed order, a short lineup records fewer slots rather than padding to nine, and a hitter with no splits is null rather than a zero RISP share -- which would assert he never bats with runners on, a strong claim and usually a false one. Wired into the snapshot best-effort, so a context failure can never break the pipeline it rides in. The three pre-registered theories are now marked input-ready rather than input-blocked: DRIVER's power x runners-on and power x lineup-position, and CATALYST's speed x on-base x power-behind. They are sample-blocked from here, and the proofs run under native cumulative correction as sample accumulates -- ingesting is not proving. Counter and frozen clusters byte-identical. 4,250 tests green (338 suites); web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
160 lines
6.3 KiB
JavaScript
160 lines
6.3 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* The lineup / baserunner-context ingest.
|
|
*
|
|
* This is the input RBI and runs have always been missing, so the tests are
|
|
* about the two ways it could quietly lie: inventing a batting order that was
|
|
* never posted, and turning an unmeasured hitter into one who never bats with
|
|
* runners on.
|
|
*/
|
|
|
|
const svc = require('../../src/services/lineupContextService');
|
|
|
|
const schedule = (lineups) => ({
|
|
dates: [{
|
|
games: [{
|
|
gamePk: 777, gameDate: '2026-08-04T23:05:00Z',
|
|
teams: { home: { team: { name: 'Chicago Cubs' } }, away: { team: { name: 'St. Louis Cardinals' } } },
|
|
...(lineups ? { lineups } : {}),
|
|
}],
|
|
}],
|
|
});
|
|
|
|
const nine = (prefix) => Array.from({ length: 9 }, (_, i) => ({ id: 100 + i, fullName: `${prefix} Player${i}` }));
|
|
|
|
describe('RUNG 1 — batting order', () => {
|
|
it('array ORDER is the batting order; index 0 is leadoff', async () => {
|
|
const rows = await svc.fetchLineups('2026-08-04', {
|
|
fetchImpl: async () => schedule({ awayPlayers: nine('A'), homePlayers: nine('H') }),
|
|
});
|
|
expect(rows).toHaveLength(18);
|
|
const away = rows.filter((r) => r.side === 'away');
|
|
expect(away[0].batting_order).toBe(1);
|
|
expect(away[0].player_name).toContain('Player0');
|
|
expect(away[8].batting_order).toBe(9);
|
|
expect(away[0].team).toBe('St. Louis Cardinals');
|
|
});
|
|
|
|
it('carries the ids and normalized keys the rest of the pipeline joins on', async () => {
|
|
const rows = await svc.fetchLineups('2026-08-04', {
|
|
fetchImpl: async () => schedule({ homePlayers: [{ id: 656941, fullName: 'Kyle Schwarber' }] }),
|
|
});
|
|
expect(rows[0].source_id).toBe(656941);
|
|
expect(rows[0].player_key).toBe('kyle schwarber');
|
|
expect(rows[0].game_pk).toBe(777);
|
|
});
|
|
|
|
it('NO lineup posted yet → empty, never a guessed order', async () => {
|
|
const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => schedule(null) });
|
|
expect(rows).toEqual([]);
|
|
});
|
|
|
|
it('a SHORT lineup records fewer slots rather than padding to nine', async () => {
|
|
const rows = await svc.fetchLineups('2026-08-04', {
|
|
fetchImpl: async () => schedule({ homePlayers: nine('H').slice(0, 4) }),
|
|
});
|
|
expect(rows).toHaveLength(4);
|
|
expect(rows[3].batting_order).toBe(4);
|
|
});
|
|
|
|
it('a failed feed is an empty slate, not a thrown pipeline', async () => {
|
|
const rows = await svc.fetchLineups('2026-08-04', {
|
|
fetchImpl: async () => { throw new Error('502'); },
|
|
});
|
|
expect(rows).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('RUNG 2 — runners in scoring position', () => {
|
|
const splits = (risp, empty) => ({
|
|
stats: [{
|
|
splits: [
|
|
...(risp ? [{ split: { code: 'risp' }, stat: risp }] : []),
|
|
...(empty ? [{ split: { code: 'r0' }, stat: empty }] : []),
|
|
],
|
|
}],
|
|
});
|
|
|
|
it('derives the RISP share from the two splits', async () => {
|
|
const o = await svc.fetchOpportunity(1, 2026, {
|
|
fetchImpl: async () => splits({ plateAppearances: 87, rbi: 25 }, { plateAppearances: 302 }),
|
|
});
|
|
expect(o.risp_pa).toBe(87);
|
|
expect(o.risp_rbi).toBe(25);
|
|
expect(o.bases_empty_pa).toBe(302);
|
|
expect(o.total_pa).toBe(389);
|
|
expect(o.risp_share).toBeCloseTo(87 / 389, 6);
|
|
});
|
|
|
|
it('separates two hitters with the same power but different opportunity', async () => {
|
|
const cleanup = await svc.fetchOpportunity(1, 2026, {
|
|
fetchImpl: async () => splits({ plateAppearances: 150, rbi: 60 }, { plateAppearances: 200 }),
|
|
});
|
|
const leadoff = await svc.fetchOpportunity(2, 2026, {
|
|
fetchImpl: async () => splits({ plateAppearances: 50, rbi: 18 }, { plateAppearances: 350 }),
|
|
});
|
|
// This is the half of RBI no amount of exit velocity can tell you.
|
|
expect(cleanup.risp_share).toBeGreaterThan(leadoff.risp_share * 2);
|
|
});
|
|
|
|
it('NO splits → null, never a zero share', async () => {
|
|
// A 0 share would assert he never bats with runners on — a strong claim,
|
|
// and usually a false one.
|
|
expect(await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => ({ stats: [] }) })).toBeNull();
|
|
expect(await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => { throw new Error('x'); } })).toBeNull();
|
|
expect(await svc.fetchOpportunity(null, 2026, {})).toBeNull();
|
|
});
|
|
|
|
it('one split present is still usable; the missing side stays null', async () => {
|
|
const o = await svc.fetchOpportunity(1, 2026, {
|
|
fetchImpl: async () => splits({ plateAppearances: 87, rbi: 25 }, null),
|
|
});
|
|
expect(o.risp_pa).toBe(87);
|
|
expect(o.bases_empty_pa).toBeNull();
|
|
expect(o.risp_share).toBe(1); // of the PAs we can classify — stated exactly
|
|
});
|
|
});
|
|
|
|
describe('the ingest as a whole', () => {
|
|
it('writes both tables and reports coverage', async () => {
|
|
const writes = {};
|
|
const sb = { from: (t) => ({ upsert: async (rows) => { writes[t] = rows; return { error: null }; } }) };
|
|
const out = await svc.refreshContext({
|
|
supabase: sb, date: '2026-08-04', season: 2026,
|
|
fetchLineups: async () => [
|
|
{ game_pk: 1, game_date: '2026-08-04', team: 'Cubs', side: 'home', player_name: 'A B', player_key: 'a b', source_id: 5, batting_order: 1 },
|
|
],
|
|
fetchOpportunity: async () => ({ risp_pa: 10, risp_rbi: 4, bases_empty_pa: 40, total_pa: 50, risp_share: 0.2 }),
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
expect(out.lineups).toBe(1);
|
|
expect(out.opportunity).toBe(1);
|
|
expect(writes.lineup_context[0].as_of_date).toBeTruthy(); // DATED, always
|
|
expect(writes.hitter_opportunity[0].as_of_date).toBeTruthy();
|
|
});
|
|
|
|
it('NEVER throws — it rides alongside a snapshot', async () => {
|
|
const out = await svc.refreshContext({
|
|
supabase: null,
|
|
fetchLineups: async () => { throw new Error('feed down'); },
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
expect(out.reason).toMatch(/feed down/);
|
|
});
|
|
|
|
it('fetches opportunity ONCE PER HITTER, not once per game', async () => {
|
|
let calls = 0;
|
|
await svc.refreshContext({
|
|
supabase: null,
|
|
fetchLineups: async () => ([
|
|
{ game_pk: 1, player_key: 'a b', player_name: 'A B', source_id: 5, batting_order: 1 },
|
|
{ game_pk: 2, player_key: 'a b', player_name: 'A B', source_id: 5, batting_order: 3 },
|
|
]),
|
|
fetchOpportunity: async () => { calls += 1; return { risp_pa: 1, total_pa: 2, risp_share: 0.5 }; },
|
|
});
|
|
// The season aggregate is per player — this is why Rung 2 is cheap.
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|