claude/llws-simulator-logic-0aqbjb #142

Merged
chrisp merged 2 commits from claude/llws-simulator-logic-0aqbjb into main 2026-08-23 01:57:38 +00:00
4 changed files with 826 additions and 96 deletions
Showing only changes of commit d4df0b65fb - Show all commits

View file

@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { LLWSSimulator } from "../llws-simulator"; import {
LLWSSimulator,
makePlayGame,
playCrossoverGame,
readBracketSlots,
} from "../llws-simulator";
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { SimulationResult } from "../types";
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
database: vi.fn(), database: vi.fn(),
@ -27,28 +34,166 @@ function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
})); }));
} }
// ─── Bracket fixtures ─────────────────────────────────────────────────────────
/** The subset of playoff_matches columns the simulator reads. */
type PlayoffMatchRow = {
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isComplete: boolean;
};
const EMPTY_MATCH: PlayoffMatchRow = {
round: "",
matchNumber: 0,
participant1Id: null,
participant2Id: null,
winnerId: null,
loserId: null,
isComplete: false,
};
/**
* A freshly generated, fully seeded llws_20 bracket with no results recorded.
*
* Mirrors generateLLWS20Bracket: U.S. matches take the low match numbers
* (Opening Round 14, Winners Round 2 12), International the high ones
* (Opening Round 58, Winners Round 2 34). Byes sit at participant1 of
* Winners Round 2. Slot order per side is ids[0..7] opening, ids[8..9] byes.
*/
function seededBracket(): PlayoffMatchRow[] {
const matches: PlayoffMatchRow[] = [];
const sides = [
{ ids: US_IDS, openingOffset: 0, wr2Offset: 0 },
{ ids: INTL_IDS, openingOffset: 4, wr2Offset: 2 },
];
for (const { ids, openingOffset, wr2Offset } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
...EMPTY_MATCH,
round: "Opening Round",
matchNumber: local + openingOffset,
participant1Id: ids[(local - 1) * 2],
participant2Id: ids[(local - 1) * 2 + 1],
});
}
for (let local = 1; local <= 2; local++) {
matches.push({
...EMPTY_MATCH,
round: "Winners Round 2",
matchNumber: local + wr2Offset,
participant1Id: ids[8 + (local - 1)],
});
}
}
return matches;
}
/**
* Mark a bracket match complete, the way the scoring flow would once the game is
* played. `loserId` is passed explicitly for matches whose second slot is filled by
* advancement rather than by the initial seeding.
*/
function completeMatch(
matches: PlayoffMatchRow[],
round: string,
matchNumber: number,
winnerId: string,
loserId: string
): PlayoffMatchRow[] {
const existing = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
const filled: PlayoffMatchRow = {
...(existing ?? { ...EMPTY_MATCH, round, matchNumber }),
participant1Id: existing?.participant1Id ?? winnerId,
participant2Id: existing?.participant2Id ?? loserId,
winnerId,
loserId,
isComplete: true,
};
return [...matches.filter((m) => m !== existing), filled];
}
/** Normalized (vig-removed) market probability for each team in an odds board. */
function marketProbabilities(odds: number[]): number[] {
const raw = odds.map(convertAmericanOddsToProbability);
const sum = raw.reduce((a, b) => a + b, 0);
return raw.map((p) => p / sum);
}
/** Look up one participant's simulated probabilities, failing loudly if absent. */
function probsFor(results: SimulationResult[], participantId: string) {
const match = results.find((r) => r.participantId === participantId);
if (!match) throw new Error(`No simulation result for ${participantId}`);
return match.probabilities;
}
/** Equal-strength Team records for direct (non-Monte-Carlo) helper tests. */
const TEST_TEAMS = new Map(
ALL_IDS.map((id) => [
id,
{
participantId: id,
side: id.startsWith("us") ? ("US" as const) : ("Intl" as const),
elo: 1500,
},
])
);
function team(participantId: string) {
const found = TEST_TEAMS.get(participantId);
if (!found) throw new Error(`No test team for ${participantId}`);
return found;
}
// ─── Tests ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
describe("LLWSSimulator", () => { describe("LLWSSimulator", () => {
let mockDb: { select: MockInstance }; let mockDb: {
select: MockInstance;
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
};
let selectCallCount: number; let selectCallCount: number;
beforeEach(async () => { beforeEach(async () => {
selectCallCount = 0; selectCallCount = 0;
const { database } = await import("~/database/context"); const { database } = await import("~/database/context");
mockDb = { select: vi.fn() }; mockDb = {
select: vi.fn(),
query: {
scoringEvents: { findFirst: vi.fn().mockResolvedValue(undefined) },
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
},
};
(database as unknown as MockInstance).mockReturnValue(mockDb); (database as unknown as MockInstance).mockReturnValue(mockDb);
}); });
function setupMockDb( function setupMockDb(
participants: { id: string; name?: string; externalId: string | null }[], participants: { id: string; name?: string; externalId: string | null }[],
evRows: { participantId: string; sourceOdds: number | null }[] evRows: { participantId: string; sourceOdds: number | null }[],
bracketMatches?: Partial<PlayoffMatchRow>[]
) { ) {
selectCallCount = 0;
mockDb.select.mockImplementation(() => { mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++; const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows; const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
}); });
if (bracketMatches) {
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
mockDb.query.playoffMatches.findMany.mockResolvedValue(
bracketMatches.map((m) => ({ ...EMPTY_MATCH, ...m }))
);
}
} }
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") { function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
@ -311,4 +456,335 @@ describe("LLWSSimulator", () => {
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/); await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
}); });
}); });
// ── Futures calibration ───────────────────────────────────────────────────
//
// A championship future already contains the ~6 wins needed to lift the trophy.
// Feeding it straight into a single game (p1 / (p1 + p2)) makes every game as
// lopsided as the whole tournament and compounds the favorite's edge round after
// round, which inflated favorites badly. The simulator decompresses futures to Elo
// first, so re-simulating a random draw should hand back roughly the prices it was
// given rather than a much more extreme distribution.
describe("futures calibration", () => {
// A representative LLWS board: a clear favorite, a long tail.
const BOARD = [
200, 750, 900, 1200, 1600, 2000, 2500, 3000, 4000, 6000,
350, 800, 1000, 1400, 1800, 2200, 2800, 3500, 5000, 8000,
];
function boardEvRows() {
return ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
}
it("reproduces the favorite's championship price instead of inflating it", async () => {
setupMockDb(defaultParticipants(), boardEvRows());
const results = await new LLWSSimulator(20_000).simulate("season-1");
const market = marketProbabilities(BOARD);
const simulated = probsFor(results, "us-1").probFirst;
// The favorite prices around 22%. The old raw-futures model simulated ~45%.
expect(simulated).toBeCloseTo(market[0], 1);
expect(simulated).toBeLessThan(market[0] + 0.06);
});
it("keeps the whole field close to its priced championship probability", async () => {
setupMockDb(defaultParticipants(), boardEvRows());
const results = await new LLWSSimulator(20_000).simulate("season-1");
const market = marketProbabilities(BOARD);
const errors = ALL_IDS.map((id, i) => probsFor(results, id).probFirst - market[i]);
const rmse = Math.sqrt(errors.reduce((s, e) => s + e * e, 0) / errors.length);
// Calibrated RMSE is ~0.003; the old model sat around 0.06.
expect(rmse).toBeLessThan(0.02);
});
it("does not starve longshots of championship probability", async () => {
setupMockDb(defaultParticipants(), boardEvRows());
const results = await new LLWSSimulator(20_000).simulate("season-1");
// The longest shot on the board prices near 0.8%. Compounding raw futures drove
// teams like this to essentially zero.
const longshot = probsFor(results, "intl-10").probFirst;
expect(longshot).toBeGreaterThan(0.002);
});
});
// ── Bracket-aware mode ────────────────────────────────────────────────────
describe("bracket-aware mode", () => {
it("uses the real draw rather than shuffling when a bracket is seeded", async () => {
// With no odds every team is equally strong, so the only edge is structural:
// the two bye teams skip the Opening Round. Under a randomized draw every team
// gets a bye equally often and this difference disappears.
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(20_000).simulate("season-1");
const byeTeam = probsFor(results, "us-9").probFirst;
const openingTeam = probsFor(results, "us-1").probFirst;
expect(byeTeam).toBeGreaterThan(openingTeam);
});
it("still returns a full, normalized distribution in bracket mode", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probThird, 0)).toBeCloseTo(1.0, 1);
});
it("falls back to a randomized draw when the bracket has no participants seeded", async () => {
const unseeded = seededBracket().map((m) => ({
...m,
participant1Id: null,
participant2Id: null,
}));
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), unseeded);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
it("throws when the bracket seeds the same team into two slots", async () => {
const duplicated = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 2
? { ...m, participant1Id: "us-1" } // us-1 already opens match 1
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), duplicated);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/more than one slot/
);
});
it("takes sides from the bracket, not externalId, once a bracket is seeded", async () => {
// The bracket is authoritative about the draw, so an externalId the pre-bracket
// path would reject must not block a season that already has a real bracket.
const participants = [
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" },
];
setupMockDb(participants, makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
it("throws when the bracket is seeded with a participant outside the season", async () => {
const foreign = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 1
? { ...m, participant1Id: "stranger-1" }
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), foreign);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/not in this sports season/
);
});
});
// ── Completed results ─────────────────────────────────────────────────────
//
// The core of the fix: games already played must stick across every iteration
// instead of being re-simulated from scratch.
describe("completed results", () => {
// us-1 is a strong favorite, so a recorded loss should visibly move its number.
const favouredEvRows = ALL_IDS.map((participantId, i) => ({
participantId,
sourceOdds: participantId === "us-1" ? 200 : 1000 + i * 200,
}));
async function probFirstFor(id: string, matches: PlayoffMatchRow[]): Promise<number> {
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(20_000).simulate("season-1");
return probsFor(results, id).probFirst;
}
it("drops a favorite's championship probability after a recorded loss", async () => {
const before = await probFirstFor("us-1", seededBracket());
// us-1 loses its Opening Round game. In double elimination that is not an
// elimination — it drops to the elimination bracket — but it now needs a much
// longer path, so its title probability must fall.
const afterLoss = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
const after = await probFirstFor("us-1", afterLoss);
expect(after).toBeLessThan(before);
// Not merely noise: a first-round loss is a real blow to a favorite.
expect(after).toBeLessThan(before * 0.8);
// But not elimination either — the elimination bracket still reaches the final.
expect(after).toBeGreaterThan(0);
});
it("raises the opponent's championship probability after that same win", async () => {
const before = await probFirstFor("us-2", seededBracket());
const afterWin = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
const after = await probFirstFor("us-2", afterWin);
expect(after).toBeGreaterThan(before);
});
it("zeroes out a team that has been eliminated (two recorded losses)", async () => {
// Fill the elimination-bracket game the way advancement would: the Opening
// Round 1 and Opening Round 4 losers meet in Elimination Round 1 match 2.
let matches = seededBracket();
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
matches = completeMatch(matches, "Opening Round", 4, "us-7", "us-8");
matches = completeMatch(matches, "Elimination Round 1", 2, "us-8", "us-1");
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
const eliminated = probsFor(results, "us-1");
// A second loss is final — every placement tier must be exactly zero.
for (const value of Object.values(eliminated)) {
expect(value).toBe(0);
}
});
it("keeps the distribution normalized once results have been recorded", async () => {
let matches = seededBracket();
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
matches = completeMatch(matches, "Opening Round", 5, "intl-2", "intl-1");
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probSecond, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probFifth, 0)).toBeCloseTo(1.0, 1);
});
it("ignores a completed result whose participants never reach that game", async () => {
// A corrupt row: Elimination Round 1 match 2 takes the Opening Round 1 and 4
// losers, so a team from Opening Round 3 can never appear there. The game must
// be simulated instead of desynchronising the rest of the bracket.
const matches = completeMatch(
seededBracket(), "Elimination Round 1", 2, "us-5", "us-6"
);
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
});
// ── Result-honoring rules ─────────────────────────────────────────────────
//
// Tested directly rather than through the Monte Carlo output: the aggregate only
// shows these effects diluted by how often a given pairing occurs, which is too
// noisy to assert on.
describe("result-honoring rules", () => {
function bracketOf(matches: PlayoffMatchRow[]) {
const bracket = readBracketSlots(matches, TEST_TEAMS);
if (!bracket) throw new Error("Expected the seeded bracket to be readable");
return bracket;
}
const us1 = team("us-1");
const us2 = team("us-2");
const us5 = team("us-5");
it("replays a completed game from its recorded result", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1")
);
const play = makePlayGame(0, bracket, 1_000);
// Deterministic across repeats — no coin flip is involved any more.
for (let i = 0; i < 25; i++) {
const result = play("Opening Round", 1, us1, us2);
expect(result.winner.participantId).toBe("us-2");
expect(result.loser.participantId).toBe("us-1");
}
});
it("returns the recorded winner regardless of which slot it arrives in", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-1", "us-2")
);
const play = makePlayGame(0, bracket, 1_000);
// Same game, arguments swapped.
expect(play("Opening Round", 1, us2, us1).winner.participantId).toBe("us-1");
});
it("simulates a game that has not been played yet", () => {
const play = makePlayGame(0, bracketOf(seededBracket()), 1_000);
const winners = new Set(
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
);
// Equal Elo, so both outcomes must show up.
expect(winners).toEqual(new Set(["us-1", "us-2"]));
});
it("ignores a recorded result between teams that did not arrive at the game", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-5", "us-2")
);
const play = makePlayGame(0, bracket, 1_000);
// us-5 belongs to a different Opening Round game, so this row cannot apply to
// the us-1 v us-2 pairing — it must be simulated instead.
const winners = new Set(
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
);
expect(winners).toEqual(new Set(["us-1", "us-2"]));
});
it("reads U.S. and International games from their own match numbers", () => {
// The same side-local game number maps to different global matches per side:
// U.S. Opening Round 1 is match 1, International Opening Round 1 is match 5.
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 5, "intl-2", "intl-1")
);
const intl1 = team("intl-1");
const intl2 = team("intl-2");
expect(makePlayGame(1, bracket, 1_000)("Opening Round", 1, intl1, intl2).winner.participantId)
.toBe("intl-2");
// The U.S. side's Opening Round 1 is untouched by that result.
const usWinners = new Set(
Array.from({ length: 200 }, () =>
makePlayGame(0, bracket, 1_000)("Opening Round", 1, us1, us2).winner.participantId
)
);
expect(usWinners).toEqual(new Set(["us-1", "us-2"]));
});
it("honors a completed World Championship", () => {
// The two crossover games are single shared matches, numbered 1.
const bracket = bracketOf(
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
);
const us3 = team("us-3");
const intl4 = team("intl-4");
const result = playCrossoverGame("World Championship", bracket, 1_000, us3, intl4);
expect(result.winner.participantId).toBe("us-3");
expect(result.loser.participantId).toBe("intl-4");
});
it("simulates the crossover game when different finalists arrive", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
);
const intl5 = team("intl-5");
const winners = new Set(
Array.from({ length: 200 }, () =>
playCrossoverGame("World Championship", bracket, 1_000, us5, intl5).winner.participantId
)
);
expect(winners).toEqual(new Set(["us-5", "intl-5"]));
});
});
}); });

View file

@ -9,28 +9,47 @@
* pool play. This mirrors the llws_20 bracket template so simulated placements line * pool play. This mirrors the llws_20 bracket template so simulated placements line
* up with the bracket admins actually score. * 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: * Algorithm:
* 1. Load all 20 participants for the sports season from DB * 1. Load all 20 participants for the sports season from DB
* (must be exactly 10 US + 10 International, identified by externalId) * 2. Load the llws_20 playoff bracket, if one exists, to get the real draw and
* 2. Load championship futures odds from participantExpectedValues.sourceOdds * whatever results have been recorded so far
* 3. Load championship futures odds from participantExpectedValues.sourceOdds
* (entered via Admin Futures Odds; American format) * (entered via Admin Futures Odds; American format)
* 3. Convert odds to normalized championship probabilities (vig removed). * 4. Convert those futures to Elo via the shared probability engine, then drive
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50. * each game with the Elo win probability (see "Why Elo" below)
* 4. Per simulation: * 5. Per simulation:
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round * a. Place each side's 10 teams into the bracket slots (real draw when known,
* teams + 2 byes). The draw is modelled as random a specific known draw * otherwise shuffled)
* is not yet expressible in participant config. * b. Simulate the 10-team double-elimination bracket for each side, replaying
* b. Simulate the 10-team double-elimination bracket for each side * completed games from their recorded result (see simulateSideBracket)
* (see simulateSideBracket for the exact game-by-game structure)
* c. Consolation game: US side loser vs Intl side loser 3rd / 4th * c. Consolation game: US side loser vs Intl side loser 3rd / 4th
* d. World Championship: US champion vs Intl champion 1st / 2nd * d. World Championship: US champion vs Intl champion 1st / 2nd
* 5. Track placement counts across all simulations. * 6. Track placement counts across all simulations
* 6. Convert counts to probability distributions. * 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 * 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 * ("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 * alone, so seasons configured for the old pool-play format keep working pools
* no longer exist, so the suffix has no effect. * 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): * Placement tiers SimulationProbabilities mapping (matches llws_20's scoring):
* probFirst = World Championship winner (1 per sim) * probFirst = World Championship winner (1 per sim)
@ -45,15 +64,20 @@
* 1. Create a Sport with simulatorType = "llws_bracket" * 1. Create a Sport with simulatorType = "llws_bracket"
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International) * 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 * 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) * "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) * 4. Enter championship futures odds via Admin Futures Odds (sourceOdds)
* 5. Run simulation via Admin Simulate * 5. Run simulation via Admin Simulate
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { convertAmericanOddsToProbability } from "~/services/probability-engine"; import {
convertFuturesToElo,
eloWinProbabilityWithParity,
} from "~/services/probability-engine";
import { llwsMatchNumber } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access"; import { positiveConfigNumber } from "./config-access";
@ -62,16 +86,45 @@ import { positiveConfigNumber } from "./config-access";
const NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
const US_TEAM_COUNT = 10; const US_TEAM_COUNT = 10;
const INTL_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 ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
type Side = "US" | "Intl"; 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 { interface Team {
participantId: string; participantId: string;
side: Side; side: Side;
/** Normalized championship win probability (01, vig removed). */ /** Single-game strength on an Elo scale, decompressed from championship futures. */
oddsProb: number; elo: number;
} }
interface PlacementCounts { interface PlacementCounts {
@ -85,6 +138,25 @@ interface PlacementCounts {
elimRound4Loser: number; 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 ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function zeroCounts(): PlacementCounts { function zeroCounts(): PlacementCounts {
@ -94,16 +166,12 @@ function zeroCounts(): PlacementCounts {
}; };
} }
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } { function matchKey(round: string, matchNumber: number): string {
// If either team has no odds entered, treat the game as a coin flip. return `${round}#${matchNumber}`;
// 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; function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } {
if (t1.oddsProb === 0 || t2.oddsProb === 0) { const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor);
p1Win = 0.5;
} else {
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
}
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
} }
@ -118,6 +186,76 @@ function shuffle<T>(arr: T[]): T[] {
return arr; 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. * Simulate one side's 10-team double-elimination bracket.
* *
@ -125,7 +263,7 @@ function shuffle<T>(arr: T[]): T[] {
* layout: slots[0..7] are the four opening-round games (two teams each) and * 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. * slots[8], slots[9] are the two bye teams entering Winners Round 2.
* *
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match): * Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket):
* Winners bracket * Winners bracket
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7 * 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 * WR2-1 s8 v OP1w WR2-2 s9 v OP2w
@ -143,47 +281,51 @@ function shuffle<T>(arr: T[]): T[] {
* Elimination Final. There is no "if necessary" game, so the side championship is * Elimination Final. There is no "if necessary" game, so the side championship is
* decided in one game. * 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 * Returns { sideChampion, sideLoser }; the two scoring elimination losers are
* bumped into the counts directly. * bumped into the counts directly.
*/ */
function simulateSideBracket( function simulateSideBracket(
slots: Team[], slots: Team[],
bump: (id: string, key: keyof PlacementCounts) => void bump: (id: string, key: keyof PlacementCounts) => void,
play: PlayGame
): { sideChampion: Team; sideLoser: Team } { ): { sideChampion: Team; sideLoser: Team } {
// ── Winners bracket ──────────────────────────────────────────────────────── // ── Winners bracket ────────────────────────────────────────────────────────
const op1 = simGame(slots[0], slots[1]); const op1 = play("Opening Round", 1, slots[0], slots[1]);
const op2 = simGame(slots[2], slots[3]); const op2 = play("Opening Round", 2, slots[2], slots[3]);
const op3 = simGame(slots[4], slots[5]); const op3 = play("Opening Round", 3, slots[4], slots[5]);
const op4 = simGame(slots[6], slots[7]); const op4 = play("Opening Round", 4, slots[6], slots[7]);
const wr21 = simGame(slots[8], op1.winner); const wr21 = play("Winners Round 2", 1, slots[8], op1.winner);
const wr22 = simGame(slots[9], op2.winner); const wr22 = play("Winners Round 2", 2, slots[9], op2.winner);
const wsf1 = simGame(op3.winner, wr21.winner); const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner);
const wsf2 = simGame(wr22.winner, op4.winner); const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner);
const wf = simGame(wsf1.winner, wsf2.winner); const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner);
// ── Elimination bracket ──────────────────────────────────────────────────── // ── Elimination bracket ────────────────────────────────────────────────────
const er11 = simGame(op2.loser, op3.loser); const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser);
const er12 = simGame(op1.loser, op4.loser); const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser);
const er21 = simGame(wr21.loser, er11.winner); const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner);
const er22 = simGame(wr22.loser, er12.winner); const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner);
// Cross-over: each semifinal loser meets the winner from the opposite half. // Cross-over: each semifinal loser meets the winner from the opposite half.
const er31 = simGame(wsf1.loser, er22.winner); const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner);
const er32 = simGame(wsf2.loser, er21.winner); const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner);
const er4 = simGame(er32.winner, er31.winner); const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner);
bump(er4.loser.participantId, "elimRound4Loser"); // 7th8th tier bump(er4.loser.participantId, "elimRound4Loser"); // 7th8th tier
// The Winners Final loser gets its second chance here. // The Winners Final loser gets its second chance here.
const ef = simGame(wf.loser, er4.winner); const ef = play("Elimination Final", 1, wf.loser, er4.winner);
bump(ef.loser.participantId, "elimFinalLoser"); // 5th6th tier bump(ef.loser.participantId, "elimFinalLoser"); // 5th6th tier
// ── Side championship ────────────────────────────────────────────────────── // ── Side championship ──────────────────────────────────────────────────────
const sideChampionship = simGame(wf.winner, ef.winner); const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner);
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser }; return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
} }
@ -209,13 +351,94 @@ function parseExternalId(raw: string | null): { side: Side } | null {
* Infer an externalId from a participant name when none is stored. * Infer an externalId from a participant name when none is stored.
* Teams whose name is exactly "US" or starts with "US " (case-insensitive) * Teams whose name is exactly "US" or starts with "US " (case-insensitive)
* are assigned to the US side; all others are assigned to Intl. * 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 { function inferExternalIdFromName(name: string): string {
const upper = name.trim().toUpperCase(); const upper = name.trim().toUpperCase();
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl"; 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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class LLWSSimulator implements Simulator { export class LLWSSimulator implements Simulator {
@ -223,6 +446,7 @@ export class LLWSSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations)); const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR);
const db = database(); const db = database();
// 1. Load all participants. // 1. Load all participants.
@ -247,41 +471,56 @@ export class LLWSSimulator implements Simulator {
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const rawOddsMap = new Map<string, number>(); // 3. Decompress the futures into single-game Elo ratings.
for (const row of evRows) { const eloMap = buildLLWSElos(evRows);
if (row.sourceOdds !== null) {
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 3. Normalize odds (remove vig) to get championship probability per team.
const normalizedOddsMap = new Map<string, number>();
if (rawOddsMap.size > 0) {
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsMap) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
}
// 4. Parse externalId for each participant to determine which side they're on. // 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 teams: Team[] = [];
const unparseableSides: Array<{ id: string; externalId: string | null }> = [];
for (const p of participants) { for (const p of participants) {
const raw = p.externalId ?? inferExternalIdFromName(p.name); const raw = p.externalId ?? inferExternalIdFromName(p.name);
const parsed = parseExternalId(raw); const parsed = parseExternalId(raw);
if (!parsed) { if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId });
throw new Error(
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
`Expected: "US" or "Intl".`
);
}
teams.push({ teams.push({
// Provisional: a seeded bracket overwrites this below.
participantId: p.id, participantId: p.id,
side: parsed.side, side: parsed?.side ?? "Intl",
oddsProb: normalizedOddsMap.get(p.id) ?? 0, elo: eloMap.get(p.id) ?? DEFAULT_ELO,
}); });
} }
// Validate team counts per side. 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 usTeams = teams.filter((t) => t.side === "US");
const intlTeams = teams.filter((t) => t.side === "Intl"); const intlTeams = teams.filter((t) => t.side === "Intl");
@ -291,8 +530,15 @@ export class LLWSSimulator implements Simulator {
if (intlTeams.length !== INTL_TEAM_COUNT) { if (intlTeams.length !== INTL_TEAM_COUNT) {
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`); throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
} }
}
// 5. Initialise placement count accumulators for all participants. 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 allIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()])); const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
const bump = (id: string, key: keyof PlacementCounts) => { const bump = (id: string, key: keyof PlacementCounts) => {
@ -300,27 +546,33 @@ export class LLWSSimulator implements Simulator {
if (entry) entry[key]++; if (entry) entry[key]++;
}; };
// 6. Run Monte Carlo simulations. // 7. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < numSimulations; s++) {
// The draw is modelled as random: shuffle each side into the 10 bracket slots // With a real bracket the draw is fixed; without one it is modelled as random.
// (8 opening-round teams, then the 2 bye teams). const usSlots = bracket ? usPool : shuffle([...usPool]);
const intlSlots = bracket ? intlPool : shuffle([...intlPool]);
const { sideChampion: usChamp, sideLoser: usLose } = const { sideChampion: usChamp, sideLoser: usLose } =
simulateSideBracket(shuffle([...usTeams]), bump); simulateSideBracket(usSlots, bump, playUS);
const { sideChampion: intlChamp, sideLoser: intlLose } = const { sideChampion: intlChamp, sideLoser: intlLose } =
simulateSideBracket(shuffle([...intlTeams]), bump); simulateSideBracket(intlSlots, bump, playIntl);
// Consolation game: 3rd / 4th place. // Consolation game: 3rd / 4th place.
const consolation = simGame(usLose, intlLose); const consolation = playCrossoverGame(
"Consolation Third Place", bracket, parityFactor, usLose, intlLose
);
bump(consolation.winner.participantId, "thirdPlace"); bump(consolation.winner.participantId, "thirdPlace");
bump(consolation.loser.participantId, "fourthPlace"); bump(consolation.loser.participantId, "fourthPlace");
// World Championship: 1st / 2nd place. // World Championship: 1st / 2nd place.
const ws = simGame(usChamp, intlChamp); const ws = playCrossoverGame(
"World Championship", bracket, parityFactor, usChamp, intlChamp
);
bump(ws.winner.participantId, "champion"); bump(ws.winner.participantId, "champion");
bump(ws.loser.participantId, "finalist"); bump(ws.loser.participantId, "finalist");
} }
// 7. Convert counts to probability distributions. // 8. Convert counts to probability distributions.
// Each of the two 58 tiers takes exactly 2 teams per sim (one per side), and // 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 // the teams within a tier are tied, so the tier probability is split across
// its two positions. // its two positions.

View file

@ -183,10 +183,12 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"], setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
}, },
llws_bracket: { llws_bracket: {
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 1_000, usTeamCount: 10, internationalTeamCount: 10 },
requiredInputs: ["sourceOdds"], requiredInputs: ["sourceOdds"],
optionalInputs: ["metadata"], optionalInputs: ["metadata"],
setupSections: ["participants", "futuresOdds"], // The bracket is optional — without one the draw is randomized — but once it
// exists the simulator reads the real draw and honors completed results from it.
setupSections: ["participants", "futuresOdds", "bracket"],
}, },
college_hockey_bracket: { college_hockey_bracket: {
// College hockey blends odds into Elo internally (and also uses NPI rank, // College hockey blends odds into Elo internally (and also uses NPI rank,

View file

@ -155,7 +155,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
llws_bracket: { llws_bracket: {
info: { info: {
name: "LLWS Bracket Monte Carlo", name: "LLWS Bracket Monte Carlo",
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.", description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Championship futures odds are decompressed to single-game Elo. When an llws_20 bracket exists it simulates the real draw and honors completed results; otherwise the draw is randomized and externalId ('US' or 'Intl') sets the sides.",
}, },
create: () => new LLWSSimulator(), create: () => new LLWSSimulator(),
}, },