Four fixes from code review of the previous commit.
1. Preserve the futures board's dispersion (llws-simulator.ts).
convertFuturesToElo finishes by rescaling any field onto a fixed
1250-1750 Elo span, discarding how spread out the board actually is: a
board with a 22%-priced favorite and one with a 6%-priced favorite both
came out 500 Elo wide. On a tight board that inflated the favorite from
6% to 13% -- worse than the raw-futures model it replaced (RMSE 0.025 vs
0.005), so the previous commit was a regression in that regime.
buildLLWSElos now maps decompressed strengths by their log-ratio to the
field's geometric mean, so Elo span tracks real dispersion. Re-swept the
parity factor across three board shapes rather than one: 550 minimizes
total error. Simulated vs priced favorite, with RMSE:
wide 21.8% -> 20.7% (0.0055), elo span 1355-1682
top-heavy 28.2% -> 25.6% (0.0085), elo span 1354-1733
tight 6.0% -> 5.8% (0.0026), elo span 1484-1518
2. Rate an unpriced team at the field's median, not 1500.
DEFAULT_ELO is the midpoint of the Elo output range, not of the field;
on a typical board it ranked an unpriced team ~6th of 20, so blanking a
team's odds promoted it. buildLLWSElos now returns the priced field's
median alongside the ratings (ranks 11th of 20 on the same board).
3. Pick the bracket event deterministically.
scoringEvents.findFirst with no ordering returned an arbitrary row when a
season had more than one llws_20 playoff event; landing on a stale one
silently reverted to a randomized draw that ignored all recorded results.
Now takes the most recent, matching world-cup-simulator.ts.
4. Fail on a partially seeded bracket instead of discarding it.
readBracketSlots returned null on any single missing participant, throwing
away the draw and every completed result with no warning. Since
playoff_matches participant columns are ON DELETE SET NULL, removing and
re-adding one participant mid-tournament was enough to put eliminated
teams back in contention. It now distinguishes "generated but not seeded"
(0 slots filled -> randomized draw) from "partially seeded" (throws).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
689 lines
30 KiB
TypeScript
689 lines
30 KiB
TypeScript
/**
|
||
* 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. buildLLWSElos undoes that compression first (the empirically
|
||
* calibrated cube-root step in decompressProbability) before mapping to an Elo
|
||
* scale, and LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much
|
||
* single-game variance there is in six-inning Little League baseball. Unlike the
|
||
* shared convertFuturesToElo helper, the mapping preserves how spread out the board
|
||
* actually is — see buildLLWSElos for why that matters.
|
||
*
|
||
* 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 {
|
||
convertAmericanOddsToProbability,
|
||
decompressProbability,
|
||
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.
|
||
*
|
||
* 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, across boards of different shape (see
|
||
* LLWS_ELO_SPREAD for why the shape matters). Total RMSE over a wide board, a
|
||
* top-heavy board, and a nearly flat one:
|
||
* parity 450 → 0.028
|
||
* parity 550 → 0.016 ← chosen
|
||
* parity 750 → 0.040
|
||
* parity 1000 → 0.061
|
||
* Overridable per season via the `parityFactor` simulator config.
|
||
*/
|
||
const LLWS_PARITY_FACTOR = 550;
|
||
|
||
/**
|
||
* Elo points per natural-log unit of relative team strength.
|
||
*
|
||
* Only the ratio LLWS_ELO_SPREAD / parityFactor affects the simulation, so this fixes
|
||
* the readable scale of the ratings and LLWS_PARITY_FACTOR does the calibrating. 300
|
||
* puts a typical 20-team board in the familiar ~1350–1700 range.
|
||
*/
|
||
const LLWS_ELO_SPREAD = 300;
|
||
|
||
/**
|
||
* Power transform undoing the compounding baked into a championship future.
|
||
* Matches DEFAULT_CALIBRATION.exponent in the probability engine.
|
||
*/
|
||
const LLWS_DECOMPRESSION_EXPONENT = 0.33;
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Side = "US" | "Intl";
|
||
|
||
/** Bracket-template side index: U.S. matches take the low match numbers. */
|
||
const SIDE_INDEX: Record<Side, 0 | 1> = { 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<Side, Team[]>;
|
||
/** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */
|
||
matches: Map<string, BracketMatch>;
|
||
}
|
||
|
||
// ─── 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<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;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Deliberately NOT convertFuturesToElo. That helper finishes by rescaling the field
|
||
* onto a fixed 1250–1750 span (mapToElo), which throws away how spread out the board
|
||
* actually is: a board whose favorite is priced at 22% and one whose favorite is
|
||
* priced at 6% both come out 500 Elo wide, so the tight board's field gets pulled
|
||
* apart into contenders and no-hopers that the market never implied. On such a board
|
||
* that inflated the favorite from 6% to 13%.
|
||
*
|
||
* Instead the decompressed strengths are mapped by their log-ratio to the field's
|
||
* geometric mean, which preserves dispersion: a tight board yields a narrow Elo span
|
||
* and a top-heavy one a wide span, both centred on DEFAULT_ELO.
|
||
*
|
||
* Returns the ratings alongside the rating to use for a team with no odds entered —
|
||
* the median of the priced field, so leaving odds blank neither promotes nor buries a
|
||
* team. (DEFAULT_ELO is the centre of the scale, but futures fields are skewed, so on
|
||
* a typical board it would rank a team around 6th of 20.)
|
||
*/
|
||
export function buildLLWSElos(
|
||
evRows: Array<{ participantId: string; sourceOdds: number | null }>
|
||
): { elos: Map<string, number>; unpricedElo: number } {
|
||
const priced = evRows.filter((row) => row.sourceOdds !== null);
|
||
|
||
// A single priced team carries no information about the rest of the field, so
|
||
// there is nothing to normalise against — treat the season as unpriced.
|
||
if (priced.length < 2) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
||
|
||
const rawProbs = priced.map((row) => convertAmericanOddsToProbability(row.sourceOdds ?? 0));
|
||
const rawSum = rawProbs.reduce((a, b) => a + b, 0);
|
||
if (rawSum <= 0) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
||
|
||
// Vig-removed championship probability → single-game strength.
|
||
const logStrengths = rawProbs.map((prob) =>
|
||
Math.log(
|
||
Math.max(decompressProbability(prob / rawSum, LLWS_DECOMPRESSION_EXPONENT), Number.MIN_VALUE)
|
||
)
|
||
);
|
||
const meanLog = logStrengths.reduce((a, b) => a + b, 0) / logStrengths.length;
|
||
|
||
const elos = new Map<string, number>(
|
||
priced.map((row, i) => [
|
||
row.participantId,
|
||
DEFAULT_ELO + LLWS_ELO_SPREAD * (logStrengths[i] - meanLog),
|
||
])
|
||
);
|
||
|
||
return { elos, unpricedElo: median([...elos.values()]) };
|
||
}
|
||
|
||
function median(values: number[]): number {
|
||
if (values.length === 0) return DEFAULT_ELO;
|
||
const sorted = values.toSorted((a, b) => a - b);
|
||
const mid = Math.floor(sorted.length / 2);
|
||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||
}
|
||
|
||
// ─── Bracket loading ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Read the seeded llws_20 bracket for this season, if there is one.
|
||
*
|
||
* Returns null only when the bracket carries no draw at all — no matches, or a
|
||
* freshly generated bracket with every slot still empty — in which case the caller
|
||
* falls back to a randomized draw.
|
||
*
|
||
* A *partially* seeded bracket is an error rather than a fallback. Silently falling
|
||
* back there would throw away the real draw and every recorded result along with it,
|
||
* putting eliminated teams back in contention; and it is reachable in practice,
|
||
* because playoff_matches.participant1Id/participant2Id are ON DELETE SET NULL, so
|
||
* removing and re-adding a single participant mid-tournament empties a slot.
|
||
* Likewise, a bracket seeded with unknown or duplicated participants fails loudly.
|
||
*/
|
||
export function readBracketSlots(
|
||
matches: BracketMatch[],
|
||
teamsById: Map<string, Team>
|
||
): LoadedBracket | null {
|
||
if (matches.length === 0) return null;
|
||
|
||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||
|
||
// Collect both sides' draws before deciding, so "nothing seeded" is judged over the
|
||
// whole bracket rather than one side at a time.
|
||
const draw: Record<Side, (string | null)[]> = { US: [], Intl: [] };
|
||
|
||
for (const side of ["US", "Intl"] as const) {
|
||
const sideIndex = SIDE_INDEX[side];
|
||
|
||
for (let local = 1; local <= 4; local++) {
|
||
const match = byKey.get(
|
||
matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local))
|
||
);
|
||
draw[side].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))
|
||
);
|
||
draw[side].push(match?.participant1Id ?? null);
|
||
}
|
||
}
|
||
|
||
const allSlots = [...draw.US, ...draw.Intl];
|
||
const seededCount = allSlots.filter((id) => id !== null).length;
|
||
|
||
// Generated but not yet filled in — no draw to honor.
|
||
if (seededCount === 0) return null;
|
||
|
||
if (seededCount < allSlots.length) {
|
||
throw new Error(
|
||
`LLWS bracket is only partially seeded (${seededCount} of ${allSlots.length} slots ` +
|
||
`filled). Re-seed the bracket in Admin → Bracket before simulating; simulating ` +
|
||
`around the gap would discard the draw and every recorded result.`
|
||
);
|
||
}
|
||
|
||
const slots: Record<Side, Team[]> = { US: [], Intl: [] };
|
||
const seen = new Set<string>();
|
||
|
||
for (const side of ["US", "Intl"] as const) {
|
||
for (const id of draw[side]) {
|
||
const participantId = id as string;
|
||
if (seen.has(participantId)) {
|
||
throw new Error(
|
||
`LLWS bracket seeds participant ${participantId} into more than one slot.`
|
||
);
|
||
}
|
||
seen.add(participantId);
|
||
|
||
const team = teamsById.get(participantId);
|
||
if (!team) {
|
||
throw new Error(
|
||
`LLWS bracket references participant ${participantId}, 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<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
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 { elos, unpricedElo } = 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: elos.get(p.id) ?? unpricedElo,
|
||
});
|
||
}
|
||
|
||
const teamsById = new Map(teams.map((t) => [t.participantId, t]));
|
||
|
||
// 5. Load the real bracket (draw + results so far), if one has been generated.
|
||
// If several llws_20 playoff events exist, take the most recent so a re-created
|
||
// event wins over a stale one — landing on the stale row would silently discard
|
||
// the real draw and every recorded result.
|
||
const playoffEvents = await db.query.scoringEvents.findMany({
|
||
where: and(
|
||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||
eq(schema.scoringEvents.eventType, "playoff_game"),
|
||
eq(schema.scoringEvents.bracketTemplateId, LLWS_TEMPLATE_ID)
|
||
),
|
||
});
|
||
const bracketEvent = playoffEvents.toSorted(
|
||
(a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0)
|
||
)[0];
|
||
|
||
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<string, PlacementCounts>(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",
|
||
};
|
||
});
|
||
}
|
||
}
|