- 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>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { barthagWinProbability, getBarthagRating, normalizeTeamName } from "../ncaaw-simulator";
|
||
|
||
// ─── Log5 win probability formula ────────────────────────────────────────────
|
||
|
||
describe("barthagWinProbability", () => {
|
||
it("returns 0.5 for equal teams", () => {
|
||
expect(barthagWinProbability(0.7, 0.7)).toBeCloseTo(0.5, 10);
|
||
});
|
||
|
||
it("returns >0.5 for team A with higher Barthag", () => {
|
||
expect(barthagWinProbability(0.9, 0.6)).toBeGreaterThan(0.5);
|
||
});
|
||
|
||
it("returns <0.5 for team A with lower Barthag", () => {
|
||
expect(barthagWinProbability(0.6, 0.9)).toBeLessThan(0.5);
|
||
});
|
||
|
||
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
|
||
const pAB = barthagWinProbability(0.9, 0.4);
|
||
const pBA = barthagWinProbability(0.4, 0.9);
|
||
expect(pAB + pBA).toBeCloseTo(1.0, 10);
|
||
});
|
||
|
||
it("satisfies the Barthag definition: barthagWinProbability(x, 0.5) === x", () => {
|
||
// The whole point of Barthag is P(team beats avg D-I opponent) = barthag
|
||
expect(barthagWinProbability(0.9, 0.5)).toBeCloseTo(0.9, 10);
|
||
expect(barthagWinProbability(0.3, 0.5)).toBeCloseTo(0.3, 10);
|
||
});
|
||
|
||
it("handles degenerate case: both teams at 0 → coin flip", () => {
|
||
expect(barthagWinProbability(0, 0)).toBe(0.5);
|
||
});
|
||
|
||
it("handles degenerate case: both teams at 1 → coin flip", () => {
|
||
expect(barthagWinProbability(1, 1)).toBe(0.5);
|
||
});
|
||
|
||
it("near-certain win: strong team (0.9996) vs weak team (0.35)", () => {
|
||
// Connecticut (.9996) vs Norfolk St. (.3468) — very high win probability
|
||
expect(barthagWinProbability(0.9996, 0.3468)).toBeGreaterThan(0.999);
|
||
});
|
||
|
||
it("matches the Log5 formula exactly for known values", () => {
|
||
const a = 0.9, b = 0.6;
|
||
const expected = (a * (1 - b)) / (a * (1 - b) + b * (1 - a));
|
||
expect(barthagWinProbability(a, b)).toBeCloseTo(expected, 10);
|
||
});
|
||
});
|
||
|
||
// ─── Team name normalization ──────────────────────────────────────────────────
|
||
|
||
describe("normalizeTeamName", () => {
|
||
it("converts to lowercase", () => {
|
||
expect(normalizeTeamName("Connecticut")).toBe("connecticut");
|
||
});
|
||
|
||
it("trims leading/trailing whitespace", () => {
|
||
expect(normalizeTeamName(" UCLA ")).toBe("ucla");
|
||
});
|
||
|
||
it("collapses internal whitespace", () => {
|
||
expect(normalizeTeamName("South Carolina")).toBe("south carolina");
|
||
});
|
||
|
||
it("preserves punctuation", () => {
|
||
expect(normalizeTeamName("N.C. State")).toBe("n.c. state");
|
||
});
|
||
});
|
||
|
||
// ─── Barthag data lookup ──────────────────────────────────────────────────────
|
||
|
||
describe("getBarthagRating", () => {
|
||
it("returns correct Barthag for exact known name (lowercase)", () => {
|
||
expect(getBarthagRating("connecticut")).toBeCloseTo(0.9996, 4);
|
||
});
|
||
|
||
it("is case-insensitive", () => {
|
||
expect(getBarthagRating("Connecticut")).toBeCloseTo(0.9996, 4);
|
||
expect(getBarthagRating("CONNECTICUT")).toBeCloseTo(0.9996, 4);
|
||
});
|
||
|
||
it("resolves uconn alias", () => {
|
||
expect(getBarthagRating("UConn")).toBeCloseTo(0.9996, 4);
|
||
});
|
||
|
||
it("returns correct Barthag for #2 team (UCLA)", () => {
|
||
expect(getBarthagRating("UCLA")).toBeCloseTo(0.9991, 4);
|
||
});
|
||
|
||
it("handles abbreviated name variants", () => {
|
||
// Both 'michigan st.' and 'michigan state' should resolve
|
||
expect(getBarthagRating("Michigan St.")).toBeCloseTo(0.9817, 4);
|
||
expect(getBarthagRating("Michigan State")).toBeCloseTo(0.9817, 4);
|
||
});
|
||
|
||
it("handles 'n.c. state' and 'nc state' aliases", () => {
|
||
expect(getBarthagRating("N.C. State")).toBeCloseTo(0.9766, 4);
|
||
expect(getBarthagRating("nc state")).toBeCloseTo(0.9766, 4);
|
||
});
|
||
|
||
it("handles ole miss alias", () => {
|
||
expect(getBarthagRating("Ole Miss")).toBeCloseTo(0.9821, 4);
|
||
});
|
||
|
||
it("returns 0.5 for an unknown team name (BARTHAG_FALLBACK = average)", () => {
|
||
expect(getBarthagRating("Fictional University")).toBe(0.5);
|
||
});
|
||
|
||
it("returns 0.5 for empty string", () => {
|
||
expect(getBarthagRating("")).toBe(0.5);
|
||
});
|
||
|
||
it("returns correct Barthag for a low-ranked team (Norfolk St.)", () => {
|
||
expect(getBarthagRating("Norfolk St.")).toBeCloseTo(0.3468, 4);
|
||
expect(getBarthagRating("Norfolk State")).toBeCloseTo(0.3468, 4);
|
||
});
|
||
});
|
||
|
||
// ─── Bracket path logic ───────────────────────────────────────────────────────
|
||
|
||
describe("NCAAW bracket advancement path", () => {
|
||
it("R64 matches 1 and 2 feed R32 match 1 (Math.ceil convention)", () => {
|
||
expect(Math.ceil(1 / 2)).toBe(1);
|
||
expect(Math.ceil(2 / 2)).toBe(1);
|
||
});
|
||
|
||
it("R64 matches 3 and 4 feed R32 match 2", () => {
|
||
expect(Math.ceil(3 / 2)).toBe(2);
|
||
expect(Math.ceil(4 / 2)).toBe(2);
|
||
});
|
||
|
||
it("R64 matches 31 and 32 feed R32 match 16", () => {
|
||
expect(Math.ceil(31 / 2)).toBe(16);
|
||
expect(Math.ceil(32 / 2)).toBe(16);
|
||
});
|
||
|
||
it("32 R64 matches produce exactly 16 R32 slots", () => {
|
||
const r32Slots = new Set<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("NCAAW placement bucket probability math", () => {
|
||
const N = 50_000;
|
||
|
||
it("champion: count/N = 1.0 when a team wins every simulation", () => {
|
||
expect(N / N).toBeCloseTo(1.0, 10);
|
||
});
|
||
|
||
it("FF loser slots: count/(2*N) sums to 1.0 across teams whose counts total 2*N", () => {
|
||
// 2 FF losers per sim → total across all teams = 2*N
|
||
const counts = [N, N];
|
||
const sum = counts.reduce((s, c) => s + c / (2 * N), 0);
|
||
expect(sum).toBeCloseTo(1.0, 10);
|
||
});
|
||
|
||
it("E8 loser slots: count/(4*N) sums to 1.0 across teams whose counts total 4*N", () => {
|
||
const counts = [N, N, N, N];
|
||
const sum = counts.reduce((s, c) => s + c / (4 * N), 0);
|
||
expect(sum).toBeCloseTo(1.0, 10);
|
||
});
|
||
|
||
it("probThird equals probFourth for the same team (same ff count / 2N)", () => {
|
||
const ffCount = 12500;
|
||
const probThird = ffCount / (2 * N);
|
||
const probFourth = ffCount / (2 * N);
|
||
expect(probThird).toBe(probFourth);
|
||
});
|
||
|
||
it("probFifth through probEighth are equal for the same team (same e8 count / 4N)", () => {
|
||
const e8Count = 6250;
|
||
const probs = [e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N)];
|
||
expect(probs[0]).toBe(probs[1]);
|
||
expect(probs[1]).toBe(probs[2]);
|
||
expect(probs[2]).toBe(probs[3]);
|
||
});
|
||
});
|
||
|
||
// ─── EV alignment with scoring rules ─────────────────────────────────────────
|
||
|
||
describe("NCAAW EV calculation alignment with scoring rules", () => {
|
||
// DEFAULT_SCORING_RULES: Champion=100, Finalist=70, FF loser=45, E8 loser=20, rest=0
|
||
// (sum = 340; must match DEFAULT_SCORING_RULES in admin.sports-seasons.$id.simulate.tsx)
|
||
const SCORING = {
|
||
first: 100,
|
||
second: 70,
|
||
thirdFourth: 45,
|
||
fifthToEighth: 20,
|
||
};
|
||
|
||
function computeEV(probs: {
|
||
probFirst: number; probSecond: number;
|
||
probThird: number; probFourth: number;
|
||
probFifth: number; probSixth: number; probSeventh: number; probEighth: number;
|
||
}): number {
|
||
return (
|
||
probs.probFirst * SCORING.first +
|
||
probs.probSecond * SCORING.second +
|
||
probs.probThird * SCORING.thirdFourth +
|
||
probs.probFourth * SCORING.thirdFourth +
|
||
probs.probFifth * SCORING.fifthToEighth +
|
||
probs.probSixth * SCORING.fifthToEighth +
|
||
probs.probSeventh * SCORING.fifthToEighth +
|
||
probs.probEighth * SCORING.fifthToEighth
|
||
);
|
||
}
|
||
|
||
it("champion with probFirst=1 earns 100 EV", () => {
|
||
const probs = {
|
||
probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0,
|
||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||
};
|
||
expect(computeEV(probs)).toBeCloseTo(100, 10);
|
||
});
|
||
|
||
it("finalist with probSecond=1 earns 70 EV", () => {
|
||
const probs = {
|
||
probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0,
|
||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||
};
|
||
expect(computeEV(probs)).toBeCloseTo(70, 10);
|
||
});
|
||
|
||
it("confirmed FF loser (probThird=probFourth=0.5 each) earns 45 EV", () => {
|
||
const probs = {
|
||
probFirst: 0, probSecond: 0, probThird: 0.5, probFourth: 0.5,
|
||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||
};
|
||
expect(computeEV(probs)).toBeCloseTo(45, 10);
|
||
});
|
||
|
||
it("confirmed E8 loser (probFifth–Eighth=0.25 each) earns 20 EV", () => {
|
||
const probs = {
|
||
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
|
||
probFifth: 0.25, probSixth: 0.25, probSeventh: 0.25, probEighth: 0.25,
|
||
};
|
||
expect(computeEV(probs)).toBeCloseTo(20, 10);
|
||
});
|
||
|
||
it("R64 loser (all zeros) earns 0 EV", () => {
|
||
const probs = {
|
||
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
|
||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||
};
|
||
expect(computeEV(probs)).toBe(0);
|
||
});
|
||
});
|