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
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:
parent
10f23839cb
commit
c3b7c77f09
5 changed files with 47 additions and 65 deletions
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue