#!/usr/bin/env node 'use strict'; /** * PHASE 5 — rebuild grade bands on p_win_calibrated, for DEPLOYED stats only. * * total_bases is the only stat that cleared LODO, so it is the only one whose * bands are rebuilt on calibrated values. The rest keep base-rate bands built on * raw p_win, and the reason is named rather than left to inference. * * The two-bar rule still applies and still bites: TB is now CALIBRATED but no * factor is PROVEN for it (barrel, exit velo and hard-contact-allowed were all * THEATER), so the bands remain a base-rate read — now an honestly-numbered one. */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const { createClient } = require('@supabase/supabase-js'); const cal = require('../src/services/model/calibration'); const lp = require('../src/services/model/lowParamCalibrator'); const gb = require('../src/services/model/gradeBands'); const guards = require('../src/services/model/calibrationGuards'); const tl = require('../src/services/model/testLedger'); const { knownNumber } = require('../src/utils/known'); const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json'); const STAT = process.env.BAND_STAT || 'total_bases'; const PAGE = 1000; async function page(sb, t, s, f) { const o = []; for (let i = 0; ; i += PAGE) { const { data, error } = await f(sb.from(t).select(s)).order('id', { ascending: true }).range(i, i + PAGE - 1); if (error) throw error; if (!data.length) break; o.push(...data); if (data.length < PAGE) break; } return o; } 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); }; const FIELD = { hits: (b) => b.hits, total_bases: (b) => b.totalBases, rbi: (b) => b.rbi, runs: (b) => b.runs }; (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; const snaps = await page(sb, 'model_snapshots', 'id, game_date, captured_at, stat, player_key, line, side, p_win, refused, archetype', (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.stat, 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.stat, 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 over = v > L; rows.push({ date: r.game_date, p: knownNumber(r.p_win), won: (String(r.side).toLowerCase() === 'under' ? !over : over) ? 1 : 0, archetype: String(r.archetype || 'UNLABELLED').toUpperCase(), }); } rows.sort((a, b) => String(a.date).localeCompare(String(b.date))); // Point-in-time map, then apply forward. const dates = [...new Set(rows.map((r) => r.date))].sort(); const perDate = new Map(); for (const r of rows) perDate.set(r.date, (perDate.get(r.date) || 0) + 1); let acc = 0; let cut = dates[dates.length - 1]; for (const d of dates) { acc += perDate.get(d); if (acc >= rows.length * 0.45) { cut = d; break; } } // Bands are built on the SERVED values. hits and total_bases serve the // low-parameter correction; rbi and runs serve raw, so their bands are raw. const DEPLOYED = ['hits', 'total_bases']; const fitRows = rows.filter((r) => r.date < cut); const evalRows = rows.filter((r) => r.date >= cut); let applied; let basis; if (DEPLOYED.includes(STAT)) { const model = lp.fitPlatt(fitRows); applied = (!model || model.refused) ? { ok: false, reason: 'low-parameter fit refused', rows: [] } : { ok: true, rows: evalRows.map((r) => ({ ...r, pc: lp.applyPlatt(model, r.p) })).filter((r) => r.pc != null) }; basis = 'p_win_lowparam (SERVED, provisional)'; } else { applied = { ok: true, rows: evalRows.map((r) => ({ ...r, pc: r.p })) }; basis = 'raw p_win (this stat serves raw)'; } if (!applied.ok) { console.log(JSON.stringify({ stat: STAT, refused: applied.reason })); process.exit(0); } const mc = await tl.recordAndCount(tl.supabaseStore(sb), []).catch(() => ({ cumulative_tests: 1 })); const byArch = new Map(); for (const r of applied.rows) { if (!byArch.has(r.archetype)) byArch.set(r.archetype, []); byArch.get(r.archetype).push({ p: r.pc, won: r.won }); } const out = []; for (const [arch, rs] of [...byArch.entries()].sort((a, b) => b[1].length - a[1].length)) { out.push(gb.buildBands(rs, { archetype: arch, cumulativeTests: mc.cumulative_tests, // TB is CALIBRATED (provisional) but no factor is PROVEN for it. proven: false, calibrated: DEPLOYED.includes(STAT), })); } console.log(JSON.stringify({ stat: STAT, basis, eval_rows: applied.rows.length, cumulative_tests: mc.cumulative_tests, two_bar_note: 'calibrated YES, proven NO -> bands stay a base-rate read, now honestly numbered', bands: out, }, null, 2)); process.exit(0); })().catch((e) => { console.error(e); process.exit(1); });