brackt/app/services/simulations/__tests__/llws-simulator.test.ts
Chris Parsons 0da80bd2c0
Add LLWS bracket Monte Carlo simulator (#280)
* Add LLWS bracket Monte Carlo simulator

Simulates the 20-team Little League World Series: pool play round-robin
(5 teams/pool, top 2 advance) → 4-team double-elimination per side
(US and International) → consolation game → World Series final.

Uses championship futures odds as the sole win-probability signal.
Pool assignments are auto-detected from externalId: bare "US"/"Intl"
randomizes pools each simulation (pre-draw mode); "US:A"/"Intl:B" etc.
uses fixed assignments (post-draw mode). Sides can differ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in LLWS simulator

- Replace non-null assertions with null-safe access (! → ?? / optional chaining)
- Replace .sort() with .toSorted() per linting rules
- Promote bump() to simulate() scope and pass it into simulateSideBracket,
  removing the need to pass counts into that function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:52:10 -04:00

272 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { LLWSSimulator } from "../llws-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/probability-engine", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return { ...actual };
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const US_IDS = Array.from({ length: 10 }, (_, i) => `us-${i + 1}`);
const INTL_IDS = Array.from({ length: 10 }, (_, i) => `intl-${i + 1}`);
const ALL_IDS = [...US_IDS, ...INTL_IDS];
/**
* Build EV rows with descending odds favouring the first team per side.
* ids[0] is the strongest (best odds → lowest American number for favorites).
*/
function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
return ids.map((participantId, i) => ({
participantId,
sourceOdds: opts.includeOdds ? (i === 0 ? -300 : 200 + i * 100) : null,
}));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("LLWSSimulator", () => {
let mockDb: { select: MockInstance };
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = { select: vi.fn() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupMockDb(
participants: { id: string; externalId: string }[],
evRows: { participantId: string; sourceOdds: number | null }[]
) {
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
if (mode === "fixed") {
const usA = US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" }));
const usB = US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" }));
const intlA = INTL_IDS.slice(0, 5).map((id) => ({ id, externalId: "Intl:A" }));
const intlB = INTL_IDS.slice(5).map((id) => ({ id, externalId: "Intl:B" }));
return [...usA, ...usB, ...intlA, ...intlB];
}
return [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
}
// ── Core output structure ─────────────────────────────────────────────────
describe("output structure", () => {
it("returns one result per participant (20 total)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
});
it("every result has source 'llws_monte_carlo'", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
expect(r.source).toBe("llws_monte_carlo");
}
});
it("all probability values are between 0 and 1", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
for (const v of Object.values(p)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
}
});
});
// ── Probability conservation ──────────────────────────────────────────────
describe("probability conservation (one winner per sim)", () => {
it("probFirst sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSecond sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probThird sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probThird, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probFourth sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
});
// ── Odds-driven probability ───────────────────────────────────────────────
describe("odds-driven win probability", () => {
it("strong favourite (us-1) has higher probFirst than a weak team", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
const results = await new LLWSSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0;
const us10prob = byId.get("us-10")?.probabilities.probFirst ?? 0;
expect(us1prob).toBeGreaterThan(us10prob);
});
it("works when no odds are entered (all 50/50 fallback)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("with equal odds, each team wins the championship roughly equally", async () => {
// Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each.
const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 }));
setupMockDb(defaultParticipants(), eqOddsRows);
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
// With equal odds and random pools, each team should win ~5% of the time.
// Allow a generous band given Monte Carlo variance.
expect(r.probabilities.probFirst).toBeGreaterThan(0.01);
expect(r.probabilities.probFirst).toBeLessThan(0.15);
}
});
});
// ── Pool assignment modes ─────────────────────────────────────────────────
describe("pool assignment modes", () => {
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("mixed mode: US fixed pools, Intl randomized", async () => {
const participants = [
...US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Error cases ───────────────────────────────────────────────────────────
describe("error cases", () => {
it("throws when participant count is not 20", async () => {
const nineteen = [...US_IDS, ...INTL_IDS.slice(0, 9)];
const participants = nineteen.map((id, i) => ({
id,
externalId: i < 10 ? "US" : "Intl",
}));
setupMockDb(participants, makeEvRows(nineteen));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 20/);
});
it("throws when a participant has a missing externalId", async () => {
const participants = [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })),
{ id: "intl-10", externalId: null as unknown as string }, // missing
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/);
});
it("throws when a participant has an unrecognized externalId", async () => {
const participants = [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })),
{ id: "intl-10", externalId: "CANADA" }, // unrecognized
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/);
});
it("throws when US team count is not 10", async () => {
// 11 US teams, 9 International
const participants = [
...Array.from({ length: 11 }, (_, i) => ({ id: `us-${i + 1}`, externalId: "US" })),
...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/10 US teams/);
});
it("throws when fixed pools have unequal A/B split", async () => {
const participants = [
// 6 in Pool A, 4 in Pool B
...US_IDS.slice(0, 6).map((id) => ({ id, externalId: "US:A" })),
...US_IDS.slice(6).map((id) => ({ id, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
});
it("throws when US externalIds mix pool suffixes and bare side", async () => {
const participants = [
// Some US:A, some "US" (no pool suffix) → mixed
...US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, externalId: "US" })), // no pool
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
});
});
});