The LLWS simulator was overestimating favorites and ignoring games that had already been played. Two separate causes: 1. Championship futures were used directly as single-game strength (p1 / (p1 + p2)). A future already compounds the ~6 wins needed to take the title, so this made every individual game as lopsided as the whole tournament and re-compounded that edge round after round. Against a representative 20-team board the favorite priced at 21.8% simulated at 44.9%, and the longest shot fell to ~0%. Futures are now decompressed to single-game Elo via convertFuturesToElo, the same pipeline the other bracket simulators use, and games are played with eloWinProbabilityWithParity. The parity factor was calibrated by sweeping it until a randomized-draw simulation reproduces the board it was fed: at 1000 the favorite simulates at 21.8% and field-wide RMSE drops from 0.062 to 0.003. It is overridable per season via config. 2. The simulator never read playoff_matches, so it re-ran the tournament from an empty bracket every time and shuffled the draw at random each iteration. A recorded loss changed nothing. It now loads the seeded llws_20 bracket, places teams in their real slots, and replays completed games from their recorded result instead of re-simulating them, so an eliminated team correctly drops to zero. When no bracket exists (or it has no participants seeded) it falls back to the previous randomized-draw behavior, and a seeded bracket is authoritative about which side a team is on, so externalId is only required on the pre-bracket path. Guards: a recorded result is only honored when its two participants are the ones the simulation routed into that game, so a corrupt or out-of-order row cannot desynchronize the rest of the bracket; brackets seeding an unknown or duplicated participant now fail loudly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
790 lines
34 KiB
TypeScript
790 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||
import {
|
||
LLWSSimulator,
|
||
makePlayGame,
|
||
playCrossoverGame,
|
||
readBracketSlots,
|
||
} from "../llws-simulator";
|
||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||
import type { SimulationResult } from "../types";
|
||
|
||
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,
|
||
}));
|
||
}
|
||
|
||
// ─── 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 ────────────────────────────────────────────────────────────────────
|
||
|
||
describe("LLWSSimulator", () => {
|
||
let mockDb: {
|
||
select: MockInstance;
|
||
query: {
|
||
scoringEvents: { findFirst: MockInstance };
|
||
playoffMatches: { findMany: MockInstance };
|
||
};
|
||
};
|
||
let selectCallCount: number;
|
||
|
||
beforeEach(async () => {
|
||
selectCallCount = 0;
|
||
const { database } = await import("~/database/context");
|
||
mockDb = {
|
||
select: vi.fn(),
|
||
query: {
|
||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(undefined) },
|
||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||
},
|
||
};
|
||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||
});
|
||
|
||
function setupMockDb(
|
||
participants: { id: string; name?: string; externalId: string | null }[],
|
||
evRows: { participantId: string; sourceOdds: number | null }[],
|
||
bracketMatches?: Partial<PlayoffMatchRow>[]
|
||
) {
|
||
selectCallCount = 0;
|
||
mockDb.select.mockImplementation(() => {
|
||
const callIndex = selectCallCount++;
|
||
const data = callIndex === 0 ? participants : evRows;
|
||
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") {
|
||
if (mode === "fixed") {
|
||
const usA = US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" }));
|
||
const usB = US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" }));
|
||
const intlA = INTL_IDS.slice(0, 5).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl:A" }));
|
||
const intlB = INTL_IDS.slice(5).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl:B" }));
|
||
return [...usA, ...usB, ...intlA, ...intlB];
|
||
}
|
||
return [
|
||
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||
...INTL_IDS.map((id) => ({ id, name: `Team ${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(1_000).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(1_000).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(1_000).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(1_000).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(1_000).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(1_000).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(1_000).simulate("season-1");
|
||
const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
|
||
expect(total).toBeCloseTo(1.0, 1);
|
||
});
|
||
|
||
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
|
||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||
expect(total).toBeCloseTo(1.0, 1);
|
||
});
|
||
|
||
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
|
||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
|
||
expect(total).toBeCloseTo(1.0, 1);
|
||
});
|
||
|
||
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
|
||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
||
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||
for (const r of results) {
|
||
const p = r.probabilities;
|
||
// Within a tier the two positions are tied.
|
||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
||
}
|
||
// The tiers are distinct outcomes (losing the Elimination Final vs losing
|
||
// Elimination Round 4), so they must not be forced equal across the field.
|
||
const differs = results.some(
|
||
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
|
||
);
|
||
expect(differs).toBe(true);
|
||
});
|
||
|
||
it("gives every team a total placement probability of at most 1", async () => {
|
||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||
for (const r of results) {
|
||
const p = r.probabilities;
|
||
// Each sim assigns a team at most one placement, so summing the distinct
|
||
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
|
||
const total =
|
||
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||
p.probFifth * 2 + p.probSeventh * 2;
|
||
expect(total).toBeLessThanOrEqual(1 + 1e-9);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── 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(1_000).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(1_000).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(1_000).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);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Legacy externalId formats ─────────────────────────────────────────────
|
||
//
|
||
// The tournament no longer has pool play, but seasons configured for the old
|
||
// format still carry pool suffixes. Those must keep loading, read as the side alone.
|
||
|
||
describe("legacy pool-suffix externalIds", () => {
|
||
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
|
||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).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("accepts a mix of suffixed and bare side ids", async () => {
|
||
const participants = [
|
||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).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("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
|
||
const participants = [
|
||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||
expect(results).toHaveLength(20);
|
||
});
|
||
});
|
||
|
||
// ── 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,
|
||
name: i < 10 ? `US Team ${id}` : `Team ${id}`,
|
||
externalId: i < 10 ? "US" : "Intl",
|
||
}));
|
||
setupMockDb(participants, makeEvRows(nineteen));
|
||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 20/);
|
||
});
|
||
|
||
it("infers US side from name prefix when externalId is null", async () => {
|
||
const participants = [
|
||
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: null })),
|
||
...INTL_IDS.map((id) => ({ id, name: `Japan ${id}`, externalId: null })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).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("infers US side from exact name 'US' when externalId is null", async () => {
|
||
const participants = [
|
||
...US_IDS.map((id) => ({ id, name: "US", externalId: null })),
|
||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: null })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
const results = await new LLWSSimulator(1_000).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("throws when a participant has an unrecognized non-null externalId", async () => {
|
||
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" }, // unrecognized
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/invalid 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}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||
...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||
});
|
||
|
||
it("throws when International team count is not 10", async () => {
|
||
const participants = [
|
||
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||
];
|
||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||
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"]));
|
||
});
|
||
});
|
||
});
|