Files
cup26/src/lib/model/monteCarlo.test.ts
T
NilsBriggen d604bb14ff Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo
- buildRatings.ts: walks 49k historical results → World-Football-Elo per team,
  data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit
  Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites)
- src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo
  (full 48-team sim — group sampling, best-third bipartite allocation, knockout
  advance probs). 20 vitest cases incl. exact per-round count invariants
- Server ModelEngine: live Elo re-rating after each result, per-match W/D/L,
  20k-sim odds, odds-over-time history; broadcast on finished-result changes
- Client: championship board, heat-shaded odds table, lazy-loaded title-race
  chart (Recharts split to its own chunk), match-prediction bars, bracket
  advance overlay
- Verified: odds render, chart populates as injected results re-rate teams live

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:25:53 +02:00

73 lines
2.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildSimulator } from './monteCarlo';
import type { RatingsModel } from './predict';
import type { FixturesFile } from '../types';
const DATA = join(process.cwd(), 'public', 'data');
const fixtures = (JSON.parse(readFileSync(join(DATA, 'fixtures.json'), 'utf8')) as FixturesFile).fixtures;
const model = JSON.parse(readFileSync(join(DATA, 'ratings.json'), 'utf8')) as RatingsModel;
/** Deterministic RNG so the test is stable. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
describe('monte carlo tournament', () => {
const sim = buildSimulator(fixtures, model);
const res = sim.run(3000, mulberry32(12345));
const byTeam = new Map(res.teams.map((t) => [t.team, t]));
const sum = (k: keyof (typeof res.teams)[number]) =>
res.teams.reduce((s, t) => s + (t[k] as number), 0);
it('simulates all 48 teams', () => {
expect(res.teams.length).toBe(48);
});
it('conserves the exact per-round counts (12/32/16/8/4/2/1)', () => {
expect(sum('winGroup')).toBeCloseTo(12, 5);
expect(sum('qualify')).toBeCloseTo(32, 5);
expect(sum('reachR16')).toBeCloseTo(16, 5);
expect(sum('reachQF')).toBeCloseTo(8, 5);
expect(sum('reachSF')).toBeCloseTo(4, 5);
expect(sum('reachFinal')).toBeCloseTo(2, 5);
expect(sum('champion')).toBeCloseTo(1, 5);
});
it('every team has monotonically non-increasing deep-run odds', () => {
for (const t of res.teams) {
expect(t.qualify).toBeGreaterThanOrEqual(t.reachR16 - 1e-9);
expect(t.reachR16).toBeGreaterThanOrEqual(t.reachQF - 1e-9);
expect(t.reachQF).toBeGreaterThanOrEqual(t.reachSF - 1e-9);
expect(t.reachSF).toBeGreaterThanOrEqual(t.reachFinal - 1e-9);
expect(t.reachFinal).toBeGreaterThanOrEqual(t.champion - 1e-9);
expect(t.winGroup).toBeGreaterThanOrEqual(t.champion - 1e-9);
}
});
it('all probabilities are within [0, 1]', () => {
for (const t of res.teams) {
for (const k of ['winGroup', 'qualify', 'reachR16', 'reachQF', 'reachSF', 'reachFinal', 'champion'] as const) {
expect(t[k]).toBeGreaterThanOrEqual(0);
expect(t[k]).toBeLessThanOrEqual(1);
}
}
});
it('ranks a strong team well above a weak one', () => {
const strong = byTeam.get('Spain')!;
const weak = byTeam.get('Curaçao')!;
expect(strong.champion).toBeGreaterThan(weak.champion);
expect(strong.qualify).toBeGreaterThan(weak.qualify);
// the favourite should have a non-trivial title chance
expect(res.teams[0]!.champion).toBeGreaterThan(0.04);
});
});