brackt/app/services/simulations/snooker-simulator.ts

644 lines
26 KiB
TypeScript
Raw Normal View History

/**
* 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";
// ─── Simulation parameters ────────────────────────────────────────────────────
const 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): Promise<SimulationResult[]> {
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.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.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);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, db);
}
}
// ─── Path A: Full bracket simulation ────────────────────────────────────────
private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, 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 < NUM_SIMULATIONS; 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, NUM_SIMULATIONS, {
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>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.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 < NUM_SIMULATIONS; 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, NUM_SIMULATIONS, {
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;
}