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