Fix simulator correctness bugs and reduce test iteration counts #61
7 changed files with 61 additions and 74 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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"],
|
||||
|
|
|
|||
|
|
@ -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