#!/usr/bin/env node 'use strict'; /** * PHASES 0-1 — is the champion really worse than a frequency table? * * The prior comparison used a leave-one-out baseline that saw the evaluation * window. This one does not: for every prop, the naive forecast is that player's * rate of clearing THAT LINE over games strictly BEFORE that date — the same * temporal discipline the champion is held to. If the champion still loses, the * defect is real and not an artefact of the peek. * * Then the champion's own knobs are ablated. Its core is * * p = 0.6 * season_frequency + 0.4 * last5_frequency * * plus a +/-0.03 opponent nudge, a +/-0.015 home nudge, and a cv pull. Each is * tested for whether it COSTS resolution. This is accounting on the champion's * existing knobs, not a causal claim, so no Bonferroni slot. */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const { createClient } = require('@supabase/supabase-js'); const guards = require('../src/services/model/calibrationGuards'); const { knownNumber } = require('../src/utils/known'); const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json'); const STATS = ['hits', 'total_bases', 'rbi', 'runs']; const FIELD = { hits: (b) => b.hits, total_bases: (b) => b.totalBases, rbi: (b) => b.rbi, runs: (b) => b.runs }; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); /** Games a player needs before we will read his own rate at all. */ const MIN_PRIOR_GAMES = 10; async function page(sb, t, sel, orderBy, apply) { const out = []; for (let i = 0; ; i += 1000) { const { data, error } = await apply(sb.from(t).select(sel)).order(orderBy, { ascending: true }).range(i, i + 999); if (error) throw new Error(`${t}: ${error.message}`); if (!data || !data.length) break; out.push(...data); if (data.length < 1000) break; } return out; } const isPreGame = (c, g) => { const et = new Date(new Date(c).getTime() - 4 * 3600 * 1000); const d = et.toISOString().slice(0, 10); return d < g || (d === g && et.getUTCHours() < 19); }; function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } function resolutionOf(rows, key) { const base = mean(rows.map((r) => r.won)); let res = 0; for (let k = 0; k < 10; k += 1) { const lo = k / 10; const hi = (k + 1) / 10; const sl = rows.filter((r) => r[key] >= lo && (hi >= 1 ? r[key] <= 1 : r[key] < hi)); if (!sl.length) continue; res += (sl.length / rows.length) * (mean(sl.map((x) => x.won)) - base) ** 2; } return res; } /** Paired date-block bootstrap on a resolution difference (a − b). */ function dateBlockResCI(rows, a, b, seed) { const byDate = new Map(); for (const r of rows) { if (!byDate.has(r.date)) byDate.set(r.date, []); byDate.get(r.date).push(r); } const keys = [...byDate.keys()]; const rnd = makeRnd(seed); const d = []; for (let it = 0; it < 3000; it += 1) { const s = []; for (let i = 0; i < keys.length; i += 1) s.push(...byDate.get(keys[Math.floor(rnd() * keys.length)])); d.push(resolutionOf(s, a) - resolutionOf(s, b)); } d.sort((x, y) => x - y); return { ci: [r5(d[Math.floor(d.length * 0.025)]), r5(d[Math.floor(d.length * 0.975)])], date_blocks: keys.length }; } (async () => { const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } }); const lines = JSON.parse(fs.readFileSync(BOX, 'utf8')).lines; // Per-player, date-ordered history. The ONLY source of the naive forecast. const hist = new Map(); for (const [k, b] of Object.entries(lines)) { const [date, key] = k.split('|'); if (!hist.has(key)) hist.set(key, []); hist.get(key).push({ date, b }); } for (const v of hist.values()) v.sort((x, y) => x.date.localeCompare(y.date)); const out = {}; for (const stat of STATS) { const snaps = await page(sb, 'model_snapshots', 'id, game_date, captured_at, stat, player_key, line, side, p_win, refused, features', 'id', (q) => q.eq('sport', 'mlb').eq('stat', stat)); const picked = new Map(); for (const r of snaps) { if (!isPreGame(r.captured_at, r.game_date) || r.refused || knownNumber(r.p_win) === null) continue; const k = [r.game_date, r.player_key, r.line].join('|'); const prev = picked.get(k); if (!prev || knownNumber(r.p_win) > knownNumber(prev.p_win)) picked.set(k, r); } guards.assertPickedSideDedup([...picked.values()].map((r) => ({ propKey: [r.game_date, r.player_key, r.line].join('|'), side: r.side, p: knownNumber(r.p_win) }))); const rows = []; for (const r of picked.values()) { const b = lines[`${r.game_date}|${r.player_key}`]; const L = knownNumber(r.line); if (!b || L === null || !r.side) continue; const v = knownNumber(FIELD[stat](b)); if (v === null) continue; const isUnder = String(r.side).toLowerCase() === 'under'; const won = (isUnder ? !(v > L) : (v > L)) ? 1 : 0; // ── THE FAIR COMPETITOR: strictly prior games only. ── const prior = (hist.get(r.player_key) || []).filter((g) => g.date < r.game_date); if (prior.length < MIN_PRIOR_GAMES) continue; const vals = prior.map((g) => knownNumber(FIELD[stat](g.b))).filter((x) => x !== null); if (vals.length < MIN_PRIOR_GAMES) continue; const season = vals.filter((x) => x > L).length / vals.length; const last5 = vals.slice(-5); const recent = last5.filter((x) => x > L).length / last5.length; const f = r.features || {}; const homeAdj = f.home_away === 1.0 ? 0.015 : f.home_away === 0.0 ? -0.015 : 0; const oppR = knownNumber(f.opp_rank_stat); const oppAdj = oppR === null ? 0 : (oppR >= 0.70 ? 0.03 : (oppR <= 0.30 ? -0.03 : 0)); const flip = (p) => Math.max(0.01, Math.min(0.99, isUnder ? 1 - p : p)); const blend = (w) => flip(0.6 === null ? season : (1 - w) * season + w * recent); rows.push({ date: r.game_date, won, champion: knownNumber(r.p_win), // Reconstructions, all point-in-time. season_only: flip(season), w40: flip(0.6 * season + 0.4 * recent), // the current blend w20: flip(0.8 * season + 0.2 * recent), w60: flip(0.4 * season + 0.6 * recent), w40_nudged: flip(Math.max(0.01, Math.min(0.99, 0.6 * season + 0.4 * recent + oppAdj + homeAdj))), season_nudged: flip(Math.max(0.01, Math.min(0.99, season + oppAdj + homeAdj))), }); } if (rows.length < 100) { out[stat] = { n: rows.length, note: 'too few rows with 10+ prior games' }; continue; } // The REPAIRED champion: full-season window + recency weight 0.20, which is // exactly what the code change produces. for (const r of rows) r.repaired = r.w20; const keys = ['champion', 'repaired', 'season_only', 'w20', 'w40', 'w60', 'w40_nudged', 'season_nudged']; const res = Object.fromEntries(keys.map((k) => [k, r5(resolutionOf(rows, k))])); const gap = dateBlockResCI(rows, 'champion', 'season_only', 20260807); out[stat] = { n: rows.length, dates: new Set(rows.map((r) => r.date)).size, resolution: res, champion_minus_fair_baseline: r5(res.champion - res.season_only), ci_champion_minus_fair: gap.ci, date_blocks: gap.date_blocks, champion_loses_fairly: res.champion < res.season_only, best_variant: keys.reduce((a, k) => (res[k] > res[a] ? k : a), keys[0]), recency_cost: r5(res.w40 - res.season_only), nudge_cost: r5(res.w40_nudged - res.w40), REPAIRED_vs_fair: r5(res.repaired - res.season_only), REPAIRED_ci: dateBlockResCI(rows, 'repaired', 'season_only', 20260807).ci, REPAIRED_vs_old_champion: r5(res.repaired - res.champion), REPAIRED_beats_old_ci: dateBlockResCI(rows, 'repaired', 'champion', 20260807).ci, }; } console.log(JSON.stringify(out, null, 2)); process.exit(0); })().catch((e) => { console.error('FAILED:', e.message); process.exit(1); }); const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000);