Fix simulator correctness bugs and reduce test iteration counts #61

Merged
chrisp merged 2 commits from fix/simulator-review-cleanups into main 2026-05-31 17:39:44 +00:00
7 changed files with 61 additions and 74 deletions

View file

@ -325,7 +325,7 @@ describe("DartsSimulator.simulate() — Path A (bracket populated)", () => {
it("each probability column sums to 1.0 across all 128 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new DartsSimulator();
const sim = new DartsSimulator(200);
const results = await sim.simulate("season-1");
const keys = [

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);
@ -156,14 +156,13 @@ describe("WorldCupSimulator", () => {
});
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
// Set up 8 participants (small bracket, 2 groups of 4)
const participants = makeParticipants(8);
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
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 +218,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 +238,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

@ -51,7 +51,7 @@ import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-set win probability.
@ -198,6 +198,12 @@ function shuffle<T>(arr: T[]): T[] {
// ─── Simulator ────────────────────────────────────────────────────────────────
export class DartsSimulator implements Simulator {
private readonly numSimulations: number;
constructor(numSimulations = 10_000) {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
@ -325,7 +331,7 @@ export class DartsSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < this.numSimulations; s++) {
// R1 (64 matches)
const r1Winners: string[] = [];
for (let i = 1; i <= 64; i++) {
@ -433,7 +439,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, NUM_SIMULATIONS, {
return buildResults(participantIds, this.numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
@ -514,7 +520,7 @@ export class DartsSimulator implements Simulator {
unseededPool.push(`__bye_${unseededPool.length}`);
}
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < this.numSimulations; s++) {
// Draw: shuffle the unseeded pool — seeded positions are pre-computed.
const drawnUnseeded = shuffle([...unseededPool]);
@ -582,7 +588,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
return buildResults(allParticipantIds, this.numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,

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,14 +156,14 @@ 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"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
},
darts_bracket: {
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"],

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;
}