diff --git a/scripts/rebuild-tb-bands.js b/scripts/rebuild-tb-bands.js index 58f54a0..6626172 100644 --- a/scripts/rebuild-tb-bands.js +++ b/scripts/rebuild-tb-bands.js @@ -18,6 +18,7 @@ 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'); @@ -86,8 +87,23 @@ const FIELD = { hits: (b) => b.hits, total_bases: (b) => b.totalBases, rbi: (b) 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; } } - const map = cal.fitIsotonic(rows.filter((r) => r.date < cut).map((r) => ({ p: r.p, won: r.won }))); - const applied = guards.applyOrRefuse(map, rows.filter((r) => r.date >= cut), cal.applyIsotonic); + // 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 })); @@ -105,13 +121,13 @@ const FIELD = { hits: (b) => b.hits, total_bases: (b) => b.totalBases, rbi: (b) cumulativeTests: mc.cumulative_tests, // TB is CALIBRATED (provisional) but no factor is PROVEN for it. proven: false, - calibrated: true, + calibrated: DEPLOYED.includes(STAT), })); } console.log(JSON.stringify({ stat: STAT, - basis: 'p_win_calibrated (PROVISIONAL)', + 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', diff --git a/scripts/resolution-diagnosis.js b/scripts/resolution-diagnosis.js new file mode 100644 index 0000000..6577d07 --- /dev/null +++ b/scripts/resolution-diagnosis.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +'use strict'; + +/** + * PHASE 2 — put a number on the resolution ceiling. + * + * Murphy's decomposition: Brier = reliability - resolution + uncertainty. + * + * reliability how far each bin's realized rate sits from its forecast (lower + * is better; this is what calibration fixes) + * resolution how far the bins' realized rates spread from the base rate + * (HIGHER is better; this is discrimination, and NO amount of + * calibration can create it) + * uncertainty the base rate's own variance -- a property of the event + * + * Calibration moves reliability and leaves resolution untouched by construction: + * a monotone map relabels bins without re-sorting the rows inside them. So if + * resolution is near zero, honest numbers are all calibration can ever deliver. + */ + +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { createClient } = require('@supabase/supabase-js'); +const lp = require('../src/services/model/lowParamCalibrator'); +const guards = require('../src/services/model/calibrationGuards'); +const { knownNumber } = require('../src/utils/known'); + +const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json'); +const STATS = ['hits', 'total_bases', 'rbi', 'runs']; +const DEPLOYED = ['hits', 'total_bases']; +const PAGE = 1000; +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, 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); +}; + +/** Murphy decomposition over K equal-width bins. */ +function decompose(rows, bins = 10) { + const base = mean(rows.map((r) => r.won)); + const uncertainty = base * (1 - base); + let reliability = 0; let resolution = 0; + const table = []; + for (let k = 0; k < bins; k += 1) { + const lo = k / bins; const hi = (k + 1) / bins; + const slice = rows.filter((r) => r.p >= lo && (hi >= 1 ? r.p <= 1 : r.p < hi)); + if (!slice.length) continue; + const w = slice.length / rows.length; + const fk = mean(slice.map((r) => r.p)); + const ok = mean(slice.map((r) => r.won)); + reliability += w * (fk - ok) ** 2; + resolution += w * (ok - base) ** 2; + table.push({ bin: [round2(lo), round2(hi)], n: slice.length, forecast: round4(fk), realized: round4(ok) }); + } + return { + base_rate: round4(base), + reliability: round5(reliability), + resolution: round5(resolution), + uncertainty: round5(uncertainty), + brier_check: round5(reliability - resolution + uncertainty), + /** What share of the event's variance the model actually explains. */ + resolution_share_of_uncertainty: round4(resolution / uncertainty), + bins: table, + }; +} + +(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', + (q) => q.eq('sport', 'mlb').in('stat', STATS)); + + 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 out = {}; + 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 }); + } + if (rows.length < 100) continue; + + const raw = decompose(rows); + let served = null; + if (DEPLOYED.includes(stat)) { + const m = lp.fitPlatt(rows); + if (m && !m.refused) { + const cal = rows.map((r) => ({ ...r, p: lp.applyPlatt(m, r.p) })).filter((r) => knownNumber(r.p) !== null); + served = decompose(cal); + } + } + out[stat] = { + n: rows.length, + deployed: DEPLOYED.includes(stat), + raw, + served, + resolution_change_from_calibration: served ? round5(served.resolution - raw.resolution) : null, + reliability_change_from_calibration: served ? round5(served.reliability - raw.reliability) : null, + }; + } + console.log(JSON.stringify(out, null, 2)); + process.exit(0); +})().catch((e) => { console.error(e); process.exit(1); }); + +const round5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000); +const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000); +const round2 = (v) => Math.round(v * 100) / 100; diff --git a/specs/resolution-ceiling-and-the-duel.md b/specs/resolution-ceiling-and-the-duel.md new file mode 100644 index 0000000..a1477f8 --- /dev/null +++ b/specs/resolution-ceiling-and-the-duel.md @@ -0,0 +1,156 @@ +# The resolution ceiling — calibration is complete, and it was never the lever + +## PHASE 0 — two honest truths, on record + +### 1. The swap is a BET, not an OOS win + +On identical held-out rows the **isotonic map scored BETTER**: hits +0.0028 +(CI [0.0013, 0.0045]), rbi +0.0042 (CI [0.0003, 0.0092]), total_bases tied. + +We serve the low-parameter map anyway, on the untestable prior that isotonic's +in-window edge is daily structure shared between the fit and evaluation windows +and will not transmit forward. At 19 dates **no instrument here can test that +prior** — LODO has 1.4–9.3% power against it. + +**Named as a bet, logged, not evidence.** Phase 1 makes it falsifiable. + +### 2. The MIN_SLOPE catch, as a standing guard rationale + +`runs` fitted `a = −0.032`. A near-zero or negative slope collapses the curve +toward *base-rate-for-everything*, which **lowers Brier** — shrinking a +miscalibrated forecaster toward its base rate always does — while destroying all +resolution. + +**A metric win that guts the product.** Any calibration layer must refuse a +non-positive slope on principle, not on inspection. That is now `MIN_SLOPE`. + +--- + +## PHASE 1 — the duel, instrumented forward + +Both corrections are computed on every hits/TB prop: +`p_win_lowparam` (served) and `p_win_isotonic_shadow` (logged, never read by +serving or by `chainAcross`). The shadow runs in its own try — it can never +break serving. + +`calibrationDuel.adjudicate` encodes the rule **in code, before any forward date +exists**, so the bar cannot drift toward whichever answer arrives: + +| condition | verdict | action | +|---|---|---| +| ≥10 forward dates AND isotonic wins, date-block CI excluding zero | **REFUTED** | revert hits/TB to isotonic, log the reversal | +| ≥10 forward dates, isotonic does not win | **UPHELD** | keep serving low-param | +| <10 forward dates | **PENDING** | keep serving, no verdict | + +A date counts as forward **only if neither map was fitted on it** — scoring +inside a fit window would ask which map memorised better. Rows lacking that +provenance are dropped, never assumed forward. Tests lock all of it, including +that a decisive shadow win at 5 dates is still PENDING. + +**Nothing swaps now.** The season decides. + +--- + +## PHASE 2 — the resolution ceiling, quantified + +Murphy decomposition: `Brier = reliability − resolution + uncertainty`. +Reliability is what calibration fixes. **Resolution is discrimination, and a +monotone map cannot create it** — it relabels bins without re-sorting the rows +inside them. + +| stat | n | base | reliability | **resolution** | uncertainty | **share of variance explained** | +|---|---|---|---|---|---|---| +| hits | 1,140 | 0.5684 | 0.01353 | **0.00252** | 0.24532 | **1.03%** | +| total_bases | 1,050 | 0.5819 | 0.01419 | **0.00442** | 0.24329 | **1.82%** | +| rbi | 630 | 0.6571 | 0.00654 | **0.03268** | 0.22531 | **14.51%** | +| runs | 597 | 0.6348 | 0.00788 | **0.00130** | 0.23182 | **0.56%** | + +### What calibration did, exactly as theory predicts + +| stat | reliability | resolution | +|---|---|---| +| hits | 0.01353 → 0.00233 (**−0.0112**) | 0.00252 → 0.00231 (−0.0002) | +| total_bases | 0.01419 → 0.00527 (**−0.0089**) | 0.00442 → 0.00414 (−0.0003) | + +**Calibration removed 83% of hits' reliability error and moved resolution by +essentially nothing.** It did the whole of its job, and its job was never the +one the grade product needs. + +**Unexpected:** `rbi` has **13× the resolution of hits** and is the one stat we +do *not* serve corrected — it needs calibration least (reliability 0.0065) and +discriminates most. Worth carrying into the factor arc. + +--- + +## PHASE 2 — the HITS factor-transmission diagnosis + +**Verdict: NOT-TRANSMITTED. Not weak — absent.** This is a plumbing defect, and +it is the highest-value finding in the order. + +Evidence, traced in code rather than recalled: + +1. **`sprayDefense.js` and `platoonSeverity.js` are required by NOTHING in + `src/`.** Only by analysis scripts and their own tests. The two + causally-correct atoms that passed the two-part gate have never been on the + serving path. + +2. **The served `p_win` reads exactly four inputs** + (`intelligence/probabilityEstimator.js:54`): game-log frequency over the line, + `opp_rank_stat` (±0.03), `home_away` (±0.015), and a cv consistency pull. + Zero occurrences of spray, platoon, hard-hit or contact-profile. + +3. **Ordering makes it structural.** `snapshotService` grades at line 454 + (`gradeAndCacheSlate`) and only computes challenger/context at line 640+. + Everything proven is computed **downstream of the grade it would inform**. + +So the three proven hits factors — `defense_by_direction`, +`pitcher_contact_profile`, `platoon_severity` — were measured on ledger rows by +analysis scripts and **have never once moved a served number.** + +That reframes every null in this programme's recent history. "Calibrated p_win +does not separate within archetype" was never a statement about factors. The +factors were not in the forecast. + +--- + +## PHASE 3 — bands on served values + +| stat | basis | eval rows | slots | slots with lift | +|---|---|---|---|---| +| hits | `p_win_lowparam` (served) | 765 | 7 | **0** | +| total_bases | `p_win_lowparam` (served) | 625 | 7 | **0** | +| rbi | raw `p_win` | 425 | 7 | **0** | +| runs | raw `p_win` | 424 | 7 | **0** | + +**28 archetype slots across four stats. Zero show lift.** Every slot is one band +indistinguishable from its own base rate. + +This is no longer an open shrug. It is the arithmetic consequence of resolution +of 0.0013–0.0327 against uncertainty of ~0.23: **a forecast explaining 1% of the +outcome's variance cannot produce bands that separate**, and no correction to its +numbers will change that. + +--- + +## THE HEADLINE + +**Calibration is complete. It delivered honest numbers on two stats and ZERO +grade separation, because the counter has no resolution — 1.03% of variance on +hits, 0.56% on runs.** + +**And the three proven hits factors are NOT WIRED INTO THE FORECAST AT ALL.** + +Those two facts together are the programme's position. The second is the reason +for the first, and it is a plumbing defect rather than a modelling wall — which +makes it the cheapest high-value fix available. + +**Per-archetype grades require proven factors that actually reach `p_win`. That +is the next and central arc.** This is the last calibration order. + +--- + +## Invariants + +Serving unchanged from `74cf1ce` — this order logs and diagnoses. `p_win` never +mutated; `p_win_lowparam` served, `p_win_isotonic_shadow` logged. No Bonferroni +slot. Counter and frozen clusters verified file-by-file. diff --git a/src/services/model/calibrationDuel.js b/src/services/model/calibrationDuel.js new file mode 100644 index 0000000..45fcb8f --- /dev/null +++ b/src/services/model/calibrationDuel.js @@ -0,0 +1,125 @@ +'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 }; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 67ea865..8d839d8 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -734,46 +734,65 @@ async function runSnapshot(sport, opts = {}) { console.warn(`[challenger] ${sp} skipped:`, e.message); } - // ── FORWARD CALIBRATION (LODO-gated, per stat) ──────────────────────── + // ── FORWARD CALIBRATION + THE SHADOW DUEL ───────────────────────────── // Fitted on games that are OVER, applied to tonight's props. `p_win` is NOT - // touched — the counter stays byte-identical and the calibrated value rides + // touched — the counter stays byte-identical and the corrected value rides // beside it, because a calibration map is a correction TO a forecast, not a // different forecast. // - // WHICH STATS SERVE IS MEASURED, NOT ASSUMED. The deploy bar is leave-one- - // date-out stability: refit dropping each settled date in turn, and the - // improvement must never reverse. That is the right instrument for a monotone - // shrink-to-observed layer — the factor gate's >=40 date-cluster interval - // floor was built for a CAUSAL claim and does not bind here. + // WHAT IS SERVED, AND WHY IT IS A BET RATHER THAN A RESULT. On identical + // held-out rows the ISOTONIC map scored BETTER than 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 and will not + // transmit forward. At 19 dates no instrument here can test that argument — + // LODO has 1.4-9.3% power — so it is a BET, not evidence. // - // Measured 2026-08-07: total_bases passes at every held-size threshold. hits - // FAILS (reverses on 2026-07-22 and 2026-07-26), so it is no longer served - // calibrated even though it was — a stat that cannot survive dropping one day - // was never calibrated, it was fitted to that day. rbi and runs also fail. + // So both are computed on every prop and the shadow is logged. Real + // out-of-window dates adjudicate it: // - // `calibrated` is true only inside a band certified out-of-sample, and it is - // what `chain.chainAcross` requires before it will compound anything. Removing - // hits here makes hits props unstackable again, which is the honest - // consequence of the measurement rather than a regression to work around. + // PRE-REGISTERED: once >=10 forward dates have settled that NEITHER map was + // fitted on, if isotonic beats low-param with a date-block bootstrap CI + // excluding zero, the capacity argument is REFUTED and hits/TB revert to + // isotonic. If low-param wins or ties, the bet was right. The season + // decides, not the argument. + // + // Serving is unchanged until that bar is met. `calibrated` is true only inside + // a band certified out-of-sample, and it is what `chain.chainAcross` requires + // before it will compound anything. if (sp === 'mlb') { for (const stat of CALIBRATION_DEPLOYED) { try { const calSvc = deps.calibrationService || require('./model/lowParamService'); + const shadowSvc = deps.shadowCalibrationService || require('./model/calibrationService'); const sbc = require('../utils/supabase').getSupabaseServiceClient(); const calibrator = sbc ? await calSvc.fromLedger(sbc, { sport: 'mlb', stat }) : null; + // The shadow must never break serving: its own try, and a null shadow + // simply means the duel has no entry for tonight. + let shadow = null; + try { shadow = sbc ? await shadowSvc.fromLedger(sbc, { sport: 'mlb', stat }) : null; } catch { shadow = null; } + if (calibrator) { - let marked = 0; + let marked = 0; let shadowed = 0; for (const g of enriched) { if (String(g.stat_type || g.stat || '').toLowerCase() !== stat) continue; const out = calibrator.calibrate(g.p_win); g.p_win_calibrated = out.p_calibrated; + g.p_win_lowparam = out.p_calibrated; // named, so the duel is legible g.calibrated = out.calibrated; g.calibration_reason = out.reason; g.calibration_status = 'provisional'; - g.calibration_basis = CALIBRATION_BASIS[stat] || null; + g.calibration_basis = CALIBRATION_BASIS[stat] || null; if (out.calibrated) marked += 1; + if (shadow) { + const sh = shadow.calibrate(g.p_win); + // SHADOW ONLY. Never read by serving, never by chainAcross. + g.p_win_isotonic_shadow = sh.p_calibrated; + g.calibration_duel_fitted_through = shadow.fitted_through || null; + if (sh.p_calibrated != null) shadowed += 1; + } } - console.log(`[calibration] ${sp} ${stat} (low-param, PROVISIONAL) — ${marked} stackable; fit n=${calibrator.fit_n} through ${calibrator.fitted_through}, a=${calibrator.model.a} shrink=${calibrator.shrinkage}`); + console.log(`[calibration] ${sp} ${stat} (low-param, PROVISIONAL) — ${marked} stackable; fit n=${calibrator.fit_n} through ${calibrator.fitted_through}, a=${calibrator.model.a} shrink=${calibrator.shrinkage}; shadow logged on ${shadowed}`); } else { console.log(`[calibration] ${sp} ${stat} — no calibrator (thin history); nothing is stackable`); } diff --git a/tests/unit/calibrationDuel.test.js b/tests/unit/calibrationDuel.test.js new file mode 100644 index 0000000..a1e4ee5 --- /dev/null +++ b/tests/unit/calibrationDuel.test.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * The forward adjudication of a bet made against the measurement. + * + * We serve the map that scored WORSE in-window, on an argument the sample + * cannot test. These lock the rule that decides whether that argument survives — + * written before any forward date exists, so the bar cannot drift toward + * whichever answer arrives. + */ + +const duel = require('../../src/services/model/calibrationDuel'); + +/** Forward rows where `edge` favours the shadow when positive. */ +function rows(dates, perDate, edge, seed = 2) { + let s = seed; + const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648; + const out = []; + for (let d = 0; d < dates; d += 1) { + for (let i = 0; i < perDate; i += 1) { + const won = rnd() < 0.6 ? 1 : 0; + const err = 0.25 + rnd() * 0.1; + out.push({ + date: `2026-09-${String(d + 1).padStart(2, '0')}`, + fitted_through: '2026-08-31', + won, + served: won ? 1 - err : err, + shadow: won ? 1 - err + edge : err - edge, + }); + } + } + return out; +} + +describe('the rule is pre-registered and cannot be met early', () => { + it('is PENDING below the forward-date bar, whatever the numbers say', () => { + // Even with the shadow winning decisively, 5 dates is not a verdict. + const v = duel.adjudicate(rows(5, 40, 0.15)); + expect(v.verdict).toBe('PENDING'); + expect(v.dates_needed).toBe(duel.MIN_FORWARD_DATES - 5); + expect(v.action).toMatch(/keep serving/); + }); + + it('REFUTES the bet when isotonic wins out-of-window at the bar', () => { + const v = duel.adjudicate(rows(12, 40, 0.15)); + expect(v.verdict).toBe('REFUTED'); + expect(v.ci[1]).toBeLessThan(0); + expect(v.action).toMatch(/REVERT/); + }); + + it('UPHOLDS the bet when the served map is not beaten', () => { + const v = duel.adjudicate(rows(12, 40, -0.15)); + expect(v.verdict).toBe('UPHELD'); + expect(v.action).toMatch(/keep serving/); + }); + + it('UPHOLDS on a tie — the burden is on refutation, not on us', () => { + const v = duel.adjudicate(rows(12, 40, 0)); + expect(v.verdict).toBe('UPHELD'); + }); +}); + +describe('only genuinely out-of-window dates count', () => { + it('drops rows inside the fit window — that would score memorisation', () => { + const inWindow = rows(12, 40, 0.15).map((r) => ({ ...r, fitted_through: '2026-12-31' })); + const v = duel.adjudicate(inWindow); + expect(v.verdict).toBe('PENDING'); + expect(v.forward_dates).toBe(0); + }); + + it('drops rows with no fit provenance rather than assuming they are forward', () => { + const noProv = rows(12, 40, 0.15).map(({ fitted_through, ...r }) => r); + expect(duel.adjudicate(noProv).forward_dates).toBe(0); + }); + + it('drops rows missing either map — a duel needs both entrants', () => { + const half = rows(12, 40, 0.15).map((r) => ({ ...r, shadow: null })); + expect(duel.adjudicate(half).forward_dates).toBe(0); + }); +});