From 47b8935be6672a5a05b8fb27ac5e42ccb800cbb7 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:31:47 -0700 Subject: [PATCH] Add NCAAM and NCAAW bracket Monte Carlo simulators (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15 - NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure - Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches - Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th) - Add bracket configuration validation: null R64 slots must exactly match First Four mapping - Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20) - Align scoring constants across simulate route, expected-values display, and server action - Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation) - Add EV total invariant warning (expected ~340) on expected-values admin page - 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing Co-authored-by: Claude Sonnet 4.6 --- ...orts-seasons.$id.expected-values.server.ts | 12 +- ...min.sports-seasons.$id.expected-values.tsx | 21 +- .../admin.sports-seasons.$id.simulate.tsx | 47 +- app/routes/admin.sports.$id.tsx | 4 +- .../__tests__/ncaam-simulator.test.ts | 287 ++++++++ .../__tests__/ncaaw-simulator.test.ts | 285 ++++++++ app/services/simulations/ncaam-simulator.ts | 643 ++++++++++++++++++ app/services/simulations/ncaaw-simulator.ts | 597 ++++++++++++++++ app/services/simulations/registry.ts | 14 +- database/schema.ts | 2 + .../0044_add_ncaam_bracket_simulator_type.sql | 9 + .../0045_add_ncaaw_bracket_simulator_type.sql | 10 + drizzle/meta/_journal.json | 14 + 13 files changed, 1923 insertions(+), 22 deletions(-) create mode 100644 app/services/simulations/__tests__/ncaam-simulator.test.ts create mode 100644 app/services/simulations/__tests__/ncaaw-simulator.test.ts create mode 100644 app/services/simulations/ncaam-simulator.ts create mode 100644 app/services/simulations/ncaaw-simulator.ts create mode 100644 drizzle/0044_add_ncaam_bracket_simulator_type.sql create mode 100644 drizzle/0045_add_ncaaw_bracket_simulator_type.sql diff --git a/app/routes/admin.sports-seasons.$id.expected-values.server.ts b/app/routes/admin.sports-seasons.$id.expected-values.server.ts index 7435dcc..303ca44 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.server.ts +++ b/app/routes/admin.sports-seasons.$id.expected-values.server.ts @@ -29,12 +29,12 @@ export async function loader({ params }: Route.LoaderArgs) { const scoringRules = { pointsFor1st: 100, pointsFor2nd: 70, - pointsFor3rd: 50, - pointsFor4th: 40, - pointsFor5th: 25, - pointsFor6th: 25, - pointsFor7th: 15, - pointsFor8th: 15, + pointsFor3rd: 45, + pointsFor4th: 45, + pointsFor5th: 20, + pointsFor6th: 20, + pointsFor7th: 20, + pointsFor8th: 20, }; export async function action({ request, params }: Route.ActionArgs) { diff --git a/app/routes/admin.sports-seasons.$id.expected-values.tsx b/app/routes/admin.sports-seasons.$id.expected-values.tsx index 22403d2..4a170dd 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.tsx +++ b/app/routes/admin.sports-seasons.$id.expected-values.tsx @@ -26,7 +26,17 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export { loader }; -const SCORING = [100, 70, 50, 40, 25, 25, 15, 15] as const; +// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route. +// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th–8th (E8 losers)=20 each. +// Sum = 100+70+45+45+20+20+20+20 = 340. +// +// Total EV invariant: Σ EV across all participants = Σ scoring values = 340, +// because each probability column sums to 1.0 across all participants. +// If total drifts from 340, likely causes: +// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now +// zeros non-bracket participants automatically) +// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams) +const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const; function evFromProbs(ev: { probFirst: string; probSecond: string; probThird: string; probFourth: string; @@ -131,7 +141,14 @@ export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) })} {existingEVs.size > 0 && ( - Total EV + + Total EV + {Math.abs(totalEV - 340) > 1 && ( + + (expected ~340; re-run simulation to fix stale data) + + )} + {totalEV.toFixed(2)} )} diff --git a/app/routes/admin.sports-seasons.$id.simulate.tsx b/app/routes/admin.sports-seasons.$id.simulate.tsx index 1458bfe..0ccc474 100644 --- a/app/routes/admin.sports-seasons.$id.simulate.tsx +++ b/app/routes/admin.sports-seasons.$id.simulate.tsx @@ -14,6 +14,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.simulate"; import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season"; import { batchUpsertParticipantEVs } from "~/models/participant-expected-value"; import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot"; +import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { getSimulator, type SimulatorType } from "~/services/simulations/registry"; import { calculateEV, type ScoringRules } from "~/services/ev-calculator"; @@ -22,12 +23,12 @@ import { calculateEV, type ScoringRules } from "~/services/ev-calculator"; const DEFAULT_SCORING_RULES: ScoringRules = { pointsFor1st: 100, pointsFor2nd: 70, - pointsFor3rd: 50, - pointsFor4th: 40, - pointsFor5th: 25, - pointsFor6th: 25, - pointsFor7th: 15, - pointsFor8th: 15, + pointsFor3rd: 45, + pointsFor4th: 45, + pointsFor5th: 20, + pointsFor6th: 20, + pointsFor7th: 20, + pointsFor8th: 20, }; export async function action({ params }: Route.ActionArgs) { @@ -65,16 +66,38 @@ export async function action({ params }: Route.ActionArgs) { throw new Error("Simulation returned no results. Check that participants have EV data."); } - // Persist updated EVs (transactional) - await batchUpsertParticipantEVs( - results.map((r) => ({ + // Build a complete set of EV inputs covering ALL season participants. + // Participants not included in simulation results (e.g., teams in the season + // but not in the bracket) get zeroed out so stale EVs from prior runs don't + // inflate the total. Without this, extra participants with old non-zero EVs + // would cause the total EV to exceed the expected sum of scoring values (340). + const allSeasonParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId); + const simulatedIds = new Set(results.map((r) => r.participantId)); + const ZERO_PROBS = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + const evInputs = [ + ...results.map((r) => ({ participantId: r.participantId, sportsSeasonId, probabilities: r.probabilities, scoringRules: DEFAULT_SCORING_RULES, - source: "elo_simulation", - })) - ); + source: "elo_simulation" as const, + })), + ...allSeasonParticipants + .filter((p) => !simulatedIds.has(p.id)) + .map((p) => ({ + participantId: p.id, + sportsSeasonId, + probabilities: ZERO_PROBS, + scoringRules: DEFAULT_SCORING_RULES, + source: "elo_simulation" as const, + })), + ]; + + // Persist updated EVs (transactional) + await batchUpsertParticipantEVs(evInputs); // Take EV snapshot from simulation output (not re-read from DB) const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD diff --git a/app/routes/admin.sports.$id.tsx b/app/routes/admin.sports.$id.tsx index af5a12c..a68392b 100644 --- a/app/routes/admin.sports.$id.tsx +++ b/app/routes/admin.sports.$id.tsx @@ -101,7 +101,7 @@ export async function action({ request, params }: Route.ActionArgs) { iconUrl = null; } - const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket", "ucl_bracket"] as const; + const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket", "ucl_bracket", "ncaam_bracket", "ncaaw_bracket"] as const; type ValidSimulatorType = typeof validSimulatorTypes[number]; const parsedSimulatorType: ValidSimulatorType | null = typeof simulatorType === "string" && validSimulatorTypes.includes(simulatorType as ValidSimulatorType) @@ -204,6 +204,8 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro Golf Qualifying Points Model Bracket Monte Carlo UCL Bracket Monte Carlo + NCAAM Bracket Monte Carlo + NCAAW Bracket Monte Carlo

diff --git a/app/services/simulations/__tests__/ncaam-simulator.test.ts b/app/services/simulations/__tests__/ncaam-simulator.test.ts new file mode 100644 index 0000000..26aac7e --- /dev/null +++ b/app/services/simulations/__tests__/ncaam-simulator.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect } from "vitest"; +import { kenpomWinProbability, getNetRating, normalizeTeamName } from "../ncaam-simulator"; + +// ─── KenPom win probability formula ────────────────────────────────────────── + +describe("kenpomWinProbability", () => { + it("returns 0.5 for equal teams", () => { + expect(kenpomWinProbability(25, 25)).toBeCloseTo(0.5, 10); + }); + + it("returns >0.5 for team A with higher netrtg", () => { + expect(kenpomWinProbability(30, 20)).toBeGreaterThan(0.5); + }); + + it("returns <0.5 for team A with lower netrtg", () => { + expect(kenpomWinProbability(20, 30)).toBeLessThan(0.5); + }); + + it("is symmetric: P(A>B) + P(B>A) = 1", () => { + const pAB = kenpomWinProbability(35, 20); + const pBA = kenpomWinProbability(20, 35); + expect(pAB + pBA).toBeCloseTo(1.0, 10); + }); + + it("matches the logistic formula exactly for known values", () => { + // netrtgA=38.90 (Duke 2025-26), netrtgB=0 → 1/(1+exp(-38.90/7.5)) + const expected = 1 / (1 + Math.exp(-38.90 / 7.5)); + expect(kenpomWinProbability(38.90, 0)).toBeCloseTo(expected, 10); + }); + + it("handles negative netrtg (weak team vs average)", () => { + // negative netrtg is valid (below-average team) + const p = kenpomWinProbability(-2, 0); + expect(p).toBeGreaterThan(0); + expect(p).toBeLessThan(0.5); + }); + + it("large AEM gap pushes probability toward 1", () => { + // 38 point gap (Duke-caliber vs near-zero) → high win prob + expect(kenpomWinProbability(38, 0)).toBeGreaterThan(0.99); + }); + + it("scale factor: a 7.5-point gap yields ~73% win probability", () => { + // 1 / (1 + exp(-7.5/7.5)) = 1/(1+exp(-1)) ≈ 0.7311 + expect(kenpomWinProbability(7.5, 0)).toBeCloseTo(0.7311, 3); + }); +}); + +// ─── Team name normalization ────────────────────────────────────────────────── + +describe("normalizeTeamName", () => { + it("converts to lowercase", () => { + expect(normalizeTeamName("Duke")).toBe("duke"); + }); + + it("trims leading/trailing whitespace", () => { + expect(normalizeTeamName(" Duke ")).toBe("duke"); + }); + + it("collapses internal whitespace", () => { + expect(normalizeTeamName("North Carolina")).toBe("north carolina"); + }); + + it("preserves punctuation", () => { + expect(normalizeTeamName("St. John's")).toBe("st. john's"); + }); +}); + +// ─── KenPom data lookup ─────────────────────────────────────────────────────── + +describe("getNetRating", () => { + it("returns correct netrtg for exact known name (lowercase)", () => { + expect(getNetRating("duke")).toBeCloseTo(38.90, 2); + }); + + it("is case-insensitive", () => { + expect(getNetRating("Duke")).toBeCloseTo(38.90, 2); + expect(getNetRating("DUKE")).toBeCloseTo(38.90, 2); + }); + + it("handles abbreviated name variants", () => { + // Both 'michigan st.' and 'michigan state' should resolve + expect(getNetRating("Michigan St.")).toBeCloseTo(28.31, 2); + expect(getNetRating("Michigan State")).toBeCloseTo(28.31, 2); + }); + + it("handles name with apostrophe", () => { + expect(getNetRating("St. John's")).toBeCloseTo(25.91, 2); + }); + + it("returns 0.0 for an unknown team name", () => { + expect(getNetRating("Fictional University")).toBe(0.0); + }); + + it("returns 0.0 for empty string", () => { + expect(getNetRating("")).toBe(0.0); + }); + + it("handles negative netrtg (below-average teams)", () => { + expect(getNetRating("Idaho")).toBeCloseTo(1.53, 2); + expect(getNetRating("Eastern Washington")).toBeCloseTo(0.00, 2); + }); +}); + +// ─── Bracket path logic ─────────────────────────────────────────────────────── + +describe("NCAAM bracket advancement path", () => { + it("R64 matches 1 and 2 feed R32 match 1 (Math.ceil convention)", () => { + expect(Math.ceil(1 / 2)).toBe(1); + expect(Math.ceil(2 / 2)).toBe(1); + }); + + it("R64 matches 3 and 4 feed R32 match 2", () => { + expect(Math.ceil(3 / 2)).toBe(2); + expect(Math.ceil(4 / 2)).toBe(2); + }); + + it("R64 matches 31 and 32 feed R32 match 16", () => { + expect(Math.ceil(31 / 2)).toBe(16); + expect(Math.ceil(32 / 2)).toBe(16); + }); + + it("32 R64 matches produce exactly 16 R32 slots", () => { + const r32Slots = new Set(); + for (let i = 1; i <= 32; i++) r32Slots.add(Math.ceil(i / 2)); + expect(r32Slots.size).toBe(16); + }); + + it("R32 match i uses 0-indexed winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 16; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p1).toBeGreaterThanOrEqual(0); + expect(p2).toBeLessThan(32); + } + }); + + it("S16 match i uses 0-indexed R32 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 8; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p1).toBeGreaterThanOrEqual(0); + expect(p2).toBeLessThan(16); + } + }); + + it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 4; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p2).toBeLessThan(8); + } + }); + + it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 2; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p2).toBeLessThan(4); + } + }); +}); + +// ─── Probability bucket math ────────────────────────────────────────────────── + +describe("NCAAM placement bucket probability math", () => { + const N = 50_000; + + it("champion: count/N = 1.0 when a team wins every simulation", () => { + const c = N; + expect(c / N).toBeCloseTo(1.0, 10); + }); + + it("finalist: count/N = 1.0 when a team reaches final every simulation", () => { + expect(N / N).toBeCloseTo(1.0, 10); + }); + + it("FF loser slots: count/(2*N) sums to 1.0 across teams whose counts total 2*N", () => { + // 2 FF losers per sim → total across all teams = 2*N + // e.g. 2 teams each appearing N times: (N + N) / (2*N) = 1.0 + const counts = [N, N]; + const sum = counts.reduce((s, c) => s + c / (2 * N), 0); + expect(sum).toBeCloseTo(1.0, 10); + }); + + it("E8 loser slots: count/(4*N) sums to 1.0 across teams whose counts total 4*N", () => { + // 4 E8 losers per sim → total across all teams = 4*N + // e.g. 4 teams each appearing N times: (4*N) / (4*N) = 1.0 + const counts = [N, N, N, N]; + const sum = counts.reduce((s, c) => s + c / (4 * N), 0); + expect(sum).toBeCloseTo(1.0, 10); + }); + + it("probThird equals probFourth for the same team (same ff count / 2N)", () => { + const ffCount = 12500; + const probThird = ffCount / (2 * N); + const probFourth = ffCount / (2 * N); + expect(probThird).toBe(probFourth); + }); + + it("probFifth through probEighth are equal for the same team (same e8 count / 4N)", () => { + const e8Count = 6250; + const probs = [e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N)]; + expect(probs[0]).toBe(probs[1]); + expect(probs[1]).toBe(probs[2]); + expect(probs[2]).toBe(probs[3]); + }); + + it("a team exiting in R64 has all-zero probabilities", () => { + // R64 losers are never counted in any bucket → all counts stay 0 + const c = 0, f = 0, ff = 0, e8 = 0; + expect(c / N).toBe(0); + expect(f / N).toBe(0); + expect(ff / (2 * N)).toBe(0); + expect(e8 / (4 * N)).toBe(0); + }); +}); + +// ─── EV alignment with scoring rules ───────────────────────────────────────── + +describe("NCAAM EV calculation alignment with scoring rules", () => { + // DEFAULT_SCORING_RULES: Champion=100, Finalist=70, FF loser=45, E8 loser=20, rest=0 + // (sum = 340; must match DEFAULT_SCORING_RULES in admin.sports-seasons.$id.simulate.tsx) + const SCORING = { + first: 100, + second: 70, + thirdFourth: 45, + fifthToEighth: 20, + }; + + function computeEV(probs: { + probFirst: number; probSecond: number; + probThird: number; probFourth: number; + probFifth: number; probSixth: number; probSeventh: number; probEighth: number; + }): number { + return ( + probs.probFirst * SCORING.first + + probs.probSecond * SCORING.second + + probs.probThird * SCORING.thirdFourth + + probs.probFourth * SCORING.thirdFourth + + probs.probFifth * SCORING.fifthToEighth + + probs.probSixth * SCORING.fifthToEighth + + probs.probSeventh * SCORING.fifthToEighth + + probs.probEighth * SCORING.fifthToEighth + ); + } + + it("champion with probFirst=1 earns 100 EV", () => { + const probs = { + probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(100, 10); + }); + + it("finalist with probSecond=1 earns 70 EV", () => { + const probs = { + probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(70, 10); + }); + + it("confirmed FF loser (probThird=probFourth=0.5 each) earns 45 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0.5, probFourth: 0.5, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(45, 10); + }); + + it("confirmed E8 loser (probFifth–Eighth=0.25 each) earns 20 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0.25, probSixth: 0.25, probSeventh: 0.25, probEighth: 0.25, + }; + expect(computeEV(probs)).toBeCloseTo(20, 10); + }); + + it("R64 loser (all zeros) earns 0 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBe(0); + }); +}); diff --git a/app/services/simulations/__tests__/ncaaw-simulator.test.ts b/app/services/simulations/__tests__/ncaaw-simulator.test.ts new file mode 100644 index 0000000..5cf3a2a --- /dev/null +++ b/app/services/simulations/__tests__/ncaaw-simulator.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect } from "vitest"; +import { barthagWinProbability, getBarthagRating, normalizeTeamName } from "../ncaaw-simulator"; + +// ─── Log5 win probability formula ──────────────────────────────────────────── + +describe("barthagWinProbability", () => { + it("returns 0.5 for equal teams", () => { + expect(barthagWinProbability(0.7, 0.7)).toBeCloseTo(0.5, 10); + }); + + it("returns >0.5 for team A with higher Barthag", () => { + expect(barthagWinProbability(0.9, 0.6)).toBeGreaterThan(0.5); + }); + + it("returns <0.5 for team A with lower Barthag", () => { + expect(barthagWinProbability(0.6, 0.9)).toBeLessThan(0.5); + }); + + it("is symmetric: P(A>B) + P(B>A) = 1", () => { + const pAB = barthagWinProbability(0.9, 0.4); + const pBA = barthagWinProbability(0.4, 0.9); + expect(pAB + pBA).toBeCloseTo(1.0, 10); + }); + + it("satisfies the Barthag definition: barthagWinProbability(x, 0.5) === x", () => { + // The whole point of Barthag is P(team beats avg D-I opponent) = barthag + expect(barthagWinProbability(0.9, 0.5)).toBeCloseTo(0.9, 10); + expect(barthagWinProbability(0.3, 0.5)).toBeCloseTo(0.3, 10); + }); + + it("handles degenerate case: both teams at 0 → coin flip", () => { + expect(barthagWinProbability(0, 0)).toBe(0.5); + }); + + it("handles degenerate case: both teams at 1 → coin flip", () => { + expect(barthagWinProbability(1, 1)).toBe(0.5); + }); + + it("near-certain win: strong team (0.9996) vs weak team (0.35)", () => { + // Connecticut (.9996) vs Norfolk St. (.3468) — very high win probability + expect(barthagWinProbability(0.9996, 0.3468)).toBeGreaterThan(0.999); + }); + + it("matches the Log5 formula exactly for known values", () => { + const a = 0.9, b = 0.6; + const expected = (a * (1 - b)) / (a * (1 - b) + b * (1 - a)); + expect(barthagWinProbability(a, b)).toBeCloseTo(expected, 10); + }); +}); + +// ─── Team name normalization ────────────────────────────────────────────────── + +describe("normalizeTeamName", () => { + it("converts to lowercase", () => { + expect(normalizeTeamName("Connecticut")).toBe("connecticut"); + }); + + it("trims leading/trailing whitespace", () => { + expect(normalizeTeamName(" UCLA ")).toBe("ucla"); + }); + + it("collapses internal whitespace", () => { + expect(normalizeTeamName("South Carolina")).toBe("south carolina"); + }); + + it("preserves punctuation", () => { + expect(normalizeTeamName("N.C. State")).toBe("n.c. state"); + }); +}); + +// ─── Barthag data lookup ────────────────────────────────────────────────────── + +describe("getBarthagRating", () => { + it("returns correct Barthag for exact known name (lowercase)", () => { + expect(getBarthagRating("connecticut")).toBeCloseTo(0.9996, 4); + }); + + it("is case-insensitive", () => { + expect(getBarthagRating("Connecticut")).toBeCloseTo(0.9996, 4); + expect(getBarthagRating("CONNECTICUT")).toBeCloseTo(0.9996, 4); + }); + + it("resolves uconn alias", () => { + expect(getBarthagRating("UConn")).toBeCloseTo(0.9996, 4); + }); + + it("returns correct Barthag for #2 team (UCLA)", () => { + expect(getBarthagRating("UCLA")).toBeCloseTo(0.9991, 4); + }); + + it("handles abbreviated name variants", () => { + // Both 'michigan st.' and 'michigan state' should resolve + expect(getBarthagRating("Michigan St.")).toBeCloseTo(0.9817, 4); + expect(getBarthagRating("Michigan State")).toBeCloseTo(0.9817, 4); + }); + + it("handles 'n.c. state' and 'nc state' aliases", () => { + expect(getBarthagRating("N.C. State")).toBeCloseTo(0.9766, 4); + expect(getBarthagRating("nc state")).toBeCloseTo(0.9766, 4); + }); + + it("handles ole miss alias", () => { + expect(getBarthagRating("Ole Miss")).toBeCloseTo(0.9821, 4); + }); + + it("returns 0.5 for an unknown team name (BARTHAG_FALLBACK = average)", () => { + expect(getBarthagRating("Fictional University")).toBe(0.5); + }); + + it("returns 0.5 for empty string", () => { + expect(getBarthagRating("")).toBe(0.5); + }); + + it("returns correct Barthag for a low-ranked team (Norfolk St.)", () => { + expect(getBarthagRating("Norfolk St.")).toBeCloseTo(0.3468, 4); + expect(getBarthagRating("Norfolk State")).toBeCloseTo(0.3468, 4); + }); +}); + +// ─── Bracket path logic ─────────────────────────────────────────────────────── + +describe("NCAAW bracket advancement path", () => { + it("R64 matches 1 and 2 feed R32 match 1 (Math.ceil convention)", () => { + expect(Math.ceil(1 / 2)).toBe(1); + expect(Math.ceil(2 / 2)).toBe(1); + }); + + it("R64 matches 3 and 4 feed R32 match 2", () => { + expect(Math.ceil(3 / 2)).toBe(2); + expect(Math.ceil(4 / 2)).toBe(2); + }); + + it("R64 matches 31 and 32 feed R32 match 16", () => { + expect(Math.ceil(31 / 2)).toBe(16); + expect(Math.ceil(32 / 2)).toBe(16); + }); + + it("32 R64 matches produce exactly 16 R32 slots", () => { + const r32Slots = new Set(); + for (let i = 1; i <= 32; i++) r32Slots.add(Math.ceil(i / 2)); + expect(r32Slots.size).toBe(16); + }); + + it("R32 match i uses 0-indexed winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 16; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p1).toBeGreaterThanOrEqual(0); + expect(p2).toBeLessThan(32); + } + }); + + it("S16 match i uses 0-indexed R32 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 8; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p1).toBeGreaterThanOrEqual(0); + expect(p2).toBeLessThan(16); + } + }); + + it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 4; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p2).toBeLessThan(8); + } + }); + + it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => { + for (let i = 1; i <= 2; i++) { + const p1 = (i - 1) * 2; + const p2 = (i - 1) * 2 + 1; + expect(p2).toBeLessThan(4); + } + }); +}); + +// ─── Probability bucket math ────────────────────────────────────────────────── + +describe("NCAAW placement bucket probability math", () => { + const N = 50_000; + + it("champion: count/N = 1.0 when a team wins every simulation", () => { + expect(N / N).toBeCloseTo(1.0, 10); + }); + + it("FF loser slots: count/(2*N) sums to 1.0 across teams whose counts total 2*N", () => { + // 2 FF losers per sim → total across all teams = 2*N + const counts = [N, N]; + const sum = counts.reduce((s, c) => s + c / (2 * N), 0); + expect(sum).toBeCloseTo(1.0, 10); + }); + + it("E8 loser slots: count/(4*N) sums to 1.0 across teams whose counts total 4*N", () => { + const counts = [N, N, N, N]; + const sum = counts.reduce((s, c) => s + c / (4 * N), 0); + expect(sum).toBeCloseTo(1.0, 10); + }); + + it("probThird equals probFourth for the same team (same ff count / 2N)", () => { + const ffCount = 12500; + const probThird = ffCount / (2 * N); + const probFourth = ffCount / (2 * N); + expect(probThird).toBe(probFourth); + }); + + it("probFifth through probEighth are equal for the same team (same e8 count / 4N)", () => { + const e8Count = 6250; + const probs = [e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N)]; + expect(probs[0]).toBe(probs[1]); + expect(probs[1]).toBe(probs[2]); + expect(probs[2]).toBe(probs[3]); + }); +}); + +// ─── EV alignment with scoring rules ───────────────────────────────────────── + +describe("NCAAW EV calculation alignment with scoring rules", () => { + // DEFAULT_SCORING_RULES: Champion=100, Finalist=70, FF loser=45, E8 loser=20, rest=0 + // (sum = 340; must match DEFAULT_SCORING_RULES in admin.sports-seasons.$id.simulate.tsx) + const SCORING = { + first: 100, + second: 70, + thirdFourth: 45, + fifthToEighth: 20, + }; + + function computeEV(probs: { + probFirst: number; probSecond: number; + probThird: number; probFourth: number; + probFifth: number; probSixth: number; probSeventh: number; probEighth: number; + }): number { + return ( + probs.probFirst * SCORING.first + + probs.probSecond * SCORING.second + + probs.probThird * SCORING.thirdFourth + + probs.probFourth * SCORING.thirdFourth + + probs.probFifth * SCORING.fifthToEighth + + probs.probSixth * SCORING.fifthToEighth + + probs.probSeventh * SCORING.fifthToEighth + + probs.probEighth * SCORING.fifthToEighth + ); + } + + it("champion with probFirst=1 earns 100 EV", () => { + const probs = { + probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(100, 10); + }); + + it("finalist with probSecond=1 earns 70 EV", () => { + const probs = { + probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(70, 10); + }); + + it("confirmed FF loser (probThird=probFourth=0.5 each) earns 45 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0.5, probFourth: 0.5, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBeCloseTo(45, 10); + }); + + it("confirmed E8 loser (probFifth–Eighth=0.25 each) earns 20 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0.25, probSixth: 0.25, probSeventh: 0.25, probEighth: 0.25, + }; + expect(computeEV(probs)).toBeCloseTo(20, 10); + }); + + it("R64 loser (all zeros) earns 0 EV", () => { + const probs = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + expect(computeEV(probs)).toBe(0); + }); +}); diff --git a/app/services/simulations/ncaam-simulator.ts b/app/services/simulations/ncaam-simulator.ts new file mode 100644 index 0000000..ebb57fa --- /dev/null +++ b/app/services/simulations/ncaam-simulator.ts @@ -0,0 +1,643 @@ +/** + * NCAAM Tournament Bracket Simulator + * + * Monte Carlo simulation of the NCAA Men's Basketball Tournament (64-team bracket). + * Win probability is derived from KenPom Adjusted Efficiency Margin (AEM). + * + * Algorithm: + * 1. Load the bracket scoring event and all playoff matches from DB + * (6 rounds: R64, R32, S16, E8, FF, Final — 63 total matches) + * 2. Load participant names from DB; look up KenPom net rating by name + * from the hardcoded KENPOM_NET_RATINGS map (updated each season) + * 3. Per-match win probability = 1 / (1 + exp(-(netrtgA - netrtgB) / 7.5)) + * (neutral-court logistic formula, calibrated to KenPom AEM scale) + * 4. Simulate 50,000 tournaments, honoring completed match results + * 5. Track placements only for point-scoring rounds: + * - Champion (1st) + * - Finalist (2nd) + * - Final Four losers (3rd/4th) — 2 per sim + * - Elite Eight losers (5th–8th) — 4 per sim + * R64 / R32 / S16 exits score 0 points → not tracked + * + * Probability output (8 slots — same SimulationProbabilities type as UCL): + * probFirst = champion / N + * probSecond = finalist / N + * probThird/Fourth = ffLoser / (2 * N) — 2 FF losers per sim + * probFifth–Eighth = e8Loser / (4 * N) — 4 E8 losers per sim + * All pre-E8 exits → 0 (no points scored) + * + * Column sums are guaranteed to equal 1.0 by construction: + * probFirst/Second — 1 per sim, N total → sums to 1 + * probThird/Fourth — 2 per sim, each column = total/2N → sum = 1 + * probFifth–Eighth — 4 per sim, each column = total/4N → sum = 1 + * + * KenPom net ratings are hardcoded in KENPOM_NET_RATINGS below (2025-26 season). + * Update this map each season. Participant names must match DB records + * (lookup is case-insensitive, whitespace-normalized). + * Unknown teams fall back to netrtg = 0.0 (near-average strength). + * + * Bracket advancement path (same as UCL): + * nextMatchNumber = Math.ceil(matchNumber / 2) + * i.e. R64 matches 1+2 → R32 match 1, R64 matches 3+4 → R32 match 2, … + */ + +import { database } from "~/database/context"; +import { eq, and, inArray } from "drizzle-orm"; +import * as schema from "~/database/schema"; +import type { BracketRegion } from "~/lib/bracket-templates"; +import { buildNCAA68SlotMap, matchIndexForSeedSlot } from "~/lib/bracket-templates"; +import type { Simulator, SimulationResult } from "./types"; + +// ─── Simulation parameters ──────────────────────────────────────────────────── + +const NUM_SIMULATIONS = 50_000; + +/** + * KenPom scale factor for the neutral-court logistic win probability formula. + * A difference of 7.5 AEM points crosses the logit 50% mark. + * Source: KenPom documentation; widely used in academic NCAAM models. + */ +const KENPOM_SCALE_FACTOR = 7.5; + +// ─── KenPom net rating data (2025-26 season) ───────────────────────────────── +// +// Update this map at the start of each tournament season with current +// KenPom Adjusted Efficiency Margin values. +// Source: kenpom.com, data through March 15, 2026 (6,195 games). +// +// Keys: lowercase, whitespace-normalized team names (normalizeTeamName output). +// Multiple aliases are included for common abbreviation variants. +// If a participant name is not found, it falls back to 0.0 (average strength). + +const KENPOM_NET_RATINGS: Record = { + // ── 1–10 ──────────────────────────────────────────────────────────────────── + "duke": 38.90, + "arizona": 37.66, + "michigan": 37.59, + "florida": 33.79, + "houston": 33.43, + "iowa st.": 32.42, "iowa state": 32.42, + "illinois": 32.10, + "purdue": 31.20, + "michigan st.": 28.31, "michigan state": 28.31, + "gonzaga": 28.10, + + // ── 11–30 ─────────────────────────────────────────────────────────────────── + "connecticut": 27.87, "uconn": 27.87, + "vanderbilt": 27.51, + "virginia": 26.71, + "nebraska": 26.16, + "arkansas": 26.05, + "tennessee": 26.02, + "st. john's": 25.91, "st johns": 25.91, + "alabama": 25.72, + "louisville": 25.42, + "texas tech": 25.22, + "kansas": 24.44, + "wisconsin": 23.39, + "byu": 23.25, + "saint mary's": 23.07, + "iowa": 22.44, + "ohio st.": 22.24, "ohio state": 22.24, + "ucla": 21.67, + "kentucky": 21.48, + "north carolina": 20.84, + + // ── 30–50 ─────────────────────────────────────────────────────────────────── + "utah st.": 20.76, "utah state": 20.76, + "miami fl": 20.68, "miami (fl)": 20.68, "miami": 20.68, + "georgia": 20.48, + "villanova": 19.97, + "nc state": 19.60, "n.c. state": 19.60, + "santa clara": 19.40, + "clemson": 19.24, + "texas": 19.03, + "auburn": 19.02, + "texas a&m": 18.67, + "oklahoma": 18.37, + "saint louis": 18.32, + "smu": 18.09, + "tcu": 17.59, + "cincinnati": 17.49, + "vcu": 17.21, + "indiana": 17.18, + "south florida": 16.39, "usf": 16.39, + "san diego st.": 16.39, "san diego state": 16.39, + "baylor": 16.00, + + // ── 50–80 ─────────────────────────────────────────────────────────────────── + "new mexico": 15.81, + "seton hall": 15.71, + "missouri": 15.39, + "washington": 15.12, + "ucf": 15.04, + "virginia tech": 13.69, + "florida st.": 13.48, "florida state": 13.48, + "northwestern": 13.41, + "stanford": 13.37, + "west virginia": 13.27, + "lsu": 13.23, + "grand canyon": 13.19, + "boise st.": 13.18, "boise state": 13.18, + "tulsa": 12.97, + "akron": 12.80, + "ole miss": 12.62, "mississippi": 12.62, + "oklahoma st.": 12.58, "oklahoma state": 12.58, + "arizona st.": 12.52, "arizona state": 12.52, + "mcneese": 12.48, "mcneese st.": 12.48, "mcneese state": 12.48, + "belmont": 12.26, + "colorado": 12.11, + "providence": 11.81, + "northern iowa": 11.81, + "california": 11.43, "cal": 11.43, + "wake forest": 11.39, + "nevada": 11.30, + "creighton": 11.01, + "minnesota": 10.91, + "dayton": 10.91, + "georgetown": 10.89, + "usc": 10.81, + + // ── 81–120 ────────────────────────────────────────────────────────────────── + "yale": 10.65, + "wichita st.": 9.78, "wichita state": 9.78, + "syracuse": 9.74, + "marquette": 9.66, + "george washington": 9.64, "gwu": 9.64, + "butler": 9.49, + "hofstra": 9.49, + "colorado st.": 9.49, "colorado state": 9.49, + "notre dame": 8.88, + "utah valley": 8.82, + "stephen f. austin": 8.43, "sf austin": 8.43, + "high point": 8.40, + "miami oh": 8.26, "miami (oh)": 8.26, + "pittsburgh": 7.60, "pitt": 7.60, + "south carolina": 7.54, + "george mason": 7.50, + "xavier": 7.47, + "wyoming": 7.40, + "oregon": 7.03, + "mississippi st.": 7.00, "mississippi state": 7.00, + "kansas st.": 6.98, "kansas state": 6.98, + "depaul": 6.83, + "illinois st.": 6.66, "illinois state": 6.66, + "uc irvine": 6.21, + "illinois chicago": 6.16, "uic": 6.16, + "cal baptist": 5.99, + "unlv": 5.97, + "hawaii": 5.97, "hawai'i": 5.97, + "st. thomas": 5.88, + "unc wilmington": 5.79, "uncw": 5.79, "nc wilmington": 5.79, + "sam houston": 5.56, "sam houston st.": 5.56, "sam houston state": 5.56, + "pacific": 5.42, + "north dakota st.": 5.13, "north dakota state": 5.13, + "davidson": 4.94, + "saint joseph's": 4.56, + "southern illinois": 4.55, + "uc san diego": 4.53, "ucsd": 4.53, + "seattle": 4.49, + "maryland": 4.25, + "san francisco": 4.20, + "murray st.": 4.12, "murray state": 4.12, + "bradley": 3.96, + "rutgers": 3.95, + "liberty": 3.90, + "utah": 3.65, + "uab": 3.46, + "duquesne": 3.45, + "florida atlantic": 3.31, + "uc santa barbara": 3.17, "ucsb": 3.17, + "toledo": 3.15, + "fresno st.": 3.09, "fresno state": 3.09, + "montana st.": 3.00, "montana state": 3.00, + + // ── 134–170 (tournament bubble / auto-bid range) ───────────────────────── + "memphis": 2.52, + "rhode island": 2.47, + "north texas": 2.46, + "washington st.": 2.32, "washington state": 2.32, + "penn st.": 2.18, "penn state": 2.18, + "st. bonaventure": 2.17, + "wright st.": 2.04, "wright state": 2.04, + "northern colorado": 1.87, + "navy": 1.84, + "troy": 1.72, + "robert morris": 1.69, + "idaho": 1.53, + "portland st.": 1.52, "portland state": 1.52, + "bowling green": 1.51, + "kent st.": 1.50, "kent state": 1.50, + "william & mary": 1.49, + "penn": 1.47, + "arkansas st.": 1.39, "arkansas state": 1.39, + "harvard": 1.26, + "central arkansas": 1.25, "c arkansas": 1.25, + "winthrop": 1.13, + "western kentucky": 0.02, "western ky.": 0.02, + "kennesaw st.": 0.57, "kennesaw state": 0.57, + "cornell": 0.51, + "east tennessee st.": 0.44, "etsu": 0.44, + "eastern washington": 0.00, "east washington": 0.00, + "charleston": -0.19, + "austin peay": -0.28, + "merrimack": -0.83, + + // ── 170–220 (low seeds / play-in range) ───────────────────────────────── + "montana": -1.72, + "umbc": -1.67, + "tennessee st.": -1.83, "tennessee state": -1.83, + "furman": -1.98, + "siena": -2.10, + "appalachian state": -2.33, "app state": -2.33, + "south alabama": -3.14, + "howard": -3.19, + "marshall": -3.19, + "samford": -3.93, + "liu": -3.95, + "lipscomb": -2.84, + "queens": -1.44, + "quinnipiac": -4.25, + "south dakota st.": -4.31, "south dakota state": -4.31, + "se missouri st.": -5.84, "southeast missouri state": -5.84, + "tennessee martin": -4.81, "ut martin": -4.81, + "vermont": -6.45, + "bethune": -6.60, "bethune-cookman": -6.60, + "colgate": -7.02, + "saint peter's": -7.51, "st. peter's": -7.51, "st peters": -7.51, + "mercer": -1.97, + "morehead st.": -10.35, "morehead state": -10.35, + "lehigh": -10.37, + "prairie view a&m": -10.69, "prairie view": -10.69, + "grambling": -11.78, "grambling st.": -11.78, + "central connecticut": -12.71, "c connecticut": -12.71, + "wagner": -12.68, + "norfolk st.": -15.38, "norfolk state": -15.38, +}; + +// ─── Public helpers (exported for unit testing) ─────────────────────────────── + +/** Normalize a team name for KenPom map lookup. */ +export function normalizeTeamName(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, " "); +} + +/** Look up KenPom AEM for a participant name. Returns 0.0 if not found. */ +export function getNetRating(name: string): number { + return KENPOM_NET_RATINGS[normalizeTeamName(name)] ?? 0.0; +} + +/** + * NCAAM neutral-court win probability using KenPom Adjusted Efficiency Margin. + * P(A beats B) = 1 / (1 + exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)) + * Exported for unit testing. + */ +export function kenpomWinProbability(netrtgA: number, netrtgB: number): number { + return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)); +} + +// ─── Simulator ──────────────────────────────────────────────────────────────── + +export class NCAAMSimulator implements Simulator { + async simulate(sportsSeasonId: string): Promise { + const db = database(); + + // 1. Find the bracket scoring event (playoff_game) for this sports season. + const bracketEvent = await db.query.scoringEvents.findFirst({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.eventType, "playoff_game") + ), + }); + + if (!bracketEvent) { + throw new Error( + `No bracket event found for sports season ${sportsSeasonId}. ` + + `Create a playoff_game scoring event and set up the 64-team bracket first.` + ); + } + + // 2. Load all playoff matches for this bracket event. + const allMatches = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + orderBy: (m, { asc }) => [asc(m.matchNumber)], + }); + + if (allMatches.length === 0) { + throw new Error( + `No playoff matches found for the bracket event. ` + + `Generate the 64-team bracket from the admin panel first.` + ); + } + + // 3. Group matches by round name. + // Rounds: "First Four" (optional, 4) | "Round of 64" (32) | "Round of 32" (16) + // | "Sweet Sixteen" (8) | "Elite Eight" (4) | "Final Four" (2) | "Championship" (1) + // + // We look up by name rather than sorting by count to avoid the ambiguity between + // "First Four" and "Elite Eight" (both 4 matches). + const byRound = new Map(); + for (const m of allMatches) { + if (!byRound.has(m.round)) byRound.set(m.round, []); + byRound.get(m.round)!.push(m); + } + + const sortByMatchNumber = (matches: typeof allMatches) => + [...matches].sort((a, b) => a.matchNumber - b.matchNumber); + + const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; + const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; + const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; + const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; + const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; + const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; + const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; + + if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { + const found = [...byRound.keys()].join(", "); + throw new Error( + `Missing expected rounds. Found: [${found}]. ` + + `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` + ); + } + + if (r64Matches.length !== 32) { + throw new Error( + `Expected 32 Round of 64 matches, found ${r64Matches.length}. ` + + `This simulator only supports the standard 64-team NCAAM format.` + ); + } + + // 4. Build the First Four → Round of 64 slot mapping (if First Four games exist). + // + // First Four match N feeds into R64 match: + // regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1 + // This mirrors the advanceFirstFourWinner() logic in playoff-match.ts. + // + // The region config is stored on the scoring event (bracketRegionConfig) so + // per-event overrides are respected. Falls back to the ncaa_68 template default. + // + // ffToR64: Map + // r64NullSlots: Set of R64 match numbers whose participant2Id is null (pending FF) + const ffToR64 = new Map(); + const r64NullSlots = new Set( + r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber) + ); + + if (firstFourMatches.length > 0) { + const regions = + (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? + // Default ncaa_68 regions if no override stored + [ + { name: "East", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], playIns: [] }, + { name: "South", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], playIns: [{ seedSlot: 16, teams: 2 }] }, + { name: "West", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15,16], playIns: [{ seedSlot: 11, teams: 2 }] }, + { name: "Midwest", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15], playIns: [{ seedSlot: 11, teams: 2 }, { seedSlot: 16, teams: 2 }] }, + ]; + + const slotMap = buildNCAA68SlotMap(regions); + for (let i = 0; i < slotMap.playInOffsets.length; i++) { + const { regionIndex, seedSlot } = slotMap.playInOffsets[i]; + const r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1; + ffToR64.set(i + 1, r64MatchNumber); // First Four matchNumbers are 1-based + } + + // Validate First Four matches have participants before simulation + for (const m of firstFourMatches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `First Four match ${m.matchNumber} is missing participants. ` + + `Assign both teams before running simulation.` + ); + } + } + + // Validate First Four → R64 mapping covers exactly the null R64 slots. + const r64SlotsCoveredByFF = new Set(ffToR64.values()); + for (const nullSlot of r64NullSlots) { + if (!r64SlotsCoveredByFF.has(nullSlot)) { + throw new Error( + `Round of 64 match ${nullSlot} has no participant2 but is not covered ` + + `by any First Four mapping. Check the bracket configuration.` + ); + } + } + for (const ffR64Slot of r64SlotsCoveredByFF) { + if (!r64NullSlots.has(ffR64Slot)) { + throw new Error( + `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + + `already filled. Check the bracket configuration.` + ); + } + } + } else { + // No First Four — all R64 slots must be directly filled + for (const m of r64Matches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `Round of 64 match ${m.matchNumber} is missing participants. ` + + `Assign all 64 teams to the bracket before running simulation.` + ); + } + } + } + + // 5. Collect all participant IDs (up to 68 when First Four is present). + // R64 direct slots (60 or 64) + First Four teams (8) — First Four losers get + // all-zero probabilities since they don't enter the scored rounds. + const participantIdSet = new Set(); + for (const m of r64Matches) { + if (m.participant1Id) participantIdSet.add(m.participant1Id); + if (m.participant2Id) participantIdSet.add(m.participant2Id); + } + for (const m of firstFourMatches) { + if (m.participant1Id) participantIdSet.add(m.participant1Id); + if (m.participant2Id) participantIdSet.add(m.participant2Id); + } + const participantIds = [...participantIdSet]; + + // 6. Load participant names from DB; build net rating lookup by ID. + const participantRows = await db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(inArray(schema.participants.id, participantIds)); + + const netRatingById = new Map(); + for (const { id, name } of participantRows) { + netRatingById.set(id, getNetRating(name)); + } + + // 7. Build per-round O(1) lookup maps keyed by matchNumber. + const firstFourByNum = new Map(firstFourMatches.map((m) => [m.matchNumber, m])); + const r64ByNum = new Map(r64Matches.map((m) => [m.matchNumber, m])); + const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m])); + const s16ByNum = new Map(s16Matches.map((m) => [m.matchNumber, m])); + const e8ByNum = new Map(e8Matches.map((m) => [m.matchNumber, m])); + const ffByNum = new Map(ffMatches.map((m) => [m.matchNumber, m])); + const champMatch = champMatches[0]; + + // ─── Helpers ────────────────────────────────────────────────────────────── + + const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { + const r1 = netRatingById.get(p1) ?? 0; + const r2 = netRatingById.get(p2) ?? 0; + const w = Math.random() < kenpomWinProbability(r1, r2) ? p1 : p2; + return { winner: w, loser: w === p1 ? p2 : p1 }; + }; + + // 8. Integer placement count maps — initialized to 0 for all participants. + const championCounts = new Map(participantIds.map((id) => [id, 0])); + const finalistCounts = new Map(participantIds.map((id) => [id, 0])); + const ffLoserCounts = new Map(participantIds.map((id) => [id, 0])); + const e8LoserCounts = new Map(participantIds.map((id) => [id, 0])); + + // 9. Monte Carlo simulation loop. + for (let s = 0; s < NUM_SIMULATIONS; s++) { + // ── First Four (4 play-in games → 4 winners placed into R64 slots) ─── + // ffSimWinners: Map for the null participant2Id slots + const ffSimWinners = new Map(); + for (const [ffMatchNum, r64MatchNum] of ffToR64) { + const m = firstFourByNum.get(ffMatchNum)!; + const winner = m.isComplete && m.winnerId + ? m.winnerId + : simMatch(m.participant1Id!, m.participant2Id!).winner; + ffSimWinners.set(r64MatchNum, winner); + } + + // ── Round of 64 (32 matches → 32 winners) ──────────────────────────── + const r64Winners: string[] = []; + for (let i = 1; i <= 32; i++) { + const m = r64ByNum.get(i)!; + // participant2Id may be null if this slot is filled by a First Four winner + const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; + if (m.isComplete && m.winnerId) { + r64Winners.push(m.winnerId); + } else { + r64Winners.push(simMatch(m.participant1Id!, p2!).winner); + } + } + + // ── Round of 32 (16 matches → 16 winners) ──────────────────────────── + // Match i uses r64Winners[(i-1)*2] and [(i-1)*2+1] + const r32Winners: string[] = []; + for (let i = 1; i <= 16; i++) { + const dbMatch = r32ByNum.get(i); + if (dbMatch?.isComplete && dbMatch.winnerId) { + r32Winners.push(dbMatch.winnerId); + } else { + const p1 = r64Winners[(i - 1) * 2]; + const p2 = r64Winners[(i - 1) * 2 + 1]; + r32Winners.push(simMatch(p1, p2).winner); + } + } + + // ── Sweet 16 (8 matches → 8 winners) ───────────────────────────────── + const s16Winners: string[] = []; + for (let i = 1; i <= 8; i++) { + const dbMatch = s16ByNum.get(i); + if (dbMatch?.isComplete && dbMatch.winnerId) { + s16Winners.push(dbMatch.winnerId); + } else { + const p1 = r32Winners[(i - 1) * 2]; + const p2 = r32Winners[(i - 1) * 2 + 1]; + s16Winners.push(simMatch(p1, p2).winner); + } + } + + // ── Elite Eight (4 matches → 4 winners + 4 tracked losers) ─────────── + const e8Winners: string[] = []; + for (let i = 1; i <= 4; i++) { + const dbMatch = e8ByNum.get(i); + let winner: string; + let loser: string; + if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + winner = dbMatch.winnerId; + loser = dbMatch.loserId; + } else { + const p1 = s16Winners[(i - 1) * 2]; + const p2 = s16Winners[(i - 1) * 2 + 1]; + ({ winner, loser } = simMatch(p1, p2)); + } + e8Winners.push(winner); + e8LoserCounts.set(loser, (e8LoserCounts.get(loser) ?? 0) + 1); + } + + // ── Final Four (2 matches → 2 winners + 2 tracked losers) ──────────── + const ffWinners: string[] = []; + for (let i = 1; i <= 2; i++) { + const dbMatch = ffByNum.get(i); + let winner: string; + let loser: string; + if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + winner = dbMatch.winnerId; + loser = dbMatch.loserId; + } else { + const p1 = e8Winners[(i - 1) * 2]; + const p2 = e8Winners[(i - 1) * 2 + 1]; + ({ winner, loser } = simMatch(p1, p2)); + } + ffWinners.push(winner); + ffLoserCounts.set(loser, (ffLoserCounts.get(loser) ?? 0) + 1); + } + + // ── Championship ────────────────────────────────────────────────────── + let champion: string; + let finalist: string; + if (champMatch?.isComplete && champMatch.winnerId && champMatch.loserId) { + champion = champMatch.winnerId; + finalist = champMatch.loserId; + } else { + ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); + } + championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); + finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); + } + + // 9. Convert integer counts to probability distributions. + // Exact denominators guarantee column sums of 1.0 by construction: + // probFirst/Second → N total per sim → sum = 1 + // probThird/Fourth → ffLoserCounts / (2*N) → 2 losers * 1/(2N) = 1 + // probFifth–Eighth → e8LoserCounts / (4*N) → 4 losers * 1/(4N) = 1 + const N = NUM_SIMULATIONS; + const results: SimulationResult[] = participantIds.map((participantId) => { + const c = championCounts.get(participantId)!; + const f = finalistCounts.get(participantId)!; + const ff = ffLoserCounts.get(participantId)!; + const e8 = e8LoserCounts.get(participantId)!; + return { + participantId, + probabilities: { + probFirst: c / N, + probSecond: f / N, + probThird: ff / (2 * N), + probFourth: ff / (2 * N), + probFifth: e8 / (4 * N), + probSixth: e8 / (4 * N), + probSeventh: e8 / (4 * N), + probEighth: e8 / (4 * N), + }, + source: "ncaam_bracket_kenpom", + }; + }); + + // 10. Per-position normalization — belt-and-suspenders guard against + // floating-point residuals. Column sums are already near-exactly 1.0 + // after step 9, but this guarantees the invariant before persisting. + const positionKeys: Array = [ + "probFirst", "probSecond", "probThird", "probFourth", + "probFifth", "probSixth", "probSeventh", "probEighth", + ]; + for (const key of positionKeys) { + const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); + const residual = 1.0 - colSum; + if (residual !== 0) { + const maxResult = results.reduce((best, r) => + r.probabilities[key] > best.probabilities[key] ? r : best + ); + maxResult.probabilities[key] += residual; + } + } + + return results; + } +} diff --git a/app/services/simulations/ncaaw-simulator.ts b/app/services/simulations/ncaaw-simulator.ts new file mode 100644 index 0000000..96fc3d3 --- /dev/null +++ b/app/services/simulations/ncaaw-simulator.ts @@ -0,0 +1,597 @@ +/** + * NCAAW Tournament Bracket Simulator + * + * Monte Carlo simulation of the NCAA Women's Basketball Tournament (64-team bracket). + * Win probability is derived from Barttorvik Barthag ratings using the Log5 formula. + * + * Algorithm: + * 1. Load the bracket scoring event and all playoff matches from DB + * (6 rounds: R64, R32, S16, E8, FF, Final — 63 total matches) + * 2. Load participant names from DB; look up Barthag rating by name + * from the hardcoded BARTHAG_RATINGS map (updated each season) + * 3. Per-match win probability = Log5: A*(1-B) / (A*(1-B) + B*(1-A)) + * (Barthag is on a 0–1 scale: probability of beating an average D-I opponent) + * 4. Simulate 50,000 tournaments, honoring completed match results + * 5. Track placements only for point-scoring rounds: + * - Champion (1st) + * - Finalist (2nd) + * - Final Four losers (3rd/4th) — 2 per sim + * - Elite Eight losers (5th–8th) — 4 per sim + * R64 / R32 / S16 exits score 0 points → not tracked + * + * Probability output (8 slots — same SimulationProbabilities type as NCAAM/UCL): + * probFirst = champion / N + * probSecond = finalist / N + * probThird/Fourth = ffLoser / (2 * N) — 2 FF losers per sim + * probFifth–Eighth = e8Loser / (4 * N) — 4 E8 losers per sim + * All pre-E8 exits → 0 (no points scored) + * + * Column sums are guaranteed to equal 1.0 by construction (same invariant as NCAAM). + * + * Barthag ratings are hardcoded in BARTHAG_RATINGS below (2025-26 season). + * Source: barttorvik.com/ncaaw — update each season before running simulations. + * Keys are lowercase, whitespace-normalized team names. + * Unknown teams fall back to BARTHAG_FALLBACK = 0.5 (average D-I strength). + * + * Win probability formula — Log5 (Bill James): + * P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A)) + * Property: barthagWinProbability(x, 0.5) === x (definition of Barthag) + * Property: barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1 + */ + +import { database } from "~/database/context"; +import { eq, and, inArray } from "drizzle-orm"; +import * as schema from "~/database/schema"; +import type { BracketRegion } from "~/lib/bracket-templates"; +import { buildNCAA68SlotMap, matchIndexForSeedSlot } from "~/lib/bracket-templates"; +import type { Simulator, SimulationResult } from "./types"; + +// ─── Simulation parameters ──────────────────────────────────────────────────── + +const NUM_SIMULATIONS = 50_000; + +/** + * Fallback Barthag for unknown team names. + * 0.5 = exactly average D-I opponent (unlike KenPom AEM where 0 = average). + */ +const BARTHAG_FALLBACK = 0.5; + +// ─── Barttorvik Barthag ratings (2025-26 NCAAW season) ──────────────────────── +// +// Source: barttorvik.com/ncaaw, data through March 15, 2026. +// Update this map at the start of each tournament season. +// +// Barthag is on a 0–1 scale: the probability of beating an average D-I team. +// Strong teams are near 1.0; weak teams near 0.0; average is 0.5. +// +// Keys: lowercase, whitespace-normalized (same as normalizeTeamName output). +// Multiple aliases for common abbreviation variants. + +const BARTHAG_RATINGS: Record = { + // ── 1–10 ──────────────────────────────────────────────────────────────────── + "connecticut": 0.9996, "uconn": 0.9996, + "ucla": 0.9991, + "texas": 0.9988, + "south carolina": 0.9986, + "lsu": 0.9977, + "michigan": 0.9952, + "duke": 0.9936, + "minnesota": 0.9914, + "iowa": 0.9907, + "vanderbilt": 0.9906, + + // ── 11–30 ─────────────────────────────────────────────────────────────────── + "louisville": 0.9904, + "maryland": 0.9893, + "ohio st.": 0.9892, "ohio state": 0.9892, + "oklahoma": 0.9886, + "tcu": 0.9885, + "west virginia": 0.9882, + "kentucky": 0.9880, + "north carolina": 0.9864, + "washington": 0.9834, + "usc": 0.9824, + "mississippi": 0.9821, "ole miss": 0.9821, + "michigan st.": 0.9817, "michigan state": 0.9817, + "notre dame": 0.9803, + "tennessee": 0.9799, + "n.c. state": 0.9766, "nc state": 0.9766, + "nebraska": 0.9751, + "villanova": 0.9745, + "oregon": 0.9741, + "alabama": 0.9720, + "texas tech": 0.9717, + + // ── 31–50 ─────────────────────────────────────────────────────────────────── + "illinois": 0.9710, + "georgia": 0.9710, + "oklahoma st.": 0.9672, "oklahoma state": 0.9672, + "iowa st.": 0.9666, "iowa state": 0.9666, + "baylor": 0.9639, + "colorado": 0.9615, + "virginia tech": 0.9589, + "florida": 0.9519, + "syracuse": 0.9502, + "virginia": 0.9437, + "stanford": 0.9407, + "mississippi st.": 0.9407, "mississippi state": 0.9407, + "james madison": 0.9374, + "richmond": 0.9341, + "arizona st.": 0.9331, "arizona state": 0.9331, + "indiana": 0.9309, + "california": 0.9268, "cal": 0.9268, + "kansas": 0.9259, + "clemson": 0.9219, + "princeton": 0.9202, + + // ── 51–70 ─────────────────────────────────────────────────────────────────── + "texas a&m": 0.9201, + "south dakota st.": 0.9187, "south dakota state": 0.9187, + "kansas st.": 0.9122, "kansas state": 0.9122, + "utah": 0.9076, + "byu": 0.8991, + "seton hall": 0.8978, + "marquette": 0.8922, + "rhode island": 0.8912, + "fairfield": 0.8902, + "georgia tech": 0.8887, + "columbia": 0.8884, + "gonzaga": 0.8849, + "george mason": 0.8834, + "north dakota st.": 0.8796, "north dakota state": 0.8796, + "miami fl": 0.8772, "miami (fl)": 0.8772, "miami": 0.8772, + "montana st.": 0.8643, "montana state": 0.8643, + "harvard": 0.8604, + "creighton": 0.8594, + "wisconsin": 0.8585, + "colorado st.": 0.8563, "colorado state": 0.8563, + + // ── 71–100 ────────────────────────────────────────────────────────────────── + "penn st.": 0.8471, "penn state": 0.8471, + "miami oh": 0.8420, "miami (oh)": 0.8420, + "san diego st.": 0.8362, "san diego state": 0.8362, + "purdue": 0.8357, + "south florida": 0.8343, "usf": 0.8343, + "georgetown": 0.8325, + "missouri": 0.8318, + "georgia southern": 0.8275, + "oregon st.": 0.8200, "oregon state": 0.8200, + "unlv": 0.8117, + "rice": 0.8109, + "st. john's": 0.8095, "st johns": 0.8095, + "davidson": 0.8093, + "ball st.": 0.8071, "ball state": 0.8071, + "auburn": 0.7978, + "louisiana tech": 0.7977, + "troy": 0.7932, + "green bay": 0.7872, "wi-green bay": 0.7872, + "idaho": 0.7859, + "santa clara": 0.7824, + + // ── 101–130 ───────────────────────────────────────────────────────────────── + "uc irvine": 0.7788, + "mcneese st.": 0.7716, "mcneese state": 0.7716, "mcneese": 0.7716, + "cincinnati": 0.7677, + "vermont": 0.7658, + "quinnipiac": 0.7633, + "saint joseph's": 0.7627, + "loyola marymount": 0.7600, "lmu": 0.7600, + "north texas": 0.7509, + "florida st.": 0.7498, "florida state": 0.7498, + "massachusetts": 0.7479, "umass": 0.7479, + "murray st.": 0.7431, "murray state": 0.7431, + "arkansas st.": 0.7412, "arkansas state": 0.7412, + "arkansas": 0.7353, + "lindenwood": 0.7312, + "butler": 0.7254, + "western illinois": 0.7238, + "arizona": 0.7023, + "abilene christian": 0.7016, + "hawaii": 0.6995, "hawai'i": 0.6995, + "new mexico": 0.6988, + + // ── 131–160 ───────────────────────────────────────────────────────────────── + "northwestern": 0.6979, + "uc san diego": 0.6846, "ucsd": 0.6846, + "cal baptist": 0.6822, + "boise st.": 0.6809, "boise state": 0.6809, + "central arkansas": 0.6775, + "providence": 0.6717, + "southern indiana": 0.6702, + "belmont": 0.6688, + "portland": 0.6683, + "pepperdine": 0.6626, + "eastern kentucky": 0.6587, + "wake forest": 0.6571, + "charleston": 0.6484, + "marshall": 0.6442, + "utsa": 0.6431, + "navy": 0.6349, + "missouri st.": 0.6208, "missouri state": 0.6208, + "fairleigh dickinson": 0.6201, + "penn": 0.6159, + "east carolina": 0.6154, + + // ── 161–200 ───────────────────────────────────────────────────────────────── + "south dakota": 0.6144, + "purdue fort wayne": 0.6117, + "grand canyon": 0.6115, + "northern colorado": 0.6111, + "rutgers": 0.6040, + "liberty": 0.5964, + "northern iowa": 0.5920, + "temple": 0.5911, + "uc santa barbara": 0.5869, "ucsb": 0.5869, + "brown": 0.5869, + "ohio": 0.5867, + "xavier": 0.5837, + "central michigan": 0.5804, + "idaho st.": 0.5792, "idaho state": 0.5792, + "army": 0.5724, + "jacksonville": 0.5719, + "cleveland st.": 0.5710, "cleveland state": 0.5710, + "ucf": 0.5702, + "old dominion": 0.5666, + "youngstown st.": 0.5643, "youngstown state": 0.5643, + + // ── 200+ (low seeds / play-in range) ──────────────────────────────────── + "holy cross": 0.5153, + "high point": 0.5091, + "howard": 0.4623, + "stephen f. austin": 0.4435, "sf austin": 0.4435, + "norfolk st.": 0.3468, "norfolk state": 0.3468, +}; + +// ─── Public helpers (exported for unit testing) ─────────────────────────────── + +/** Normalize a team name for Barthag map lookup. */ +export function normalizeTeamName(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, " "); +} + +/** + * Look up Barttorvik Barthag rating for a participant name. + * Returns BARTHAG_FALLBACK (0.5 = average strength) if not found. + */ +export function getBarthagRating(name: string): number { + return BARTHAG_RATINGS[normalizeTeamName(name)] ?? BARTHAG_FALLBACK; +} + +/** + * NCAAW neutral-court win probability using Barttorvik Barthag (Log5 formula). + * P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A)) + * + * Key properties: + * barthagWinProbability(x, 0.5) === x (Barthag definition) + * barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1 (symmetric) + * + * Exported for unit testing. + */ +export function barthagWinProbability(barthagA: number, barthagB: number): number { + const pA = barthagA * (1 - barthagB); + const pB = barthagB * (1 - barthagA); + const total = pA + pB; + // Degenerate case: both teams identical at 0 or 1 → coin flip + if (total === 0) return 0.5; + return pA / total; +} + +// ─── Simulator ──────────────────────────────────────────────────────────────── + +export class NCAAWSimulator implements Simulator { + async simulate(sportsSeasonId: string): Promise { + const db = database(); + + // 1. Find the bracket scoring event (playoff_game) for this sports season. + const bracketEvent = await db.query.scoringEvents.findFirst({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.eventType, "playoff_game") + ), + }); + + if (!bracketEvent) { + throw new Error( + `No bracket event found for sports season ${sportsSeasonId}. ` + + `Create a playoff_game scoring event and set up the 64-team bracket first.` + ); + } + + // 2. Load all playoff matches for this bracket event. + const allMatches = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + orderBy: (m, { asc }) => [asc(m.matchNumber)], + }); + + if (allMatches.length === 0) { + throw new Error( + `No playoff matches found for the bracket event. ` + + `Generate the 64-team bracket from the admin panel first.` + ); + } + + // 3. Group matches by round name. + // Rounds: "First Four" (optional, 4) | "Round of 64" (32) | "Round of 32" (16) + // | "Sweet Sixteen" (8) | "Elite Eight" (4) | "Final Four" (2) | "Championship" (1) + const byRound = new Map(); + for (const m of allMatches) { + if (!byRound.has(m.round)) byRound.set(m.round, []); + byRound.get(m.round)!.push(m); + } + + const sortByMatchNumber = (matches: typeof allMatches) => + [...matches].sort((a, b) => a.matchNumber - b.matchNumber); + + const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; + const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; + const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; + const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; + const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; + const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; + const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; + + if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { + const found = [...byRound.keys()].join(", "); + throw new Error( + `Missing expected rounds. Found: [${found}]. ` + + `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` + ); + } + + if (r64Matches.length !== 32) { + throw new Error( + `Expected 32 Round of 64 matches, found ${r64Matches.length}. ` + + `This simulator only supports the standard 64-team NCAAW format.` + ); + } + + // 4. Build First Four → Round of 64 slot mapping (if First Four games exist). + const ffToR64 = new Map(); + const r64NullSlots = new Set( + r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber) + ); + + if (firstFourMatches.length > 0) { + const regions = + (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? + [ + { name: "East", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], playIns: [] }, + { name: "South", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], playIns: [{ seedSlot: 16, teams: 2 }] }, + { name: "West", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15,16], playIns: [{ seedSlot: 11, teams: 2 }] }, + { name: "Midwest", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15], playIns: [{ seedSlot: 11, teams: 2 }, { seedSlot: 16, teams: 2 }] }, + ]; + + const slotMap = buildNCAA68SlotMap(regions); + for (let i = 0; i < slotMap.playInOffsets.length; i++) { + const { regionIndex, seedSlot } = slotMap.playInOffsets[i]; + const r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1; + ffToR64.set(i + 1, r64MatchNumber); + } + + for (const m of firstFourMatches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `First Four match ${m.matchNumber} is missing participants. ` + + `Assign both teams before running simulation.` + ); + } + } + + // Validate First Four → R64 mapping covers exactly the null R64 slots. + const r64SlotsCoveredByFF = new Set(ffToR64.values()); + for (const nullSlot of r64NullSlots) { + if (!r64SlotsCoveredByFF.has(nullSlot)) { + throw new Error( + `Round of 64 match ${nullSlot} has no participant2 but is not covered ` + + `by any First Four mapping. Check the bracket configuration.` + ); + } + } + for (const ffR64Slot of r64SlotsCoveredByFF) { + if (!r64NullSlots.has(ffR64Slot)) { + throw new Error( + `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + + `already filled. Check the bracket configuration.` + ); + } + } + } else { + for (const m of r64Matches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `Round of 64 match ${m.matchNumber} is missing participants. ` + + `Assign all 64 teams to the bracket before running simulation.` + ); + } + } + } + + // 5. Collect all participant IDs (up to 68 when First Four is present). + const participantIdSet = new Set(); + for (const m of r64Matches) { + if (m.participant1Id) participantIdSet.add(m.participant1Id); + if (m.participant2Id) participantIdSet.add(m.participant2Id); + } + for (const m of firstFourMatches) { + if (m.participant1Id) participantIdSet.add(m.participant1Id); + if (m.participant2Id) participantIdSet.add(m.participant2Id); + } + const participantIds = [...participantIdSet]; + + // 6. Load participant names from DB; build Barthag lookup by ID. + const participantRows = await db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(inArray(schema.participants.id, participantIds)); + + const barthagById = new Map(); + for (const { id, name } of participantRows) { + barthagById.set(id, getBarthagRating(name)); + } + + // 7. Build per-round O(1) lookup maps keyed by matchNumber. + const firstFourByNum = new Map(firstFourMatches.map((m) => [m.matchNumber, m])); + const r64ByNum = new Map(r64Matches.map((m) => [m.matchNumber, m])); + const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m])); + const s16ByNum = new Map(s16Matches.map((m) => [m.matchNumber, m])); + const e8ByNum = new Map(e8Matches.map((m) => [m.matchNumber, m])); + const ffByNum = new Map(ffMatches.map((m) => [m.matchNumber, m])); + const champMatch = champMatches[0]; + + // ─── Helpers ────────────────────────────────────────────────────────────── + + const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { + const b1 = barthagById.get(p1) ?? BARTHAG_FALLBACK; + const b2 = barthagById.get(p2) ?? BARTHAG_FALLBACK; + const w = Math.random() < barthagWinProbability(b1, b2) ? p1 : p2; + return { winner: w, loser: w === p1 ? p2 : p1 }; + }; + + // 8. Integer placement count maps — initialized to 0 for all participants. + const championCounts = new Map(participantIds.map((id) => [id, 0])); + const finalistCounts = new Map(participantIds.map((id) => [id, 0])); + const ffLoserCounts = new Map(participantIds.map((id) => [id, 0])); + const e8LoserCounts = new Map(participantIds.map((id) => [id, 0])); + + // 9. Monte Carlo simulation loop. + for (let s = 0; s < NUM_SIMULATIONS; s++) { + // ── First Four ──────────────────────────────────────────────────────── + const ffSimWinners = new Map(); + for (const [ffMatchNum, r64MatchNum] of ffToR64) { + const m = firstFourByNum.get(ffMatchNum)!; + const winner = m.isComplete && m.winnerId + ? m.winnerId + : simMatch(m.participant1Id!, m.participant2Id!).winner; + ffSimWinners.set(r64MatchNum, winner); + } + + // ── Round of 64 ─────────────────────────────────────────────────────── + const r64Winners: string[] = []; + for (let i = 1; i <= 32; i++) { + const m = r64ByNum.get(i)!; + const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; + if (m.isComplete && m.winnerId) { + r64Winners.push(m.winnerId); + } else { + r64Winners.push(simMatch(m.participant1Id!, p2!).winner); + } + } + + // ── Round of 32 ─────────────────────────────────────────────────────── + const r32Winners: string[] = []; + for (let i = 1; i <= 16; i++) { + const dbMatch = r32ByNum.get(i); + if (dbMatch?.isComplete && dbMatch.winnerId) { + r32Winners.push(dbMatch.winnerId); + } else { + const p1 = r64Winners[(i - 1) * 2]; + const p2 = r64Winners[(i - 1) * 2 + 1]; + r32Winners.push(simMatch(p1, p2).winner); + } + } + + // ── Sweet 16 ────────────────────────────────────────────────────────── + const s16Winners: string[] = []; + for (let i = 1; i <= 8; i++) { + const dbMatch = s16ByNum.get(i); + if (dbMatch?.isComplete && dbMatch.winnerId) { + s16Winners.push(dbMatch.winnerId); + } else { + const p1 = r32Winners[(i - 1) * 2]; + const p2 = r32Winners[(i - 1) * 2 + 1]; + s16Winners.push(simMatch(p1, p2).winner); + } + } + + // ── Elite Eight (tracked losers → 5th–8th) ──────────────────────────── + const e8Winners: string[] = []; + for (let i = 1; i <= 4; i++) { + const dbMatch = e8ByNum.get(i); + let winner: string; + let loser: string; + if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + winner = dbMatch.winnerId; + loser = dbMatch.loserId; + } else { + const p1 = s16Winners[(i - 1) * 2]; + const p2 = s16Winners[(i - 1) * 2 + 1]; + ({ winner, loser } = simMatch(p1, p2)); + } + e8Winners.push(winner); + e8LoserCounts.set(loser, (e8LoserCounts.get(loser) ?? 0) + 1); + } + + // ── Final Four (tracked losers → 3rd/4th) ───────────────────────────── + const ffWinners: string[] = []; + for (let i = 1; i <= 2; i++) { + const dbMatch = ffByNum.get(i); + let winner: string; + let loser: string; + if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + winner = dbMatch.winnerId; + loser = dbMatch.loserId; + } else { + const p1 = e8Winners[(i - 1) * 2]; + const p2 = e8Winners[(i - 1) * 2 + 1]; + ({ winner, loser } = simMatch(p1, p2)); + } + ffWinners.push(winner); + ffLoserCounts.set(loser, (ffLoserCounts.get(loser) ?? 0) + 1); + } + + // ── Championship ────────────────────────────────────────────────────── + let champion: string; + let finalist: string; + if (champMatch?.isComplete && champMatch.winnerId && champMatch.loserId) { + champion = champMatch.winnerId; + finalist = champMatch.loserId; + } else { + ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); + } + championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); + finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); + } + + // 10. Convert integer counts to probability distributions. + const N = NUM_SIMULATIONS; + const results: SimulationResult[] = participantIds.map((participantId) => { + const c = championCounts.get(participantId)!; + const f = finalistCounts.get(participantId)!; + const ff = ffLoserCounts.get(participantId)!; + const e8 = e8LoserCounts.get(participantId)!; + return { + participantId, + probabilities: { + probFirst: c / N, + probSecond: f / N, + probThird: ff / (2 * N), + probFourth: ff / (2 * N), + probFifth: e8 / (4 * N), + probSixth: e8 / (4 * N), + probSeventh: e8 / (4 * N), + probEighth: e8 / (4 * N), + }, + source: "ncaaw_bracket_barthag", + }; + }); + + // 11. Per-position normalization — belt-and-suspenders guard against + // floating-point residuals. Column sums are already near-exactly 1.0. + const positionKeys: Array = [ + "probFirst", "probSecond", "probThird", "probFourth", + "probFifth", "probSixth", "probSeventh", "probEighth", + ]; + for (const key of positionKeys) { + const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); + const residual = 1.0 - colSum; + if (residual !== 0) { + const maxResult = results.reduce((best, r) => + r.probabilities[key] > best.probabilities[key] ? r : best + ); + maxResult.probabilities[key] += residual; + } + } + + return results; + } +} diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index c91ab99..3ea29a7 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -11,13 +11,17 @@ import { BracketSimulator } from "./bracket-simulator"; import { F1Simulator } from "./f1-simulator"; import { GolfSimulator } from "./golf-simulator"; import { UCLSimulator } from "./ucl-simulator"; +import { NCAAMSimulator } from "./ncaam-simulator"; +import { NCAAWSimulator } from "./ncaaw-simulator"; export type SimulatorType = | "f1_standings" | "indycar_standings" | "golf_qualifying_points" | "playoff_bracket" - | "ucl_bracket"; + | "ucl_bracket" + | "ncaam_bracket" + | "ncaaw_bracket"; export interface SimulatorInfo { name: string; @@ -45,6 +49,14 @@ const REGISTRY: Record Simul info: { name: "UCL Bracket Monte Carlo", description: "Simulates the UEFA Champions League 16-team knockout bracket using blended Elo + futures odds" }, create: () => new UCLSimulator(), }, + ncaam_bracket: { + info: { name: "NCAAM Bracket Monte Carlo", description: "Simulates the NCAA Men's Basketball Tournament 64-team bracket using KenPom net ratings" }, + create: () => new NCAAMSimulator(), + }, + ncaaw_bracket: { + info: { name: "NCAAW Bracket Monte Carlo", description: "Simulates the NCAA Women's Basketball Tournament 64-team bracket using Barttorvik Barthag ratings" }, + create: () => new NCAAWSimulator(), + }, }; export function getSimulator(simulatorType: SimulatorType): Simulator { diff --git a/database/schema.ts b/database/schema.ts index 53d0d80..728f9ea 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -82,6 +82,8 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [ "golf_qualifying_points", "playoff_bracket", "ucl_bracket", + "ncaam_bracket", + "ncaaw_bracket", ]); export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [ diff --git a/drizzle/0044_add_ncaam_bracket_simulator_type.sql b/drizzle/0044_add_ncaam_bracket_simulator_type.sql new file mode 100644 index 0000000..c1c8f8b --- /dev/null +++ b/drizzle/0044_add_ncaam_bracket_simulator_type.sql @@ -0,0 +1,9 @@ +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'ncaam_bracket' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type') + ) THEN + ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaam_bracket'; + END IF; +END $$; diff --git a/drizzle/0045_add_ncaaw_bracket_simulator_type.sql b/drizzle/0045_add_ncaaw_bracket_simulator_type.sql new file mode 100644 index 0000000..82bb4ab --- /dev/null +++ b/drizzle/0045_add_ncaaw_bracket_simulator_type.sql @@ -0,0 +1,10 @@ +-- Add ncaaw_bracket to simulator_type enum (idempotent) +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'ncaaw_bracket' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type') + ) THEN + ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaaw_bracket'; + END IF; +END $$; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c138f3a..d4d2f5c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -309,6 +309,20 @@ "when": 1773634394633, "tag": "0043_demonic_vanisher", "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1773700000000, + "tag": "0044_add_ncaam_bracket_simulator_type", + "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1773710000000, + "tag": "0045_add_ncaaw_bracket_simulator_type", + "breakpoints": true } ] } \ No newline at end of file