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);
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||||
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
|
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
|
||||||
(getSportsSeasonSimulatorConfig 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.tournamentGroups.findMany.mockResolvedValue([]);
|
||||||
mockDb.query.playoffMatches.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");
|
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ describe("WorldCupSimulator", () => {
|
||||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||||
mockDb.query.playoffMatches.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 results = await sim.simulate("season-1");
|
||||||
expect(results).toHaveLength(48);
|
expect(results).toHaveLength(48);
|
||||||
const ids = new Set(results.map((r) => r.participantId));
|
const ids = new Set(results.map((r) => r.participantId));
|
||||||
|
|
@ -141,7 +141,7 @@ describe("WorldCupSimulator", () => {
|
||||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||||
mockDb.query.playoffMatches.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 results = await sim.simulate("season-1");
|
||||||
|
|
||||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
|
|
@ -163,7 +163,7 @@ describe("WorldCupSimulator", () => {
|
||||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||||
mockDb.query.playoffMatches.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 results = await sim.simulate("season-1");
|
||||||
|
|
||||||
// probFirst + probSecond + probThird + probFourth should cover all probability mass
|
// probFirst + probSecond + probThird + probFourth should cover all probability mass
|
||||||
|
|
@ -219,7 +219,7 @@ describe("WorldCupSimulator", () => {
|
||||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
|
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
|
||||||
mockDb.query.playoffMatches.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 results = await sim.simulate("season-1");
|
||||||
|
|
||||||
const p3Result = results.find((r) => r.participantId === "p3");
|
const p3Result = results.find((r) => r.participantId === "p3");
|
||||||
|
|
@ -239,7 +239,7 @@ describe("WorldCupSimulator", () => {
|
||||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||||
mockDb.query.playoffMatches.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 results = await sim.simulate("season-1");
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,10 @@ export class EPLSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbEloMap.size === 0) {
|
const oddsInput = evRows
|
||||||
const oddsInput = evRows
|
.filter((row) => !dbEloMap.has(row.participantId) && row.sourceOdds !== null && row.sourceOdds !== undefined)
|
||||||
.filter((row) => row.sourceOdds !== null && row.sourceOdds !== undefined)
|
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
|
||||||
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
|
if (oddsInput.length > 0) {
|
||||||
const converted = convertFuturesToElo(oddsInput);
|
const converted = convertFuturesToElo(oddsInput);
|
||||||
for (const [participantId, elo] of converted) dbEloMap.set(participantId, elo);
|
for (const [participantId, elo] of converted) dbEloMap.set(participantId, elo);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
epl_standings: {
|
epl_standings: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
|
iterations: 10_000,
|
||||||
seasonGames: 38,
|
seasonGames: 38,
|
||||||
parityFactor: 400,
|
parityFactor: 400,
|
||||||
matchParityFactor: 400,
|
matchParityFactor: 400,
|
||||||
|
|
@ -155,7 +156,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
world_cup: {
|
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"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,9 @@ import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
||||||
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
||||||
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { logger } from "~/lib/logger";
|
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) ──
|
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
||||||
|
|
||||||
|
|
@ -51,7 +52,7 @@ function normalizeTeamName(name: string): string {
|
||||||
|
|
||||||
// ─── Parameters ──────────────────────────────────────────────────────────────
|
// ─── Parameters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const NUM_SIMULATIONS = 10_000;
|
||||||
const ELO_WEIGHT = 0.7;
|
const ELO_WEIGHT = 0.7;
|
||||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||||||
|
|
||||||
|
|
@ -217,8 +218,10 @@ function simGroup(
|
||||||
else { sa.pts += 1; sb.pts += 1; }
|
else { sa.pts += 1; sb.pts += 1; }
|
||||||
|
|
||||||
// Mark this pairing as done so we don't re-simulate it
|
// Mark this pairing as done so we don't re-simulate it
|
||||||
const key = [m.participant1Id, m.participant2Id].toSorted().join(":");
|
const [p1, p2] = m.participant1Id < m.participant2Id
|
||||||
completedPairs.add(key);
|
? [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++) {
|
for (let j = i + 1; j < teamIds.length; j++) {
|
||||||
const a = teamIds[i];
|
const a = teamIds[i];
|
||||||
const b = teamIds[j];
|
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
|
if (completedPairs.has(key)) continue; // already applied above
|
||||||
|
|
||||||
const result = simGroupMatch(eloFn(a), eloFn(b));
|
const result = simGroupMatch(eloFn(a), eloFn(b));
|
||||||
|
|
@ -279,24 +282,6 @@ function simKnockout(
|
||||||
return { winner, loser: winner === teamA ? teamB : teamA };
|
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 ───────────────────────────────────────────────────────────
|
// ─── Main simulator ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class WorldCupSimulator implements Simulator {
|
export class WorldCupSimulator implements Simulator {
|
||||||
|
|
@ -468,6 +453,31 @@ export class WorldCupSimulator implements Simulator {
|
||||||
counts.qfLoser.set(id, 0);
|
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++) {
|
for (let sim = 0; sim < this.numSimulations; sim++) {
|
||||||
// ── Group stage ──────────────────────────────────────────────
|
// ── Group stage ──────────────────────────────────────────────
|
||||||
const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams)
|
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
|
// Completed knockout matches are honoured by round+matchNumber, so as the real
|
||||||
// bracket plays out the simulation locks in actual results automatically.
|
// 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 r32 = runKnockoutRound(r32Pool, "Round of 32");
|
||||||
const r16 = runKnockoutRound(r32.winners, "Round of 16");
|
const r16 = runKnockoutRound(r32.winners, "Round of 16");
|
||||||
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
|
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
|
||||||
|
|
@ -595,11 +580,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalise floating-point residuals per position
|
normalizeSimulationResultColumns(results);
|
||||||
const positionKeys: Array<keyof SimulationProbabilities> = [
|
|
||||||
"probFirst", "probSecond", "probThird", "probFourth",
|
|
||||||
];
|
|
||||||
normalizeProbabilities(results, positionKeys);
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue