e4dae0e6b0
The causal insight is right -- the game is a sequence and the matchup does shift mid-game. The direction is backwards, measured on 93,663 plate appearances from 1,238 games pulled free from statsapi. LINK 1 PROVES. Starter batters-faced, point-in-time from his own prior starts only, clustered on the pitcher: MAE 3.2226 -> 2.7990, delta -0.4236, CI [-0.6006,-0.2731] at 0.9995 corrected for 107 tests, 1,706 starts across 204 pitchers. It finds the tail the chain needed -- early exits are a 23.2% base rate, model-flagged starts are 34.0% early, lift +10.8pp. Scope correction inside Link 1: the order specifies fatigue x GAME SCRIPT, but game script is not available at grade time -- whether he gets hit tonight is the thing being projected, not an input to it. Only the workload half is measured; the in-game half is recorded as a live feature, out of scope, rather than quietly folded in. LINK 2 DOES NOT PROVE, twice over. Model accuracy 17.2% vs an 8.6% baseline -- doubling it sounds good and is not, since naming a specific arm is wrong five times in six. And structurally the entity is the BULLPEN: 39,629 post-starter plate appearances across 30 clubs is 30 readings, below the 40-cluster floor, the same permanent ceiling as park geometry and team defence. LINK 3 NOT RUN, per the order's own rule. THE PREMISE IS REFUTED, and this chains on nothing so it was safe to measure: vs STARTER n=48,492 hit rate 0.2444 +/-0.0038 vs BULLPEN n=35,760 hit rate 0.2373 +/-0.0044 The pen is 0.7pp HARDER. The specific effect the chain exists to exploit -- early exit making later at-bats softer -- is +0.0010 on 35,760 PAs. A well-powered null, not a sample problem. What IS real is times through the order: TTO1 0.2351 -> TTO2 0.2515 -> TTO3 0.2518. A starter does decay as the lineup sees him again, but that advantage is SURRENDERED when he leaves, not extended -- the pen is harder than his second and third time through. A modern bullpen is a queue of fresh specialists throwing one inning each; there is no tiring arm to punish. So the insight survives inverted, and Link 1 stays valuable for the opposite reason it was built: a likely early hook predicts the hitter LOSES his third-time-through look (0.2518 -> 0.2373 on that PA). The mispricing is on hitters who get an EXTRA look at a starter going deep. BUILT: predictionGate.js + tests -- the two-part gate for a continuous prediction. factorGate binarises outcomes for Brier, which would destroy a target like batters faced. Same discipline, same THEATER verdict, real scale. PRE-REGISTERED NOT RUN: Link 2' using a PA-weighted bullpen AGGREGATE rather than a named arm. Recorded rather than substituted in -- running Link 3 on a swapped-in Link 2 is the assumed-link failure the order forbids. Given the premise result its expected value is now low. PARALLEL TRACK logged: total_bases n=948 pooled, BOMBER x TB 340, short by 160. Sample-readiness only, not a verdict. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
147 lines
4.8 KiB
JavaScript
147 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* ingest-game-sequences — the raw material for the reliever chain.
|
|
*
|
|
* Every link in this chain needs something the ledger does not carry: when the
|
|
* starter actually left, and which arm actually faced each plate appearance.
|
|
* Both are free from statsapi (playByPlay + boxscore), on the same host we
|
|
* already use for game logs and schedules.
|
|
*
|
|
* Caches to disk so Link 1, 2 and 3 all read one fetch rather than three.
|
|
*
|
|
* node scripts/ingest-game-sequences.js # default window
|
|
* SEQ_FROM=2026-06-01 SEQ_TO=2026-08-04 node scripts/ingest-game-sequences.js
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const axios = require('axios');
|
|
|
|
const FROM = process.env.SEQ_FROM || '2026-06-15';
|
|
const TO = process.env.SEQ_TO || '2026-08-04';
|
|
const OUT = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
|
const CONCURRENCY = 6;
|
|
|
|
const get = async (url) => (await axios.get(url, { timeout: 45_000 })).data;
|
|
|
|
/** Innings pitched come as '5.2' meaning five and TWO THIRDS — parseFloat is wrong. */
|
|
function ipToOuts(ip) {
|
|
if (ip == null) return null;
|
|
const [whole, frac] = String(ip).split('.');
|
|
const w = Number(whole); const f = Number(frac || 0);
|
|
if (!Number.isFinite(w)) return null;
|
|
return w * 3 + (Number.isFinite(f) ? f : 0);
|
|
}
|
|
|
|
function datesBetween(from, to) {
|
|
const out = [];
|
|
const d = new Date(`${from}T12:00:00Z`);
|
|
const end = new Date(`${to}T12:00:00Z`);
|
|
while (d <= end) { out.push(d.toISOString().slice(0, 10)); d.setUTCDate(d.getUTCDate() + 1); }
|
|
return out;
|
|
}
|
|
|
|
async function pool(items, fn, n = CONCURRENCY) {
|
|
const out = []; let i = 0;
|
|
await Promise.all(Array.from({ length: n }, async () => {
|
|
while (i < items.length) {
|
|
const idx = i; i += 1;
|
|
try { out[idx] = await fn(items[idx]); } catch { out[idx] = null; }
|
|
}
|
|
}));
|
|
return out.filter(Boolean);
|
|
}
|
|
|
|
async function loadGame(g) {
|
|
const pk = g.gamePk;
|
|
const [box, pbp] = await Promise.all([
|
|
get(`https://statsapi.mlb.com/api/v1/game/${pk}/boxscore`),
|
|
get(`https://statsapi.mlb.com/api/v1/game/${pk}/playByPlay`),
|
|
]);
|
|
|
|
const sides = {};
|
|
for (const side of ['home', 'away']) {
|
|
const t = box.teams[side];
|
|
if (!t) return null;
|
|
const arms = (t.pitchers || []).map((id) => {
|
|
const pl = t.players[`ID${id}`];
|
|
const s = pl && pl.stats && pl.stats.pitching;
|
|
if (!s) return null;
|
|
return {
|
|
id: Number(id),
|
|
name: pl.person && pl.person.fullName,
|
|
started: Number(s.gamesStarted || 0) === 1,
|
|
bf: s.battersFaced == null ? null : Number(s.battersFaced),
|
|
outs: ipToOuts(s.inningsPitched),
|
|
pitches: s.pitchesThrown == null ? null : Number(s.pitchesThrown),
|
|
};
|
|
}).filter(Boolean);
|
|
sides[side] = { team: t.team && t.team.name, abbr: t.team && t.team.abbreviation, arms };
|
|
}
|
|
|
|
// Every plate appearance in order, with who threw it.
|
|
const pas = [];
|
|
for (const p of pbp.allPlays || []) {
|
|
const m = p.matchup || {}; const a = p.about || {};
|
|
if (!m.batter || !m.pitcher) continue;
|
|
pas.push({
|
|
batter: Number(m.batter.id),
|
|
batter_name: m.batter.fullName,
|
|
pitcher: Number(m.pitcher.id),
|
|
bats: m.batSide && m.batSide.code,
|
|
throws: m.pitchHand && m.pitchHand.code,
|
|
inning: a.inning,
|
|
half: a.halfInning,
|
|
idx: a.atBatIndex,
|
|
event: p.result && p.result.eventType,
|
|
});
|
|
}
|
|
if (!pas.length) return null;
|
|
|
|
return {
|
|
gamePk: pk,
|
|
date: g.officialDate || (g.gameDate || '').slice(0, 10),
|
|
venue_id: g.venue && g.venue.id,
|
|
home: sides.home, away: sides.away,
|
|
pas,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const dates = datesBetween(FROM, TO);
|
|
console.error(`[seq] ${dates.length} dates ${FROM} -> ${TO}`);
|
|
|
|
const allGames = [];
|
|
for (const d of dates) {
|
|
try {
|
|
const s = await get(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue`);
|
|
for (const day of s.dates || []) {
|
|
for (const g of day.games || []) {
|
|
if (String(g.status && g.status.detailedState) === 'Final') allGames.push(g);
|
|
}
|
|
}
|
|
} catch { /* absent day */ }
|
|
}
|
|
console.error(`[seq] ${allGames.length} final games; fetching sequences`);
|
|
|
|
const games = await pool(allGames, loadGame);
|
|
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
|
fs.writeFileSync(OUT, JSON.stringify({ from: FROM, to: TO, games }));
|
|
|
|
const starters = games.reduce((s, g) =>
|
|
s + ['home', 'away'].filter((k) => g[k].arms.some((a) => a.started)).length, 0);
|
|
console.log(JSON.stringify({
|
|
dates: dates.length,
|
|
final_games: allGames.length,
|
|
games_loaded: games.length,
|
|
starter_games: starters,
|
|
plate_appearances: games.reduce((s, g) => s + g.pas.length, 0),
|
|
cache: OUT,
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|