// v5 hyperparameter search: rolling-origin cross-validation. // // The v4 protocol tuned on ONE validation window (2018–2022) — selection could // overfit that window's quirks. v5 selects by MEAN walk-forward RPS across five // rolling two-year folds, each with its own pre-fold parameter fit (no fold // ever sees information from its future): // F1 2014–16 · F2 2016–18 · F3 2018–20 · F4 2020–22 · F5 2022–24 // FINAL TEST: 2024-01-01 → 2026-06-01, untouched by selection, evaluated once. // // Search axes (staged greedy, like v4 — full grid is combinatorially silly): // stage 1: Team-DC ξ (decay), k (shrinkage), w (ensemble weight) // stage 2: fast-Elo blend (m, β) // stage 3: recent-form goal multiplier γ // stage 4: Elo-walk axes — HOME_ADV_ELO and K-scale σ // Ship bar (pre-registered): champion must beat the current shipped config by // ≥ MIN_GAIN mean-fold RPS. Output → data/cv-report.json (research artifact, // not public; the public evidence stays scripts/buildBacktest.ts). // // Self-contained on purpose: this is a research harness, and keeping it apart // from the publication script means it can never destabilize shipped evidence. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { canonicalTeam } from '../src/lib/teams'; import { importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo'; import { lambdasFromElo, scoreMatrix, outcomeProbs, poissonPmf, type ModelParams } from '../src/lib/model/poisson'; import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch, type TeamDcParams } from '../src/lib/model/teamDc'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const CSV = join(ROOT, 'data', 'raw', 'international-results.csv'); const OUT = join(ROOT, 'data', 'cv-report.json'); const START_ELO = 1500; const REFIT_DAYS = 60; const FIT_WINDOW_YEARS = 15; const PARAM_YEARS = 12; // params fit on the 12 years before each fold/test const DAY = 24 * 60 * 60 * 1000; const MIN_GAIN = 0.0005; const FOLDS = [ { from: '2014-01-01', to: '2016-01-01' }, { from: '2016-01-01', to: '2018-01-01' }, { from: '2018-01-01', to: '2020-01-01' }, { from: '2020-01-01', to: '2022-01-01' }, { from: '2022-01-01', to: '2024-01-01' }, ]; const TEST = { from: '2024-01-01', to: '2026-06-01' }; const XI_GRID = [0.25, 0.5, 0.75, 1, 1.5, 2]; const K_GRID = [3, 5, 10, 20]; const M_GRID = [2, 3]; const BETA_GRID = [0.1, 0.2, 0.3, 0.4]; const GAMMA_GRID = [0.05, 0.1, 0.15]; const HOMEADV_GRID = [50, 70, 90]; const KSCALE_GRID = [0.75, 1, 1.25]; const FORM_WINDOW = 10; /** the currently shipped v4 config — the bar to clear (homeAdv 70, σ 1) */ const SHIPPED = { xi: 0.25, k: 3, w: 0.45, m: 3, beta: 0.3, gamma: 0, homeAdv: 70, kScale: 1 }; interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean; t: number } type Probs = { h: number; d: number; a: number }; type Outcome = 'h' | 'd' | 'a'; function parse(): Row[] { const lines = readFileSync(CSV, 'utf8').split('\n'); const rows: Row[] = []; for (let i = 1; i < lines.length; i++) { const c = lines[i]?.split(','); if (!c || c.length < 9) continue; const hs = Number(c[3]); const as = Number(c[4]); if (!Number.isFinite(hs) || !Number.isFinite(as)) continue; rows.push({ date: c[0]!, home: canonicalTeam(c[1]!), away: canonicalTeam(c[2]!), hs, as, tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE', t: new Date(c[0]!).getTime(), }); } rows.sort((a, b) => a.t - b.t); return rows; } function dcTau(h: number, a: number, lh: number, la: number, rho: number): number { if (h === 0 && a === 0) return 1 - lh * la * rho; if (h === 0 && a === 1) return 1 + lh * rho; if (h === 1 && a === 0) return 1 + la * rho; if (h === 1 && a === 1) return 1 - rho; return 1; } const outcomeOf = (hs: number, as: number): Outcome => (hs > as ? 'h' : hs < as ? 'a' : 'd'); const probsOf = (m: number[][]): Probs => { const p = outcomeProbs(m); return { h: p.home, d: p.draw, a: p.away }; }; function rps(p: Probs, o: Outcome): number { const y1 = o === 'h' ? 1 : 0; const y2 = o === 'a' ? 0 : 1; return ((p.h - y1) ** 2 + (p.h + p.d - y2) ** 2) / 2; } // --------------------------------------------------------------------------- // Elo walk variants: one pass per (homeAdv, kScale), storing pre-match slow + // fast ratings and the pre-match form signal (mean Elo surprise, last 10 // non-friendly). Everything is strictly pre-match by construction. // --------------------------------------------------------------------------- interface Walk { preH: Float64Array; preA: Float64Array; // slow Elo, pre-match fast: Map; // per m formH: Float64Array; formA: Float64Array; } function runWalk(rows: Row[], homeAdvElo: number, kScale: number): Walk { const n = rows.length; const w: Walk = { preH: new Float64Array(n), preA: new Float64Array(n), fast: new Map(M_GRID.map((m) => [m, { h: new Float64Array(n), a: new Float64Array(n) }])), formH: new Float64Array(n), formA: new Float64Array(n), }; const elo = new Map(); const eloFast = new Map>(M_GRID.map((m) => [m, new Map()])); const formArr = new Map(); const get = (map: Map, t: string) => map.get(t) ?? START_ELO; const formMean = (t: string) => { const a = formArr.get(t); return a && a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }; for (let i = 0; i < n; i++) { const r = rows[i]!; const adv = r.neutral ? 0 : homeAdvElo; const eh = get(elo, r.home), ea = get(elo, r.away); w.preH[i] = eh; w.preA[i] = ea; for (const m of M_GRID) { const fm = eloFast.get(m)!; const arr = w.fast.get(m)!; const feh = get(fm, r.home), fea = get(fm, r.away); arr.h[i] = feh; arr.a[i] = fea; const fd = eloDelta(r.hs, r.as, feh, fea, importanceWeight(r.tournament) * kScale * m, adv); fm.set(r.home, feh + fd); fm.set(r.away, fea - fd); } w.formH[i] = formMean(r.home); w.formA[i] = formMean(r.away); if (r.tournament !== 'Friendly') { const eH = expectedHome(eh, ea, adv); const sH = r.hs > r.as ? 1 : r.hs < r.as ? 0 : 0.5; const push = (t: string, v: number) => { const a = formArr.get(t) ?? []; a.push(v); if (a.length > FORM_WINDOW) a.shift(); formArr.set(t, a); }; push(r.home, sH - eH); push(r.away, (1 - sH) - (1 - eH)); } const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament) * kScale, adv); elo.set(r.home, eh + d); elo.set(r.away, ea - d); } return w; } /** Fit goalsPerElo/avgGoals/rho on the PARAM_YEARS before `fromT` (pre-fold only). */ function fitParams(rows: Row[], walk: Walk, homeAdvElo: number, fromT: number): ModelParams { const startT = fromT - PARAM_YEARS * 365 * DAY; let sum = 0, n = 0, sxy = 0, sxx = 0; const calib: { d: number; h: number; a: number }[] = []; for (let i = 0; i < rows.length; i++) { const r = rows[i]!; if (r.t < startT) continue; if (r.t >= fromT) break; const eff = walk.preH[i]! - walk.preA[i]! + (r.neutral ? 0 : homeAdvElo); sum += r.hs + r.as; n++; sxy += eff * (r.hs - r.as); sxx += eff * eff; calib.push({ d: eff, h: r.hs, a: r.as }); } const avgGoals = sum / Math.max(1, n); const goalsPerElo = sxy / Math.max(1e-9, sxx); let rho0 = -0.05, bestLL = -Infinity; for (let r0 = -0.2; r0 <= 0.1 + 1e-9; r0 += 0.02) { let ll = 0; for (const m of calib) { const sup = goalsPerElo * m.d; const lh = Math.min(8, Math.max(0.15, avgGoals / 2 + sup / 2)); const la = Math.min(8, Math.max(0.15, avgGoals / 2 - sup / 2)); ll += Math.log(Math.max(1e-12, poissonPmf(lh, m.h) * poissonPmf(la, m.a) * dcTau(m.h, m.a, lh, la, r0))); } if (ll > bestLL) { bestLL = ll; rho0 = +r0.toFixed(2); } } return { goalsPerElo, avgGoals, rho: rho0, homeAdvElo }; } // --------------------------------------------------------------------------- // Walk-forward Team-DC lambdas per fold per (ξ, k) — the expensive part, cached. // --------------------------------------------------------------------------- type Pred = { i: number; lDcH: number; lDcA: number }; function walkTeamDc(rows: Row[], xi: number, k: number, fromT: number, toT: number): Pred[] { const preds: Pred[] = []; let fit: TeamDcParams | null = null; let fitAt = -Infinity; const windowMs = FIT_WINDOW_YEARS * 365 * DAY; for (let i = 0; i < rows.length; i++) { const r = rows[i]!; if (r.t < fromT) continue; if (r.t >= toT) break; if (r.t - fitAt > REFIT_DAYS * DAY) { const train: DcMatch[] = []; for (let j = 0; j < i; j++) { const m = rows[j]!; if (r.t - m.t > windowMs) continue; train.push({ daysAgo: (r.t - m.t) / DAY, home: m.home, away: m.away, homeGoals: m.hs, awayGoals: m.as, neutral: m.neutral, }); } fit = fitTeamDc(train, xi, k); fitAt = r.t; } const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral); preds.push({ i, lDcH: lambdaHome, lDcA: lambdaAway }); } return preds; } // --------------------------------------------------------------------------- // Scoring: mean RPS over one fold for a full config, sweeping w cheaply. // --------------------------------------------------------------------------- const W_STEPS: number[] = []; for (let w = 0; w <= 1.0001; w += 0.05) W_STEPS.push(+w.toFixed(2)); const clampL = (x: number) => Math.min(8, Math.max(0.1, x)); interface Cfg { xi: number; k: number; w: number; m: number; beta: number; gamma: number; homeAdv: number; kScale: number } function sweepW( rows: Row[], preds: Pred[], walk: Walk, params: ModelParams, beta: number, m: number, gamma: number, ): { w: number; rps: number }[] { const acc = new Float64Array(W_STEPS.length); const fast = walk.fast.get(m)!; for (const p of preds) { const r = rows[p.i]!; const o = outcomeOf(r.hs, r.as); const adv = r.neutral ? 0 : params.homeAdvElo; const eh = (1 - beta) * walk.preH[p.i]! + beta * fast.h[p.i]!; const ea = (1 - beta) * walk.preA[p.i]! + beta * fast.a[p.i]!; let { lambdaHome: elh, lambdaAway: ela } = lambdasFromElo(eh, ea, params, adv); let dlh = p.lDcH, dla = p.lDcA; if (gamma > 0) { const fh = Math.exp(gamma * walk.formH[p.i]!), fa = Math.exp(gamma * walk.formA[p.i]!); elh = clampL(elh * fh); ela = clampL(ela * fa); dlh = clampL(dlh * fh); dla = clampL(dla * fa); } const mE = scoreMatrix(elh, ela, params.rho); const mD = scoreMatrix(dlh, dla, params.rho); for (let wi = 0; wi < W_STEPS.length; wi++) { const w = W_STEPS[wi]!; const pooled = w === 0 ? mD : w === 1 ? mE : poolMatrices(mE, mD, w); acc[wi] += rps(probsOf(pooled), o); } } return W_STEPS.map((w, wi) => ({ w, rps: acc[wi]! / Math.max(1, preds.length) })); } // --------------------------------------------------------------------------- // Isotonic regression (PAV) for the calibration experiment: monotone map from // predicted probability to observed frequency, fit per outcome class. // --------------------------------------------------------------------------- interface IsoMap { x: number[]; y: number[] } function fitIsotonic(pairs: { p: number; y: number }[]): IsoMap { const sorted = [...pairs].sort((a, b) => a.p - b.p); // pool-adjacent-violators on means, weighted by block size const blocks: { sum: number; n: number; minP: number; maxP: number }[] = []; for (const { p, y } of sorted) { blocks.push({ sum: y, n: 1, minP: p, maxP: p }); while (blocks.length > 1) { const b = blocks[blocks.length - 1]!; const a = blocks[blocks.length - 2]!; if (a.sum / a.n <= b.sum / b.n) break; blocks.splice(blocks.length - 2, 2, { sum: a.sum + b.sum, n: a.n + b.n, minP: a.minP, maxP: b.maxP }); } } const x: number[] = [0]; const y: number[] = [0]; for (const b of blocks) { x.push((b.minP + b.maxP) / 2); y.push(b.sum / b.n); } x.push(1); y.push(1); // enforce strictly increasing x for interpolation for (let i = 1; i < x.length; i++) if (x[i]! <= x[i - 1]!) x[i] = x[i - 1]! + 1e-9; return { x, y }; } function applyIso(map: IsoMap, p: number): number { const { x, y } = map; if (p <= x[0]!) return y[0]!; for (let i = 1; i < x.length; i++) { if (p <= x[i]!) { const f = (p - x[i - 1]!) / (x[i]! - x[i - 1]!); return y[i - 1]! + f * (y[i]! - y[i - 1]!); } } return y[y.length - 1]!; } function calibrate(p: Probs, maps: { h: IsoMap; d: IsoMap; a: IsoMap }): Probs { const h = Math.max(1e-6, applyIso(maps.h, p.h)); const d = Math.max(1e-6, applyIso(maps.d, p.d)); const a = Math.max(1e-6, applyIso(maps.a, p.a)); const z = h + d + a; return { h: h / z, d: d / z, a: a / z }; } function eceOf(preds: { p: Probs; o: Outcome }[]): number { const bins = Array.from({ length: 10 }, () => ({ sp: 0, sy: 0, n: 0 })); for (const { p, o } of preds) { for (const [v, hit] of [[p.h, o === 'h'], [p.d, o === 'd'], [p.a, o === 'a']] as [number, boolean][]) { const i = Math.min(9, Math.floor(v * 10)); bins[i]!.sp += v; bins[i]!.sy += hit ? 1 : 0; bins[i]!.n++; } } const total = preds.length * 3; return bins.reduce((s, b) => s + (b.n ? (b.n / total) * Math.abs(b.sp / b.n - b.sy / b.n) : 0), 0); } function main(): void { const t0 = Date.now(); const rows = parse(); console.log(`${rows.length} rows · ${FOLDS.length} folds · test ${TEST.from}→${TEST.to}`); const foldTs = FOLDS.map((f) => ({ fromT: new Date(f.from).getTime(), toT: new Date(f.to).getTime() })); // Elo walks + per-fold params, per (homeAdv, kScale) — cheap, precompute all. const walks = new Map(); const foldParams = new Map(); // key → params per fold for (const ha of HOMEADV_GRID) { for (const ks of KSCALE_GRID) { const key = `${ha}|${ks}`; const w = runWalk(rows, ha, ks); walks.set(key, w); foldParams.set(key, foldTs.map((f) => fitParams(rows, w, ha, f.fromT))); } } console.log(`walks + per-fold params ready (${((Date.now() - t0) / 1000).toFixed(0)}s)`); const baseKey = `${SHIPPED.homeAdv}|${SHIPPED.kScale}`; const baseWalk = walks.get(baseKey)!; const baseParams = foldParams.get(baseKey)!; /** mean fold RPS for (xi,k) preds under a config, returning per-w means */ const dcCache = new Map(); // `${xi}|${k}` → preds per fold const foldPreds = (xi: number, k: number): Pred[][] => { const key = `${xi}|${k}`; let v = dcCache.get(key); if (!v) { v = foldTs.map((f) => walkTeamDc(rows, xi, k, f.fromT, f.toT)); dcCache.set(key, v); } return v; }; const meanSweep = (xi: number, k: number, beta: number, m: number, gamma: number, walk = baseWalk, params = baseParams): { w: number; rps: number }[] => { const folds = foldPreds(xi, k); const acc = new Float64Array(W_STEPS.length); for (let fi = 0; fi < folds.length; fi++) { const res = sweepW(rows, folds[fi]!, walk, params[fi]!, beta, m, gamma); for (let wi = 0; wi < W_STEPS.length; wi++) acc[wi] += res[wi]!.rps; } return W_STEPS.map((w, wi) => ({ w, rps: acc[wi]! / folds.length })); }; // ---- control: shipped config under the CV protocol ---- const shippedMean = meanSweep(SHIPPED.xi, SHIPPED.k, SHIPPED.beta, SHIPPED.m, SHIPPED.gamma) .find((x) => x.w === SHIPPED.w)!.rps; console.log(`shipped v4 config mean-fold rps ${shippedMean.toFixed(4)}`); // ---- stage 1: ξ, k, w ---- let best: Cfg & { rps: number } = { ...SHIPPED, rps: Infinity }; for (const xi of XI_GRID) { for (const k of K_GRID) { for (const { w, rps: r } of meanSweep(xi, k, 0, 2, 0)) { if (r < best.rps) best = { ...SHIPPED, xi, k, w, m: 2, beta: 0, gamma: 0, rps: r }; } console.log(` s1 ξ=${xi} k=${k} (best ξ=${best.xi} k=${best.k} w=${best.w} rps=${best.rps.toFixed(4)})`); } } // ---- stage 2: fast-Elo m, β ---- for (const m of M_GRID) { for (const beta of BETA_GRID) { for (const { w, rps: r } of meanSweep(best.xi, best.k, beta, m, 0)) { if (r < best.rps) best = { ...best, m, beta, w, rps: r }; } } } console.log(` s2 best: m=${best.m} β=${best.beta} w=${best.w} rps=${best.rps.toFixed(4)}`); // ---- stage 3: form γ ---- for (const gamma of GAMMA_GRID) { for (const { w, rps: r } of meanSweep(best.xi, best.k, best.beta, best.m, gamma)) { if (r < best.rps) best = { ...best, gamma, w, rps: r }; } } console.log(` s3 best: γ=${best.gamma} w=${best.w} rps=${best.rps.toFixed(4)}`); // ---- stage 4: Elo-walk axes (homeAdv, kScale) ---- for (const ha of HOMEADV_GRID) { for (const ks of KSCALE_GRID) { if (ha === SHIPPED.homeAdv && ks === SHIPPED.kScale && best.rps < Infinity) continue; const key = `${ha}|${ks}`; for (const { w, rps: r } of meanSweep(best.xi, best.k, best.beta, best.m, best.gamma, walks.get(key)!, foldParams.get(key)!)) { if (r < best.rps) best = { ...best, homeAdv: ha, kScale: ks, w, rps: r }; } } } console.log(` s4 best: homeAdv=${best.homeAdv} σ=${best.kScale} w=${best.w} rps=${best.rps.toFixed(4)}`); const gain = shippedMean - best.rps; const ship = gain >= MIN_GAIN; console.log(`champion mean-fold rps ${best.rps.toFixed(4)} vs shipped ${shippedMean.toFixed(4)} → gain ${gain.toFixed(4)} → ${ship ? 'SHIP' : 'KEEP shipped'}`); // ---- final test (2024–26), evaluated once for champion + shipped ---- const testFromT = new Date(TEST.from).getTime(); const testToT = new Date(TEST.to).getTime(); const testEval = (cfg: Cfg): number => { const key = `${cfg.homeAdv}|${cfg.kScale}`; const walk = walks.get(key)!; const params = fitParams(rows, walk, cfg.homeAdv, testFromT); const preds = walkTeamDc(rows, cfg.xi, cfg.k, testFromT, testToT); return sweepW(rows, preds, walk, params, cfg.beta, cfg.m, cfg.gamma).find((x) => x.w === cfg.w)!.rps; }; const champTest = testEval(best); const shippedTest = testEval(SHIPPED); console.log(`TEST 2024–26: champion rps ${champTest.toFixed(4)} | shipped rps ${shippedTest.toFixed(4)}`); // ---- isotonic calibration experiment (leave-one-fold-out, shipped config) ---- // Bar (pre-registered): mean LOFO RPS must not worsen by >0.0002 AND mean // LOFO ECE must improve by ≥10% relative — otherwise the layer stays out. console.log('isotonic calibration experiment (LOFO)…'); const probsFor = (preds: Pred[]): { p: Probs; o: Outcome }[] => { const out: { p: Probs; o: Outcome }[] = []; for (const p of preds) { const r = rows[p.i]!; out.push({ p: pooledFor(p), o: outcomeOf(r.hs, r.as) }); } return out; }; const pooledFor = (p: Pred): Probs => { const el = eloLambdasFor(p, SHIPPED); const mE = scoreMatrix(el.lh, el.la, baseParamsForRow(p).rho); const mD = scoreMatrix(p.lDcH, p.lDcA, baseParamsForRow(p).rho); return probsOf(SHIPPED.w === 0 ? mD : SHIPPED.w === 1 ? mE : poolMatrices(mE, mD, SHIPPED.w)); }; // helpers bound to the shipped config + per-fold params const foldOfRow = (i: number): number => { const t = rows[i]!.t; return foldTs.findIndex((f) => t >= f.fromT && t < f.toT); }; const baseParamsForRow = (p: Pred): ModelParams => baseParams[Math.max(0, foldOfRow(p.i))]!; const eloLambdasFor = (p: Pred, cfg: Cfg): { lh: number; la: number } => { const fast = baseWalk.fast.get(cfg.m)!; const eh = (1 - cfg.beta) * baseWalk.preH[p.i]! + cfg.beta * fast.h[p.i]!; const ea = (1 - cfg.beta) * baseWalk.preA[p.i]! + cfg.beta * fast.a[p.i]!; const params = baseParamsForRow(p); const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, rows[p.i]!.neutral ? 0 : params.homeAdvElo); return { lh: lambdaHome, la: lambdaAway }; }; const foldProbSets = foldPreds(SHIPPED.xi, SHIPPED.k).map(probsFor); let rawRps = 0, calRps = 0, rawEce = 0, calEce = 0; for (let held = 0; held < foldProbSets.length; held++) { const train = foldProbSets.flatMap((s, i) => (i === held ? [] : s)); const maps = { h: fitIsotonic(train.map(({ p, o }) => ({ p: p.h, y: o === 'h' ? 1 : 0 }))), d: fitIsotonic(train.map(({ p, o }) => ({ p: p.d, y: o === 'd' ? 1 : 0 }))), a: fitIsotonic(train.map(({ p, o }) => ({ p: p.a, y: o === 'a' ? 1 : 0 }))), }; const heldSet = foldProbSets[held]!; const calSet = heldSet.map(({ p, o }) => ({ p: calibrate(p, maps), o })); rawRps += heldSet.reduce((s, x) => s + rps(x.p, x.o), 0) / heldSet.length; calRps += calSet.reduce((s, x) => s + rps(x.p, x.o), 0) / calSet.length; rawEce += eceOf(heldSet); calEce += eceOf(calSet); } const nF = foldProbSets.length; rawRps /= nF; calRps /= nF; rawEce /= nF; calEce /= nF; const isoOk = calRps <= rawRps + 0.0002 && calEce <= rawEce * 0.9; console.log(` LOFO raw: rps ${rawRps.toFixed(4)} ece ${rawEce.toFixed(4)} | calibrated: rps ${calRps.toFixed(4)} ece ${calEce.toFixed(4)} → ${isoOk ? 'SHIP isotonic layer' : 'KEEP raw (bar not met)'}`); // Final maps (fit on ALL folds) — committed via data/calibration-maps.json and // embedded into ratings.json by buildRatings.ts when the experiment ships. let finalMaps: { home: IsoMap; draw: IsoMap; away: IsoMap } | null = null; if (isoOk) { const all = foldProbSets.flat(); finalMaps = { home: fitIsotonic(all.map(({ p, o }) => ({ p: p.h, y: o === 'h' ? 1 : 0 }))), draw: fitIsotonic(all.map(({ p, o }) => ({ p: p.d, y: o === 'd' ? 1 : 0 }))), away: fitIsotonic(all.map(({ p, o }) => ({ p: p.a, y: o === 'a' ? 1 : 0 }))), }; const round = (m: IsoMap): IsoMap => ({ x: m.x.map((v) => +v.toFixed(5)), y: m.y.map((v) => +v.toFixed(5)) }); finalMaps = { home: round(finalMaps.home), draw: round(finalMaps.draw), away: round(finalMaps.away) }; writeFileSync(join(ROOT, 'data', 'calibration-maps.json'), JSON.stringify({ generatedAt: new Date().toISOString(), fitOn: FOLDS, config: SHIPPED, lofo: { rawRps: +rawRps.toFixed(4), calRps: +calRps.toFixed(4), rawEce: +rawEce.toFixed(4), calEce: +calEce.toFixed(4) }, maps: finalMaps, }, null, 2)); console.log(' final maps → data/calibration-maps.json'); } mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), protocol: { folds: FOLDS, test: TEST, paramYears: PARAM_YEARS, minGain: MIN_GAIN, selection: 'mean fold RPS, staged greedy' }, shipped: { ...SHIPPED, meanFoldRps: +shippedMean.toFixed(4), testRps: +shippedTest.toFixed(4) }, champion: { ...best, rps: undefined, meanFoldRps: +best.rps.toFixed(4), testRps: +champTest.toFixed(4) }, shipChange: ship, isotonic: { bar: 'LOFO RPS worsens ≤0.0002 AND LOFO ECE improves ≥10%', lofo: { rawRps: +rawRps.toFixed(4), calRps: +calRps.toFixed(4), rawEce: +rawEce.toFixed(4), calEce: +calEce.toFixed(4) }, ship: isoOk, }, runtimeSec: Math.round((Date.now() - t0) / 1000), }, null, 2)); console.log(`report → data/cv-report.json (${Math.round((Date.now() - t0) / 1000)}s total)`); } main();