The LLWS simulator was overestimating favorites and ignoring games that had already been played. Two separate causes: 1. Championship futures were used directly as single-game strength (p1 / (p1 + p2)). A future already compounds the ~6 wins needed to take the title, so this made every individual game as lopsided as the whole tournament and re-compounded that edge round after round. Against a representative 20-team board the favorite priced at 21.8% simulated at 44.9%, and the longest shot fell to ~0%. Futures are now decompressed to single-game Elo via convertFuturesToElo, the same pipeline the other bracket simulators use, and games are played with eloWinProbabilityWithParity. The parity factor was calibrated by sweeping it until a randomized-draw simulation reproduces the board it was fed: at 1000 the favorite simulates at 21.8% and field-wide RMSE drops from 0.062 to 0.003. It is overridable per season via config. 2. The simulator never read playoff_matches, so it re-ran the tournament from an empty bracket every time and shuffled the draw at random each iteration. A recorded loss changed nothing. It now loads the seeded llws_20 bracket, places teams in their real slots, and replays completed games from their recorded result instead of re-simulating them, so an eliminated team correctly drops to zero. When no bracket exists (or it has no participants seeded) it falls back to the previous randomized-draw behavior, and a seeded bracket is authoritative about which side a team is on, so externalId is only required on the pre-bracket path. Guards: a recorded result is only honored when its two participants are the ones the simulation routed into that game, so a corrupt or out-of-order row cannot desynchronize the rest of the bracket; brackets seeding an unknown or duplicated participant now fail loudly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
602 lines
26 KiB
TypeScript
602 lines
26 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. 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<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.
|
||
*
|
||
* 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<string, number> {
|
||
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<string, Team>
|
||
): LoadedBracket | null {
|
||
if (matches.length === 0) return null;
|
||
|
||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||
const slots: Record<Side, Team[]> = { US: [], Intl: [] };
|
||
const seen = new Set<string>();
|
||
|
||
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<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 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<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",
|
||
};
|
||
});
|
||
}
|
||
}
|