brackt/app/services/simulations/__tests__/llws-simulator.test.ts

791 lines
34 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
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,
}));
}
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
// ─── 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", () => {
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
let mockDb: {
select: MockInstance;
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
};
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
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 }[],
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
evRows: { participantId: string; sourceOdds: number | null }[],
bracketMatches?: Partial<PlayoffMatchRow>[]
) {
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
selectCallCount = 0;
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
const results = await new LLWSSimulator(1_000).simulate("season-1");
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
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;
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
// Within a tier the two positions are tied.
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
// 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 }));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
}
});
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
// ── 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.
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
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" })),
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
...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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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);
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
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));
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
});
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
it("throws when International team count is not 10", async () => {
const participants = [
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
...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));
Add LLWS 20-team double-elimination bracket The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
});
});
Make LLWS simulator bracket-aware and calibrate its futures model 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
2026-08-21 21:09:47 +00:00
// ── 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"]));
});
});
});