'use strict'; /** * BACKTEST HARNESS (Session 64) — the validator every metric family is gated on. * * ITS FIRST PROPERTY IS THE REFUSAL. A harness that emits a confident number on * a handful of rows is worse than no harness: it launders noise into a claim, * and claims become marketing. Below the sample threshold this returns * INSUFFICIENT with n and the shortfall — and NO rate anywhere in the payload, * so a downstream renderer cannot accidentally surface one. * * Design rules, each enforced by a test: * - NEVER mix sports or model_versions in a bucket. A backtest across model * eras is meaningless, and `ledger_entries` is already contaminated * pre-2026-07-19 with no per-row version. * - The denominator excludes quarantined, void, unrecoverable, pending AND * push — the same discipline the public record already uses. * - Overlapping confidence intervals are reported as NOT DISTINGUISHABLE * rather than ranked. Two buckets 4 points apart on n=25 are noise. * - The replay seam reads the STORED feature vector only. A backtest that * refetches today's data to score a past night is invalid; a row whose * input was never retained is UN-BACKTESTABLE, and says so. * * Pure functions over rows — no I/O, so it is unit-testable and cannot * accidentally reach the network mid-backtest. */ const DEFAULT_MIN_SAMPLE = Number(process.env.BACKTEST_MIN_SAMPLE || 20); const TERMINAL_NON_RESULTS = new Set(['void', 'unrecoverable']); /** * Wilson score interval — correct for small n, unlike the normal approximation * (which happily emits negative lower bounds on the sample sizes we actually * have). z=1.96 → 95%. */ function wilsonInterval(hits, n, z = 1.96) { if (!n || n <= 0) return { low: null, high: null }; const p = hits / n; const z2 = z * z; const denom = 1 + z2 / n; const centre = p + z2 / (2 * n); const margin = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n); return { low: Math.max(0, (centre - margin) / denom), high: Math.min(1, (centre + margin) / denom), }; } /** Split rows into scored vs each excluded reason. The counts are reported so * a shrinking denominator is visible rather than silent. */ function partition(rows) { const scored = []; const counts = { total: rows.length, scored: 0, excluded_quarantine: 0, excluded_terminal: 0, excluded_pending: 0, excluded_push: 0, }; for (const r of rows || []) { if (!r) continue; if (r.quarantine_reason || r.snap_quarantine) { counts.excluded_quarantine += 1; continue; } const o = r.outcome; if (o == null) { counts.excluded_pending += 1; continue; } if (o === 'push') { counts.excluded_push += 1; continue; } if (TERMINAL_NON_RESULTS.has(o)) { counts.excluded_terminal += 1; continue; } if (o !== 'hit' && o !== 'miss') { counts.excluded_terminal += 1; continue; } scored.push(r); } counts.scored = scored.length; return { scored, counts }; } /** One bucket's calibration. Below `minSample` it reports the refusal and NO rate. */ function bucketStats(bucket, rows, minSample) { const n = rows.length; const hits = rows.filter((r) => r.outcome === 'hit').length; if (n < minSample) { return { bucket, n, hits, status: 'INSUFFICIENT', need: minSample, short_by: minSample - n, hit_rate: null, ci_low: null, ci_high: null }; } const ci = wilsonInterval(hits, n); return { bucket, n, hits, status: 'OK', hit_rate: hits / n, ci_low: ci.low, ci_high: ci.high, }; } function groupBy(rows, keyFn) { const m = new Map(); for (const r of rows) { const k = keyFn(r); if (!m.has(k)) m.set(k, []); m.get(k).push(r); } return m; } /** * Monotonicity: does a higher grade actually hit more often? Only buckets that * BOTH cleared the sample gate are compared, and a comparison whose intervals * overlap is reported as not distinguishable rather than ranked. */ function monotonicity(buckets, order) { const ok = buckets.filter((b) => b.status === 'OK'); const comparisons = []; for (let i = 0; i < ok.length; i += 1) { for (let j = i + 1; j < ok.length; j += 1) { const a = ok[i]; const b = ok[j]; const ra = order.indexOf(a.bucket); const rb = order.indexOf(b.bucket); if (ra === -1 || rb === -1) continue; const better = ra < rb ? a : b; // earlier in order = higher grade const worse = ra < rb ? b : a; const overlap = !(better.ci_low > worse.ci_high || worse.ci_low > better.ci_high); comparisons.push({ pair: `${a.bucket}>${b.bucket}`, higher: better.bucket, lower: worse.bucket, higher_rate: better.hit_rate, lower_rate: worse.hit_rate, distinguishable: !overlap, holds: overlap ? null : better.hit_rate > worse.hit_rate, }); } } let verdict; if (comparisons.length === 0) verdict = 'INSUFFICIENT'; else if (comparisons.every((c) => !c.distinguishable)) verdict = 'NOT_DISTINGUISHABLE'; else if (comparisons.filter((c) => c.distinguishable).every((c) => c.holds)) verdict = 'MONOTONIC'; else verdict = 'BROKEN'; return { verdict, comparisons }; } /** Brier score + reliability curve. Rows without a p_win are EXCLUDED — never * defaulted to 0.5, which would invent a prediction we did not make. */ function probabilityCalibration(rows, bins = 10, minSample = null) { // STRICT null guard. `Number(null) === 0` is this codebase's signature // fabrication bug, and a null p_win slipping through would be scored as a // confident 0% prediction — inventing a forecast we never made, inside the // harness whose whole purpose is to refuse invented numbers. const usable = rows.filter((r) => r && r.p_win != null && r.p_win !== '' && Number.isFinite(Number(r.p_win))); if (usable.length === 0) return { n: 0, status: 'INSUFFICIENT', brier: null, reliability: [] }; let sum = 0; const buckets = Array.from({ length: bins }, () => ({ n: 0, hits: 0, p_sum: 0 })); for (const r of usable) { const p = Number(r.p_win); const y = r.outcome === 'hit' ? 1 : 0; sum += (p - y) ** 2; const idx = Math.min(bins - 1, Math.max(0, Math.floor(p * bins))); buckets[idx].n += 1; buckets[idx].hits += y; buckets[idx].p_sum += p; } // The refusal applies here too: a Brier score on a handful of rows is noise // wearing a decimal point. Below threshold we report the count and NO score, // so a reader can never mistake it for a validated number. if (minSample != null && usable.length < minSample) { return { n: usable.length, status: 'INSUFFICIENT', need: minSample, brier: null, reliability: [] }; } return { n: usable.length, status: 'OK', brier: sum / usable.length, reliability: buckets .map((b, i) => (b.n ? { bin: `${(i / bins).toFixed(1)}-${((i + 1) / bins).toFixed(1)}`, n: b.n, predicted: b.p_sum / b.n, realized: b.hits / b.n, } : null)) .filter(Boolean), }; } const FOUR_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F']; const ELEVEN_ORDER = ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F']; /** * Run the backtest. * @param {Array} rows joined snapshot+outcome rows * @param {Object} opts { minSample } */ function runBacktest(rows, opts = {}) { const minSample = Number.isFinite(opts.minSample) ? opts.minSample : DEFAULT_MIN_SAMPLE; const { scored, counts } = partition(rows || []); // Strata: NEVER mix sport or model_version. const strata = []; for (const [key, group] of groupBy(scored, (r) => `${r.sport}|${r.model_version}`)) { const [sport, model_version] = key.split('|'); const g4 = [...groupBy(group, (r) => r.grade || '?')] .map(([b, rs]) => bucketStats(b, rs, minSample)); const g11 = [...groupBy(group, (r) => r.grade_11 || '?')] .map(([b, rs]) => bucketStats(b, rs, minSample)); strata.push({ sport, model_version, n: group.length, grade_buckets: g4, grade_11_buckets: g11, monotonicity: monotonicity(g4, FOUR_ORDER), probability: probabilityCalibration(group, 10, minSample), }); } // Flat views (used by the tests + the log) — computed over ALL scored rows. const grade_buckets = [...groupBy(scored, (r) => r.grade || '?')] .map(([b, rs]) => bucketStats(b, rs, minSample)); const grade_11_buckets = [...groupBy(scored, (r) => r.grade_11 || '?')] .map(([b, rs]) => bucketStats(b, rs, minSample)); const anyOk = grade_buckets.some((b) => b.status === 'OK'); return { generated_at: opts.now ? opts.now() : new Date().toISOString(), min_sample: minSample, counts, // THE REFUSAL: with nothing above threshold there is no validation to give. can_validate: anyOk, verdict: anyOk ? 'PARTIAL' : 'INSUFFICIENT_HISTORY', grade_buckets, grade_11_buckets, monotonicity: monotonicity(grade_buckets, FOUR_ORDER), probability: probabilityCalibration(scored, 10, minSample), strata, }; } /** * ALTERNATIVE-MODEL REPLAY SEAM. * * `scoreFn(row)` sees ONLY the stored row (including its retained feature * vector) and returns `{ p_win }` — or `{ unavailable: '' }` when an * input it needs was never retained. Those rows are counted as un-backtestable * instead of being scored with substituted current data, which would make the * result a fiction. */ function replayAlternative(rows, scoreFn, opts = {}) { const minSample = Number.isFinite(opts.minSample) ? opts.minSample : DEFAULT_MIN_SAMPLE; const { scored } = partition(rows || []); const liveRows = []; const candRows = []; let un_backtestable = 0; for (const r of scored) { let out; try { out = scoreFn(r); } catch { out = { unavailable: 'scorer_threw' }; } if (!out || out.unavailable || out.p_win == null || !Number.isFinite(Number(out.p_win))) { un_backtestable += 1; continue; } liveRows.push(r); candRows.push({ ...r, p_win: Number(out.p_win) }); } const live = probabilityCalibration(liveRows); const candidate = probabilityCalibration(candRows); const identical = live.n === candidate.n && live.n > 0 && Math.abs((live.brier ?? 0) - (candidate.brier ?? 0)) < 1e-9; return { n_considered: scored.length, un_backtestable, live, candidate, identical, // Same refusal rule as the main report. verdict: candidate.n >= minSample ? 'COMPARABLE' : 'INSUFFICIENT_HISTORY', improvement: (candidate.n >= minSample && live.brier != null && candidate.brier != null) ? live.brier - candidate.brier // positive = candidate is better : null, }; } module.exports = { runBacktest, replayAlternative, wilsonInterval, probabilityCalibration, monotonicity, partition, DEFAULT_MIN_SAMPLE, };