claude/llws-simulator-logic-0aqbjb #142
4 changed files with 826 additions and 96 deletions
|
|
@ -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 1–4, Winners Round 2 1–2), International the high ones
|
||||||
|
* (Opening Round 5–8, Winners Round 2 3–4). 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"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 (0–1, 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;
|
|
||||||
if (t1.oddsProb === 0 || t2.oddsProb === 0) {
|
|
||||||
p1Win = 0.5;
|
|
||||||
} else {
|
|
||||||
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
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"); // 7th–8th tier
|
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th 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"); // 5th–6th tier
|
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th 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 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||||||
// the teams within a tier are tied, so the tier probability is split across
|
// the teams within a tier are tied, so the tier probability is split across
|
||||||
// its two positions.
|
// its two positions.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue