* 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>
272 lines
12 KiB
TypeScript
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/);
|
|
});
|
|
});
|
|
});
|