Ingest lineup + baserunner context: the input RBI and runs always needed
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
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* lineupContextService — THE INPUT RBI AND RUNS HAVE ALWAYS NEEDED.
|
||||
*
|
||||
* RBI is power TIMES opportunity. The same swing drives in one run or three
|
||||
* depending on who is standing 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 in this codebase 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 inputs are FREE from statsapi.mlb.com, which we already call for game
|
||||
* logs, schedules and probable pitchers. No new provider, no new key, no quota.
|
||||
*
|
||||
* ── RUNG 1 — BATTING ORDER ───────────────────────────────────────────────
|
||||
* `schedule?hydrate=lineups` returns `homePlayers` / `awayPlayers` as ORDERED
|
||||
* arrays of nine. 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 — RUNNERS IN SCORING POSITION ─────────────────────────────────
|
||||
* The cheapest-first rule expected this to need play-by-play reconstruction
|
||||
* across a season. It does not: 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 RISP 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.
|
||||
*
|
||||
* ── HONESTY ──────────────────────────────────────────────────────────────
|
||||
* Lineups are PRE-GAME facts that change by the hour — scratches, late swaps.
|
||||
* Everything here is DATED (`as_of_date` in the primary key) so what we knew at
|
||||
* grade time is never overwritten by what turned out to be true. An in-place
|
||||
* table would be worse here than anywhere else in the codebase.
|
||||
*
|
||||
* A missing lineup is ABSENT, never a guessed order; a hitter with no splits is
|
||||
* ABSENT, never a zero RISP share (which would assert he never bats with runners
|
||||
* on — a strong claim, and usually a false one).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { knownNumber } = require('../utils/known');
|
||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||
|
||||
const BASE = 'https://statsapi.mlb.com/api/v1';
|
||||
const HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** ET calendar date — the day a slate belongs to. */
|
||||
function dateET(d = new Date()) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
async function getJson(url, opts = {}) {
|
||||
const fetchImpl = opts.fetchImpl;
|
||||
if (fetchImpl) return fetchImpl(url);
|
||||
const res = await axios.get(url, { timeout: opts.timeout || HTTP_TIMEOUT_MS });
|
||||
return res && res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUNG 1 — confirmed/projected batting order for a slate.
|
||||
*
|
||||
* @returns {Array<{game_pk, game_date, team, side, player_name, player_key, source_id, batting_order}>}
|
||||
* Empty when the feed has no lineups yet — an empty slate is a valid answer,
|
||||
* and posting a guessed order would be worse than posting none.
|
||||
*/
|
||||
async function fetchLineups(date, opts = {}) {
|
||||
const d = String(date || dateET()).slice(0, 10);
|
||||
const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=lineups`;
|
||||
let data = null;
|
||||
try { data = await getJson(url, opts); } catch { return []; }
|
||||
const games = ((data && data.dates) || []).flatMap((day) => day.games || []);
|
||||
const out = [];
|
||||
for (const g of games) {
|
||||
const lineups = g.lineups;
|
||||
if (!lineups) continue;
|
||||
for (const [key, side] of [['awayPlayers', 'away'], ['homePlayers', 'home']]) {
|
||||
const players = lineups[key];
|
||||
if (!Array.isArray(players) || players.length === 0) continue;
|
||||
const team = g.teams && g.teams[side] && g.teams[side].team
|
||||
? g.teams[side].team.name : null;
|
||||
players.forEach((p, i) => {
|
||||
if (!p || !p.fullName) return;
|
||||
out.push({
|
||||
game_pk: g.gamePk ?? null,
|
||||
game_date: String(g.gameDate || '').slice(0, 10) || d,
|
||||
team,
|
||||
side,
|
||||
player_name: normalizeName(p.fullName).display,
|
||||
player_key: nameKey(p.fullName),
|
||||
source_id: knownNumber(p.id),
|
||||
// ORDER IS THE ORDER — index 0 is the leadoff hitter. Nothing is
|
||||
// inferred; if the array is short we simply record fewer slots.
|
||||
batting_order: i + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUNG 2 — a hitter's runners-in-scoring-position opportunity.
|
||||
*
|
||||
* @returns {object|null} null when the splits are unavailable — never a zero
|
||||
* share, which would assert he never bats with runners on.
|
||||
*/
|
||||
async function fetchOpportunity(sourceId, season, opts = {}) {
|
||||
const id = knownNumber(sourceId);
|
||||
if (id == null) return null;
|
||||
const url = `${BASE}/people/${id}/stats?stats=statSplits&sitCodes=risp,r0&season=${season}&group=hitting`;
|
||||
let data = null;
|
||||
try { data = await getJson(url, opts); } catch { return null; }
|
||||
const splits = ((data && data.stats) || []).flatMap((s) => s.splits || []);
|
||||
if (splits.length === 0) return null;
|
||||
|
||||
let rispPa = null; let rispRbi = null; let emptyPa = null;
|
||||
for (const sp of splits) {
|
||||
const code = sp && sp.split && sp.split.code;
|
||||
const st = (sp && sp.stat) || {};
|
||||
if (code === 'risp') {
|
||||
rispPa = knownNumber(st.plateAppearances);
|
||||
rispRbi = knownNumber(st.rbi);
|
||||
} else if (code === 'r0') {
|
||||
emptyPa = knownNumber(st.plateAppearances);
|
||||
}
|
||||
}
|
||||
if (rispPa == null && emptyPa == null) return null;
|
||||
|
||||
// total_pa is derived from the two splits we asked for. It UNDERCOUNTS the
|
||||
// runner-on-first-only case, which belongs to neither bucket — so the share is
|
||||
// "RISP as a fraction of the plate appearances we can classify", stated
|
||||
// exactly, rather than a fraction of a season total we did not fetch.
|
||||
const classified = (rispPa ?? 0) + (emptyPa ?? 0);
|
||||
return {
|
||||
risp_pa: rispPa,
|
||||
risp_rbi: rispRbi,
|
||||
bases_empty_pa: emptyPa,
|
||||
total_pa: classified > 0 ? classified : null,
|
||||
risp_share: classified > 0 && rispPa != null ? rispPa / classified : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a slate's lineups. Best-effort like every other side-write: a context
|
||||
* failure must never break the pipeline it rides in.
|
||||
*/
|
||||
async function persistLineups(sb, rows, { sport = 'mlb', asOf = null } = {}) {
|
||||
if (!sb || !rows || rows.length === 0) return { written: 0 };
|
||||
const as_of_date = asOf || dateET();
|
||||
const payload = rows.map((r) => ({ ...r, sport, as_of_date }));
|
||||
const { error } = await sb.from('lineup_context')
|
||||
.upsert(payload, { onConflict: 'as_of_date,sport,game_pk,player_key' });
|
||||
if (error) return { written: 0, error: error.message };
|
||||
return { written: payload.length, as_of_date };
|
||||
}
|
||||
|
||||
async function persistOpportunity(sb, rows, { sport = 'mlb', season, asOf = null } = {}) {
|
||||
if (!sb || !rows || rows.length === 0) return { written: 0 };
|
||||
const as_of_date = asOf || dateET();
|
||||
const payload = rows.map((r) => ({ ...r, sport, season, as_of_date }));
|
||||
const { error } = await sb.from('hitter_opportunity')
|
||||
.upsert(payload, { onConflict: 'as_of_date,sport,season,player_key' });
|
||||
if (error) return { written: 0, error: error.message };
|
||||
return { written: payload.length, as_of_date };
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole ingest for one slate. Returns a summary and NEVER throws — the
|
||||
* caller rides it alongside a snapshot and must not be broken by a bad feed.
|
||||
*/
|
||||
async function refreshContext(opts = {}) {
|
||||
const sport = opts.sport || 'mlb';
|
||||
const date = opts.date || dateET();
|
||||
const season = opts.season || Number(String(date).slice(0, 4));
|
||||
const sb = opts.supabase || null;
|
||||
const summary = { sport, date, season, lineups: 0, opportunity: 0 };
|
||||
try {
|
||||
const lineups = await (opts.fetchLineups || fetchLineups)(date, opts);
|
||||
summary.lineup_rows = lineups.length;
|
||||
summary.games_with_lineups = new Set(lineups.map((l) => l.game_pk)).size;
|
||||
if (sb && lineups.length) {
|
||||
const res = await persistLineups(sb, lineups, { sport, asOf: opts.asOfDate });
|
||||
summary.lineups = res.written;
|
||||
if (res.error) summary.lineup_error = res.error;
|
||||
}
|
||||
|
||||
// Opportunity is a SEASON aggregate, so it is fetched once per hitter in the
|
||||
// slate rather than once per game — the reason Rung 2 turned out cheap.
|
||||
const ids = [...new Map(lineups.filter((l) => l.source_id != null)
|
||||
.map((l) => [l.source_id, l])).values()];
|
||||
const rows = [];
|
||||
const getOpp = opts.fetchOpportunity || fetchOpportunity;
|
||||
for (const p of ids.slice(0, opts.maxPlayers || 400)) {
|
||||
const o = await getOpp(p.source_id, season, opts);
|
||||
if (!o) continue; // ABSENT, never a zero share
|
||||
rows.push({
|
||||
player_key: p.player_key, player_name: p.player_name,
|
||||
source_id: p.source_id, ...o,
|
||||
});
|
||||
}
|
||||
summary.opportunity_rows = rows.length;
|
||||
if (sb && rows.length) {
|
||||
const res = await persistOpportunity(sb, rows, { sport, season, asOf: opts.asOfDate });
|
||||
summary.opportunity = res.written;
|
||||
if (res.error) summary.opportunity_error = res.error;
|
||||
}
|
||||
summary.ok = true;
|
||||
} catch (e) {
|
||||
summary.ok = false;
|
||||
summary.reason = e.message;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchLineups, fetchOpportunity, persistLineups, persistOpportunity,
|
||||
refreshContext, dateET,
|
||||
};
|
||||
@@ -355,20 +355,27 @@ const PRE_REGISTERED = [
|
||||
sport: 'mlb', archetype: 'DRIVER', stat: 'rbi', skill: 'POWER',
|
||||
interaction: 'power_x_on_base_ahead',
|
||||
mechanism: 'An RBI is power TIMES opportunity: the same swing drives in one run or three depending on who is standing on base. DRIVER is the archetype where that context IS the signal, which is why RBI has resisted every context-free model so far.',
|
||||
blocked_on: 'BASERUNNER STATE IS NOT INGESTED. We hold no on-base-ahead data at all, so this cannot be tested at any sample — it is input-blocked, not sample-blocked.',
|
||||
// UNBLOCKED 2026-08-04: hitter_opportunity now carries each hitter's
|
||||
// runners-in-scoring-position share from statsapi situational splits. This
|
||||
// is sample-blocked from here, not input-blocked.
|
||||
blocked_on: null,
|
||||
},
|
||||
{
|
||||
sport: 'mlb', archetype: 'DRIVER', stat: 'rbi', skill: 'OPPORTUNITY',
|
||||
interaction: 'power_x_lineup_position',
|
||||
mechanism: 'Batting 3rd/4th/5th is a systematically different RBI opportunity than batting 8th, independent of skill.',
|
||||
blocked_on: 'BATTING ORDER IS NOT INGESTED (player_role_profiles / lineup_role_profiles are 0 rows).',
|
||||
// UNBLOCKED 2026-08-04: lineup_context carries confirmed batting order per
|
||||
// player per game, dated.
|
||||
blocked_on: null,
|
||||
},
|
||||
// ── CATALYST (table-setter, leadoff) ───────────────────────────────────
|
||||
{
|
||||
sport: 'mlb', archetype: 'CATALYST', stat: 'runs', skill: 'SPEED',
|
||||
interaction: 'speed_x_on_base_x_power_behind',
|
||||
mechanism: "A leadoff hitter's runs are mostly not his own doing: he gets on, and the power behind him drives him in. Runs scored is the least self-contained stat in the batter cluster.",
|
||||
blocked_on: 'Requires both baserunner state and lineup order — neither ingested.',
|
||||
// UNBLOCKED 2026-08-04: both halves now ingested — batting order from
|
||||
// lineup_context, RISP opportunity from hitter_opportunity.
|
||||
blocked_on: null,
|
||||
},
|
||||
// ── SINKER (ground-ball arm) — the one that is INPUT-READY ─────────────
|
||||
{
|
||||
|
||||
@@ -700,6 +700,23 @@ async function runSnapshot(sport, opts = {}) {
|
||||
console.warn(`[challenger] ${sp} skipped:`, e.message);
|
||||
}
|
||||
|
||||
// LINEUP + BASERUNNER CONTEXT — the input RBI and runs have always needed.
|
||||
// Best-effort and dated: a context failure must never break a snapshot, and a
|
||||
// lineup is a PRE-GAME fact that changes by the hour, so what we knew at grade
|
||||
// time must never be overwritten by what turned out to be true.
|
||||
if (sp === 'mlb') {
|
||||
try {
|
||||
const ctx = deps.lineupContext || require('./lineupContextService');
|
||||
const sbc = require('../utils/supabase').getSupabaseServiceClient();
|
||||
if (sbc) {
|
||||
const res = await ctx.refreshContext({ supabase: sbc, sport: 'mlb' });
|
||||
console.log(`[lineup-context] ${sp} — lineups ${res.lineups}/${res.lineup_rows ?? 0} (${res.games_with_lineups ?? 0} games), opportunity ${res.opportunity}/${res.opportunity_rows ?? 0}${res.reason ? ` reason: ${res.reason}` : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[lineup-context] skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await persistRetention(enriched);
|
||||
|
||||
// Line deltas vs the previous snapshot's locked lines.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
'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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user