#!/usr/bin/env node 'use strict'; /** * LINK 2 — can we say WHICH arm faces the later plate appearances? * * Link 1 proved, so this link is allowed to be attempted at all. It is gated the * same way: predict the reliever who actually threw a given post-starter plate * appearance, against a naive baseline, point-in-time. * * BASELINE the team's most-used reliever to date — "guess the busiest arm" * PREDICTION the reliever that team has most often used IN THIS INNING to * date, which is the cheapest expression of bullpen ROLE * * Loss is misclassification: 0 when the named arm actually threw it, 1 otherwise. * * ── THE REPLICATION UNIT IS THE BULLPEN, AND THERE ARE THIRTY ──────────── * Bullpen usage is a team-level process — the same manager, the same arms, the * same roles all season — so errors are correlated within team and the entity * this prediction rides on is the club. That caps replication at 30 whatever the * row count, exactly like park geometry and team defence. Reported explicitly * rather than dissolved into a row count of tens of thousands. * * node scripts/link2-reliever-identity.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'); /** Pick the key with the highest count; null when there is nothing to pick from. */ function argmax(counter) { let best = null; let bestN = -1; for (const [k, v] of counter) if (v > bestN) { best = k; bestN = v; } return bestN > 0 ? best : null; } function build() { const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8')); games.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk); // Point-in-time bullpen histories, accumulated as we walk forward in time. const overall = new Map(); // team -> Map(pitcherId -> appearances) const byInning = new Map(); // `team|inning` -> Map(pitcherId -> appearances) const rows = []; for (const g of games) { for (const side of ['home', 'away']) { const team = g[side].abbr || g[side].team; if (!team) continue; const starter = (g[side].arms || []).find((a) => a.started); if (!starter) continue; const relievers = new Set((g[side].arms || []).filter((a) => !a.started).map((a) => a.id)); if (!relievers.size) continue; // This side PITCHES in the opposite half-inning. const half = side === 'home' ? 'top' : 'bottom'; const post = g.pas.filter((p) => p.half === half && p.pitcher !== starter.id); for (const pa of post) { const ov = overall.get(team); const inn = byInning.get(`${team}|${pa.inning}`); const basePick = ov ? argmax(ov) : null; const modelPick = inn ? argmax(inn) : basePick; // No history yet is honestly unreadable, not a wrong guess. if (basePick === null || modelPick === null) continue; rows.push({ cluster: team, baseline: Number(basePick) === pa.pitcher ? 1 : 0, prediction: Number(modelPick) === pa.pitcher ? 1 : 0, actual: 1, inning: pa.inning, }); } // Now fold this game into history — never before predicting from it. if (!overall.has(team)) overall.set(team, new Map()); const ovm = overall.get(team); for (const r of relievers) ovm.set(String(r), (ovm.get(String(r)) || 0) + 1); for (const pa of post) { const k = `${team}|${pa.inning}`; if (!byInning.has(k)) byInning.set(k, new Map()); const m = byInning.get(k); m.set(String(pa.pitcher), (m.get(String(pa.pitcher)) || 0) + 1); } } } return rows; } (async () => { const rows = build(); 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: 'reliever_identity', archetype: null, interaction: 'link2:reliever_identity', target: 'actual_arm' }, ]); cumulative = mc.cumulative_tests; } catch { /* offline */ } const verdict = pg.adjudicate(rows, { link: 'link2_reliever_identity', loss: 'absolute', cumulativeTests: cumulative, }); const acc = (k) => (rows.length ? rows.filter((r) => r[k] === 1).length / rows.length : null); console.log(JSON.stringify({ link: 'LINK 2 — reliever identity', post_starter_plate_appearances: rows.length, distinct_bullpens: new Set(rows.map((r) => r.cluster)).size, cumulative_tests: cumulative, baseline_accuracy: round4(acc('baseline')), model_accuracy: round4(acc('prediction')), verdict, structural_note: 'the entity this prediction rides on is the BULLPEN, and there are 30 — row count cannot create replication that does not exist', }, null, 2)); process.exit(0); })(); const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);