#!/usr/bin/env node 'use strict'; /** * build-grade-bands — publish what each letter actually means, per archetype. * * Runs the real settled ledger through gradeBands. Because no factor has passed * the gate for any archetype, every band comes back a BASE-RATE read — which is * the honest answer today, and the output states it rather than leaving a reader * to infer it. * * SUPABASE_URL=... node scripts/build-grade-bands.js */ require('dotenv').config(); const { createClient } = require('@supabase/supabase-js'); const gb = require('../src/services/model/gradeBands'); const tl = require('../src/services/model/testLedger'); const cal = require('../src/services/model/calibration'); const { knownNumber } = require('../src/utils/known'); const { paginate } = require('../src/utils/safePaginate'); const { uniqueKeyFor } = require('../src/utils/tableKeys'); const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; const STAT = process.env.BAND_STAT || 'hits'; const PAGE = 1000; /** * PROVEN, PER ARCHETYPE. Empty, and that is the measured state — see * specs/per-archetype-re-audit.md. Nothing may be added here that has not * cleared the gate FOR THAT ARCHETYPE; pooled proof does not qualify a slot. */ const PROVEN_BY_ARCHETYPE = Object.freeze({}); // ── FIX A2 (2026-08-09) — THE SAFE WALK ─────────────────────────────────── // `page()` above walks with no ORDER BY. Measured on production, that returned // the correct row COUNT and the wrong ROWS: up to 33.6% of a read came back // twice while an equal share never came back at all. `pageSafe` routes the same // call through `src/utils/safePaginate`, which orders on a UNIQUE key on every // page, verifies uniqueness at runtime, and THROWS on a query error instead of // treating it as end-of-data. // // `page()` SURVIVES only for the context tables (statcast_aggregates, // batter_spray, team_defense, platoon_splits, park_dimensions, game_context...). // Those have COMPOSITE primary keys with no single unique column, so // safePaginate cannot express them. They measure 0% corruption today; making // them safe needs a composite-key ordering the helper does not yet have. Do not // use `page()` for ledger_entries or model_snapshots. async function pageSafe(sb, table, select, apply, key = uniqueKeyFor(table)) { return paginate(() => apply(sb.from(table).select(select)), { key, pageSize: PAGE, label: `${table}` }); } /** THE MEASURED READS — main() and the harness call the same functions. */ const READS = { snaps: (sb, stat = STAT) => pageSafe(sb, 'model_snapshots', 'id, player_key, game_date, archetype, stat', (q) => q.eq('sport', 'mlb').eq('stat', stat).not('archetype', 'is', null)), ledger: (sb, stat = STAT) => pageSafe(sb, 'ledger_entries', 'id, player_key, game_date, outcome, p_win, quarantine_reason', (q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', stat) .in('outcome', ['hit', 'miss']).not('p_win', 'is', null)), }; async function main() { const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } }); const snaps = await READS.snaps(sb); const archOf = new Map(); for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype); const led = await READS.ledger(sb); const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book')); const byArch = new Map(); for (const r of clean) { const a = String(archOf.get(`${r.player_key}|${r.game_date}`) || 'UNLABELLED').toUpperCase(); if (!byArch.has(a)) byArch.set(a, []); byArch.get(a).push({ p: knownNumber(r.p_win), won: r.outcome === 'hit' ? 1 : 0 }); } const mc = await tl.recordAndCount(tl.supabaseStore(sb), [...byArch.keys()].map((a) => ({ sport: 'mlb', stat: STAT, archetype: a === 'UNLABELLED' ? null : a, interaction: 'grade_band_lift', target: 'outcome', }))); // Calibration is measured, not assumed. Today it is certified for hits only in // a middle band (specs — held-out error 0.477->0.506, 0.587->0.580), which is // NOT the same as an archetype's probabilities being calibrated. const certified = typeof cal.certifyBands === 'function'; const out = []; for (const [arch, rows] of [...byArch.entries()].sort((a, b) => b[1].length - a[1].length)) { out.push(gb.buildBands(rows, { archetype: arch, cumulativeTests: mc.cumulative_tests, proven: Boolean(PROVEN_BY_ARCHETYPE[arch]), calibrated: false, // no archetype's distribution is certified calibrated })); } console.log(JSON.stringify({ stat: STAT, settled_rows: clean.length, archetypes: byArch.size, cumulative_tests: mc.cumulative_tests, calibration_helper_present: certified, proven_by_archetype: PROVEN_BY_ARCHETYPE, note: 'every band is a BASE-RATE read — no factor has passed the gate for any archetype', bands: out, }, null, 2)); process.exit(0); } if (require.main === module) { main().catch((e) => { console.error(e); process.exit(1); }); } // Exported so the read-integrity harness measures THE REAL FUNCTION. module.exports = { READS };