Fix simulator correctness bugs and reduce test iteration counts
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Failing after 2m31s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m18s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- Fix EPL Elo/odds fallback to populate odds-derived Elo for teams
  missing sourceElo even when other teams have it (was silently throwing)
- Reduce WorldCupSimulator default iterations 50k→10k; test iterations
  500→100 (WorldCup) and 10k→500 (EPL) to prevent timeouts on slow CI
- Override manifest defaultConfig iterations to 10k for world_cup and
  epl_standings so production fallback matches source-level defaults
- Extract runKnockoutRound out of the 10k-iteration simulation loop
- Replace hot-loop toSorted() on 2-element arrays with a conditional
  string comparison (eliminates allocation+sort on every group pair)
- Remove local normalizeProbabilities duplicate; use shared
  normalizeSimulationResultColumns from simulation-probabilities.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-31 10:28:48 -07:00
parent 10f23839cb
commit c3b7c77f09
5 changed files with 47 additions and 65 deletions

View file

@ -75,7 +75,7 @@ describe("EPLSimulator.simulate()", () => {
(database as unknown as MockInstance).mockReturnValue(mockDb);
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
(getSportsSeasonSimulatorConfig as unknown as MockInstance).mockResolvedValue({
config: { iterations: 10_000, matchParityFactor: 400 },
config: { iterations: 500, matchParityFactor: 400 },
});
});

View file

@ -114,7 +114,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
});
@ -125,7 +125,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
const results = await sim.simulate("season-1");
expect(results).toHaveLength(48);
const ids = new Set(results.map((r) => r.participantId));
@ -141,7 +141,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
const results = await sim.simulate("season-1");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
@ -163,7 +163,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
const results = await sim.simulate("season-1");
// probFirst + probSecond + probThird + probFourth should cover all probability mass
@ -219,7 +219,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
const results = await sim.simulate("season-1");
const p3Result = results.find((r) => r.participantId === "p3");
@ -239,7 +239,7 @@ describe("WorldCupSimulator", () => {
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const sim = new WorldCupSimulator(100);
const results = await sim.simulate("season-1");
for (const r of results) {

View file

@ -127,10 +127,10 @@ export class EPLSimulator implements Simulator {
}
}
if (dbEloMap.size === 0) {
const oddsInput = evRows
.filter((row) => row.sourceOdds !== null && row.sourceOdds !== undefined)
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
const oddsInput = evRows
.filter((row) => !dbEloMap.has(row.participantId) && row.sourceOdds !== null && row.sourceOdds !== undefined)
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
if (oddsInput.length > 0) {
const converted = convertFuturesToElo(oddsInput);
for (const [participantId, elo] of converted) dbEloMap.set(participantId, elo);
}

View file

@ -116,6 +116,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
epl_standings: {
defaultConfig: {
...BASE_CONFIG,
iterations: 10_000,
seasonGames: 38,
parityFactor: 400,
matchParityFactor: 400,
@ -155,7 +156,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"],
},
world_cup: {
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -40,8 +40,9 @@ import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import type { Simulator, SimulationResult, SimulationProbabilities } from "./types";
import type { Simulator, SimulationResult } from "./types";
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
@ -51,7 +52,7 @@ function normalizeTeamName(name: string): string {
// ─── Parameters ──────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const NUM_SIMULATIONS = 10_000;
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
@ -217,8 +218,10 @@ function simGroup(
else { sa.pts += 1; sb.pts += 1; }
// Mark this pairing as done so we don't re-simulate it
const key = [m.participant1Id, m.participant2Id].toSorted().join(":");
completedPairs.add(key);
const [p1, p2] = m.participant1Id < m.participant2Id
? [m.participant1Id, m.participant2Id]
: [m.participant2Id, m.participant1Id];
completedPairs.add(`${p1}:${p2}`);
}
}
@ -227,7 +230,7 @@ function simGroup(
for (let j = i + 1; j < teamIds.length; j++) {
const a = teamIds[i];
const b = teamIds[j];
const key = [a, b].toSorted().join(":");
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (completedPairs.has(key)) continue; // already applied above
const result = simGroupMatch(eloFn(a), eloFn(b));
@ -279,24 +282,6 @@ function simKnockout(
return { winner, loser: winner === teamA ? teamB : teamA };
}
// ─── Probability normalisation ────────────────────────────────────────────────
function normalizeProbabilities(
results: SimulationResult[],
positionKeys: Array<keyof SimulationProbabilities>
): void {
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0 && Math.abs(residual) < 1e-9) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
}
// ─── Main simulator ───────────────────────────────────────────────────────────
export class WorldCupSimulator implements Simulator {
@ -468,6 +453,31 @@ export class WorldCupSimulator implements Simulator {
counts.qfLoser.set(id, 0);
}
function runKnockoutRound(
teams: string[],
roundName: string
): { winners: string[]; losers: string[] } {
const winners: string[] = [];
const losers: string[] = [];
for (let i = 0; i < teams.length; i += 2) {
const a = teams[i];
const b = teams[i + 1];
if (!a || !b) continue;
const matchNum = Math.floor(i / 2) + 1;
const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`);
if (fixed) {
winners.push(fixed.winnerId);
losers.push(fixed.loserId);
} else {
const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb);
winners.push(winner);
losers.push(loser);
}
}
return { winners, losers };
}
for (let sim = 0; sim < this.numSimulations; sim++) {
// ── Group stage ──────────────────────────────────────────────
const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams)
@ -504,31 +514,6 @@ export class WorldCupSimulator implements Simulator {
// Completed knockout matches are honoured by round+matchNumber, so as the real
// bracket plays out the simulation locks in actual results automatically.
function runKnockoutRound(
teams: string[],
roundName: string
): { winners: string[]; losers: string[] } {
const winners: string[] = [];
const losers: string[] = [];
for (let i = 0; i < teams.length; i += 2) {
const a = teams[i];
const b = teams[i + 1];
if (!a || !b) continue;
const matchNum = Math.floor(i / 2) + 1;
const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`);
if (fixed) {
winners.push(fixed.winnerId);
losers.push(fixed.loserId);
} else {
const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb);
winners.push(winner);
losers.push(loser);
}
}
return { winners, losers };
}
const r32 = runKnockoutRound(r32Pool, "Round of 32");
const r16 = runKnockoutRound(r32.winners, "Round of 16");
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
@ -595,11 +580,7 @@ export class WorldCupSimulator implements Simulator {
};
});
// Normalise floating-point residuals per position
const positionKeys: Array<keyof SimulationProbabilities> = [
"probFirst", "probSecond", "probThird", "probFourth",
];
normalizeProbabilities(results, positionKeys);
normalizeSimulationResultColumns(results);
return results;
}