brackt/app/services/simulations/llws-simulator.ts
Claude d4df0b65fb
Make LLWS simulator bracket-aware and calibrate its futures model
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
2026-08-21 21:09:47 +00:00

602 lines
26 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 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 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).
* 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 5th6th tier (1 per side per sim). */
elimFinalLoser: number;
/** Lost Elimination Round 4 — the 7th8th 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 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.
*
* 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"); // 7th8th 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"); // 5th6th 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 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",
};
});
}
}