brackt/app/services/simulations/__tests__/ncaaw-simulator.test.ts
2026-05-12 22:20:36 -07:00

226 lines
8.2 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 { barthagWinProbability } from "../ncaaw-simulator";
import { buildFirstFourMappings } from "../ncaa-basketball-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);
});
});
// ─── 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);
}
});
it("maps First Four winners into the canonical NCAA 68 Round of 64 slots", () => {
expect(buildFirstFourMappings()).toEqual([
{ firstFourMatchNumber: 1, r64MatchNumber: 9, seedSlot: 16, regionName: "South" },
{ firstFourMatchNumber: 2, r64MatchNumber: 21, seedSlot: 11, regionName: "West" },
{ firstFourMatchNumber: 3, r64MatchNumber: 29, seedSlot: 11, regionName: "Midwest" },
{ firstFourMatchNumber: 4, r64MatchNumber: 25, seedSlot: 16, regionName: "Midwest" },
]);
});
});
// ─── 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 (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);
});
});