brackt/app/services/simulations/__tests__/llws-simulator.test.ts
Claude 7ec17e417d
Fix EV reporting 20 pts for both LLWS 5-6 and 7-8 locked tiers
After an LLWS simulation, a team locked into the 5th-6th tier and one
locked into the 7th-8th tier both showed 20 points EV. They should show
25 and 15.

The simulator and calculateEV were both right. A team locked into the
5-6 tier comes out of llws-simulator at probFifth = probSixth = 0.5, and
against DEFAULT_SCORING_RULES (100/70/50/40/25/25/15/15) that is 25 —
matching calculateBracketPoints, which already knows llws_20 splits 5-8
into two tiers. The Admin -> Expected Values page just wasn't using that
table. It hardcoded its own stale copy:

  const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;

0.5*20 + 0.5*20 = 20 for either tier.

It is not LLWS-specific. Four places carried that same stale table, and
it stayed invisible because a standard single-elimination bracket puts
all four quarterfinal losers in one tier worth avg(25,25,15,15) = 20 —
the same number. It only diverges for the templates that split 5-8
(llws_20, afl_10) and those with a distinct 3rd/4th (llws_20, fifa_48,
where 45/45 should be 50/40). Two of the four *persist* EVs computed
that way, so the wrong values reached the database:

  - expected-values.tsx      displayed EV, the total, and the sort order
  - expected-values.server   manual EV entry, written to expected_value
  - golf-skills.tsx          simulation EVs + snapshots, written
  - surface-elo.tsx          simulation EVs + snapshots, written

All four now use the shared DEFAULT_SCORING_RULES. probability-updater
had a fourth inline copy with the right values; it is folded in too so
there is one table left. The page's 340 total-EV invariant is unchanged
— both tables sum to 340.

A second path collapses the same two tiers, this time in real fantasy
points. calculateBracketPoints falls back to the flat avg([5,6,7,8])
when bracketTemplateId is null, and four call sites resolved the
template by taking an arbitrary scoringEvents row for the sports season
— unordered, and not filtered to rows that actually carry a template. A
season can own several events (a bracket plus schedule events, or a
re-created bracket beside a stale one), so a null row wins at random and
llws_20 is lost. New getBracketTemplateIdsForSportsSeasons in
models/bracket-template.ts filters to events with a template and takes
the most recent, the same rule llws-simulator uses to pick its bracket
event; standings, calculateTeamScore, calculateTeamProjectedScore and
getDraftedParticipantsWithPoints all go through it.

Tests: evFromProbs pinned to 25 / 15 / 20-for-a-single-5-8-tier and the
340 invariant; the new lookup against a mixed set of events; and two
llws-simulator tests that play out a full U.S. side so a team really is
locked into each tier and must come out at exactly 50/50 across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 16:11:59 +00:00

950 lines
42 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 14, Winners Round 2 12), International the high ones
* (Opening Round 58, Winners Round 2 34). Byes sit at participant1 of
* Winners Round 2. Slot order per side is ids[0..7] opening, ids[8..9] byes.
*/
function seededBracket(): PlayoffMatchRow[] {
const matches: PlayoffMatchRow[] = [];
const sides = [
{ ids: US_IDS, openingOffset: 0, wr2Offset: 0 },
{ ids: INTL_IDS, openingOffset: 4, wr2Offset: 2 },
];
for (const { ids, openingOffset, wr2Offset } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
...EMPTY_MATCH,
round: "Opening Round",
matchNumber: local + openingOffset,
participant1Id: ids[(local - 1) * 2],
participant2Id: ids[(local - 1) * 2 + 1],
});
}
for (let local = 1; local <= 2; local++) {
matches.push({
...EMPTY_MATCH,
round: "Winners Round 2",
matchNumber: local + wr2Offset,
participant1Id: ids[8 + (local - 1)],
});
}
}
return matches;
}
/**
* Mark a bracket match complete, the way the scoring flow would once the game is
* played. `loserId` is passed explicitly for matches whose second slot is filled by
* advancement rather than by the initial seeding.
*/
function completeMatch(
matches: PlayoffMatchRow[],
round: string,
matchNumber: number,
winnerId: string,
loserId: string
): PlayoffMatchRow[] {
const existing = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
const filled: PlayoffMatchRow = {
...(existing ?? { ...EMPTY_MATCH, round, matchNumber }),
participant1Id: existing?.participant1Id ?? winnerId,
participant2Id: existing?.participant2Id ?? loserId,
winnerId,
loserId,
isComplete: true,
};
return [...matches.filter((m) => m !== existing), filled];
}
/** Normalized (vig-removed) market probability for each team in an odds board. */
function marketProbabilities(odds: number[]): number[] {
const raw = odds.map(convertAmericanOddsToProbability);
const sum = raw.reduce((a, b) => a + b, 0);
return raw.map((p) => p / sum);
}
/** Look up one participant's simulated probabilities, failing loudly if absent. */
function probsFor(results: SimulationResult[], participantId: string) {
const match = results.find((r) => r.participantId === participantId);
if (!match) throw new Error(`No simulation result for ${participantId}`);
return match.probabilities;
}
/** Equal-strength Team records for direct (non-Monte-Carlo) helper tests. */
const TEST_TEAMS = new Map(
ALL_IDS.map((id) => [
id,
{
participantId: id,
side: id.startsWith("us") ? ("US" as const) : ("Intl" as const),
elo: 1500,
},
])
);
function team(participantId: string) {
const found = TEST_TEAMS.get(participantId);
if (!found) throw new Error(`No test team for ${participantId}`);
return found;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("LLWSSimulator", () => {
let mockDb: {
select: MockInstance;
query: {
scoringEvents: { findMany: MockInstance };
playoffMatches: { findMany: MockInstance };
};
};
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = {
select: vi.fn(),
query: {
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
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.findMany.mockResolvedValue([
{ id: "event-1", createdAt: new Date("2026-08-01") },
]);
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);
});
// Regression: the previous mapping rescaled every field onto a fixed 12501750
// Elo span, which discarded how spread out the board actually was and pulled a
// nearly flat field apart into contenders and no-hopers the market never implied.
const TIGHT_BOARD = Array.from({ length: 20 }, (_, i) => 1500 + i * 35);
it("does not inflate the favorite on a tightly priced board", async () => {
setupMockDb(
defaultParticipants(),
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
);
const results = await new LLWSSimulator(20_000).simulate("season-1");
const market = marketProbabilities(TIGHT_BOARD);
const simulated = probsFor(results, "us-1").probFirst;
// The favorite prices near 6%. A fixed-span mapping simulated it around 13%,
// so the band is wide enough for Monte Carlo noise but nowhere near that.
expect(Math.abs(simulated - market[0])).toBeLessThan(0.015);
});
it("keeps a tightly priced field tight", async () => {
setupMockDb(
defaultParticipants(),
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
);
const results = await new LLWSSimulator(20_000).simulate("season-1");
const probs = ALL_IDS.map((id) => probsFor(results, id).probFirst);
// Every team prices between roughly 4% and 6%, so nobody should run away with
// it and nobody should be written off.
expect(Math.max(...probs)).toBeLessThan(0.09);
expect(Math.min(...probs)).toBeGreaterThan(0.02);
});
it("rates a team with no odds entered around the middle of the field", async () => {
// us-5 is priced mid-board; blanking its odds should not move it far. The old
// 1500 fallback was the centre of the Elo scale rather than of the field, which
// promoted an unpriced team to roughly 6th of 20.
const priced = ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
setupMockDb(defaultParticipants(), priced);
const withOdds = probsFor(
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
).probFirst;
const blanked = priced.map((row) =>
row.participantId === "us-5" ? { ...row, sourceOdds: null } : row
);
setupMockDb(defaultParticipants(), blanked);
const withoutOdds = probsFor(
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
).probFirst;
// Priced 5th of 20, so the median rating should land it in the same territory.
expect(withoutOdds).toBeGreaterThan(withOdds / 2);
expect(withoutOdds).toBeLessThan(withOdds * 2);
});
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("uses the most recent bracket event when several exist", async () => {
// A stale event's matches would carry no draw, silently reverting to a
// randomized one and discarding every recorded result.
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
mockDb.query.scoringEvents.findMany.mockResolvedValue([
{ id: "stale-event", createdAt: new Date("2026-07-01") },
{ id: "event-1", createdAt: new Date("2026-08-01") },
]);
const results = await new LLWSSimulator(20_000).simulate("season-1");
// Bracket mode is in force, so the fixed bye slots still show their advantage.
expect(probsFor(results, "us-9").probFirst).toBeGreaterThan(
probsFor(results, "us-1").probFirst
);
});
it("throws when the bracket is only partially seeded", async () => {
// participant1Id/participant2Id are ON DELETE SET NULL, so removing and
// re-adding one participant mid-tournament empties a single slot. Falling back
// to a randomized draw there would put eliminated teams back in contention.
const holed = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 3
? { ...m, participant2Id: null }
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), holed);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/partially seeded \(19 of 20/
);
});
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);
});
/**
* Play out the entire U.S. side, so two of its teams are locked into a scoring tier:
* us-7 loses Elimination Round 4 (the 7th-8th tier) and us-9 loses the Elimination
* Final (the 5th-6th tier). Every game feeding those two is recorded, which is what
* makes the results honorable — makePlayGame only replays a result when the teams
* the simulation routed into the game are the pair the result was recorded between.
*
* Slot order per side is ids[0..7] into the four Opening Round games and ids[8..9]
* as the byes, so the U.S. draw is us-1 v us-2, us-3 v us-4, us-5 v us-6,
* us-7 v us-8, with us-9 and us-10 entering at Winners Round 2.
*/
function usSidePlayedOut(): PlayoffMatchRow[] {
let matches = seededBracket();
const play = (round: string, matchNumber: number, winnerId: string, loserId: string) => {
matches = completeMatch(matches, round, matchNumber, winnerId, loserId);
};
// Winners bracket
play("Opening Round", 1, "us-1", "us-2");
play("Opening Round", 2, "us-3", "us-4");
play("Opening Round", 3, "us-5", "us-6");
play("Opening Round", 4, "us-7", "us-8");
play("Winners Round 2", 1, "us-9", "us-1"); // bye us-9 v OP1 winner
play("Winners Round 2", 2, "us-10", "us-3"); // bye us-10 v OP2 winner
play("Winners Semifinals", 1, "us-5", "us-9");
play("Winners Semifinals", 2, "us-10", "us-7");
play("Winners Final", 1, "us-5", "us-10");
// Elimination bracket, including the deliberate cross-overs
play("Elimination Round 1", 1, "us-4", "us-6"); // OP2 loser v OP3 loser
play("Elimination Round 1", 2, "us-2", "us-8"); // OP1 loser v OP4 loser
play("Elimination Round 2", 1, "us-1", "us-4");
play("Elimination Round 2", 2, "us-3", "us-2");
play("Elimination Round 3", 1, "us-9", "us-3");
play("Elimination Round 3", 2, "us-7", "us-1");
play("Elimination Round 4", 1, "us-9", "us-7"); // us-7 out: 7th-8th tier
play("Elimination Final", 1, "us-10", "us-9"); // us-9 out: 5th-6th tier
return matches;
}
it("puts a team locked into the 5th-6th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-9");
// The tier is two tied positions, so its probability splits evenly across them.
// Under DEFAULT_SCORING_RULES that is 0.5 x 25 + 0.5 x 25 = 25 points of EV —
// the 5th-6th tier value, not the flat 5th-8th average of 20.
expect(locked.probFifth).toBe(0.5);
expect(locked.probSixth).toBe(0.5);
expect(locked.probSeventh).toBe(0);
expect(locked.probEighth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
it("puts a team locked into the 7th-8th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-7");
// 0.5 x 15 + 0.5 x 15 = 15 points of EV, again distinct from the flat 20.
expect(locked.probSeventh).toBe(0.5);
expect(locked.probEighth).toBe(0.5);
expect(locked.probFifth).toBe(0);
expect(locked.probSixth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
});
// ── 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"]));
});
});
});