import { readFileSync, existsSync } from 'node:fs'; import { eloDelta, importanceWeight } from '../../src/lib/model/elo'; import { predictMatch, type RatingsModel } from '../../src/lib/model/predict'; import { hostAdvantage } from '../../src/lib/model/hosts'; import { buildSimulator } from '../../src/lib/model/monteCarlo'; import type { Fixture, MatchPrediction, ModelSnapshot, OddsHistoryPoint } from '../../src/lib/types'; const ITERATIONS = Number(process.env.SIM_ITER) || 20_000; const WC_K = importanceWeight('FIFA World Cup'); function loadRatings(staticDir: string): RatingsModel { const candidates = [ `${staticDir}/data/ratings.json`, `${process.cwd()}/public/data/ratings.json`, `${process.cwd()}/dist/data/ratings.json`, ]; for (const p of candidates) { if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as RatingsModel; } throw new Error(`ratings.json not found. Run "npm run data:ratings". Looked in: ${candidates.join(', ')}`); } /** Owns the predictive model: live Elo re-rating, per-match probabilities, and * the Monte Carlo tournament simulation. Recomputes only when results change. */ export class ModelEngine { private readonly base: RatingsModel; private history: OddsHistoryPoint[] = []; private finishedKey = ''; // sentinel so the first recompute always runs private snapshot: ModelSnapshot | null = null; constructor(staticDir: string) { this.base = loadRatings(staticDir); } current(): ModelSnapshot { if (!this.snapshot) throw new Error('ModelEngine.recompute() not called yet'); return this.snapshot; } /** Recompute predictions if the set of finished results changed. Returns true * when it actually recomputed (so the caller can broadcast). */ recompute(fixtures: Fixture[]): boolean { const finished = fixtures .filter((f) => f.status === 'finished' && f.home.team && f.away.team && f.homeScore !== null && f.awayScore !== null) .sort((a, b) => a.num - b.num); const key = finished.map((f) => `${f.num}:${f.homeScore}-${f.awayScore}`).join('|'); if (this.snapshot && key === this.finishedKey) return false; this.finishedKey = key; const live = this.reRate(finished); const matches = this.predictMatches(fixtures, live); const sim = buildSimulator(fixtures, live).run(ITERATIONS); const last = finished[finished.length - 1]; const label = last ? `${last.home.team} ${last.homeScore}–${last.awayScore} ${last.away.team}` : 'Pre-tournament'; this.history.push({ t: new Date().toISOString(), label, top: sim.teams.slice(0, 8).map((o) => ({ team: o.team, champion: o.champion })), }); if (this.history.length > 200) this.history.shift(); this.snapshot = { generatedAt: new Date().toISOString(), asOf: live.asOf, iterations: ITERATIONS, matches, odds: sim.teams, history: this.history, }; return true; } /** Base ratings + an Elo update for every finished World Cup result so far. */ private reRate(finished: Fixture[]): RatingsModel { const ratings: Record = { ...this.base.ratings }; const elo = (t: string) => ratings[t] ?? 1500; let asOf = this.base.asOf; for (const f of finished) { const home = f.home.team!; const away = f.away.team!; const homeAdv = hostAdvantage(home, away, f.venue, this.base.params.homeAdvElo); const d = eloDelta(f.homeScore!, f.awayScore!, elo(home), elo(away), WC_K, homeAdv); ratings[home] = elo(home) + d; ratings[away] = elo(away) - d; const day = f.kickoff.slice(0, 10); if (day > asOf) asOf = day; } return { asOf, params: this.base.params, ratings }; } private predictMatches(fixtures: Fixture[], live: RatingsModel): MatchPrediction[] { const out: MatchPrediction[] = []; for (const f of fixtures) { if (!f.home.team || !f.away.team || f.status === 'finished') continue; const homeAdv = hostAdvantage(f.home.team, f.away.team, f.venue, live.params.homeAdvElo); const p = predictMatch(f.home.team, f.away.team, live, homeAdv); const top = p.scorelines[0] ?? { home: 0, away: 0, p: 0 }; out.push({ num: f.num, home: f.home.team, away: f.away.team, probs: p.probs, lambdaHome: Math.round(p.lambdaHome * 100) / 100, lambdaAway: Math.round(p.lambdaAway * 100) / 100, topScore: { home: top.home, away: top.away, p: top.p }, }); } return out; } }