#!/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 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({}); async function page(sb, table, select, apply) { const out = []; for (let from = 0; ; from += PAGE) { const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1); if (error) throw error; if (!data || data.length === 0) break; out.push(...data); if (data.length < PAGE) break; } return out; } async function main() { const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } }); const snaps = await page(sb, 'model_snapshots', 'player_key, game_date, archetype, stat', (q) => q.eq('sport', 'mlb').eq('stat', STAT).not('archetype', 'is', null)); const archOf = new Map(); for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype); const led = await page(sb, 'ledger_entries', '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)); 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); } main().catch((e) => { console.error(e); process.exit(1); });