#!/usr/bin/env node 'use strict'; /** * LINK 1 — does a starter's exit point predict, before the game? * * Target: batters faced by the starter, because that is what decides how many of * a hitter's plate appearances come against him rather than the pen. * * ── WHAT A PRE-GAME PREDICTOR CAN AND CANNOT SEE ───────────────────────── * The order specifies fatigue profile x GAME SCRIPT ("getting hit -> pulled * early"). Game script is not available when a prop is graded: whether he gets * hit tonight is the thing we are trying to project, not an input to it. Using * it would be reading the answer. * * So the honest pre-game form of Link 1 is the fatigue and workload half alone — * the starter's own history, strictly truncated to starts BEFORE the game being * predicted. That is measured here. The in-game half is a LIVE feature, not a * grade-time one, and it is recorded as out of scope rather than quietly folded * in. * * Baseline: the league mean batters faced — the naive "a starter goes about six" * null this must beat to be worth anything. * * node scripts/link1-pull-timing.js */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const pg = require('../src/services/model/predictionGate'); const tl = require('../src/services/model/testLedger'); const { createClient } = require('@supabase/supabase-js'); const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json'); /** Starts needed before we will read a pitcher's own history at all. */ const MIN_PRIOR = 3; /** Shrinkage: how many prior starts before his own mean carries half the weight. */ const STABILIZE = 5; /** A start at or under this many batters faced is an EARLY EXIT — the edge case. */ const EARLY_BF = 20; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); function main() { const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8')); // Every starter-game, in chronological order. const starts = []; for (const g of games) { for (const side of ['home', 'away']) { const s = (g[side].arms || []).find((a) => a.started); if (!s || s.bf == null) continue; starts.push({ date: g.date, gamePk: g.gamePk, pitcher: s.id, name: s.name, bf: s.bf, outs: s.outs, pitches: s.pitches }); } } starts.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk); const leagueMean = mean(starts.map((s) => s.bf)); // POINT-IN-TIME: each start is predicted only from starts strictly before it. const history = new Map(); const rows = []; for (const s of starts) { const prior = history.get(s.pitcher) || []; if (prior.length >= MIN_PRIOR) { const own = mean(prior.map((p) => p.bf)); const w = prior.length / (prior.length + STABILIZE); rows.push({ pitcher: s.pitcher, name: s.name, cluster: s.pitcher, // his starts are not independent readings baseline: leagueMean, prediction: w * own + (1 - w) * leagueMean, actual: s.bf, prior_starts: prior.length, }); } history.set(s.pitcher, prior.concat([s])); } return { starts, leagueMean, rows }; } (async () => { const { starts, leagueMean, rows } = main(); let cumulative = 1; try { const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } }); const mc = await tl.recordAndCount(tl.supabaseStore(sb), [ { sport: 'mlb', stat: 'starter_bf', archetype: null, interaction: 'link1:pull_timing', target: 'actual_exit' }, ]); cumulative = mc.cumulative_tests; } catch { /* offline: reported below */ } const verdict = pg.adjudicate(rows, { link: 'link1_pull_timing', loss: 'absolute', cumulativeTests: cumulative, }); // Does it find the EARLY EXITS specifically? That is where the edge lives — // being right about a median start is worth nothing to this chain. const early = rows.filter((r) => r.actual <= EARLY_BF); const late = rows.filter((r) => r.actual > EARLY_BF); const predEarly = rows.filter((r) => r.prediction <= EARLY_BF + 2); const hitRate = predEarly.length ? predEarly.filter((r) => r.actual <= EARLY_BF).length / predEarly.length : null; const baseEarlyRate = rows.length ? early.length / rows.length : null; console.log(JSON.stringify({ link: 'LINK 1 — starter pull timing', starter_games_total: starts.length, league_mean_bf: round2(leagueMean), gated_rows: rows.length, distinct_pitchers: new Set(rows.map((r) => r.pitcher)).size, cumulative_tests: cumulative, verdict, early_exit_analysis: { definition: `actual batters faced <= ${EARLY_BF}`, early_exits: early.length, normal_starts: late.length, base_rate_of_early_exit: round4(baseEarlyRate), flagged_early_by_model: predEarly.length, of_those_actually_early: round4(hitRate), lift_over_base_rate: hitRate !== null && baseEarlyRate !== null ? round4(hitRate - baseEarlyRate) : null, note: 'the chain needs the EARLY tail, not the median start', }, scope_note: 'GAME SCRIPT is deliberately excluded — whether he gets hit tonight is the thing being projected, not an input available at grade time', }, null, 2)); process.exit(0); })(); const round2 = (v) => (v == null ? null : Math.round(v * 100) / 100); const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);