'use strict'; /** * calibrationDuel — the forward adjudication of a bet we made against the * measurement. * * On identical held-out rows the ISOTONIC map beat the low-parameter one (hits * +0.0028, rbi +0.0042, total_bases tied). We serve the low-parameter map * anyway, on the argument that isotonic's in-window edge is daily structure * shared between the fit and evaluation windows. At 19 dates that argument * cannot be tested — LODO has 1.4-9.3% power against it. * * So it is a BET. This module is what makes it falsifiable: both maps are * computed on every prop, the shadow is logged, and once enough genuinely * out-of-window dates settle, the season adjudicates. * * ── THE RULE IS PRE-REGISTERED, IN CODE ────────────────────────────────── * Written before any forward date exists, so the bar cannot drift toward * whichever answer arrives: * * REFUTED >=10 forward dates AND isotonic beats low-param with a date-block * bootstrap CI excluding zero -> revert hits/TB to isotonic * UPHELD >=10 forward dates and it does not -> the bet was right * PENDING fewer than 10 forward dates -> no verdict, keep serving * * A date is FORWARD only if NEITHER map was fitted on it. Scoring on a date * inside either fit window would be asking which map memorised better. */ const { knownNumber } = require('../../utils/known'); /** Forward dates required before the duel may return a verdict. */ const MIN_FORWARD_DATES = 10; const ITERS = 4000; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } const brier = (rows, key) => { const usable = rows.filter((r) => knownNumber(r[key]) !== null && knownNumber(r.won) !== null); if (!usable.length) return null; return mean(usable.map((r) => (knownNumber(r[key]) - knownNumber(r.won)) ** 2)); }; /** * @param {Array} rows [{ date, won, served, shadow, fitted_through }] * @param {object} opts { minForwardDates, seed } */ function adjudicate(rows, opts = {}) { const minDates = opts.minForwardDates ?? MIN_FORWARD_DATES; // FORWARD ONLY: a row counts when its date postdates the window BOTH maps // were fitted on. Rows without that provenance are dropped, never assumed. const forward = (rows || []).filter((r) => { if (!r || !r.date) return false; if (knownNumber(r.served) === null || knownNumber(r.shadow) === null) return false; if (knownNumber(r.won) === null) return false; if (!r.fitted_through) return false; return String(r.date) > String(r.fitted_through); }); const dates = [...new Set(forward.map((r) => String(r.date)))].sort(); if (dates.length < minDates) { return { verdict: 'PENDING', forward_dates: dates.length, forward_rows: forward.length, dates_needed: minDates - dates.length, reason: `${dates.length} forward dates < ${minDates} — the season has not spoken yet`, action: 'keep serving the low-parameter map', }; } const bServed = brier(forward, 'served'); const bShadow = brier(forward, 'shadow'); if (bServed === null || bShadow === null) { return { verdict: 'PENDING', forward_dates: dates.length, reason: 'no scorable forward rows' }; } // Paired date-block bootstrap on (isotonic - lowparam). Negative means the // shadow is better, which is the direction that refutes us. const byDate = new Map(); for (const r of forward) { if (!byDate.has(String(r.date))) byDate.set(String(r.date), []); byDate.get(String(r.date)).push(r); } const keys = [...byDate.keys()]; const rnd = makeRnd(opts.seed ?? 20260807); const diffs = []; for (let it = 0; it < ITERS; it += 1) { const s = []; for (let i = 0; i < keys.length; i += 1) s.push(...byDate.get(keys[Math.floor(rnd() * keys.length)])); const a = brier(s, 'shadow'); const b = brier(s, 'served'); if (a === null || b === null) continue; diffs.push(a - b); } diffs.sort((a, b) => a - b); const ci = diffs.length ? [round5(diffs[Math.floor(diffs.length * 0.025)]), round5(diffs[Math.floor(diffs.length * 0.975)])] : null; const shadowWins = ci !== null && ci[1] < 0; return { verdict: shadowWins ? 'REFUTED' : 'UPHELD', forward_dates: dates.length, forward_rows: forward.length, brier_served_lowparam: round5(bServed), brier_shadow_isotonic: round5(bShadow), delta_isotonic_minus_lowparam: round5(bShadow - bServed), ci, reason: shadowWins ? 'isotonic beats the served low-parameter map out-of-window with a date-block interval excluding zero — the capacity argument is refuted' : 'the served low-parameter map is not beaten out-of-window — the bet stands', action: shadowWins ? 'REVERT hits and total_bases to isotonic and log the reversal' : 'keep serving the low-parameter map', }; } const round5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000); module.exports = { adjudicate, MIN_FORWARD_DATES };