#!/usr/bin/env node 'use strict'; /** * THE COLLAPSED SEQUENCE EDGE — Link 1 x pen-season-quality, on later at-bats. * * Link 3 is correctly skipped: reliever IDENTITY did not prove and is genuine * baseball unpredictability. But Link 2's QUALITY grain DID prove, so pen quality * here is a measured predictor rather than a fallback. * * ── THE MECHANICAL CEILING, MEASURED FIRST ─────────────────────────────── * A hitter's third or fourth plate appearance is ALREADY against the bullpen * 70-73% of the time even when the starter is projected to go deep. An elevated * early-exit flag lifts that to only 77-83%. So Link 1 buys roughly TEN POINTS * of extra pen exposure, not a switch from starter to pen — and any adjustment * built on it is bounded at about a tenth of the starter-versus-pen quality gap. * That ceiling is a property of baseball, not of the model, and it is the reason * the deltas below are small before anything is even fitted. * * ── WHAT IS ADJUSTED, AND WHAT IS REFUSED ──────────────────────────────── * The order specifies pen-quality x pen-ARCHETYPE x hitter-APPROACH. Two of * those three cannot be used honestly: * * pen archetype did NOT prove (0.5669 vs a 0.5309 modal baseline, corrected * interval spanning zero). Building it into the adjustment * would be chaining on an unproven link. * hitter approach "fastball-hunter" / "finesse-vulnerable" identities do not * exist in this registry. MLB batter archetypes are BOMBER / * GHOST / TORCH / BRUSH / DRIVER / FLEX / ALPHA / HYBRID / * CATALYST. Inventing an identity to condition on would be * fabricating the very thing the gate exists to catch. * * So the adjustment uses the PROVEN component alone, and a hitter split derived * from the sequence data itself (power vs contact by home-run rate) is tested as * a SEPARATE gated addition rather than assumed into the main effect. * * node scripts/collapsed-sequence-edge.js */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const fg = require('../src/services/model/factorGate'); const tl = require('../src/services/model/testLedger'); const pq = require('../src/services/model/penQuality'); const { createClient } = require('@supabase/supabase-js'); const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json'); const HIT = new Set(['single', 'double', 'triple', 'home_run']); const PA = new Set(['single', 'double', 'triple', 'home_run', 'field_out', 'strikeout', 'grounded_into_double_play', 'force_out', 'field_error', 'fielders_choice', 'fielders_choice_out', 'double_play', 'sac_fly', 'pop_out', 'line_out', 'fly_out', 'strikeout_double_play']); const MIN_ARM_PA = 40; const MIN_PRIOR_GAMES = 5; const MIN_HITTER_PA = 60; const MIN_PRIOR_STARTS = 3; const EARLY_FLAG_BF = 22; const LEAGUE_BF = 21.56; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 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); const arm = new Map(); const bat = new Map(); // hitter -> { n, h, hr } const penHist = new Map(); const startHist = new Map(); const rows = []; for (const g of games) { for (const side of ['home', 'away']) { const team = g[side].abbr || g[side].team; const st = (g[side].arms || []).find((a) => a.started); if (!team || !st) continue; const half = side === 'home' ? 'top' : 'bottom'; const pas = g.pas.filter((p) => p.half === half && PA.has(p.event)); const ps = startHist.get(st.id) || []; let predBf = null; if (ps.length >= MIN_PRIOR_STARTS) { const w = ps.length / (ps.length + 5); predBf = w * mean(ps) + (1 - w) * LEAGUE_BF; } const hist = penHist.get(team) || []; const pen = pq.projectPen(hist.map((q) => ({ quality: q }))); const seen = new Map(); for (const p of pas) { const k = p.batter; seen.set(k, (seen.get(k) || 0) + 1); const paNum = seen.get(k); const b = bat.get(k); // knownRate abstain: no readable hitter, starter or pen -> no row at all. if (paNum < 3 || predBf === null || !pen || !b || b.n < MIN_HITTER_PA) continue; rows.push({ gamePk: g.gamePk, cluster: g.gamePk, batter: k, paNum, early: predBf <= EARLY_FLAG_BF, pen_quality: pen.quality, hitter_base: b.h / b.n, hitter_hr_rate: b.hr / b.n, won: HIT.has(p.event) ? 1 : 0, }); } const faced = []; for (const p of pas.filter((x) => x.pitcher !== st.id)) { const h = arm.get(p.pitcher); if (h && h.n >= MIN_ARM_PA) faced.push(h.h / h.n); } if (faced.length) penHist.set(team, hist.concat([mean(faced)])); if (st.bf != null) startHist.set(st.id, ps.concat([st.bf])); for (const p of pas) { const c = arm.get(p.pitcher) || { n: 0, h: 0, k: 0 }; c.n += 1; c.h += HIT.has(p.event) ? 1 : 0; c.k += p.event === 'strikeout' ? 1 : 0; arm.set(p.pitcher, c); } for (const p of pas) { const c = bat.get(p.batter) || { n: 0, h: 0, hr: 0 }; c.n += 1; c.h += HIT.has(p.event) ? 1 : 0; c.hr += p.event === 'home_run' ? 1 : 0; bat.set(p.batter, c); } } } return rows; } /** The adjustment: the hitter's own rate, shifted by the PROVEN pen signal. */ const adjust = (r) => { const shift = pq.hitRateShift(r.pen_quality); if (shift === null) return null; return Math.max(0.01, Math.min(0.99, r.hitter_base + shift)); }; (async () => { const all = build(); const qs = all.map((r) => r.pen_quality).sort((a, b) => a - b); const weakCut = qs[Math.floor(qs.length * 2 / 3)]; const strongCut = qs[Math.floor(qs.length / 3)]; const subsets = { // The order's concentrated subset. concentrated_early_x_weak_pen: all.filter((r) => r.early && r.pen_quality >= weakCut), // The mirror, where the descriptive pass suggested the larger movement. mirror_early_x_strong_pen: all.filter((r) => r.early && r.pen_quality <= strongCut), // Every later at-bat with an early-exit flag, both directions of pen quality. all_early_exit_later_abs: all.filter((r) => r.early), pooled_all_later_abs: all, }; 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), Object.keys(subsets).map((k) => ({ sport: 'mlb', stat: 'hits', archetype: null, interaction: `collapsed_sequence:${k}`, target: 'later_ab_outcome', }))); cumulative = mc.cumulative_tests; } catch { /* offline */ } const gate = (rs, label) => fg.adjudicate( rs.map((r) => ({ cluster: r.cluster, baseline: r.hitter_base, conditioned: adjust(r), won: r.won })) .filter((r) => r.conditioned !== null), { factor: label, stat: 'hits', cumulativeTests: cumulative }, ); const results = {}; for (const [k, rs] of Object.entries(subsets)) results[k] = gate(rs, k); // Hitter split as a SEPARATE gated addition — never assumed into the main effect. const conc = subsets.concentrated_early_x_weak_pen; const hrs = conc.map((r) => r.hitter_hr_rate).sort((a, b) => a - b); const hrCut = hrs[Math.floor(hrs.length / 2)]; const bySplit = { power_hitters: gate(conc.filter((r) => r.hitter_hr_rate >= hrCut), 'concentrated_power'), contact_hitters: gate(conc.filter((r) => r.hitter_hr_rate < hrCut), 'concentrated_contact'), }; console.log(JSON.stringify({ later_at_bats_readable: all.length, cumulative_tests: cumulative, subset_sizes: Object.fromEntries(Object.entries(subsets).map(([k, v]) => [k, v.length])), gate: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { n: v.movement.n, clusters: v.improvement ? v.improvement.effective_n : null, mean_abs_shift: v.movement.mean_abs_shift, brier_delta: v.improvement ? v.improvement.brier_delta : null, ci: v.improvement ? v.improvement.ci : null, verdict: v.verdict, }])), hitter_split_separate_gate: Object.fromEntries(Object.entries(bySplit).map(([k, v]) => [k, { n: v.movement.n, brier_delta: v.improvement ? v.improvement.brier_delta : null, ci: v.improvement ? v.improvement.ci : null, verdict: v.verdict, }])), refused: { pen_archetype: 'did not prove at the corrected bar — excluded from the adjustment', hitter_approach_identity: 'SPRAY / fastball-hunter identities do not exist in this registry', link3_per_reliever: 'SKIPPED — reliever identity is genuine baseball unpredictability', }, }, null, 2)); process.exit(0); })();