brackt/app/services/simulations/snooker-simulator.ts
chrisp 3e50619629
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
claude/probability-config-review-1tdolw (#119)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00

647 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Snooker World Championship Simulator
*
* Monte Carlo simulation of the World Snooker Championship (Crucible, Sheffield).
* The tournament is a 32-player single-elimination bracket with 5 rounds, using
* best-of frame formats that increase in length each round.
*
* Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB.
* 2. Load Elo ratings from participantExpectedValues.sourceElo.
* 3. Compute per-match win probability using the Bernoulli frame model:
* p_frame = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR))
* P(win match) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
* where F = frames to win, which varies by round.
* 4. Two simulation paths:
* a. Bracket populated: simulate from actual draw, respecting completed matches.
* b. No bracket: simulate qualifying (ranks 17-48 play one round best-of-19),
* randomly pair 16 winners vs top 16 seeds, then simulate 5-round bracket.
* 5. Track integer placement counts per tier across 50,000 simulations.
* 6. Convert to probability distributions using exact denominators (column sums = 1.0).
*
* Round format (Crucible main draw):
* First Round (R32): best-of-19, first to 10
* Second Round (R16): best-of-25, first to 13
* Quarter-Finals (QF): best-of-25, first to 13
* Semi-Finals (SF): best-of-33, first to 17
* Final: best-of-35, first to 18
*
* Placement bucketing (8-slot probability model):
* probFirst → Champion
* probSecond → Finalist
* probThird/Fourth → SF losers (2/sim)
* probFifthEighth → QF losers (4/sim)
* R16 losers → all 0
* R32 losers → all 0
*
* World rankings (for pre-bracket path): hardcoded below. Update annually.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-frame win probability.
* Higher = softer probabilities (more randomness, less Elo dominance).
* Lower = sharper probabilities (Elo differences matter more).
*
* Standard chess Elo uses 400. Snooker frames are more random than chess
* games, so a higher value is appropriate. Tune this until tournament win
* probabilities for the top seed feel realistic (~15% for a clear favourite
* in a 32-player field).
*
* Reference calibration points at ELO_DIVISOR=700:
* 100-pt gap → 53.5% per frame
* 300-pt gap → 60.6% per frame
* At ELO_DIVISOR=400 (standard chess):
* 100-pt gap → 56.2% per frame
* 300-pt gap → 67.9% per frame
*/
const ELO_DIVISOR = 700;
/**
* Frames needed to win per round, indexed by round order (most matches first).
* Index 0 = R32 (16 matches, best-of-19, need 10)
* Index 1 = R16 (8 matches, best-of-25, need 13)
* Index 2 = QF (4 matches, best-of-25, need 13)
* Index 3 = SF (2 matches, best-of-33, need 17)
* Index 4 = Final (1 match, best-of-35, need 18)
*/
const FRAMES_TO_WIN = [10, 13, 13, 17, 18] as const;
// ─── Tournament seedings (2025 World Snooker Championship) ────────────────────
// Seed 1 = defending champion; seeds 2-16 = world ranking order (skipping champion).
// Seeds 17+ = qualifiers — only their relative order here matters (all > 16).
// Update this map each year when a new snooker season is added.
// Players not in this map default to seed 999 (treated as qualifiers).
const SEEDINGS_2026: Record<string, number> = {
"Judd Trump": 1,
"Kyren Wilson": 2,
"Neil Robertson": 3,
"Mark Williams": 4,
"Zhao Xintong": 5,
"John Higgins": 6,
"Mark Selby": 7,
"Shaun Murphy": 8,
"Xiao Guodong": 9,
"Ronnie O'Sullivan": 10,
"Barry Hawkins": 11,
"Wu Yize": 12,
"Chris Wakelin": 13,
"Mark Allen": 14,
"Si Jiahui": 15,
"Ding Junhui": 16,
"Stuart Bingham": 17,
"Jack Lisowski": 18,
"Jak Jones": 19,
"Zhang Anda": 20,
"Elliot Slessor": 21,
"Thepchaiya Un-Nooh": 22,
"Ali Carter": 23,
"Gary Wilson": 24,
"Zhou Yuelong": 25,
"David Gilbert": 26,
"Stephen Maguire": 27,
"Joe O'Connor": 28,
"Pang Junxu": 29,
"Lei Peifan": 30,
"Yuan Sijun": 31,
"Tom Ford": 32,
"Hossein Vafaei": 33,
"Jimmy Robertson": 34,
"Ryan Day": 35,
"Jackson Page": 36,
"Matthew Selt": 37,
"Xu Si": 38,
"Ben Woollaston": 39,
"Aaron Hill": 40,
"Daniel Wells": 41,
"Anthony McGill": 42,
"Zak Surety": 43,
"Stan Moody": 44,
"Noppon Saengkham": 45,
"Luca Brecel": 46,
"He Guoqiang": 47,
"Matthew Stevens": 48,
};
/**
* Standard seeded R32 bracket for 32-player single-elimination.
* Each entry is [seedA, seedB] for one R32 match, in bracket order.
* Consecutive pairs of R32 winners form R16 matches (bracket structure preserved).
* Ensures seed 1 and seed 2 can only meet in the Final.
*
* Structure (seeds 1-16 are fixed; seeds 17-32 are randomly drawn qualifiers):
* Top half: 1v32, 16v17, 9v24, 8v25, 5v28, 12v21, 13v20, 4v29
* Bottom half: 3v30, 14v19, 11v22, 6v27, 7v26, 10v23, 15v18, 2v31
*/
const R32_BRACKET: Array<[number, number]> = [
[1, 32], [16, 17],
[9, 24], [8, 25],
[5, 28], [12, 21],
[13, 20], [4, 29],
[3, 30], [14, 19],
[11, 22], [6, 27],
[7, 26], [10, 23],
[15, 18], [2, 31],
];
// ─── Name normalization ──────────────────────────────────────────────────────
// Normalize unicode apostrophes/quotes so DB names (which may use curly quotes
// like U+2019 ') match the ASCII apostrophes (U+0027 ') in SEEDINGS keys.
function normalizeApostrophes(name: string): string {
return name.replace(/[\u2018\u2019\u2032\u0060]/g, "'");
}
const SEEDINGS_NORMALIZED = new Map(
Object.entries(SEEDINGS_2026).map(([name, seed]) => [normalizeApostrophes(name), seed])
);
function getSeedingForName(name: string): number {
return SEEDINGS_NORMALIZED.get(normalizeApostrophes(name)) ?? 999;
}
// ─── Math helpers ──────────────────────────────────────────────────────────────
/**
* Per-frame win probability for player 1 vs player 2 based on their Elo ratings.
*
* Uses the logistic sigmoid: p = 1 / (1 + e^(-(R1 - R2) / ELO_DIVISOR))
*
* ELO_DIVISOR is set higher than standard chess (400) to reflect the greater
* randomness of snooker frames vs chess games. At ELO_DIVISOR=700:
* 100-pt gap → ~53.5% per frame
* 300-pt gap → ~60.6% per frame
*
* Exported for unit testing.
*/
export function frameWinProb(elo1: number, elo2: number): number {
return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR));
}
/**
* Match win probability for player 1 using the Bernoulli frame model.
*
* For a best-of-(2F-1) match (first to F frames wins):
* P(win) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
*
* where p = per-frame win probability and F = framesToWin.
* This sums over all winning score combinations (F-0, F-1, ..., F-(F-1)).
* Exported for unit testing.
*/
export function matchWinProb(p: number, framesToWin: number): number {
const F = framesToWin;
let prob = 0;
for (let w2 = 0; w2 < F; w2++) {
prob += binomialCoeff(F - 1 + w2, w2) * Math.pow(p, F) * Math.pow(1 - p, w2);
}
return prob;
}
/** Binomial coefficient C(n, k) using iterative multiplication to avoid overflow. */
function binomialCoeff(n: number, k: number): number {
if (k === 0) return 1;
if (k > n - k) k = n - k; // Symmetry: C(n,k) = C(n, n-k)
let result = 1;
for (let i = 0; i < k; i++) {
result = (result * (n - i)) / (i + 1);
}
return result;
}
/** Fisher-Yates shuffle (in-place). */
function shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database();
// 1. Find the bracket scoring event for this sports season.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
// 2. Load playoff matches (may be empty if bracket hasn't been drawn yet).
const allMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
})
: [];
// 3. Load Elo ratings from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo map; fall back to 1500 for any participant with no stored rating.
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
} else if (!eloMap.has(r.participantId)) {
eloMap.set(r.participantId, 1500);
}
}
// Determine which simulation path to take.
const bracketPopulated =
allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap, numSimulations);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, db, numSimulations);
}
}
// ─── Path A: Full bracket simulation ────────────────────────────────────────
private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>,
numSimulations: number
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first).
const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []);
byRound.get(m.round)?.push(m);
}
const sortedRounds = [...byRound.values()]
.toSorted((a, b) => b.length - a.length)
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
if (sortedRounds.length !== 5) {
throw new Error(
`Expected 5 rounds for World Snooker Championship, found ${sortedRounds.length}. ` +
`Rounds: ${[...byRound.keys()].join(", ")}`
);
}
const [r32Matches, r16Matches, qfMatches, sfMatches, finalMatches] = sortedRounds;
if (r32Matches.length !== 16) {
throw new Error(
`Expected 16 First Round matches, found ${r32Matches.length}.`
);
}
// Collect all 32 participant IDs from R32.
const participantIds: string[] = [];
for (const m of r32Matches) {
if (!m.participant1Id || !m.participant2Id) {
throw new Error(
`First Round match ${m.matchNumber} is missing participants. ` +
`Assign all 32 players to the bracket before running simulation.`
);
}
participantIds.push(m.participant1Id, m.participant2Id);
}
// Build lookup maps by matchNumber for O(1) access in the hot loop.
const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m]));
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
const finalMatch = finalMatches[0];
const fallbackElo = 1500;
// Cache matchWinProb results — the Elo values and framesToWin are fixed across
// all simulations, so the same (elo1, elo2, framesToWin) triple always yields
// the same probability. Avoids recomputing the Bernoulli sum ~2M times per run.
const matchProbCache = new Map<string, number>();
const simMatch = (p1: string, p2: string, framesToWin: number): { winner: string; loser: string } => {
const elo1 = eloMap.get(p1) ?? fallbackElo;
const elo2 = eloMap.get(p2) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
const winner = Math.random() < winProb ? p1 : p2;
return { winner, loser: winner === p1 ? p2 : p1 };
};
// Integer placement counts.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
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 < numSimulations; s++) {
// ── First Round (R32) ──────────────────────────────────────────────────
const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) {
const m = r32ByNum.get(i);
if (!m) continue;
if (m.isComplete && m.winnerId) {
r32Winners.push(m.winnerId);
} else {
const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", FRAMES_TO_WIN[0]);
r32Winners.push(winner);
}
}
// ── Second Round (R16) ────────────────────────────────────────────────
// R16 losers score 0 — only the winner is tracked.
const r16Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
const dbMatch = r16ByNum.get(i);
let winner: string;
if (dbMatch?.isComplete && dbMatch.winnerId) {
winner = dbMatch.winnerId;
} else {
const p1 = r32Winners[(i - 1) * 2];
const p2 = r32Winners[(i - 1) * 2 + 1];
({ winner } = simMatch(p1, p2, FRAMES_TO_WIN[1]));
}
r16Winners.push(winner);
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 1; i <= 4; i++) {
const dbMatch = qfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = r16Winners[(i - 1) * 2];
const p2 = r16Winners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[2]));
}
qfWinners.push(winner);
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 1; i <= 2; i++) {
const dbMatch = sfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = qfWinners[(i - 1) * 2];
const p2 = qfWinners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[3]));
}
sfWinners.push(winner);
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
let champion: string;
let finalist: string;
if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
champion = finalMatch.winnerId;
finalist = finalMatch.loserId;
} else {
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]));
}
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
// ─── Path B: Pre-bracket simulation ─────────────────────────────────────────
// Players 17-48 haven't qualified yet. Each simulation:
// 1. Randomly pairs the qualifiers (17-48) and simulates 16 best-of-19 matches.
// 2. The 16 winners are shuffled and randomly drawn into seed slots 17-32.
// 3. The full 32-player bracket is then simulated using the proper seeded structure.
private async simulatePreBracket(
sportsSeasonId: string,
eloMap: Map<string, number>,
db: ReturnType<typeof database>,
numSimulations: number
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 17) {
throw new Error(
`Pre-bracket simulation requires at least 17 participants (got ${allParticipants.length}). ` +
`Add players to this sports season first.`
);
}
// Sort by seeding. Unranked players default to 999 (treated as qualifiers).
const ranked = allParticipants.toSorted((a, b) => {
return getSeedingForName(a.name) - getSeedingForName(b.name);
});
const topSeeds = ranked.slice(0, 16); // Fixed seed positions 1-16
const qualifiers = ranked.slice(16); // Randomly drawn into positions 17-32
const fallbackElo = 1500;
const matchProbCache = new Map<string, number>();
const simMatch = (p1Id: string, p2Id: string, framesToWin: number): string => {
const elo1 = eloMap.get(p1Id) ?? fallbackElo;
const elo2 = eloMap.get(p2Id) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
return Math.random() < winProb ? p1Id : p2Id;
};
// Pre-extract qualifier IDs once — reused (with a fresh shuffle) each iteration.
const qualifierIds = qualifiers.map((p) => p.id);
const allParticipantIds = allParticipants.map((p) => p.id);
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
// Whether qualifying needs to be simulated (>16 players competing for 16 bracket slots).
// If exactly 16 qualifiers are already loaded (the common case when only 32 total players
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
const needsQualifying = qualifiers.length > 16;
for (let s = 0; s < numSimulations; s++) {
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
let drawnQualifiers: string[];
if (!needsQualifying) {
// All 16 qualifiers go straight through — just shuffle the draw order.
drawnQualifiers = shuffle([...qualifierIds]);
} else {
// Multiple qualifying rounds (best-of-19 each) to whittle down to 16.
let pool = shuffle([...qualifierIds]);
while (pool.length > 16) {
const winners: string[] = [];
for (let i = 0; i + 1 < pool.length; i += 2) {
winners.push(simMatch(pool[i], pool[i + 1], FRAMES_TO_WIN[0]));
}
// Odd player out gets a bye.
if (pool.length % 2 !== 0) {
winners.push(pool[pool.length - 1]);
}
pool = shuffle(winners);
}
drawnQualifiers = pool;
}
// Seeds 5-16: randomly swap adjacent pairs to reflect uncertainty in
// mid-tier seedings (top 4 are locked in).
const midSeeds = topSeeds.slice(4).map((p) => p.id);
for (let i = 0; i < midSeeds.length - 1; i++) {
if (Math.random() < 0.5) {
[midSeeds[i], midSeeds[i + 1]] = [midSeeds[i + 1], midSeeds[i]];
}
}
// Map seed number (1-indexed) → participant ID.
const seedToId = new Map<number, string>();
topSeeds.slice(0, 4).forEach((p, i) => seedToId.set(i + 1, p.id));
midSeeds.forEach((id, i) => seedToId.set(i + 5, id));
drawnQualifiers.forEach((id, i) => seedToId.set(i + 17, id));
// ── First Round (R32) — proper seeded bracket ─────────────────────────
// R32_BRACKET defines which seeds meet in each match, in bracket order so
// consecutive R32 winner pairs form the correct R16 matchups.
const r32Winners: string[] = [];
for (const [s1, s2] of R32_BRACKET) {
const p1 = seedToId.get(s1) ?? "";
const p2 = seedToId.get(s2) ?? "";
r32Winners.push(simMatch(p1, p2, FRAMES_TO_WIN[0]));
}
// ── Second Round (R16) ────────────────────────────────────────────────
const r16Winners: string[] = [];
for (let i = 0; i < r32Winners.length; i += 2) {
r16Winners.push(simMatch(r32Winners[i], r32Winners[i + 1], FRAMES_TO_WIN[1]));
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 0; i < r16Winners.length; i += 2) {
const p1 = r16Winners[i], p2 = r16Winners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[2]);
qfWinners.push(winner);
qfLoserCounts.set(winner === p1 ? p2 : p1, (qfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 0; i < qfWinners.length; i += 2) {
const p1 = qfWinners[i], p2 = qfWinners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[3]);
sfWinners.push(winner);
sfLoserCounts.set(winner === p1 ? p2 : p1, (sfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
const champion = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]);
const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0];
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
}
// ─── Shared result builder ─────────────────────────────────────────────────────
function buildResults(
participantIds: string[],
N: number,
counts: {
championCounts: Map<string, number>;
finalistCounts: Map<string, number>;
sfLoserCounts: Map<string, number>;
qfLoserCounts: Map<string, number>;
}
): SimulationResult[] {
const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const sf = sfLoserCounts.get(participantId) ?? 0;
const qf = qfLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: sf / (2 * N),
probFourth: sf / (2 * N),
probFifth: qf / (4 * N),
probSixth: qf / (4 * N),
probSeventh: qf / (4 * N),
probEighth: qf / (4 * N),
},
source: "snooker_world_championship_monte_carlo",
};
});
// Per-position column normalization — ensures sums are exactly 1.0.
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}