Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-03-18 01:37:55 -07:00
|
|
|
|
import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates";
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
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<string, number> = {
|
|
|
|
|
|
// ── 1–10 ────────────────────────────────────────────────────────────────────
|
2026-03-15 23:43:46 -07:00
|
|
|
|
"connecticut": 0.9996, "uconn": 0.9996, "connecticut (uconn)": 0.9996,
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
"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<SimulationResult[]> {
|
|
|
|
|
|
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<string, typeof allMatches>();
|
|
|
|
|
|
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) =>
|
2026-03-21 09:44:05 -07:00
|
|
|
|
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
|
|
|
|
|
|
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.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-18 01:37:55 -07:00
|
|
|
|
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}.`);
|
|
|
|
|
|
}
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
|
|
|
|
|
|
// 4. Build First Four → Round of 64 slot mapping (if First Four games exist).
|
|
|
|
|
|
const ffToR64 = new Map<number, number>();
|
|
|
|
|
|
const r64NullSlots = new Set<number>(
|
|
|
|
|
|
r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (firstFourMatches.length > 0) {
|
|
|
|
|
|
const regions =
|
|
|
|
|
|
(bracketEvent.bracketRegionConfig as BracketRegion[] | null) ??
|
2026-03-18 01:37:55 -07:00
|
|
|
|
// Fall back to the template's built-in regions — single source of truth
|
|
|
|
|
|
NCAA_68.regions!;
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
|
|
|
|
|
|
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.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 01:37:55 -07:00
|
|
|
|
// 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.
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
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.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 01:37:55 -07:00
|
|
|
|
for (const [ffMatchNum, ffR64Slot] of ffToR64) {
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
if (!r64NullSlots.has(ffR64Slot)) {
|
2026-03-18 01:37:55 -07:00
|
|
|
|
// 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) {
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
throw new Error(
|
2026-03-18 01:37:55 -07:00
|
|
|
|
`Round of 64 match ${m.matchNumber} is missing participant1. ` +
|
|
|
|
|
|
`Check the bracket configuration.`
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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<string>();
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-03-18 01:37:55 -07:00
|
|
|
|
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}).`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
const barthagById = new Map<string, number>();
|
|
|
|
|
|
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<string, number>(participantIds.map((id) => [id, 0]));
|
|
|
|
|
|
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|
|
|
|
|
const ffLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|
|
|
|
|
const e8LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|
|
|
|
|
|
|
|
|
|
|
// 9. Monte Carlo simulation loop.
|
|
|
|
|
|
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
|
|
|
|
|
// ── First Four ────────────────────────────────────────────────────────
|
|
|
|
|
|
const ffSimWinners = new Map<number, string>();
|
|
|
|
|
|
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;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
if (dbMatch?.isComplete && dbMatch.winnerId) {
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
winner = dbMatch.winnerId;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
// Derive loser from stored participants if loserId is missing
|
|
|
|
|
|
loser = dbMatch.loserId ??
|
|
|
|
|
|
(dbMatch.participant1Id === dbMatch.winnerId
|
|
|
|
|
|
? dbMatch.participant2Id!
|
|
|
|
|
|
: dbMatch.participant1Id!);
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
} 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;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
if (dbMatch?.isComplete && dbMatch.winnerId) {
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
winner = dbMatch.winnerId;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
loser = dbMatch.loserId ??
|
|
|
|
|
|
(dbMatch.participant1Id === dbMatch.winnerId
|
|
|
|
|
|
? dbMatch.participant2Id!
|
|
|
|
|
|
: dbMatch.participant1Id!);
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
} 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;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
if (champMatch?.isComplete && champMatch.winnerId) {
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
champion = champMatch.winnerId;
|
2026-03-18 01:37:55 -07:00
|
|
|
|
finalist = champMatch.loserId ??
|
|
|
|
|
|
(champMatch.participant1Id === champMatch.winnerId
|
|
|
|
|
|
? champMatch.participant2Id!
|
|
|
|
|
|
: champMatch.participant1Id!);
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
} 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<keyof (typeof results)[0]["probabilities"]> = [
|
|
|
|
|
|
"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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|