* Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
169 lines
6 KiB
TypeScript
169 lines
6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { srsToElo, simSeriesN, resolveElo } from "../wnba-simulator";
|
|
import { eloWinProbability } from "~/services/probability-engine";
|
|
|
|
// ─── srsToElo ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("srsToElo", () => {
|
|
it("maps SRS 0 to league-average Elo 1500", () => {
|
|
expect(srsToElo(0)).toBe(1500);
|
|
});
|
|
|
|
it("maps positive SRS above 1500", () => {
|
|
expect(srsToElo(5)).toBe(1600);
|
|
expect(srsToElo(10)).toBe(1700);
|
|
});
|
|
|
|
it("maps negative SRS below 1500", () => {
|
|
expect(srsToElo(-5)).toBe(1400);
|
|
expect(srsToElo(-10)).toBe(1300);
|
|
});
|
|
|
|
it("is linear with scale 20", () => {
|
|
expect(srsToElo(3)).toBe(srsToElo(1) + 40);
|
|
});
|
|
});
|
|
|
|
// ─── eloWinProbability ────────────────────────────────────────────────────────
|
|
|
|
describe("eloWinProbability", () => {
|
|
it("returns 0.5 for equal Elo ratings", () => {
|
|
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
|
|
});
|
|
|
|
it("favors the higher-rated team", () => {
|
|
expect(eloWinProbability(1600, 1500)).toBeGreaterThan(0.5);
|
|
expect(eloWinProbability(1500, 1600)).toBeLessThan(0.5);
|
|
});
|
|
|
|
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
|
|
const p = eloWinProbability(1640, 1430);
|
|
expect(p + eloWinProbability(1430, 1640)).toBeCloseTo(1.0, 10);
|
|
});
|
|
|
|
it("SRS +10 vs SRS -10 gives ~76% win probability", () => {
|
|
// srsToElo(+10) = 1700, srsToElo(-10) = 1300 → gap 400 → P = 1/(1+10^(-1)) ≈ 0.909
|
|
// Actually gap of 400 → P ≈ 0.909. Let's verify the ±10 SRS case:
|
|
// srsToElo(+10) = 1700, srsToElo(-10) = 1300 → gap = 400 → P ≈ 0.909
|
|
const p = eloWinProbability(srsToElo(10), srsToElo(-10));
|
|
expect(p).toBeGreaterThan(0.9);
|
|
expect(p).toBeLessThan(0.92);
|
|
});
|
|
|
|
it("SRS +5 vs SRS 0 gives ~64% win probability", () => {
|
|
// srsToElo(+5) = 1600, srsToElo(0) = 1500 → gap = 100 → P = 1/(1+10^(-0.25)) ≈ 0.640
|
|
const p = eloWinProbability(srsToElo(5), srsToElo(0));
|
|
expect(p).toBeGreaterThan(0.63);
|
|
expect(p).toBeLessThan(0.66);
|
|
});
|
|
});
|
|
|
|
// ─── resolveElo ───────────────────────────────────────────────────────────────
|
|
|
|
describe("resolveElo", () => {
|
|
it("SRS mode with SRS present: uses SRS-derived Elo", () => {
|
|
expect(resolveElo(5, 1600, true)).toBe(srsToElo(5)); // 1600
|
|
});
|
|
|
|
it("SRS mode with no SRS: falls back to futures Elo", () => {
|
|
expect(resolveElo(null, 1620, true)).toBe(1620);
|
|
});
|
|
|
|
it("SRS mode with no SRS and no futures: falls back to 1500", () => {
|
|
expect(resolveElo(null, null, true)).toBe(1500);
|
|
});
|
|
|
|
it("futures mode: uses futures Elo regardless of SRS", () => {
|
|
expect(resolveElo(8, 1650, false)).toBe(1650);
|
|
});
|
|
|
|
it("futures mode with no futures: falls back to 1500", () => {
|
|
expect(resolveElo(8, null, false)).toBe(1500);
|
|
});
|
|
|
|
it("sourceElo overrides SRS when in SRS mode", () => {
|
|
expect(resolveElo(5, 1600, true, 1750)).toBe(1750);
|
|
});
|
|
|
|
it("sourceElo overrides futures when not in SRS mode", () => {
|
|
expect(resolveElo(null, 1600, false, 1750)).toBe(1750);
|
|
});
|
|
|
|
it("sourceElo overrides 1500 fallback when all other signals are absent", () => {
|
|
expect(resolveElo(null, null, false, 1700)).toBe(1700);
|
|
});
|
|
|
|
it("null sourceElo falls through to normal priority chain", () => {
|
|
expect(resolveElo(5, 1600, true, null)).toBe(srsToElo(5));
|
|
});
|
|
|
|
it("undefined sourceElo falls through to normal priority chain", () => {
|
|
expect(resolveElo(null, 1620, false, undefined)).toBe(1620);
|
|
});
|
|
});
|
|
|
|
// ─── simSeriesN ───────────────────────────────────────────────────────────────
|
|
|
|
const makeTeam = (id: string, elo: number) => ({
|
|
id,
|
|
name: id,
|
|
elo,
|
|
currentWins: 0,
|
|
remainingGames: 0,
|
|
winProb: 0.5,
|
|
});
|
|
|
|
describe("simSeriesN", () => {
|
|
it("best-of-3: winner reaches exactly 2 wins", () => {
|
|
const a = makeTeam("A", 1600);
|
|
const b = makeTeam("B", 1400);
|
|
for (let i = 0; i < 100; i++) {
|
|
const { winner, loser } = simSeriesN(a, b, 2);
|
|
expect(winner === a || winner === b).toBe(true);
|
|
expect(loser === a || loser === b).toBe(true);
|
|
expect(winner).not.toBe(loser);
|
|
}
|
|
});
|
|
|
|
it("best-of-5: winner and loser are always different teams", () => {
|
|
const a = makeTeam("A", 1500);
|
|
const b = makeTeam("B", 1500);
|
|
for (let i = 0; i < 100; i++) {
|
|
const { winner, loser } = simSeriesN(a, b, 3);
|
|
expect(winner).not.toBe(loser);
|
|
}
|
|
});
|
|
|
|
it("best-of-7: returns the input team objects (referential identity)", () => {
|
|
const a = makeTeam("TeamA", 1550);
|
|
const b = makeTeam("TeamB", 1450);
|
|
const { winner, loser } = simSeriesN(a, b, 4);
|
|
expect(winner === a || winner === b).toBe(true);
|
|
expect(loser === a || loser === b).toBe(true);
|
|
});
|
|
|
|
it("heavily favored team wins most best-of-3 series", () => {
|
|
const strong = makeTeam("Strong", 1800);
|
|
const weak = makeTeam("Weak", 1200);
|
|
let strongWins = 0;
|
|
const N = 1000;
|
|
for (let i = 0; i < N; i++) {
|
|
if (simSeriesN(strong, weak, 2).winner === strong) strongWins++;
|
|
}
|
|
// P(game) ≈ 0.994, P(series) should be > 0.99
|
|
expect(strongWins / N).toBeGreaterThan(0.97);
|
|
});
|
|
|
|
it("even matchup produces roughly 50% win rate in a large sample", () => {
|
|
const a = makeTeam("A", 1500);
|
|
const b = makeTeam("B", 1500);
|
|
let aWins = 0;
|
|
const N = 2000;
|
|
for (let i = 0; i < N; i++) {
|
|
if (simSeriesN(a, b, 3).winner === a) aWins++;
|
|
}
|
|
// Should be close to 50% — allow ±5%
|
|
expect(aWins / N).toBeGreaterThan(0.45);
|
|
expect(aWins / N).toBeLessThan(0.55);
|
|
});
|
|
});
|