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,
|
||||
};
|
||||
Reference in New Issue
Block a user