/** * 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, NCAA_68 } 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].toSorted((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.` ); } 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 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) ?? // 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); // 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 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 { // 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)); if (participantRows.length < participantIds.length) { const foundIds = new Set(participantRows.map((r) => r.id)); const missing = participantIds.filter((id) => !foundIds.has(id)); console.warn( `[NCAAMSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + `They will be treated as average strength (KenPom 0.0).` ); } 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) { 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 (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) { 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. // 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; } }