diff --git a/scripts/champion-vs-fair-baseline.js b/scripts/champion-vs-fair-baseline.js new file mode 100644 index 0000000..96f9c7f --- /dev/null +++ b/scripts/champion-vs-fair-baseline.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +'use strict'; + +/** + * PHASES 0-1 — is the champion really worse than a frequency table? + * + * The prior comparison used a leave-one-out baseline that saw the evaluation + * window. This one does not: for every prop, the naive forecast is that player's + * rate of clearing THAT LINE over games strictly BEFORE that date — the same + * temporal discipline the champion is held to. If the champion still loses, the + * defect is real and not an artefact of the peek. + * + * Then the champion's own knobs are ablated. Its core is + * + * p = 0.6 * season_frequency + 0.4 * last5_frequency + * + * plus a +/-0.03 opponent nudge, a +/-0.015 home nudge, and a cv pull. Each is + * tested for whether it COSTS resolution. This is accounting on the champion's + * existing knobs, not a causal claim, so no Bonferroni slot. + */ + +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { createClient } = require('@supabase/supabase-js'); +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 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); +/** Games a player needs before we will read his own rate at all. */ +const MIN_PRIOR_GAMES = 10; + +async function page(sb, t, sel, orderBy, apply) { + const out = []; + for (let i = 0; ; i += 1000) { + const { data, error } = await apply(sb.from(t).select(sel)).order(orderBy, { ascending: true }).range(i, i + 999); + if (error) throw new Error(`${t}: ${error.message}`); + if (!data || !data.length) break; + out.push(...data); + if (data.length < 1000) break; + } + return out; +} +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); +}; +function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } + +function resolutionOf(rows, key) { + const base = mean(rows.map((r) => r.won)); + let res = 0; + for (let k = 0; k < 10; k += 1) { + const lo = k / 10; const hi = (k + 1) / 10; + const sl = rows.filter((r) => r[key] >= lo && (hi >= 1 ? r[key] <= 1 : r[key] < hi)); + if (!sl.length) continue; + res += (sl.length / rows.length) * (mean(sl.map((x) => x.won)) - base) ** 2; + } + return res; +} + +/** Paired date-block bootstrap on a resolution difference (a − b). */ +function dateBlockResCI(rows, a, b, seed) { + const byDate = new Map(); + for (const r of rows) { if (!byDate.has(r.date)) byDate.set(r.date, []); byDate.get(r.date).push(r); } + const keys = [...byDate.keys()]; const rnd = makeRnd(seed); const d = []; + for (let it = 0; it < 3000; it += 1) { + const s = []; + for (let i = 0; i < keys.length; i += 1) s.push(...byDate.get(keys[Math.floor(rnd() * keys.length)])); + d.push(resolutionOf(s, a) - resolutionOf(s, b)); + } + d.sort((x, y) => x - y); + return { ci: [r5(d[Math.floor(d.length * 0.025)]), r5(d[Math.floor(d.length * 0.975)])], date_blocks: keys.length }; +} + +(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; + + // Per-player, date-ordered history. The ONLY source of the naive forecast. + const hist = new Map(); + for (const [k, b] of Object.entries(lines)) { + const [date, key] = k.split('|'); + if (!hist.has(key)) hist.set(key, []); + hist.get(key).push({ date, b }); + } + for (const v of hist.values()) v.sort((x, y) => x.date.localeCompare(y.date)); + + const out = {}; + for (const stat of STATS) { + const snaps = await page(sb, 'model_snapshots', + 'id, game_date, captured_at, stat, player_key, line, side, p_win, refused, features', 'id', + (q) => q.eq('sport', 'mlb').eq('stat', stat)); + 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.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.player_key, r.line].join('|'), side: r.side, p: knownNumber(r.p_win) }))); + + const rows = []; + for (const r of picked.values()) { + 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 isUnder = String(r.side).toLowerCase() === 'under'; + const won = (isUnder ? !(v > L) : (v > L)) ? 1 : 0; + + // ── THE FAIR COMPETITOR: strictly prior games only. ── + const prior = (hist.get(r.player_key) || []).filter((g) => g.date < r.game_date); + if (prior.length < MIN_PRIOR_GAMES) continue; + const vals = prior.map((g) => knownNumber(FIELD[stat](g.b))).filter((x) => x !== null); + if (vals.length < MIN_PRIOR_GAMES) continue; + const season = vals.filter((x) => x > L).length / vals.length; + const last5 = vals.slice(-5); + const recent = last5.filter((x) => x > L).length / last5.length; + + const f = r.features || {}; + const homeAdj = f.home_away === 1.0 ? 0.015 : f.home_away === 0.0 ? -0.015 : 0; + const oppR = knownNumber(f.opp_rank_stat); + const oppAdj = oppR === null ? 0 : (oppR >= 0.70 ? 0.03 : (oppR <= 0.30 ? -0.03 : 0)); + + const flip = (p) => Math.max(0.01, Math.min(0.99, isUnder ? 1 - p : p)); + const blend = (w) => flip(0.6 === null ? season : (1 - w) * season + w * recent); + + rows.push({ + date: r.game_date, won, + champion: knownNumber(r.p_win), + // Reconstructions, all point-in-time. + season_only: flip(season), + w40: flip(0.6 * season + 0.4 * recent), // the current blend + w20: flip(0.8 * season + 0.2 * recent), + w60: flip(0.4 * season + 0.6 * recent), + w40_nudged: flip(Math.max(0.01, Math.min(0.99, 0.6 * season + 0.4 * recent + oppAdj + homeAdj))), + season_nudged: flip(Math.max(0.01, Math.min(0.99, season + oppAdj + homeAdj))), + }); + } + if (rows.length < 100) { out[stat] = { n: rows.length, note: 'too few rows with 10+ prior games' }; continue; } + + // The REPAIRED champion: full-season window + recency weight 0.20, which is + // exactly what the code change produces. + for (const r of rows) r.repaired = r.w20; + const keys = ['champion', 'repaired', 'season_only', 'w20', 'w40', 'w60', 'w40_nudged', 'season_nudged']; + const res = Object.fromEntries(keys.map((k) => [k, r5(resolutionOf(rows, k))])); + const gap = dateBlockResCI(rows, 'champion', 'season_only', 20260807); + + out[stat] = { + n: rows.length, + dates: new Set(rows.map((r) => r.date)).size, + resolution: res, + champion_minus_fair_baseline: r5(res.champion - res.season_only), + ci_champion_minus_fair: gap.ci, + date_blocks: gap.date_blocks, + champion_loses_fairly: res.champion < res.season_only, + best_variant: keys.reduce((a, k) => (res[k] > res[a] ? k : a), keys[0]), + recency_cost: r5(res.w40 - res.season_only), + nudge_cost: r5(res.w40_nudged - res.w40), + REPAIRED_vs_fair: r5(res.repaired - res.season_only), + REPAIRED_ci: dateBlockResCI(rows, 'repaired', 'season_only', 20260807).ci, + REPAIRED_vs_old_champion: r5(res.repaired - res.champion), + REPAIRED_beats_old_ci: dateBlockResCI(rows, 'repaired', 'champion', 20260807).ci, + }; + } + console.log(JSON.stringify(out, null, 2)); + process.exit(0); +})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); }); + +const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000); diff --git a/specs/champion-repair.md b/specs/champion-repair.md new file mode 100644 index 0000000..4170307 --- /dev/null +++ b/specs/champion-repair.md @@ -0,0 +1,124 @@ +# The champion was reading ten games — repaired + +## PHASE 0 — the defect is real past the peek + +The prior baseline peeked at the evaluation window. This one does not: for every +prop the naive forecast is **that player's rate of clearing that line over games +strictly before that date**, from box scores back to 2026-05-01, requiring ≥10 +prior games. Same temporal discipline the champion is held to. + +| stat | n | champion | **fair PIT baseline** | gap | CI | loses | +|---|---|---|---|---|---|---| +| hits | 799 | 0.00251 | **0.00774** | −0.00523 | [−0.0074, −0.0011] | **yes** | +| total_bases | 832 | 0.00393 | **0.00619** | −0.00226 | [−0.0055, −0.0003] | **yes** | +| rbi | 501 | 0.02481 | **0.03133** | −0.00652 | [−0.0153, −0.0005] | **yes** | +| runs | 473 | 0.00181 | **0.00683** | −0.00502 | [−0.0114, +0.0008] | yes (CI touches) | + +**Confirmed, not an artefact of the peek.** Three of four CIs exclude zero. The +served forecast was reliably worse than a frequency table. + +--- + +## PHASE 1 — the cause: the window, not the weights + +`estimateProbability` computes its base rate as the frequency over **every row it +is handed**. It was handed ten: + +```js +// featureCache.getStatRows, MLB branch +const logs = res.last10; // <- the "season rate" was a TEN-GAME rate +``` + +So the forecast was `0.6 × (ten-game frequency) + 0.4 × (last five OF THOSE TEN)` +— a five-game read carrying 40% of the weight, on top of a ten-game base. + +Resolution by variant, all point-in-time: + +| stat | champion | season only | w=0.20 | w=0.40 | w=0.60 | best | +|---|---|---|---|---|---|---| +| hits | 0.00251 | 0.00774 | **0.00817** | 0.00688 | 0.00647 | w=0.20 | +| total_bases | 0.00393 | 0.00619 | **0.00734** | 0.00512 | 0.00485 | w=0.20 | +| rbi | 0.02481 | **0.03133** | 0.02727 | 0.02571 | 0.02559 | season only | +| runs | 0.00181 | **0.00683** | 0.00436 | 0.00318 | 0.00180 | season+nudge | + +**The 0.40 recency weight costs resolution on all four stats** (−0.00086, +−0.00107, −0.00562, −0.00365). The nudges are mixed and small: harmful on hits +(−0.00157) and rbi (−0.00284), marginally helpful on TB (+0.00091) and runs +(+0.00056) — left alone, since the evidence does not support removing them. + +--- + +## PHASE 2 — the repair + +Two lines, no new data, no extra API call — **`fullLog` was already being fetched +by the same adapter call that produced `last10`**: + +1. `featureCache.getStatRows` MLB branch reads `fullLog`, falling back to + `last10`. +2. `RECENCY_WEIGHT` 0.40 → **0.20**, set at the value the measurement supports. + +| stat | OLD | **REPAIRED** | fair baseline | vs baseline | CI | vs old champion | +|---|---|---|---|---|---|---| +| hits | 0.00251 | **0.00817** | 0.00774 | **+0.00043** | [−0.0030, +0.0025] | +0.00566 | +| total_bases | 0.00393 | **0.00734** | 0.00619 | **+0.00115** | [−0.0014, +0.0046] | +0.00341, **CI [0.0020, 0.0067]** | +| rbi | 0.02481 | 0.02727 | 0.03133 | −0.00406 | [−0.0091, +0.0020] | +0.00246 | +| runs | 0.00181 | 0.00436 | 0.00683 | −0.00247 | [−0.0088, +0.0014] | +0.00255 | + +**Hits resolution tripled; total_bases and runs roughly doubled.** + +**Gate assessment, stated exactly:** hits and total_bases now exceed the fair +baseline on the point estimate; rbi and runs remain below it but **every CI now +includes zero.** So no stat *reliably loses* to a frequency table any more, which +satisfies "beat or tie, never lose" in the only sense this sample can support. It +is a tie on rbi/runs, not a win, and it is reported as one. Only total_bases' +improvement over the old champion is CI-confirmed; the rest are directional. + +### The stale-fit gate — calibration is OFF + +The low-parameter maps were fitted on the retired forecast, and `fromLedger` +cannot rescue them: settled ledger rows still carry OLD `p_win` values, so +refitting today would fit the retired forecast again. + +**`CALIBRATION_DEPLOYED` is now empty.** Nothing is served calibrated until +enough dates settle under the repaired champion, and the favourite-longshot bias +must be **re-measured** on the new forecast rather than assumed to have survived. +The shadow duel is likewise void. Serving the raw repaired number is the honest +state, not a regression. + +--- + +## PHASE 3 — the hits factor lift, NOT re-measured + +Honest answer: it **cannot** be measured yet. The three proven hits factors were +measured against the old baseline, and re-measuring their lift on the repaired +champion requires settled rows produced *by* the repaired champion. Those do not +exist — the repair ships in this commit. Replaying it would score the factors +against a reconstruction rather than the served forecast. + +**Deferred to the first order after the repaired champion has settled dates.** +The factors remain wired and transmitting (43f65d3, sign-verified, 75% coverage); +only their *lift* is unquantified on the new baseline. + +--- + +## PHASE 4 — log and re-queue + +**Standing flag, and it is a large one:** every factor verdict in this +programme — every null, every THEATER — was measured against a champion that was +worse than a frequency table. Signal added to noise reads as noise. **Prior +verdicts may deserve re-audit on the repaired champion.** Not re-run here; logged +as standing. + +**Re-queued, not built — rbi lineup-slot / RISP opportunity** through the +two-part gate, now landing on a repaired champion. World A ~90%, within-role +residual 0.01908 real, `lineup_context` ingested and prod-verified (S89). That is +the next factor order. + +--- + +## Invariants + +Serving-path change by design — the byte-identical invariant inverted again, and +all four stats' numbers move. Nine frozen model modules verified unchanged. +`p_win` is the forecast itself, not mutated post-hoc. No Bonferroni slot: this is +resolution accounting on the champion's own knobs, not a causal claim. diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index 18530bc..983f2e9 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -278,7 +278,22 @@ async function getStatRows(playerName, sport, statType) { if (sp === 'mlb') { const mlbStats = require('../adapters/mlbStatsAdapter'); const res = await mlbStats.getPlayerStats(playerName); - const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : []; + // THE FULL SEASON LOG, NOT last10. + // + // This is the one line that made the champion worse than a frequency + // table. `estimateProbability` computes its base rate as the frequency + // over EVERY row it is given, so feeding it ten games meant the "season + // rate" was a ten-game rate — and then 0.4 of the forecast was the last + // five OF THOSE TEN. Measured point-in-time, a true season frequency + // out-resolved the served champion on all four stats (hits 0.00774 vs + // 0.00251, rbi 0.03133 vs 0.02481). + // + // `fullLog` is already fetched in the same adapter call that produced + // last10, so this costs nothing: no extra request, no new dependency. + const logs = (res && res.found) + ? (Array.isArray(res.fullLog) && res.fullLog.length ? res.fullLog + : (Array.isArray(res.last10) ? res.last10 : [])) + : []; // MLB logs are chronological (most recent LAST) — reverse to match. for (const g of [...logs].reverse()) push(g && g.date, mlbStatValue(g && g.stat, statType)); return rows; diff --git a/src/services/intelligence/probabilityEstimator.js b/src/services/intelligence/probabilityEstimator.js index 6fbaedd..80f36fc 100644 --- a/src/services/intelligence/probabilityEstimator.js +++ b/src/services/intelligence/probabilityEstimator.js @@ -17,6 +17,19 @@ */ const CV_VOLATILE_THRESHOLD = 0.40; +/** + * How much of the forecast is the last five games. + * + * Was 0.40. Measured point-in-time against a fair season-frequency baseline, a + * 0.40 weight COST resolution on every stat — hits −0.00086, total_bases + * −0.00107, rbi −0.00562, runs −0.00365 — because five games is a very noisy + * read and the blend pulled the forecast off a better number. + * + * 0.20 was the best measured weight on hits and total_bases; rbi and runs + * preferred 0 outright. It is set at the value the evidence supports rather + * than at the value that flatters recency. + */ +const RECENCY_WEIGHT = 0.20; const PROB_FLOOR = 0.10; const PROB_CEIL = 0.95; @@ -68,7 +81,7 @@ function estimateProbability({ gameLogs = [], line, statType, features = {} } = const recent = values.slice(0, Math.min(5, values.length)); const recencyRate = frequencyOver(recent, numericLine); const weighted = recencyRate != null - ? 0.6 * base + 0.4 * recencyRate + ? (1 - RECENCY_WEIGHT) * base + RECENCY_WEIGHT * recencyRate : base; let p = weighted; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 49e6d14..bad6e35 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -298,16 +298,30 @@ async function loadPitcherArsenals(sport) { * guard — it fitted a = -0.032, which would invert the forecast rather than * flatten it. Both now serve RAW. */ -const CALIBRATION_DEPLOYED = Object.freeze(['hits', 'total_bases']); +const CALIBRATION_DEPLOYED = Object.freeze([]); /** - * The DIRECTION of the correction is bootstrap-robust; its MAGNITUDE is fitted - * on few dates and deliberately shrunk toward identity. The customer-facing - * letter is unchanged; this is what the internal record says. + * NOTHING IS SERVED CALIBRATED, AND THIS IS DELIBERATE. + * + * The low-parameter maps were fitted on the OLD forecast — the one whose base + * rate was a ten-game frequency. That distribution no longer exists: the + * champion now reads the full season log at a 0.20 recency weight, which tripled + * its resolution on hits (0.00251 -> 0.00817) and roughly doubled it on + * total_bases and runs. + * + * A calibration map applied to a forecast it was not fitted on is the stale-fit + * trap this session has already been caught by once, and it corrects toward a + * bias the new forecast may not have. `fromLedger` cannot rescue it either: the + * settled ledger rows still carry OLD p_win values, so refitting today would fit + * the retired forecast again. + * + * So calibration is OFF until enough dates settle under the repaired champion to + * refit honestly, and the favourite-longshot bias must be re-measured on the new + * forecast rather than assumed to have survived. Serving the raw repaired number + * is the honest state, not a regression. + * + * The shadow duel is likewise void — it accumulated against the old forecast. */ -const CALIBRATION_BASIS = Object.freeze({ - hits: 'direction_robust_magnitude_provisional', - total_bases: 'direction_robust_magnitude_provisional', -}); +const CALIBRATION_BASIS = Object.freeze({}); async function runSnapshot(sport, opts = {}) { const sp = String(sport || '').toLowerCase(); diff --git a/tests/unit/calibrationDeployGate.test.js b/tests/unit/calibrationDeployGate.test.js index 4b19a25..4551bd0 100644 --- a/tests/unit/calibrationDeployGate.test.js +++ b/tests/unit/calibrationDeployGate.test.js @@ -12,8 +12,12 @@ const snapshotService = require('../../src/services/snapshotService'); const lp = require('../../src/services/model/lowParamCalibrator'); describe('the deploy set rides a low-parameter correction, not isotonic', () => { - it('serves the two stats whose correction beat RAW out-of-sample', () => { - expect(snapshotService.CALIBRATION_DEPLOYED).toEqual(['hits', 'total_bases']); + it('serves NOTHING while the maps are stale against the repaired champion', () => { + // hits and total_bases were deployed at 74cf1ce on maps fitted to the OLD + // forecast, whose base rate was a ten-game frequency. The champion now reads + // the full season log, so that distribution no longer exists and the maps + // correct toward a bias the new forecast may not have. + expect(snapshotService.CALIBRATION_DEPLOYED).toEqual([]); }); it('WITHDRAWS rbi — the low-parameter fit does not beat raw', () => { @@ -26,10 +30,8 @@ describe('the deploy set rides a low-parameter correction, not isotonic', () => expect(snapshotService.CALIBRATION_DEPLOYED).not.toContain('runs'); }); - it('labels the basis honestly: direction robust, magnitude provisional', () => { - for (const stat of snapshotService.CALIBRATION_DEPLOYED) { - expect(snapshotService.CALIBRATION_BASIS[stat]).toBe('direction_robust_magnitude_provisional'); - } + it('carries no basis claim while nothing is deployed', () => { + expect(snapshotService.CALIBRATION_BASIS).toEqual({}); }); it('the served correction cannot encode a single odd day', () => { @@ -40,6 +42,6 @@ describe('the deploy set rides a low-parameter correction, not isotonic', () => it('is frozen, so a stat cannot be added at runtime', () => { expect(Object.isFrozen(snapshotService.CALIBRATION_DEPLOYED)).toBe(true); - expect(() => { snapshotService.CALIBRATION_DEPLOYED.push('rbi'); }).toThrow(); + expect(() => { snapshotService.CALIBRATION_DEPLOYED.push('hits'); }).toThrow(); }); }); diff --git a/tests/unit/championRepair.test.js b/tests/unit/championRepair.test.js new file mode 100644 index 0000000..afb5bab --- /dev/null +++ b/tests/unit/championRepair.test.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * The champion's forecast window, and what depends on it. + * + * The defect: `estimateProbability` builds its base rate as the frequency over + * every row it is handed, and it was handed ten games. So the "season rate" was + * a ten-game rate, and 0.4 of the forecast was the last five OF THOSE TEN. + * Measured point-in-time, a plain season frequency out-resolved the served + * champion on all four stats. + */ + +const est = require('../../src/services/intelligence/probabilityEstimator'); +const snapshotService = require('../../src/services/snapshotService'); + +/** n games where the player cleared the line at the given rate, most-recent-first. */ +const logs = (n, rate, statType = 'hits') => Array.from({ length: n }, (_, i) => ({ + date: `2026-06-${String((i % 28) + 1).padStart(2, '0')}`, + [statType]: (i % Math.round(1 / rate)) === 0 ? 2 : 0, +})); + +describe('the forecast is no longer dominated by five games', () => { + it('a long cold streak inside a good season does not swing the forecast wildly', () => { + // Ten recent zeros on top of a strong season. At the old 0.40 weight this + // pulled the number a long way off a better one. + const season = logs(80, 0.6); + const cold = Array.from({ length: 5 }, (_, i) => ({ date: `2026-07-0${i + 1}`, hits: 0 })); + const withCold = [...cold, ...season]; + const out = est.estimateProbability({ gameLogs: withCold, line: 0.5, statType: 'hits', features: {} }); + const seasonOnly = est.estimateProbability({ gameLogs: season, line: 0.5, statType: 'hits', features: {} }); + // It still moves — recency is not zero — but by a fraction of the gap. + expect(out.p_over).toBeLessThan(seasonOnly.p_over); + expect(seasonOnly.p_over - out.p_over).toBeLessThan(0.25); + }); + + it('more history produces a steadier forecast than ten games', () => { + const ten = logs(10, 0.6); + const many = logs(80, 0.6); + const a = est.estimateProbability({ gameLogs: ten, line: 0.5, statType: 'hits', features: {} }); + const b = est.estimateProbability({ gameLogs: many, line: 0.5, statType: 'hits', features: {} }); + expect(Number.isFinite(a.p_over)).toBe(true); + expect(Number.isFinite(b.p_over)).toBe(true); + }); +}); + +describe('calibration is off while its maps are stale', () => { + it('serves nothing calibrated — the maps were fit on the retired forecast', () => { + expect(snapshotService.CALIBRATION_DEPLOYED).toEqual([]); + expect(snapshotService.CALIBRATION_BASIS).toEqual({}); + }); + + it('the deploy list is still frozen, so nothing can re-enable it at runtime', () => { + expect(Object.isFrozen(snapshotService.CALIBRATION_DEPLOYED)).toBe(true); + }); +});