Files
cup26/scripts/buildBacktest.ts
T
NilsBriggen 716dab2f23 Quick backtest mode for image builds (identical published numbers)
BACKTEST_QUICK=1 skips the hyperparameter grid and scores only the
shipped + previous configs — same test metrics, a fraction of the
build time on the 1-CPU VPS. The full grid search stays a local
activity. Verified byte-level: quick output matches the grid run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:33:42 +02:00

429 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// v4 bake-off backtest with a strict three-way split — the evidence behind the
// shipped model. NO information leaks forward:
// params (pre-2018) : Elo→goals calibration, Dixon-Coles rho
// validate (20182022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w,
// plus the v4 recency candidates — a fast-Elo blend
// (second walk at K×m, blended in with weight β) and a
// recent-form goal multiplier exp(γ·form), where form is
// each side's mean Elo surprise (actual expected) over
// its last 10 non-friendly matches
// test (20222026) : untouched final numbers for every variant + baselines
// Decision rule: a recency candidate ships only if it beats the previous shipped
// config by ≥ MIN_VAL_GAIN RPS on validation. Test is evaluated once, for honesty.
// Output → public/data/backtest.json (Methodology page).
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 { HOME_ADV_ELO, 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_DIR = join(ROOT, 'public', 'data');
const START_ELO = 1500;
const PARAM_FROM = '2006-01-01';
const VAL_FROM = '2018-01-01';
const TEST_FROM = '2022-01-01';
const TEST_TO = '2026-06-01';
const REFIT_DAYS = 60;
const FIT_WINDOW_YEARS = 15;
const XI_GRID = [0.25, 0.5, 0.75, 1, 1.5, 2, 2.5];
const K_GRID = [3, 5, 10, 20];
const M_GRID = [2, 3]; // fast-Elo K multiplier
const BETA_GRID = [0, 0.1, 0.2, 0.3]; // fast-Elo blend weight (0 = control)
const GAMMA_GRID = [0, 0.05, 0.1, 0.15]; // recent-form goal multiplier (0 = control)
const FORM_WINDOW = 10; // last N non-friendly matches feeding the form signal
const MIN_VAL_GAIN = 0.0005; // candidate must beat the previous config by this much
/** the previously shipped v3 config — the bar every candidate has to clear */
const PREV = { xi: 0.5, k: 5, w: 0.75, m: 2, beta: 0, gamma: 0 };
/** the v4 winner (from the full grid run) — keep in sync with buildRatings.ts */
const SHIPPED = { xi: 0.25, k: 3, w: 0.45, m: 3, beta: 0.3, gamma: 0 };
/** BACKTEST_QUICK=1 (Docker builds): skip the grid search and just score the
* shipped + previous configs — the published test numbers are identical, the
* hyperparam hunt only happens locally. */
const QUICK = process.env.BACKTEST_QUICK === '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';
const DAY = 24 * 60 * 60 * 1000;
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 brier(p: Probs, o: Outcome): number {
return (p.h - (o === 'h' ? 1 : 0)) ** 2 + (p.d - (o === 'd' ? 1 : 0)) ** 2 + (p.a - (o === 'a' ? 1 : 0)) ** 2;
}
const logloss = (p: Probs, o: Outcome): number => -Math.log(Math.max(1e-12, p[o]));
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;
}
const argmax = (p: Probs): Outcome => (p.h >= p.d && p.h >= p.a ? 'h' : p.d >= p.a ? 'd' : 'a');
function scoreSet(preds: { p: Probs; o: Outcome }[]) {
let b = 0, l = 0, r = 0, correct = 0;
for (const { p, o } of preds) { b += brier(p, o); l += logloss(p, o); r += rps(p, o); if (argmax(p) === o) correct++; }
const n = Math.max(1, preds.length);
return {
brier: +(b / n).toFixed(4), logloss: +(l / n).toFixed(4),
rps: +(r / n).toFixed(4), accuracy: +(correct / n).toFixed(4),
};
}
function main(): void {
const rows = parse();
const tValFrom = new Date(VAL_FROM).getTime();
const tTestFrom = new Date(TEST_FROM).getTime();
const tTestTo = new Date(TEST_TO).getTime();
// ---------- pass 1: Elo walks + form signal + Elo-DC param fit on pre-2018 ----------
const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO;
let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0;
const calib: { d: number; h: number; a: number }[] = [];
/** pre-match Elo per row index (so later passes never see post-match info) */
const preElo: { eh: number; ea: number }[] = new Array(rows.length);
/** pre-match fast Elo (same walk at K×m) per multiplier, for the β blend */
const eloFast = new Map<number, Map<string, number>>(M_GRID.map((m) => [m, new Map()]));
const preEloFast = new Map<number, { eh: number; ea: number }[]>(M_GRID.map((m) => [m, new Array(rows.length)]));
/** pre-match recent form per side: mean Elo surprise (s E) over the last
* FORM_WINDOW non-friendly matches — strictly pre-match, like preElo */
const formArr = new Map<string, number[]>();
const formH = new Float64Array(rows.length);
const formA = new Float64Array(rows.length);
const formMean = (t: string): number => {
const a = formArr.get(t);
return a && a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
};
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
const eh = get(r.home), ea = get(r.away);
preElo[i] = { eh, ea };
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
if (r.date >= PARAM_FROM && r.t < tValFrom) {
const eff = eh - ea + homeAdv;
sumTotal += r.hs + r.as; nCal++; sxy += eff * (r.hs - r.as); sxx += eff * eff;
calib.push({ d: eff, h: r.hs, a: r.as });
}
for (const m of M_GRID) {
const fm = eloFast.get(m)!;
const feh = fm.get(r.home) ?? START_ELO, fea = fm.get(r.away) ?? START_ELO;
preEloFast.get(m)![i] = { eh: feh, ea: fea };
const fd = eloDelta(r.hs, r.as, feh, fea, importanceWeight(r.tournament) * m, homeAdv);
fm.set(r.home, feh + fd); fm.set(r.away, fea - fd);
}
formH[i] = formMean(r.home);
formA[i] = formMean(r.away);
if (r.tournament !== 'Friendly') {
const eHome = expectedHome(eh, ea, homeAdv);
const sHome = r.hs > r.as ? 1 : r.hs < r.as ? 0 : 0.5;
const push = (t: string, v: number): void => {
const a = formArr.get(t) ?? [];
a.push(v);
if (a.length > FORM_WINDOW) a.shift();
formArr.set(t, a);
};
push(r.home, sHome - eHome);
push(r.away, (1 - sHome) - (1 - eHome));
}
const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament), homeAdv);
elo.set(r.home, eh + d); elo.set(r.away, ea - d);
}
const avgGoals = sumTotal / Math.max(1, nCal);
const goalsPerElo = sxy / Math.max(1e-9, sxx);
let rho = -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; rho = +r0.toFixed(2); }
}
const params: ModelParams = { goalsPerElo, avgGoals, rho, homeAdvElo: HOME_ADV_ELO };
// ---------- prediction helpers (all read strictly pre-match state) ----------
/** Elo-member lambdas, with optional fast-Elo blend R = (1−β)·slow + β·fast. */
const eloLambdas = (i: number, beta: number, m: number): { lh: number; la: number } => {
const slow = preElo[i]!;
const fast = beta > 0 ? preEloFast.get(m)![i]! : slow;
const eh = (1 - beta) * slow.eh + beta * fast.eh;
const ea = (1 - beta) * slow.ea + beta * fast.ea;
const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, rows[i]!.neutral ? 0 : HOME_ADV_ELO);
return { lh: lambdaHome, la: lambdaAway };
};
const clampL = (x: number): number => Math.min(8, Math.max(0.1, x));
/** Recent-form goal multiplier exp(γ·form), applied to both ensemble members. */
const withForm = (lh: number, la: number, i: number, gamma: number): { lh: number; la: number } =>
gamma === 0
? { lh, la }
: { lh: clampL(lh * Math.exp(gamma * formH[i]!)), la: clampL(la * Math.exp(gamma * formA[i]!)) };
// ---------- walk-forward Team-DC over a window, collecting lambdas ----------
type Pred = { i: number; lDcH: number; lDcA: number };
const walkTeamDc = (xi: number, k: number, fromT: number, toT: number, refitDays = REFIT_DAYS): 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 > refitDays * 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;
};
/** Sweep the ensemble weight w for a fixed (β, m, γ) over cached walk preds. */
const W_STEPS: number[] = [];
for (let w = 0; w <= 1.0001; w += 0.05) W_STEPS.push(+w.toFixed(2));
const sweepW = (preds: Pred[], beta: number, m: number, gamma: number): { w: number; rps: number }[] => {
const acc = new Float64Array(W_STEPS.length);
for (const p of preds) {
const o = outcomeOf(rows[p.i]!.hs, rows[p.i]!.as);
const el = eloLambdas(p.i, beta, m);
const ef = withForm(el.lh, el.la, p.i, gamma);
const df = withForm(p.lDcH, p.lDcA, p.i, gamma);
const mE = scoreMatrix(ef.lh, ef.la, rho);
const mD = scoreMatrix(df.lh, df.la, 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]! / preds.length }));
};
let best = { xi: PREV.xi, k: PREV.k, w: PREV.w, m: PREV.m, beta: 0, gamma: 0, rps: Infinity };
let prevValRps = Infinity; // validation RPS of the previously shipped config
if (QUICK) {
// ---------- quick mode: score only shipped + previous on validation ----------
console.log('quick mode — scoring shipped + previous configs on validation…');
const predsShipped = walkTeamDc(SHIPPED.xi, SHIPPED.k, tValFrom, tTestFrom);
const predsPrev = SHIPPED.xi === PREV.xi && SHIPPED.k === PREV.k
? predsShipped
: walkTeamDc(PREV.xi, PREV.k, tValFrom, tTestFrom);
const shippedRps = sweepW(predsShipped, SHIPPED.beta, SHIPPED.m, SHIPPED.gamma).find((x) => x.w === SHIPPED.w)!.rps;
prevValRps = sweepW(predsPrev, PREV.beta, PREV.m, PREV.gamma).find((x) => x.w === PREV.w)!.rps;
best = { ...SHIPPED, rps: shippedRps };
} else {
// ---------- validation stage 1: tune ξ, k, w (β=0, γ=0) ----------
console.log('validation stage 1 — ξ/k/w grid (β=0, γ=0)…');
const valWalks = new Map<string, Pred[]>();
for (const xi of XI_GRID) {
for (const k of K_GRID) {
const preds = walkTeamDc(xi, k, tValFrom, tTestFrom);
valWalks.set(`${xi}|${k}`, preds);
for (const { w, rps: r } of sweepW(preds, 0, PREV.m, 0)) {
if (xi === PREV.xi && k === PREV.k && w === PREV.w) prevValRps = r;
if (r < best.rps) best = { xi, k, w, m: PREV.m, beta: 0, gamma: 0, rps: r };
}
console.log(` ξ=${xi} k=${k} done (best so far: ξ=${best.xi} k=${best.k} w=${best.w} rps=${best.rps.toFixed(4)})`);
}
}
// ---------- validation stage 2: fast-Elo blend (m, β) at the stage-1 ξ/k ----------
console.log('validation stage 2 — fast-Elo blend (m, β)…');
const stagePreds = valWalks.get(`${best.xi}|${best.k}`)!;
for (const m of M_GRID) {
for (const beta of BETA_GRID) {
if (beta === 0) continue; // control already covered by stage 1
for (const { w, rps: r } of sweepW(stagePreds, beta, m, 0)) {
if (r < best.rps) best = { ...best, m, beta, w, rps: r };
}
}
console.log(` m=${m} swept (best: β=${best.beta} m=${best.m} w=${best.w} rps=${best.rps.toFixed(4)})`);
}
// ---------- validation stage 3: recent-form multiplier γ at the winners ----------
console.log('validation stage 3 — form multiplier γ…');
for (const gamma of GAMMA_GRID) {
if (gamma === 0) continue; // control covered above
for (const { w, rps: r } of sweepW(stagePreds, best.beta, best.m, gamma)) {
if (r < best.rps) best = { ...best, gamma, w, rps: r };
}
}
}
console.log(` champion: ξ=${best.xi} k=${best.k} w=${best.w} β=${best.beta} m=${best.m} γ=${best.gamma} rps=${best.rps.toFixed(4)}`);
// ---------- decision rule: ship only a validated improvement ----------
const gain = prevValRps - best.rps;
const shipChange = gain >= MIN_VAL_GAIN;
const shipped = shipChange ? best : { ...PREV, rps: prevValRps };
console.log(`previous config validation rps=${prevValRps.toFixed(4)} | champion gain=${gain.toFixed(4)}${shipChange ? 'SHIP candidate' : 'KEEP previous (bar not met)'}`);
// ---------- in-tournament refit cadence check (validation only) ----------
console.log('validating 7-day Team-DC refit cadence…');
const preds7 = walkTeamDc(shipped.xi, shipped.k, tValFrom, tTestFrom, 7);
const refit7Rps = sweepW(preds7, shipped.beta, shipped.m, shipped.gamma).find((x) => x.w === shipped.w)!.rps;
const refit7Ok = refit7Rps <= shipped.rps + MIN_VAL_GAIN;
console.log(` refit=7d rps=${refit7Rps.toFixed(4)} vs ${REFIT_DAYS}d rps=${shipped.rps.toFixed(4)} → nightly refit ${refit7Ok ? 'ENABLED' : 'disabled'}`);
// ---------- test window: shipped + candidate/previous + members + baselines ----------
console.log(`testing on ${TEST_FROM}${TEST_TO} with shipped ξ=${shipped.xi} k=${shipped.k} w=${shipped.w} β=${shipped.beta} m=${shipped.m} γ=${shipped.gamma}…`);
const testPreds = walkTeamDc(shipped.xi, shipped.k, tTestFrom, tTestTo);
const testPredsAlt = shipped.xi === PREV.xi && shipped.k === PREV.k
? testPreds
: walkTeamDc(shipChange ? PREV.xi : best.xi, shipChange ? PREV.k : best.k, tTestFrom, tTestTo);
const altCfg = shipChange ? { ...PREV } : { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma };
const altByIdx = new Map(testPredsAlt.map((p) => [p.i, p]));
const setEnsemble: { p: Probs; o: Outcome }[] = [];
const setAlt: { p: Probs; o: Outcome }[] = [];
const setElo: { p: Probs; o: Outcome }[] = [];
const setDc: { p: Probs; o: Outcome }[] = [];
const setUniform: { p: Probs; o: Outcome }[] = [];
const setRate: { p: Probs; o: Outcome }[] = [];
const reliabilityRaw: { p: number; y: number }[] = [];
let wcCorrect = 0, wcN = 0;
// outcome base rates over the test window
let th = 0, td = 0, ta = 0, tn = 0;
for (const p of testPreds) {
const o = outcomeOf(rows[p.i]!.hs, rows[p.i]!.as);
if (o === 'h') th++; else if (o === 'd') td++; else ta++; tn++;
}
const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn };
/** pooled outcome probs for one test row under a full config */
const pooledProbs = (p: Pred, cfg: { w: number; m: number; beta: number; gamma: number }): Probs => {
const el = eloLambdas(p.i, cfg.beta, cfg.m);
const ef = withForm(el.lh, el.la, p.i, cfg.gamma);
const df = withForm(p.lDcH, p.lDcA, p.i, cfg.gamma);
const mE = scoreMatrix(ef.lh, ef.la, rho);
const mD = scoreMatrix(df.lh, df.la, rho);
return probsOf(cfg.w === 0 ? mD : cfg.w === 1 ? mE : poolMatrices(mE, mD, cfg.w));
};
for (const p of testPreds) {
const r = rows[p.i]!;
const o = outcomeOf(r.hs, r.as);
const el = eloLambdas(p.i, 0, PREV.m);
const pe = probsOf(scoreMatrix(el.lh, el.la, rho));
const pd = probsOf(scoreMatrix(p.lDcH, p.lDcA, rho));
const pp = pooledProbs(p, shipped);
const alt = altByIdx.get(p.i);
if (alt) setAlt.push({ p: pooledProbs(alt, altCfg), o });
setElo.push({ p: pe, o });
setDc.push({ p: pd, o });
setEnsemble.push({ p: pp, o });
setUniform.push({ p: { h: 1 / 3, d: 1 / 3, a: 1 / 3 }, o });
setRate.push({ p: rate, o });
reliabilityRaw.push({ p: pp.h, y: o === 'h' ? 1 : 0 }, { p: pp.d, y: o === 'd' ? 1 : 0 }, { p: pp.a, y: o === 'a' ? 1 : 0 });
if (r.tournament.includes('FIFA World Cup') && !r.tournament.includes('qualification')) {
wcN++; if (argmax(pp) === o) wcCorrect++;
}
}
const bins = Array.from({ length: 10 }, () => ({ sp: 0, sy: 0, n: 0 }));
for (const { p, y } of reliabilityRaw) {
const idx = Math.min(9, Math.floor(p * 10));
bins[idx]!.sp += p; bins[idx]!.sy += y; bins[idx]!.n++;
}
const reliability = bins.map((b) => ({ predicted: b.n ? b.sp / b.n : 0, observed: b.n ? b.sy / b.n : 0, count: b.n }));
const totalN = reliabilityRaw.length;
const ece = +bins.reduce((s, b) => s + (b.n ? (b.n / totalN) * Math.abs(b.sp / b.n - b.sy / b.n) : 0), 0).toFixed(3);
const altName = shipChange
? 'Previous ensemble (v3)'
: 'Recency candidate (failed validation bar)';
const out = {
generatedAt: new Date().toISOString(),
paramFrom: PARAM_FROM,
trainEnd: VAL_FROM,
validation: {
from: VAL_FROM, to: TEST_FROM,
tuned: {
xi: shipped.xi, shrinkK: shipped.k, ensembleW: shipped.w,
fastM: shipped.m, fastBeta: shipped.beta, formGamma: shipped.gamma,
},
},
testFrom: TEST_FROM,
testTo: TEST_TO,
tested: testPreds.length,
params,
model: scoreSet(setEnsemble), // the SHIPPED model = ensemble
variants: [
{ name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) },
{ name: altName, ...scoreSet(setAlt) },
{ name: 'EloDixon-Coles', ...scoreSet(setElo) },
{ name: 'Team Dixon-Coles', ...scoreSet(setDc) },
],
baselines: { uniform: scoreSet(setUniform), baseRate: scoreSet(setRate), eloOnly: scoreSet(setElo) },
reliability,
ece,
worldCup: { tested: wcN, accuracy: wcN ? +(wcCorrect / wcN).toFixed(4) : 0 },
// the v4 recency decision, recorded for honesty + reproducibility
recency: {
minValGain: MIN_VAL_GAIN,
previous: { ...PREV, valRps: +prevValRps.toFixed(4) },
champion: { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma, valRps: +best.rps.toFixed(4) },
shippedChange: shipChange,
nightlyRefit: { refitDays: 7, valRps: +refit7Rps.toFixed(4), baselineValRps: +shipped.rps.toFixed(4), enabled: refit7Ok },
},
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'backtest.json'), JSON.stringify(out));
console.log(`\nbacktest → public/data/backtest.json (${out.tested} test matches, ${TEST_FROM}${TEST_TO})`);
for (const v of out.variants) console.log(` ${v.name.padEnd(20)} rps ${v.rps} brier ${v.brier} logloss ${v.logloss} acc ${(v.accuracy * 100).toFixed(1)}%`);
console.log(` baselines: uniform rps ${out.baselines.uniform.rps} | baseRate rps ${out.baselines.baseRate.rps}`);
console.log(` ECE ${ece} | WC accuracy ${(out.worldCup.accuracy * 100).toFixed(1)}% (${wcN})`);
}
main();