brackt/app/services/simulations/__tests__/ncaa-football-simulator.test.ts
Claude 24de966b3b
Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.

Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
  sources: raw Elo / projected wins / projected table points) and oddsWeight
  (0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
  odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
  override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.

Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
  convertFuturesToElo calls, and per-match probability blend; they read the
  resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
  optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
  from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
  central resolver can't), so its central oddsWeight is set to 0 to avoid
  double-counting; the simulator is unchanged.

Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).

UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.

Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.

Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-26 01:39:12 +00:00

228 lines
9.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { NCAAFootballSimulator } from "../ncaa-football-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
// Use real implementations of pure math functions to avoid mock call accumulation
// across 50k-iteration simulations. Only mock convertFuturesToElo (complex behavior).
vi.mock("~/services/probability-engine", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
convertFuturesToElo: vi.fn((oddsInput: { participantId: string; odds: number }[]) => {
return new Map(oddsInput.map((o, i) => [o.participantId, 1600 - i * 50]));
}),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
const TEAM_IDS = Array.from({ length: 12 }, (_, i) => `team-${i + 1}`);
/** Build EV rows with sourceElo ratings (descending — team-1 is best). */
function makeEvRows(
ids: string[],
opts: { includeOdds?: boolean } = {}
) {
return ids.map((participantId, i) => ({
participantId,
sourceElo: 1700 - i * 50, // 1700, 1650, 1600, …
sourceOdds: opts.includeOdds ? (i === 0 ? -200 : 300 + i * 50) : null,
}));
}
function makeParticipants(ids: string[]) {
return ids.map((id) => ({ id }));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("NCAAFootballSimulator", () => {
let mockDb: { select: MockInstance };
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = { select: vi.fn() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupMockDb(participantIds: string[], opts: { includeOdds?: boolean } = {}) {
const participants = makeParticipants(participantIds);
const evRows = makeEvRows(participantIds, opts);
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
// ── Post-bracket mode (exactly 12 teams) ─────────────────────────────────
describe("post-bracket mode (exactly 12 teams)", () => {
it("returns one result per participant", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12);
});
it("all probabilities are between 0 and 1", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probFirst).toBeLessThanOrEqual(1);
expect(p.probSecond).toBeGreaterThanOrEqual(0);
expect(p.probSecond).toBeLessThanOrEqual(1);
}
});
it("top-rated team has highest champion probability", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
byId.get("team-12")?.probabilities.probFirst ?? 0
);
});
it("probFirst sums to approximately 1.0", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSecond sums to approximately 1.0", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probThird equals probFourth for every team (tied SF placement)", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth through probEighth are equal for every team (tied QF placement)", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
it("weakest team has very low champion probability", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-12 (Elo 1150) vs team-1 (Elo 1700): 550-point gap → should rarely win
expect(byId.get("team-12")?.probabilities.probFirst).toBeLessThan(0.02);
});
it("source is cfp_monte_carlo", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
expect(r.source).toBe("cfp_monte_carlo");
}
});
it("runs from the resolved Elo (futures already blended upstream)", async () => {
// sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
describe("pre-bracket mode (>12 teams)", () => {
it("returns one result per participant when pool is larger than 12", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(20);
});
it("probFirst still sums to ~1.0 across all teams (one champion per sim)", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("top team has much higher probFirst than bottom team in larger pool", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-1 (Elo 1700) should win far more often than team-20 (Elo 750)
expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
(byId.get("team-20")?.probabilities.probFirst ?? 0) * 10
);
});
it("bottom-pool teams (large Elo gap) rarely appear in champion slot", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-19 and team-20 have Elos of 800 and 750 — far below the top 12 (~1150+)
// With softmax T=100, their selection weight is negligible
expect(byId.get("team-19")?.probabilities.probFirst).toBeLessThan(0.01);
expect(byId.get("team-20")?.probabilities.probFirst).toBeLessThan(0.01);
});
it("uses futures odds as selection weights when provided", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams, { includeOdds: true });
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Error cases ───────────────────────────────────────────────────────────
describe("error cases", () => {
it("throws when fewer than 12 participants provided", async () => {
const elevenTeams = TEAM_IDS.slice(0, 11);
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? makeParticipants(elevenTeams) : makeEvRows(elevenTeams);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
});
it("throws when participant list is empty", async () => {
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? [] : [];
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
});
});
});