/** * 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, NCAA_68 } 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, "connecticut (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; } function sortByMatchNumber(matches: T[]): T[] { return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); } // ─── 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 rawFirstFour = byRound.get("First Four"); const rawR64 = byRound.get("Round of 64"); const rawR32 = byRound.get("Round of 32"); const rawS16 = byRound.get("Sweet Sixteen"); const rawE8 = byRound.get("Elite Eight"); const rawFF = byRound.get("Final Four"); const rawChamp = byRound.get("Championship"); const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : []; const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null; const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null; const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null; const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null; const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null; const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : 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.` ); } if (r32Matches.length !== 16) { throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`); } if (s16Matches.length !== 8) { throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`); } if (e8Matches.length !== 4) { throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`); } if (ffMatches.length !== 2) { throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`); } if (champMatches.length !== 1) { throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`); } // 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) ?? // Fall back to the template's built-in regions — single source of truth NCAA_68.regions ?? []; 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 all null R64 slots. // Note: FF-mapped slots may already be filled if the First Four game was completed // and the winner was advanced — that's expected and handled in the sim loop. 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 [ffMatchNum, ffR64Slot] of ffToR64) { if (!r64NullSlots.has(ffR64Slot)) { // Slot is already filled — only valid if the FF game is complete const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum); if (!ffMatch?.isComplete) { throw new Error( `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + `already filled. Check the bracket configuration.` ); } } } // In the First Four path, participant1Id must always be set; participant2Id // may be null only for FF-pending slots (already validated above). for (const m of r64Matches) { if (!m.participant1Id) { throw new Error( `Round of 64 match ${m.matchNumber} is missing participant1. ` + `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)); if (participantRows.length < participantIds.length) { const foundIds = new Set(participantRows.map((r) => r.id)); const missing = participantIds.filter((id) => !foundIds.has(id)); console.warn( `[NCAAWSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + `They will be treated as average strength (Barthag ${BARTHAG_FALLBACK}).` ); } 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); if (!m) continue; 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); if (!m) continue; 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) { winner = dbMatch.winnerId; // Derive loser from stored participants if loserId is missing loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId ? dbMatch.participant2Id ?? "" : dbMatch.participant1Id ?? ""); } 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) { winner = dbMatch.winnerId; loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId ? dbMatch.participant2Id ?? "" : dbMatch.participant1Id ?? ""); } 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) { champion = champMatch.winnerId; finalist = champMatch.loserId ?? (champMatch.participant1Id === champMatch.winnerId ? champMatch.participant2Id ?? "" : champMatch.participant1Id ?? ""); } 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) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const ff = ffLoserCounts.get(participantId) ?? 0; const e8 = e8LoserCounts.get(participantId) ?? 0; 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; } }