brackt/app/services/simulations/llws-simulator.ts
Claude 4142b21c55
Honor engine knobs across simulators, de-dupe odds, unify config UI
Two problems addressed:

1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied).
   - The NHL simulator hardcoded its parity factor (1000) and ignored the
     season config's parityFactor, so the knob meant to flatten the
     distribution did nothing. It also re-blended raw futures odds into every
     game on top of the odds->Elo conversion, double-counting the same signal.
   - NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config
     and no longer re-blends odds per game (odds enter once, via the central
     odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to
     ~13% title odds.

2. "Season Config" and "Input Policy" were two forms over the same stored
   object that didn't reflect each other, and the engine-knob half was inert
   for many simulators.
   - Every simulator now reads its engine knobs (iterations everywhere;
     parityFactor for all Elo-based sims) from the merged config, passed in by
     the runner via the Simulator interface. Defaults equal the former
     hardcoded constants, so behavior is unchanged unless a season overrides.
   - The admin simulator page is now a single "Simulator Configuration" card
     with structured Engine and Input-derivation sections (profile-driven, so
     each sport shows only the knobs it honors) plus an Advanced raw-JSON
     escape hatch — all writing the same config.

Also: centralized the duplicated configNumber helpers into config-access.ts;
the central odds->Elo resolver now maps onto the configured Elo floor/ceiling
so those bounds set the odds-derived spread (a real flattening dial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf
2026-06-30 22:00:33 +00:00

405 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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