brackt/app/services/simulations/__tests__/ncaam-simulator.test.ts
Chris Parsons 47b8935be6
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

287 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import { kenpomWinProbability, getNetRating, normalizeTeamName } from "../ncaam-simulator";
// ─── KenPom win probability formula ──────────────────────────────────────────
describe("kenpomWinProbability", () => {
it("returns 0.5 for equal teams", () => {
expect(kenpomWinProbability(25, 25)).toBeCloseTo(0.5, 10);
});
it("returns >0.5 for team A with higher netrtg", () => {
expect(kenpomWinProbability(30, 20)).toBeGreaterThan(0.5);
});
it("returns <0.5 for team A with lower netrtg", () => {
expect(kenpomWinProbability(20, 30)).toBeLessThan(0.5);
});
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
const pAB = kenpomWinProbability(35, 20);
const pBA = kenpomWinProbability(20, 35);
expect(pAB + pBA).toBeCloseTo(1.0, 10);
});
it("matches the logistic formula exactly for known values", () => {
// netrtgA=38.90 (Duke 2025-26), netrtgB=0 → 1/(1+exp(-38.90/7.5))
const expected = 1 / (1 + Math.exp(-38.90 / 7.5));
expect(kenpomWinProbability(38.90, 0)).toBeCloseTo(expected, 10);
});
it("handles negative netrtg (weak team vs average)", () => {
// negative netrtg is valid (below-average team)
const p = kenpomWinProbability(-2, 0);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(0.5);
});
it("large AEM gap pushes probability toward 1", () => {
// 38 point gap (Duke-caliber vs near-zero) → high win prob
expect(kenpomWinProbability(38, 0)).toBeGreaterThan(0.99);
});
it("scale factor: a 7.5-point gap yields ~73% win probability", () => {
// 1 / (1 + exp(-7.5/7.5)) = 1/(1+exp(-1)) ≈ 0.7311
expect(kenpomWinProbability(7.5, 0)).toBeCloseTo(0.7311, 3);
});
});
// ─── Team name normalization ──────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("converts to lowercase", () => {
expect(normalizeTeamName("Duke")).toBe("duke");
});
it("trims leading/trailing whitespace", () => {
expect(normalizeTeamName(" Duke ")).toBe("duke");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("North Carolina")).toBe("north carolina");
});
it("preserves punctuation", () => {
expect(normalizeTeamName("St. John's")).toBe("st. john's");
});
});
// ─── KenPom data lookup ───────────────────────────────────────────────────────
describe("getNetRating", () => {
it("returns correct netrtg for exact known name (lowercase)", () => {
expect(getNetRating("duke")).toBeCloseTo(38.90, 2);
});
it("is case-insensitive", () => {
expect(getNetRating("Duke")).toBeCloseTo(38.90, 2);
expect(getNetRating("DUKE")).toBeCloseTo(38.90, 2);
});
it("handles abbreviated name variants", () => {
// Both 'michigan st.' and 'michigan state' should resolve
expect(getNetRating("Michigan St.")).toBeCloseTo(28.31, 2);
expect(getNetRating("Michigan State")).toBeCloseTo(28.31, 2);
});
it("handles name with apostrophe", () => {
expect(getNetRating("St. John's")).toBeCloseTo(25.91, 2);
});
it("returns 0.0 for an unknown team name", () => {
expect(getNetRating("Fictional University")).toBe(0.0);
});
it("returns 0.0 for empty string", () => {
expect(getNetRating("")).toBe(0.0);
});
it("handles negative netrtg (below-average teams)", () => {
expect(getNetRating("Idaho")).toBeCloseTo(1.53, 2);
expect(getNetRating("Eastern Washington")).toBeCloseTo(0.00, 2);
});
});
// ─── Bracket path logic ───────────────────────────────────────────────────────
describe("NCAAM bracket advancement path", () => {
it("R64 matches 1 and 2 feed R32 match 1 (Math.ceil convention)", () => {
expect(Math.ceil(1 / 2)).toBe(1);
expect(Math.ceil(2 / 2)).toBe(1);
});
it("R64 matches 3 and 4 feed R32 match 2", () => {
expect(Math.ceil(3 / 2)).toBe(2);
expect(Math.ceil(4 / 2)).toBe(2);
});
it("R64 matches 31 and 32 feed R32 match 16", () => {
expect(Math.ceil(31 / 2)).toBe(16);
expect(Math.ceil(32 / 2)).toBe(16);
});
it("32 R64 matches produce exactly 16 R32 slots", () => {
const r32Slots = new Set<number>();
for (let i = 1; i <= 32; i++) r32Slots.add(Math.ceil(i / 2));
expect(r32Slots.size).toBe(16);
});
it("R32 match i uses 0-indexed winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 16; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p1).toBeGreaterThanOrEqual(0);
expect(p2).toBeLessThan(32);
}
});
it("S16 match i uses 0-indexed R32 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 8; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p1).toBeGreaterThanOrEqual(0);
expect(p2).toBeLessThan(16);
}
});
it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 4; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(8);
}
});
it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 2; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(4);
}
});
});
// ─── Probability bucket math ──────────────────────────────────────────────────
describe("NCAAM placement bucket probability math", () => {
const N = 50_000;
it("champion: count/N = 1.0 when a team wins every simulation", () => {
const c = N;
expect(c / N).toBeCloseTo(1.0, 10);
});
it("finalist: count/N = 1.0 when a team reaches final every simulation", () => {
expect(N / N).toBeCloseTo(1.0, 10);
});
it("FF loser slots: count/(2*N) sums to 1.0 across teams whose counts total 2*N", () => {
// 2 FF losers per sim → total across all teams = 2*N
// e.g. 2 teams each appearing N times: (N + N) / (2*N) = 1.0
const counts = [N, N];
const sum = counts.reduce((s, c) => s + c / (2 * N), 0);
expect(sum).toBeCloseTo(1.0, 10);
});
it("E8 loser slots: count/(4*N) sums to 1.0 across teams whose counts total 4*N", () => {
// 4 E8 losers per sim → total across all teams = 4*N
// e.g. 4 teams each appearing N times: (4*N) / (4*N) = 1.0
const counts = [N, N, N, N];
const sum = counts.reduce((s, c) => s + c / (4 * N), 0);
expect(sum).toBeCloseTo(1.0, 10);
});
it("probThird equals probFourth for the same team (same ff count / 2N)", () => {
const ffCount = 12500;
const probThird = ffCount / (2 * N);
const probFourth = ffCount / (2 * N);
expect(probThird).toBe(probFourth);
});
it("probFifth through probEighth are equal for the same team (same e8 count / 4N)", () => {
const e8Count = 6250;
const probs = [e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N)];
expect(probs[0]).toBe(probs[1]);
expect(probs[1]).toBe(probs[2]);
expect(probs[2]).toBe(probs[3]);
});
it("a team exiting in R64 has all-zero probabilities", () => {
// R64 losers are never counted in any bucket → all counts stay 0
const c = 0, f = 0, ff = 0, e8 = 0;
expect(c / N).toBe(0);
expect(f / N).toBe(0);
expect(ff / (2 * N)).toBe(0);
expect(e8 / (4 * N)).toBe(0);
});
});
// ─── EV alignment with scoring rules ─────────────────────────────────────────
describe("NCAAM EV calculation alignment with scoring rules", () => {
// DEFAULT_SCORING_RULES: Champion=100, Finalist=70, FF loser=45, E8 loser=20, rest=0
// (sum = 340; must match DEFAULT_SCORING_RULES in admin.sports-seasons.$id.simulate.tsx)
const SCORING = {
first: 100,
second: 70,
thirdFourth: 45,
fifthToEighth: 20,
};
function computeEV(probs: {
probFirst: number; probSecond: number;
probThird: number; probFourth: number;
probFifth: number; probSixth: number; probSeventh: number; probEighth: number;
}): number {
return (
probs.probFirst * SCORING.first +
probs.probSecond * SCORING.second +
probs.probThird * SCORING.thirdFourth +
probs.probFourth * SCORING.thirdFourth +
probs.probFifth * SCORING.fifthToEighth +
probs.probSixth * SCORING.fifthToEighth +
probs.probSeventh * SCORING.fifthToEighth +
probs.probEighth * SCORING.fifthToEighth
);
}
it("champion with probFirst=1 earns 100 EV", () => {
const probs = {
probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(100, 10);
});
it("finalist with probSecond=1 earns 70 EV", () => {
const probs = {
probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(70, 10);
});
it("confirmed FF loser (probThird=probFourth=0.5 each) earns 45 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0.5, probFourth: 0.5,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(45, 10);
});
it("confirmed E8 loser (probFifthEighth=0.25 each) earns 20 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0.25, probSixth: 0.25, probSeventh: 0.25, probEighth: 0.25,
};
expect(computeEV(probs)).toBeCloseTo(20, 10);
});
it("R64 loser (all zeros) earns 0 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBe(0);
});
});