From c3b7c77f0937daed76953b341f705967a0f8eecc Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 31 May 2026 10:28:48 -0700 Subject: [PATCH 1/2] Fix simulator correctness bugs and reduce test iteration counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../__tests__/epl-simulator.test.ts | 2 +- .../__tests__/world-cup-simulator.test.ts | 12 +-- app/services/simulations/epl-simulator.ts | 8 +- app/services/simulations/manifest.ts | 3 +- .../simulations/world-cup-simulator.ts | 87 ++++++++----------- 5 files changed, 47 insertions(+), 65 deletions(-) diff --git a/app/services/simulations/__tests__/epl-simulator.test.ts b/app/services/simulations/__tests__/epl-simulator.test.ts index 546b9fa..9e89ee6 100644 --- a/app/services/simulations/__tests__/epl-simulator.test.ts +++ b/app/services/simulations/__tests__/epl-simulator.test.ts @@ -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 }, }); }); diff --git a/app/services/simulations/__tests__/world-cup-simulator.test.ts b/app/services/simulations/__tests__/world-cup-simulator.test.ts index ab18632..bf2e9e4 100644 --- a/app/services/simulations/__tests__/world-cup-simulator.test.ts +++ b/app/services/simulations/__tests__/world-cup-simulator.test.ts @@ -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) { diff --git a/app/services/simulations/epl-simulator.ts b/app/services/simulations/epl-simulator.ts index f2b4f49..b0f08d8 100644 --- a/app/services/simulations/epl-simulator.ts +++ b/app/services/simulations/epl-simulator.ts @@ -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); } diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 589b6ec..7558cda 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -116,6 +116,7 @@ const PROFILES: Record -): 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 = [ - "probFirst", "probSecond", "probThird", "probFourth", - ]; - normalizeProbabilities(results, positionKeys); + normalizeSimulationResultColumns(results); return results; } -- 2.45.3 From c0f674f648b4f3ef96e86e13e7cff0dca07b1773 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 31 May 2026 10:34:47 -0700 Subject: [PATCH 2/2] Fix darts simulator timeout and world-cup bracket edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add numSimulations constructor param to DartsSimulator (same pattern as WorldCupSimulator); drop default from 50k→10k; update manifest to match - Pass 200 iterations in the slow darts column-sum test to prevent CI timeout - Fix world-cup "SF losers" test to use 48 participants — with only 8, the R32 pool has 6 teams and SF never runs, leaving all placement counts at 0 and normalization producing probFirst=probThird=1 for one team - Add manifest iterations: 10_000 override for darts_bracket Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/darts-simulator.test.ts | 2 +- .../__tests__/world-cup-simulator.test.ts | 3 +-- app/services/simulations/darts-simulator.ts | 16 +++++++++++----- app/services/simulations/manifest.ts | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/services/simulations/__tests__/darts-simulator.test.ts b/app/services/simulations/__tests__/darts-simulator.test.ts index 8a45803..6f0c842 100644 --- a/app/services/simulations/__tests__/darts-simulator.test.ts +++ b/app/services/simulations/__tests__/darts-simulator.test.ts @@ -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 = [ diff --git a/app/services/simulations/__tests__/world-cup-simulator.test.ts b/app/services/simulations/__tests__/world-cup-simulator.test.ts index bf2e9e4..0ea67b6 100644 --- a/app/services/simulations/__tests__/world-cup-simulator.test.ts +++ b/app/services/simulations/__tests__/world-cup-simulator.test.ts @@ -156,8 +156,7 @@ 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([]); diff --git a/app/services/simulations/darts-simulator.ts b/app/services/simulations/darts-simulator.ts index 673b37c..1671259 100644 --- a/app/services/simulations/darts-simulator.ts +++ b/app/services/simulations/darts-simulator.ts @@ -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(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 { const db = database(); @@ -325,7 +331,7 @@ export class DartsSimulator implements Simulator { const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map(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, diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 7558cda..e2e7f9f 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -163,7 +163,7 @@ const PROFILES: Record