288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
|
|
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 (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);
|
|||
|
|
});
|
|||
|
|
});
|