brackt/app/services/simulations/llws-simulator.ts
Claude 1bd23a4419
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.

Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.

The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.

Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.

Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.

Also:

- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
  hardcoded a 5th-place floor for winners of non-scoring rounds feeding
  a scoring one, which is wrong inside a losers bracket where a win can
  guarantee only 7th. Opt-in, so no existing template changes behavior.

- Fixes TabbedBracketLayout's mobile path, which built its match map
  unfiltered and so would have merged U.S. and International games into
  one column. No-op for NCAA and NBA, whose groups already cover every
  match in their phases.

- Rewrites the LLWS Monte Carlo simulator, which still modelled the
  retired pool-play format (5 teams per pool, then a 4-team bracket per
  side) and no longer described the tournament being scored. It now runs
  the real 10-team double elimination and splits the 5–8 probabilities
  into the correct tiers instead of one even four-way split. Legacy
  "US:A"/"Intl:B" externalIds are still accepted, read as the side
  alone, so seasons configured for the old format keep loading.

Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00

350 lines
15 KiB
TypeScript
Raw Permalink 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 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.
*
* 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. Per simulation:
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
* teams + 2 byes). The draw is modelled as random — a specific known draw
* is not yet expressible in participant config.
* b. Simulate the 10-team double-elimination bracket for each side
* (see simulateSideBracket for the exact game-by-game structure)
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
* d. World Championship: US champion vs Intl champion → 1st / 2nd
* 5. Track placement counts across all simulations.
* 6. Convert counts to probability distributions.
*
* 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.
*
* 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 13)
*
* 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)
* 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";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const US_TEAM_COUNT = 10;
const INTL_TEAM_COUNT = 10;
// ─── Types ────────────────────────────────────────────────────────────────────
type Side = "US" | "Intl";
interface Team {
participantId: string;
side: Side;
/** Normalized championship win probability (01, vig removed). */
oddsProb: number;
}
interface PlacementCounts {
champion: number;
finalist: number;
thirdPlace: number;
fourthPlace: number;
/** Lost the Elimination Final — the 5th6th tier (1 per side per sim). */
elimFinalLoser: number;
/** Lost Elimination Round 4 — the 7th8th tier (1 per side per sim). */
elimRound4Loser: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function zeroCounts(): PlacementCounts {
return {
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
elimFinalLoser: 0, elimRound4Loser: 0,
};
}
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;
}
/**
* 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 models/playoff-match):
* 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 7th8th tier
* EF WFl v ER4w → loser is the 5th6th 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.
*
* 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
): { sideChampion: Team; sideLoser: Team } {
// ── Winners bracket ────────────────────────────────────────────────────────
const op1 = simGame(slots[0], slots[1]);
const op2 = simGame(slots[2], slots[3]);
const op3 = simGame(slots[4], slots[5]);
const op4 = simGame(slots[6], slots[7]);
const wr21 = simGame(slots[8], op1.winner);
const wr22 = simGame(slots[9], op2.winner);
const wsf1 = simGame(op3.winner, wr21.winner);
const wsf2 = simGame(wr22.winner, op4.winner);
const wf = simGame(wsf1.winner, wsf2.winner);
// ── Elimination bracket ────────────────────────────────────────────────────
const er11 = simGame(op2.loser, op3.loser);
const er12 = simGame(op1.loser, op4.loser);
const er21 = simGame(wr21.loser, er11.winner);
const er22 = simGame(wr22.loser, er12.winner);
// Cross-over: each semifinal loser meets the winner from the opposite half.
const er31 = simGame(wsf1.loser, er22.winner);
const er32 = simGame(wsf2.loser, er21.winner);
const er4 = simGame(er32.winner, er31.winner);
bump(er4.loser.participantId, "elimRound4Loser"); // 7th8th tier
// The Winners Final loser gets its second chance here.
const ef = simGame(wf.loser, er4.winner);
bump(ef.loser.participantId, "elimFinalLoser"); // 5th6th tier
// ── Side championship ──────────────────────────────────────────────────────
const sideChampionship = simGame(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.
* The inferred value never has a pool suffix, so pools will be randomized.
*/
function inferExternalIdFromName(name: string): string {
const upper = name.trim().toUpperCase();
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class LLWSSimulator implements Simulator {
constructor(private numSimulations = NUM_SIMULATIONS) {}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
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));
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 which side they're on.
const teams: Team[] = [];
for (const p of participants) {
const raw = p.externalId ?? inferExternalIdFromName(p.name);
const parsed = parseExternalId(raw);
if (!parsed) {
throw new Error(
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
`Expected: "US" or "Intl".`
);
}
teams.push({
participantId: p.id,
side: parsed.side,
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}.`);
}
// 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, zeroCounts()]));
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 < numSimulations; s++) {
// The draw is modelled as random: shuffle each side into the 10 bracket slots
// (8 opening-round teams, then the 2 bye teams).
const { sideChampion: usChamp, sideLoser: usLose } =
simulateSideBracket(shuffle([...usTeams]), bump);
const { sideChampion: intlChamp, sideLoser: intlLose } =
simulateSideBracket(shuffle([...intlTeams]), bump);
// Consolation game: 3rd / 4th place.
const consolation = simGame(usLose, intlLose);
bump(consolation.winner.participantId, "thirdPlace");
bump(consolation.loser.participantId, "fourthPlace");
// World Championship: 1st / 2nd place.
const ws = simGame(usChamp, intlChamp);
bump(ws.winner.participantId, "champion");
bump(ws.loser.participantId, "finalist");
}
// 7. Convert counts to probability distributions.
// Each of the two 58 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; // 5th6th
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th8th
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",
};
});
}
}