brackt/app/services/simulations/llws-simulator.ts
Chris Parsons 0da80bd2c0
Add LLWS bracket Monte Carlo simulator (#280)
* Add LLWS bracket Monte Carlo simulator

Simulates the 20-team Little League World Series: pool play round-robin
(5 teams/pool, top 2 advance) → 4-team double-elimination per side
(US and International) → consolation game → World Series final.

Uses championship futures odds as the sole win-probability signal.
Pool assignments are auto-detected from externalId: bare "US"/"Intl"
randomizes pools each simulation (pre-draw mode); "US:A"/"Intl:B" etc.
uses fixed assignments (post-draw mode). Sides can differ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in LLWS simulator

- Replace non-null assertions with null-safe access (! → ?? / optional chaining)
- Replace .sort() with .toSorted() per linting rules
- Promote bump() to simulate() scope and pass it into simulateSideBracket,
  removing the need to pass counts into that function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:52:10 -04:00

389 lines
16 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.

/**
* Little League World Series (LLWS) Bracket Simulator
*
* Monte Carlo simulation of the LLWS (20-team format, 2022present).
*
* Algorithm:
* 1. Load all 20 participants for the sports season from DB
* (must be exactly 10 US + 10 International, identified by externalId)
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
* (entered via Admin → Futures Odds; American format)
* 3. Convert odds to normalized championship probabilities (vig removed).
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
* 4. Determine pool assignment mode from externalId:
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
* → use these exact pool assignments every simulation.
* - Randomized pools: externalId is "US" or "Intl" only
* → randomly shuffle each side into Pool A / Pool B each simulation.
* 5. Per simulation:
* a. Assign pools (fixed or random)
* b. Simulate pool play round-robin within each pool (10 games/pool)
* Top 2 by W-L record advance. Ties broken randomly.
* c. Simulate 4-team double-elimination bracket per side:
* G1: A1 vs B2 (WB)
* G2: B1 vs A2 (WB)
* G3: G1W vs G2W (WB Final)
* G4: G1L vs G2L (LB R1 — loser eliminated)
* G5: G3L vs G4W (LB Final — loser eliminated)
* G6: G3W vs G5W (Side Championship — loser eliminated)
* d. Consolation game: US loser vs Intl loser → 3rd / 4th
* e. World Series: US champion vs Intl champion → 1st / 2nd
* 6. Track placement counts across all simulations.
* 7. Convert counts to probability distributions.
*
* Pool assignment (externalId format):
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
* "US" / "Intl" → randomized pools (pre-draw mode)
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = World Series Champion (1 per sim)
* probSecond = World Series Runner-up (1 per sim)
* probThird = Consolation game winner / 3rd place (1 per sim)
* probFourth = Consolation game loser / 4th place (1 per sim)
* probFifthprobEighth = Double-elim bracket losers before side championships
* (4 per sim — split evenly: 2 US + 2 Intl)
* Pool play losers → all 0 (12 teams, did not advance from pool play)
*
* 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:
* Pre-draw: "US" or "Intl"
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
* 5. Run simulation via Admin → Simulate
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const US_TEAM_COUNT = 10;
const INTL_TEAM_COUNT = 10;
const POOL_SIZE = 5; // teams per pool within each side
// ─── Types ────────────────────────────────────────────────────────────────────
type Side = "US" | "Intl";
interface Team {
participantId: string;
side: Side;
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
fixedPool: "A" | "B" | null;
/** Normalized championship win probability (01, vig removed). */
oddsProb: number;
}
interface PlacementCounts {
champion: number;
finalist: number;
thirdPlace: number;
fourthPlace: number;
bracketLoser: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
// If either team has no odds entered, treat the game as a coin flip.
// The 50/50 fallback must cover the one-sided case (one team known, one not)
// because oddsProb=0 would otherwise give the unknown team a 0% win rate.
let p1Win: number;
if (t1.oddsProb === 0 || t2.oddsProb === 0) {
p1Win = 0.5;
} else {
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
}
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
/**
* 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;
}
/**
* Assign teams to Pool A / Pool B for one side.
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
*/
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
if (!randomized) {
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
}
const shuffled = shuffle([...teams]);
return [shuffled.slice(0, 5), shuffled.slice(5)];
}
/**
* Simulate round-robin pool play among 5 teams.
* Returns the top 2 teams by win count (ties broken randomly).
*/
function simulatePoolPlay(pool: Team[]): [Team, Team] {
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
// Each pair plays once.
for (let i = 0; i < pool.length; i++) {
for (let j = i + 1; j < pool.length; j++) {
const { winner } = simGame(pool[i], pool[j]);
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
}
}
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
// the comparator is consistent (Math.random() inside a comparator is a bug — the
// engine may call it multiple times per pair and get contradictory results).
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
const ranked = pool.toSorted((a, b) => {
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
});
return [ranked[0], ranked[1]];
}
/**
* Simulate a 4-team double-elimination bracket for one side.
*
* Seeds (pool results):
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
*
* Bracket:
* G1 (WB): A1 vs B2
* G2 (WB): B1 vs A2
* G3 (WB Final): G1W vs G2W
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
*
* Returns: { sideChampion, sideLoser }
* bracketLosers (2) are bumped into counts directly.
*/
function simulateSideBracket(
poolA1: Team,
poolA2: Team,
poolB1: Team,
poolB2: Team,
bump: (id: string, key: keyof PlacementCounts) => void
): { sideChampion: Team; sideLoser: Team } {
// Winners bracket
const g1 = simGame(poolA1, poolB2);
const g2 = simGame(poolB1, poolA2);
const g3 = simGame(g1.winner, g2.winner); // WB Final
// Losers bracket
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
bump(g4.loser.participantId, "bracketLoser");
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
bump(g5.loser.participantId, "bracketLoser");
// Side championship
const g6 = simGame(g3.winner, g5.winner);
return { sideChampion: g6.winner, sideLoser: g6.loser };
}
// ─── Validation helpers ───────────────────────────────────────────────────────
type PoolSuffix = "A" | "B" | null;
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
if (!raw) return null;
const upper = raw.toUpperCase();
if (upper === "US") return { side: "US", pool: null };
if (upper === "INTL") return { side: "Intl", pool: null };
if (upper === "US:A") return { side: "US", pool: "A" };
if (upper === "US:B") return { side: "US", pool: "B" };
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
return null;
}
/**
* Determine whether pool assignments should be randomized for one side.
* - If ALL teams on the side have a pool suffix → fixed pools (returns false).
* - If NO teams have a pool suffix → randomized (returns true).
* - Mixed → throws.
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
*/
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
if (withPool.length > 0 && withoutPool.length > 0) {
throw new Error(
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
);
}
if (withPool.length === sideTeams.length) {
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
throw new Error(
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
);
}
return false; // fixed pools
}
return true; // randomized
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class LLWSSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants.
const participants = await db
.select({ id: schema.participants.id, externalId: schema.participants.externalId })
.from(schema.participants)
.where(eq(schema.participants.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.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const rawOddsMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceOdds !== null) {
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 3. Normalize odds (remove vig) to get championship probability per team.
const normalizedOddsMap = new Map<string, number>();
if (rawOddsMap.size > 0) {
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsMap) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
}
// 4. Parse externalId for each participant to determine side and fixed pool.
const teams: Team[] = [];
for (const p of participants) {
const parsed = parseExternalId(p.externalId);
if (!parsed) {
throw new Error(
`Participant ${p.id} has invalid or missing externalId "${p.externalId}". ` +
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
);
}
teams.push({
participantId: p.id,
side: parsed.side,
fixedPool: parsed.pool,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
});
}
// Validate team counts per side.
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}.`);
}
// Determine pool assignment mode for each side.
const usRandomized = determineRandomized(usTeams, "US");
const intlRandomized = determineRandomized(intlTeams, "International");
// 5. Initialise placement count accumulators for all participants.
const allIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>(
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
);
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
if (entry) entry[key]++;
};
// 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Assign pools for this simulation.
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
// Pool play: top 2 from each pool advance.
const [usA1, usA2] = simulatePoolPlay(usPoolA);
const [usB1, usB2] = simulatePoolPlay(usPoolB);
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
// Double-elimination bracket per side.
const { sideChampion: usChamp, sideLoser: usLose } =
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
const { sideChampion: intlChamp, sideLoser: intlLose } =
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
// Consolation game: 3rd / 4th place.
const consolation = simGame(usLose, intlLose);
bump(consolation.winner.participantId, "thirdPlace");
bump(consolation.loser.participantId, "fourthPlace");
// World Series: 1st / 2nd place.
const ws = simGame(usChamp, intlChamp);
bump(ws.winner.participantId, "champion");
bump(ws.loser.participantId, "finalist");
}
// 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
const bracketLosersPerSim = 4;
const bracketDivisor = bracketLosersPerSim * NUM_SIMULATIONS;
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
return allIds.map((id) => {
const c = counts.get(id) ?? zeroCounts;
const bracketProb = c.bracketLoser / bracketDivisor;
return {
participantId: id,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probThird: c.thirdPlace / NUM_SIMULATIONS,
probFourth: c.fourthPlace / NUM_SIMULATIONS,
probFifth: bracketProb,
probSixth: bracketProb,
probSeventh: bracketProb,
probEighth: bracketProb,
},
source: "llws_monte_carlo",
};
});
}
}