1f40014256
routed as date-driven PHASE 0 — threshold derived BLIND, before any stat was re-read. A reversal is informative only if that date's Brier delta is distinguishable from zero at its row count. Per-row Brier difference d_i = (pc-y)^2 - (p-y)^2, so SE(n) = SD(d)/sqrt(n) and n* = (SD(d)/|effect|)^2. Pooled across all four stats so no single stat's verdict could shape the threshold deciding it: pooled rows 3,417 | SD(d) 0.09816 | |effect| 0.01175 n* = (0.09816/0.01175)^2 = 69.8 -> 70 The hand-chosen 20 sat at 0.54 SE -- a coin flip. That is the defect this removes, and why the previous verdict moved with the number. Committed as calibrationRegistry.LODO_MIN_HELD_ROWS = 70 with LODO_THRESHOLD_BASIS; a test recomputes (SD/effect)^2 and asserts it equals the constant, so it cannot drift from its own justification. The derivation script prints no stat verdict, no date and no reversal. PHASE 1 — LODO at n*, applied cold: hits 5 informative drops, 0 reversals PASS total_bases 4 informative drops, 0 reversals PASS rbi reverses 2026-08-01 (n=99) FAIL runs reverses 08-01 (n=86), 08-05 (244) FAIL hits held-out deltas -0.0041/-0.0080/-0.0192/-0.0140/-0.0139 across 123-272 row dates, favourite sign holding on every testable drop. THIS IS THE INSTRUMENT FINALLY POWERED, NOT VINDICATION OF A PREDICTION -- the withdrawal at6ae11f1was correct on the instrument available then, which admitted 20- and 25-row dates as evidence. Nothing about hits changed; the threshold stopped being chosen. PHASE 2 — both failures are DATE-DRIVEN, not underpowered. Every reversal sits above n*=70 (99, 86, 244), so no threshold and no further accrual rescues either: isotonic is fitting day-structure. Routed to the low-parameter calibrator queue (Platt/beta), not built here. PHASE 3 — CALIBRATION_DEPLOYED is now ['hits','total_bases'], frozen and tested, both PROVISIONAL with auto-demotion armed and the >=40 date-cluster promotion bar unchanged. hits stackability for chain.chainAcross is RESTORED, and the record shows it returned through the powered gate rather than by fiat. hits bands rebuilt on p_win_calibrated (765 eval rows): every archetype still one band, still base_rate -- calibrated YES, proven-per-archetype NO. PHASE 4 logged: the deploy set is now set by a power-derived, pre-committed, tested constant rather than an operator-chosen number. At6ae11f1that rule moved the live path AGAINST the operator; it has now moved it back on the same evidence because the instrument changed. Both directions are the rule working. And calibrated p_win separates within archetype no better than raw across 13 archetype slots on two deployed stats -- per-archetype separation will come from proven factors or not at all. p_win never mutated; no Bonferroni slot consumed; counter and frozen clusters verified byte-identical file by file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
200 lines
8.6 KiB
JavaScript
200 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* lodo-calibration — Phases 2 and 3.
|
|
*
|
|
* The ≥40 date-cluster floor was factorGate's interval bar for a CAUSAL claim,
|
|
* mis-applied to a monotone shrink-to-observed layer. Calibration makes no causal
|
|
* claim, consumes no Bonferroni slot, and has a bounded failure mode (it can only
|
|
* over- or under-shrink). Its real risk is that the correction is DATE-DRIVEN —
|
|
* that one unusual day's offensive environment is doing all the work.
|
|
*
|
|
* Leave-one-date-out tests exactly that, and it is a harder bar than a cluster
|
|
* count: a single date whose removal reverses the improvement, or flips the
|
|
* favourite-longshot sign, fails the stat outright.
|
|
*
|
|
* ── WHAT LODO IS AND IS NOT ──────────────────────────────────────────────
|
|
* Refitting on all-but-one date uses dates that follow the held-out one, so this
|
|
* is a STABILITY test, not a point-in-time backtest. The point-in-time result is
|
|
* separate and already established (fit-past / apply-forward, CI excluding zero
|
|
* on hits / TB / RBI). Both are required; neither substitutes for the other.
|
|
*
|
|
* SUPABASE_URL=... node scripts/lodo-calibration.js
|
|
*/
|
|
|
|
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 guards = require('../src/services/model/calibrationGuards');
|
|
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 BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
|
|
const STATS = ['hits', 'total_bases', 'rbi', 'runs'];
|
|
const PAGE = 1000;
|
|
/** The favourite bucket where the over-prediction concentrates. */
|
|
const FAVOURITE_FLOOR = 0.9;
|
|
/**
|
|
* Minimum rows on a held-out date for that drop to be informative.
|
|
*
|
|
* POWER-DERIVED AND PRE-COMMITTED (n* = 70). Not chosen here, and not tunable
|
|
* from here -- it is imported so the value that decides the verdicts cannot be
|
|
* edited alongside them.
|
|
*/
|
|
const { LODO_MIN_HELD_ROWS: MIN_HELD_ROWS } = require('../src/services/model/calibrationRegistry');
|
|
|
|
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);
|
|
|
|
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))
|
|
.order('id', { ascending: true }).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;
|
|
}
|
|
|
|
const isPreGame = (capturedAt, gameDate) => {
|
|
const et = new Date(new Date(capturedAt).getTime() - 4 * 3600 * 1000);
|
|
const d = et.toISOString().slice(0, 10);
|
|
return d < gameDate || (d === gameDate && et.getUTCHours() < 19);
|
|
};
|
|
|
|
async function main() {
|
|
const sb = createClient(SB_URL, SB_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',
|
|
(q) => q.eq('sport', 'mlb').in('stat', STATS));
|
|
|
|
// Build the RAW population first so the guard has something to catch.
|
|
const raw = [];
|
|
const picked = new Map();
|
|
for (const r of snaps) {
|
|
if (!isPreGame(r.captured_at, r.game_date)) continue;
|
|
if (r.refused || knownNumber(r.p_win) === null) continue;
|
|
const propKey = [r.game_date, r.stat, r.player_key, r.line].join('|');
|
|
raw.push({ propKey, side: r.side, p: knownNumber(r.p_win) });
|
|
const prev = picked.get(propKey);
|
|
if (!prev || knownNumber(r.p_win) > knownNumber(prev.p_win)) picked.set(propKey, r);
|
|
}
|
|
|
|
// GUARD 1 — prove the raw population would have lied, then prove dedup fixes it.
|
|
const rawCheck = guards.checkPickedSideDedup(raw);
|
|
const pickedRows = [...picked.values()].map((r) => ({
|
|
propKey: [r.game_date, r.stat, r.player_key, r.line].join('|'),
|
|
side: r.side, p: knownNumber(r.p_win),
|
|
}));
|
|
guards.assertPickedSideDedup(pickedRows); // throws if dedup failed
|
|
|
|
const out = {
|
|
guard_1_raw_population: { violated: rawCheck.violated, mean_p: rawCheck.mean_p, both_sides_share: rawCheck.both_sides_share },
|
|
guard_1_after_dedup: guards.checkPickedSideDedup(pickedRows),
|
|
per_stat: {},
|
|
};
|
|
|
|
for (const stat of STATS) {
|
|
const rows = [];
|
|
for (const r of picked.values()) {
|
|
if (r.stat !== stat) continue;
|
|
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,
|
|
});
|
|
}
|
|
const dates = [...new Set(rows.map((r) => r.date))].sort();
|
|
|
|
const full = cal.fitIsotonic(rows.map((r) => ({ p: r.p, won: r.won })));
|
|
if (!full) {
|
|
out.per_stat[stat] = {
|
|
n: rows.length, dates: dates.length,
|
|
lodo: 'NOT RUN', decision: 'REFUSE',
|
|
reason: `no isotonic map is fittable at n=${rows.length} (needs ${cal.MIN_TOTAL || 200})`,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
// ── LEAVE ONE DATE OUT ──
|
|
const table = [];
|
|
for (const d of dates) {
|
|
const fit = rows.filter((r) => r.date !== d);
|
|
const held = rows.filter((r) => r.date === d);
|
|
if (held.length < MIN_HELD_ROWS) {
|
|
table.push({ dropped: d, held_n: held.length, verdict: 'UNINFORMATIVE', reason: 'too few rows on this date' });
|
|
continue;
|
|
}
|
|
const map = cal.fitIsotonic(fit.map((r) => ({ p: r.p, won: r.won })));
|
|
const applied = guards.applyOrRefuse(map, held, cal.applyIsotonic);
|
|
if (!applied.ok) {
|
|
table.push({ dropped: d, held_n: held.length, verdict: 'UNINFORMATIVE', reason: applied.reason });
|
|
continue;
|
|
}
|
|
const ys = applied.rows.map((r) => r.won);
|
|
const bRaw = guards.safeBrier(applied.rows.map((r) => r.p), ys);
|
|
const bCal = guards.safeBrier(applied.rows.map((r) => r.pc), ys);
|
|
if (bRaw === null || bCal === null) {
|
|
table.push({ dropped: d, held_n: held.length, verdict: 'UNINFORMATIVE', reason: 'a null reached the metric' });
|
|
continue;
|
|
}
|
|
const fav = applied.rows.filter((r) => r.p >= FAVOURITE_FLOOR);
|
|
const favBias = fav.length >= 5 ? mean(fav.map((r) => r.p)) - mean(fav.map((r) => r.won)) : null;
|
|
table.push({
|
|
dropped: d,
|
|
held_n: held.length,
|
|
brier_delta: round4(bCal - bRaw),
|
|
improves: bCal < bRaw,
|
|
favourite_n: fav.length,
|
|
favourite_bias: favBias === null ? null : round4(favBias),
|
|
favourite_sign_holds: favBias === null ? null : favBias > 0,
|
|
verdict: bCal < bRaw ? 'holds' : 'REVERSES',
|
|
});
|
|
}
|
|
|
|
const informative = table.filter((t) => t.verdict !== 'UNINFORMATIVE');
|
|
const anyReversal = informative.some((t) => t.verdict === 'REVERSES');
|
|
const signTested = informative.filter((t) => t.favourite_sign_holds !== null);
|
|
const anySignFlip = signTested.some((t) => t.favourite_sign_holds === false);
|
|
const passes = informative.length > 0 && !anyReversal && !anySignFlip;
|
|
|
|
out.per_stat[stat] = {
|
|
n: rows.length,
|
|
dates: dates.length,
|
|
lodo_table: table,
|
|
informative_drops: informative.length,
|
|
brier_reversals: informative.filter((t) => t.verdict === 'REVERSES').length,
|
|
favourite_sign_flips: signTested.filter((t) => t.favourite_sign_holds === false).length,
|
|
favourite_sign_untested: informative.length - signTested.length,
|
|
lodo: passes ? 'PASS' : 'FAIL',
|
|
reason: passes
|
|
? 'improvement never reverses and the favourite over-prediction never flips sign across any single-date drop'
|
|
: (anyReversal
|
|
? `improvement reverses when ${informative.filter((t) => t.verdict === 'REVERSES').map((t) => t.dropped).join(', ')} is dropped — the effect is date-driven`
|
|
: `the favourite over-prediction flips sign when ${signTested.filter((t) => t.favourite_sign_holds === false).map((t) => t.dropped).join(', ')} is dropped`),
|
|
};
|
|
}
|
|
|
|
console.log(JSON.stringify(out, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|