/** * Little League World Series (LLWS) Bracket Simulator * * Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+). * * The tournament is two independent 10-team double-elimination brackets — United * States and International — each producing a side champion, then a World * Championship game and a Consolation game between the side runners-up. There is no * pool play. This mirrors the llws_20 bracket template so simulated placements line * up with the bracket admins actually score. * * Two modes: * 1. Pre-bracket mode: no llws_20 bracket exists yet (or it has no participants * seeded). Each side is shuffled into the 10 bracket slots every iteration, so * the draw is modelled as random. * 2. Bracket-aware mode: a seeded llws_20 bracket exists. Teams sit in their real * slots and completed match results are honored rather than re-simulated, so a * team that has already lost carries that loss into every iteration. * * Algorithm: * 1. Load all 20 participants for the sports season from DB * 2. Load the llws_20 playoff bracket, if one exists, to get the real draw and * whatever results have been recorded so far * 3. Load championship futures odds from participantExpectedValues.sourceOdds * (entered via Admin → Futures Odds; American format) * 4. Convert those futures to Elo via the shared probability engine, then drive * each game with the Elo win probability (see "Why Elo" below) * 5. Per simulation: * a. Place each side's 10 teams into the bracket slots (real draw when known, * otherwise shuffled) * b. Simulate the 10-team double-elimination bracket for each side, replaying * completed games from their recorded result (see simulateSideBracket) * c. Consolation game: US side loser vs Intl side loser → 3rd / 4th * d. World Championship: US champion vs Intl champion → 1st / 2nd * 6. Track placement counts across all simulations * 7. Convert counts to probability distributions * * Why Elo rather than raw futures: * A championship future already bakes in the ~6 wins needed to lift the trophy, so * using it directly as a single-game strength (p1 / (p1 + p2)) makes every * individual game as lopsided as the whole tournament and compounds the favorite's * edge over and over. convertFuturesToElo undoes that compression first (the * empirically calibrated cube-root step in decompressProbability) before mapping to * an Elo scale, which is how the other bracket simulators on the platform consume * futures. LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much * single-game variance there is in six-inning Little League baseball. * * Side assignment (externalId): "US" or "Intl". The legacy pool suffixes * ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side * alone, so seasons configured for the old pool-play format keep working — pools * no longer exist, so the suffix has no effect. When a seeded bracket exists the * bracket's own slots decide the sides and externalId is not consulted. * * Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring): * probFirst = World Championship winner (1 per sim) * probSecond = World Championship loser (1 per sim) * probThird = Consolation winner (1 per sim) * probFourth = Consolation loser (1 per sim) * probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side) * probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side) * Everyone else → all 0 (12 teams out in Elimination Rounds 1–3) * * Admin setup: * 1. Create a Sport with simulatorType = "llws_bracket" * 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International) * 3. Set externalId on each participant via Admin → Manage Participants to "US" or * "Intl" (optional — names starting with "US " infer US, all others infer Intl). * Once the bracket is generated and seeded this is no longer used. * 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds) * 5. Run simulation via Admin → Simulate */ import { database } from "~/database/context"; import { and, eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertFuturesToElo, eloWinProbabilityWithParity, } from "~/services/probability-engine"; import { llwsMatchNumber } from "~/lib/bracket-templates"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; const US_TEAM_COUNT = 10; const INTL_TEAM_COUNT = 10; const DEFAULT_ELO = 1500; const LLWS_TEMPLATE_ID = "llws_20"; /** * Elo scaling for a single LLWS game. * * Much higher than the 400-point standard because a six-inning Little League game * between 12-year-olds is far closer to a coin flip than a pro game: one pitcher, one * big inning, and the mercy rule all compress the gap. * * Calibrated by sweeping this value until a randomized-draw simulation reproduces the * championship futures it was fed. Against a representative 20-team futures board, * simulated championship probability vs. the market it came from: * parity 400 → favorite 21.8% priced, 51.5% simulated (RMSE 0.075) * parity 700 → favorite 21.8% priced, 31.5% simulated (RMSE 0.026) * parity 1000 → favorite 21.8% priced, 21.8% simulated (RMSE 0.003) ← chosen * parity 1200 → favorite 21.8% priced, 18.3% simulated (RMSE 0.010) * Overridable per season via the `parityFactor` simulator config. */ const LLWS_PARITY_FACTOR = 1_000; // ─── Types ──────────────────────────────────────────────────────────────────── type Side = "US" | "Intl"; /** Bracket-template side index: U.S. matches take the low match numbers. */ const SIDE_INDEX: Record = { US: 0, Intl: 1 }; /** The playoff_matches columns the simulator actually reads. */ export type BracketMatch = Pick< typeof schema.playoffMatches.$inferSelect, "round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete" >; interface Team { participantId: string; side: Side; /** Single-game strength on an Elo scale, decompressed from championship futures. */ elo: number; } interface PlacementCounts { champion: number; finalist: number; thirdPlace: number; fourthPlace: number; /** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */ elimFinalLoser: number; /** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */ elimRound4Loser: number; } /** * Plays one bracket game. `round`/`localMatch` identify the game within its side so a * completed result can be looked up; `t1`/`t2` are the teams the simulation has * routed into it. */ type PlayGame = ( round: string, localMatch: number, t1: Team, t2: Team ) => { winner: Team; loser: Team }; interface LoadedBracket { /** Each side's 10 teams in bracket slot order (8 opening-round, then 2 byes). */ slots: Record; /** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */ matches: Map; } // ─── Helpers ───────────────────────────────────────────────────────────────── function zeroCounts(): PlacementCounts { return { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, elimFinalLoser: 0, elimRound4Loser: 0, }; } function matchKey(round: string, matchNumber: number): string { return `${round}#${matchNumber}`; } function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } { const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor); return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } /** * Fisher-Yates shuffle (in-place). */ function shuffle(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; } /** * The recorded loser of a completed match. loserId is written by the scoring flow, * but fall back to "whichever slot isn't the winner" for older rows. */ function completedLoser(match: BracketMatch): string | null { if (match.loserId) return match.loserId; if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; return null; } /** * Build the game-playing function for one side. * * When the bracket has a completed result for a game AND that result is between the * two teams the simulation routed into it, the recorded winner is used verbatim — * that is what makes an already-played loss stick across all iterations. Anything * else is simulated. The pair check keeps a corrupt or out-of-order row from * desynchronising the rest of the bracket. */ export function makePlayGame( sideIndex: 0 | 1, bracket: LoadedBracket | null, parityFactor: number ): PlayGame { if (!bracket) { return (_round, _localMatch, t1, t2) => simGame(t1, t2, parityFactor); } return (round, localMatch, t1, t2) => { const match = bracket.matches.get( matchKey(round, llwsMatchNumber(round, sideIndex, localMatch)) ); if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); const arrived = [t1.participantId, t2.participantId]; if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { return match.winnerId === t1.participantId ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } } return simGame(t1, t2, parityFactor); }; } /** * Play one of the two cross-side games (Consolation, World Championship). Both are a * single shared match numbered 1, so they don't go through the side-local mapping. */ export function playCrossoverGame( round: string, bracket: LoadedBracket | null, parityFactor: number, t1: Team, t2: Team ): { winner: Team; loser: Team } { const match = bracket?.matches.get(matchKey(round, 1)); if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); const arrived = [t1.participantId, t2.participantId]; if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { return match.winnerId === t1.participantId ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } } return simGame(t1, t2, parityFactor); } /** * Simulate one side's 10-team double-elimination bracket. * * `slots` holds the side's teams in bracket order, matching the llws_20 participant * layout: slots[0..7] are the four opening-round games (two teams each) and * slots[8], slots[9] are the two bye teams entering Winners Round 2. * * Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket): * Winners bracket * OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7 * WR2-1 s8 v OP1w WR2-2 s9 v OP2w * WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w * WF WSF1w v WSF2w → winner to the side championship * Elimination bracket (a loss here is final) * ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l * ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w * ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over) * ER4 ER3-2w v ER3-1w → loser is the 7th–8th tier * EF WFl v ER4w → loser is the 5th–6th tier * Side championship: WFw v EFw → loser drops to the consolation game * * Note the double-chance path: the Winners Final loser is NOT out, it drops to the * Elimination Final. There is no "if necessary" game, so the side championship is * decided in one game. * * The team order passed to `play` matches each match's participant1 / participant2 * slots in the generated bracket, so recorded results line up game for game. * * Returns { sideChampion, sideLoser }; the two scoring elimination losers are * bumped into the counts directly. */ function simulateSideBracket( slots: Team[], bump: (id: string, key: keyof PlacementCounts) => void, play: PlayGame ): { sideChampion: Team; sideLoser: Team } { // ── Winners bracket ──────────────────────────────────────────────────────── const op1 = play("Opening Round", 1, slots[0], slots[1]); const op2 = play("Opening Round", 2, slots[2], slots[3]); const op3 = play("Opening Round", 3, slots[4], slots[5]); const op4 = play("Opening Round", 4, slots[6], slots[7]); const wr21 = play("Winners Round 2", 1, slots[8], op1.winner); const wr22 = play("Winners Round 2", 2, slots[9], op2.winner); const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner); const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner); const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner); // ── Elimination bracket ──────────────────────────────────────────────────── const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser); const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser); const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner); const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner); // Cross-over: each semifinal loser meets the winner from the opposite half. const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner); const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner); const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner); bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier // The Winners Final loser gets its second chance here. const ef = play("Elimination Final", 1, wf.loser, er4.winner); bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier // ── Side championship ────────────────────────────────────────────────────── const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner); return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser }; } // ─── Validation helpers ─────────────────────────────────────────────────────── /** * Parse a participant's externalId into a side. * * The legacy pool-play suffixes ("US:A", "Intl:B", …) are still accepted so seasons * configured before the format change keep loading; the pool part is ignored because * the tournament no longer has pools. */ function parseExternalId(raw: string | null): { side: Side } | null { if (!raw) return null; const side = raw.toUpperCase().split(":")[0]; if (side === "US") return { side: "US" }; if (side === "INTL") return { side: "Intl" }; return null; } /** * Infer an externalId from a participant name when none is stored. * Teams whose name is exactly "US" or starts with "US " (case-insensitive) * are assigned to the US side; all others are assigned to Intl. */ function inferExternalIdFromName(name: string): string { const upper = name.trim().toUpperCase(); return upper === "US" || upper.startsWith("US ") ? "US" : "Intl"; } // ─── Elo construction ───────────────────────────────────────────────────────── /** * Map participants to single-game Elo ratings from their championship futures. * * Teams with no odds entered sit at DEFAULT_ELO, which is also where every team lands * when the season has no odds at all — so an unconfigured season still simulates as a * field of coin flips rather than throwing. */ export function buildLLWSElos( evRows: Array<{ participantId: string; sourceOdds: number | null }> ): Map { const oddsInput = evRows .filter((row) => row.sourceOdds !== null) .map((row) => ({ participantId: row.participantId, odds: row.sourceOdds ?? 0 })); // convertFuturesToElo needs a spread to normalise against; a single priced team // carries no information about the rest of the field. if (oddsInput.length < 2) return new Map(); return convertFuturesToElo(oddsInput); } // ─── Bracket loading ────────────────────────────────────────────────────────── /** * Read the seeded llws_20 bracket for this season, if there is one. * * Returns null when no bracket exists yet or its opening slots have not been filled * in — in that case the caller falls back to a randomized draw. Throws when the * bracket is seeded with participants that don't belong to the season, which is a * misconfiguration worth surfacing rather than silently ignoring. */ export function readBracketSlots( matches: BracketMatch[], teamsById: Map ): LoadedBracket | null { if (matches.length === 0) return null; const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m])); const slots: Record = { US: [], Intl: [] }; const seen = new Set(); for (const side of ["US", "Intl"] as const) { const sideIndex = SIDE_INDEX[side]; const ids: (string | null)[] = []; for (let local = 1; local <= 4; local++) { const match = byKey.get( matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local)) ); ids.push(match?.participant1Id ?? null, match?.participant2Id ?? null); } for (let local = 1; local <= 2; local++) { const match = byKey.get( matchKey("Winners Round 2", llwsMatchNumber("Winners Round 2", sideIndex, local)) ); ids.push(match?.participant1Id ?? null); } // An unseeded (or partially seeded) bracket carries no draw information. if (ids.some((id) => id === null)) return null; for (const id of ids) { if (seen.has(id as string)) { throw new Error(`LLWS bracket seeds participant ${id} into more than one slot.`); } seen.add(id as string); const team = teamsById.get(id as string); if (!team) { throw new Error( `LLWS bracket references participant ${id}, which is not in this sports season.` ); } // The bracket is authoritative about which side a team is on. slots[side].push({ ...team, side }); } } return { slots, matches: byKey }; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class LLWSSimulator implements Simulator { constructor(private numSimulations = NUM_SIMULATIONS) {} async simulate(sportsSeasonId: string, config: Record = {}): Promise { const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations)); const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR); const db = database(); // 1. Load all participants. const participants = await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name, externalId: schema.seasonParticipants.externalId }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); if (participants.length !== US_TEAM_COUNT + INTL_TEAM_COUNT) { throw new Error( `LLWS simulator requires exactly ${US_TEAM_COUNT + INTL_TEAM_COUNT} participants, ` + `found ${participants.length}.` ); } // 2. Load championship futures odds. const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); // 3. Decompress the futures into single-game Elo ratings. const eloMap = buildLLWSElos(evRows); // 4. Parse externalId for each participant to determine which side they're on. // A seeded bracket overrides this below, but the field still has to be a legal // 10/10 split before we know whether a bracket exists. const teams: Team[] = []; const unparseableSides: Array<{ id: string; externalId: string | null }> = []; for (const p of participants) { const raw = p.externalId ?? inferExternalIdFromName(p.name); const parsed = parseExternalId(raw); if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId }); teams.push({ // Provisional: a seeded bracket overwrites this below. participantId: p.id, side: parsed?.side ?? "Intl", elo: eloMap.get(p.id) ?? DEFAULT_ELO, }); } const teamsById = new Map(teams.map((t) => [t.participantId, t])); // 5. Load the real bracket (draw + results so far), if one has been generated. const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game"), eq(schema.scoringEvents.bracketTemplateId, LLWS_TEMPLATE_ID) ), }); const bracketMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), }) : []; const bracket = readBracketSlots(bracketMatches, teamsById); // Validate sides. A seeded bracket already fixes the draw and an even 10/10 split, // so externalId only has to be usable on the randomized pre-bracket path. if (!bracket) { const [firstBad] = unparseableSides; if (firstBad) { throw new Error( `Participant ${firstBad.id} has invalid externalId "${firstBad.externalId}". ` + `Expected: "US" or "Intl".` ); } const usTeams = teams.filter((t) => t.side === "US"); const intlTeams = teams.filter((t) => t.side === "Intl"); if (usTeams.length !== US_TEAM_COUNT) { throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`); } if (intlTeams.length !== INTL_TEAM_COUNT) { throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`); } } const usPool = bracket ? bracket.slots.US : teams.filter((t) => t.side === "US"); const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl"); const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor); const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor); // 6. Initialise placement count accumulators for all participants. const allIds = participants.map((p) => p.id); const counts = new Map(allIds.map((id) => [id, zeroCounts()])); const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); if (entry) entry[key]++; }; // 7. Run Monte Carlo simulations. for (let s = 0; s < numSimulations; s++) { // With a real bracket the draw is fixed; without one it is modelled as random. const usSlots = bracket ? usPool : shuffle([...usPool]); const intlSlots = bracket ? intlPool : shuffle([...intlPool]); const { sideChampion: usChamp, sideLoser: usLose } = simulateSideBracket(usSlots, bump, playUS); const { sideChampion: intlChamp, sideLoser: intlLose } = simulateSideBracket(intlSlots, bump, playIntl); // Consolation game: 3rd / 4th place. const consolation = playCrossoverGame( "Consolation Third Place", bracket, parityFactor, usLose, intlLose ); bump(consolation.winner.participantId, "thirdPlace"); bump(consolation.loser.participantId, "fourthPlace"); // World Championship: 1st / 2nd place. const ws = playCrossoverGame( "World Championship", bracket, parityFactor, usChamp, intlChamp ); bump(ws.winner.participantId, "champion"); bump(ws.loser.participantId, "finalist"); } // 8. Convert counts to probability distributions. // Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and // the teams within a tier are tied, so the tier probability is split across // its two positions. const tierDivisor = 2 * numSimulations; const empty = zeroCounts(); return allIds.map((id) => { const c = counts.get(id) ?? empty; const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th return { participantId: id, probabilities: { probFirst: c.champion / numSimulations, probSecond: c.finalist / numSimulations, probThird: c.thirdPlace / numSimulations, probFourth: c.fourthPlace / numSimulations, probFifth: upperTier, probSixth: upperTier, probSeventh: lowerTier, probEighth: lowerTier, }, source: "llws_monte_carlo", }; }); } }