brackt/app/services/simulations/__tests__/auto-racing-simulator.test.ts
Claude adceda3aca
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.

`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.

- Add `countSeasonRaces` in a new leaf model. A race is every event except
  `final_standings`, and completion is inferred from the event date, since
  nobody marks rows labelled "Non-Scoring" complete. Its own module because
  `scoring-event.ts` reaches back into `simulator.ts` through
  `scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
  a finished season reverted to an odds draw instead of reporting the final
  standings.
- Warn when a season has championship points but no calendar, in the
  simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
  by the same book sum guts the favourite in a 27-driver market: a 75%
  implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
  gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
  market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
  without tripping the manifest/registry import cycle.

The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00

411 lines
17 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, vi, beforeEach, type MockInstance } from "vitest";
import { AutoRacingSimulator } from "../auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "../race-points";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/participant-season-result", () => ({
getSeasonResults: vi.fn(),
}));
vi.mock("~/models/participant-expected-value", () => ({
getAllParticipantEVsForSeason: vi.fn(),
}));
vi.mock("~/models/season-races", () => ({
countSeasonRaces: vi.fn(),
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
const PROB_KEYS = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
function makeSeasonResult(participantId: string, currentPoints: string) {
return { participant: { id: participantId }, currentPoints };
}
function makeEv(participantId: string, sourceOdds: number | null) {
return { participantId, sourceOdds };
}
function mockDb(drivers: { id: string }[] = DRIVERS) {
return {
query: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(drivers),
},
},
};
}
/** Set the race counts the simulator reads from the calendar. */
async function setRaceCounts(completed: number, remaining: number) {
const { countSeasonRaces } = await import("~/models/season-races");
(countSeasonRaces as unknown as MockInstance).mockResolvedValue({
completed,
remaining,
total: completed + remaining,
});
}
async function setStandings(results: ReturnType<typeof makeSeasonResult>[]) {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(results);
}
async function setOdds(evs: ReturnType<typeof makeEv>[]) {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue(evs);
}
async function useDrivers(drivers: { id: string }[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb(drivers));
}
// ─── Setup ────────────────────────────────────────────────────────────────────
beforeEach(async () => {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb());
await setStandings([]);
await setOdds([]);
await setRaceCounts(0, 0);
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AutoRacingSimulator", () => {
it("throws when no participants are found", async () => {
await useDrivers([]);
await expect(
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
).rejects.toThrow(/No participants found/);
});
describe("pre-season path (no races run, none remaining)", () => {
beforeEach(async () => {
await setRaceCounts(0, 0);
// Heavy favourite: d1 at 500, all others at +1000
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
makeEv("d5", 1000),
]);
});
it("returns one result per driver", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(results).toHaveLength(5);
});
it("normalizes each position column to sum to 1.0", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("heavy favourite ranks first more often than long shots", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const favourite = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
expect(favourite).toBeDefined();
expect(longShot).toBeDefined();
if (!favourite || !longShot) return;
expect(favourite.probabilities.probFirst).toBeGreaterThan(longShot.probabilities.probFirst);
});
it("drivers without odds get equal fallback probability", async () => {
await setOdds([]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// With equal weights all 5 drivers should finish 1st roughly equally
for (const r of results) {
expect(r.probabilities.probFirst).toBeGreaterThan(0.1);
expect(r.probabilities.probFirst).toBeLessThan(0.3);
}
});
it("floors an unpriced driver at the bottom of the priced market", async () => {
// d5 has no odds at all; d2d4 are +1000 long shots. Before power devig
// an unpriced driver was handed 1/N, which rated them above the field.
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const unpriced = results.find((r) => r.participantId === "d5");
const longShot = results.find((r) => r.participantId === "d2");
expect(unpriced).toBeDefined();
expect(longShot).toBeDefined();
if (!unpriced || !longShot) return;
expect(unpriced.probabilities.probFirst).toBeLessThanOrEqual(
longShot.probabilities.probFirst + 0.02
);
});
});
describe("season complete (races run, none remaining)", () => {
it("returns the final standings order deterministically", async () => {
await setRaceCounts(17, 0);
// getSeasonResults returns rows already sorted by championship position.
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
makeSeasonResult("d2", "420"),
makeSeasonResult("d4", "398"),
]);
// Futures odds disagree entirely — they must be ignored once it is over.
await setOdds([makeEv("d1", -10000), makeEv("d3", 20000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probFirst).toBe(0);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
expect(byId.get("d2")?.probFourth).toBe(1);
expect(byId.get("d4")?.probFifth).toBe(1);
});
it("warns and falls back to odds when there are no standings rows", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(17, 0);
await setStandings([]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("no races left but no standings rows")
);
// Still produces a usable distribution rather than all zeroes.
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 6);
warnSpy.mockRestore();
});
});
describe("no race calendar", () => {
it("warns when the season has championship points but no events", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([makeSeasonResult("d1", "400"), makeSeasonResult("d2", "300")]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("stays quiet for a genuine pre-season with no points yet", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe("in-season path (races remaining)", () => {
beforeEach(async () => {
await setRaceCounts(10, 5);
});
it("normalizes each position column to sum to 1.0", async () => {
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("standings leader ranks higher than a driver far behind when standings dominate", async () => {
// 20/25 races done → seasonProgress = 0.8 → standings weighted 80%
await setRaceCounts(20, 5);
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
await setStandings([
makeSeasonResult("d1", "400"),
makeSeasonResult("d2", "50"),
makeSeasonResult("d3", "40"),
makeSeasonResult("d4", "30"),
makeSeasonResult("d5", "20"),
]);
// Futures odds heavily favour d2 (pretend markets disagree)
await setOdds([
makeEv("d1", 5000), // very long shot per futures
makeEv("d2", -500), // heavy favourite per futures
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const leader = results.find((r) => r.participantId === "d1");
const distant = results.find((r) => r.participantId === "d2");
expect(leader).toBeDefined();
expect(distant).toBeDefined();
if (!leader || !distant) return;
// Standings signal should dominate: d1's massive points lead wins out
expect(leader.probabilities.probFirst).toBeGreaterThan(distant.probabilities.probFirst);
});
it("falls back to odds for all drivers when no standings data exists", async () => {
// totalCurrentPoints = 0 → standings signal disabled, odds take over
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const fav = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
expect(fav).toBeDefined();
expect(longShot).toBeDefined();
if (!fav || !longShot) return;
// Without standings, odds-favoured driver should still rank higher
expect(fav.probabilities.probFirst).toBeGreaterThan(longShot.probabilities.probFirst);
});
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
// Early season → standings gap is small
await setRaceCounts(2, 20);
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
await setStandings([
makeSeasonResult("d1", "10"),
makeSeasonResult("d2", "8"),
makeSeasonResult("d3", "6"),
makeSeasonResult("d4", "4"),
// d5 intentionally absent → falls back to odds weight
]);
await setOdds([makeEv("d5", -500)]); // strong odds favourite despite 0 pts
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// d5 should win championships at a non-trivial rate given their strong odds weight
const d5 = results.find((r) => r.participantId === "d5");
expect(d5).toBeDefined();
if (!d5) return;
expect(d5.probabilities.probFirst).toBeGreaterThan(0.1);
});
it("emits a warning when participants are missing from standings", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Only 3 of 5 drivers have standings rows
await setStandings([
makeSeasonResult("d1", "100"),
makeSeasonResult("d2", "80"),
makeSeasonResult("d3", "60"),
]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("2 participant(s) missing from standings")
);
warnSpy.mockRestore();
});
});
describe("IndyCar regression: near-clinched championship leader", () => {
// The reported bug. A 121-point lead with 2 races left is arithmetically
// unassailable (max 100 available, and the leader banks at least 10), but
// the simulator skipped `schedule_event` rows, saw zero remaining races,
// took the pre-season branch and echoed stale futures odds at ~55%.
const POINTS = [
601, 480, 446, 420, 398, 372, 350, 331, 315, 300, 288, 270, 255, 240,
228, 215, 200, 188, 175, 160, 148, 135, 120, 105, 90, 70, 55,
];
const ODDS = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
];
const FIELD = POINTS.map((_, i) => ({ id: `driver${i}` }));
beforeEach(async () => {
await useDrivers(FIELD);
await setStandings(FIELD.map((d, i) => makeSeasonResult(d.id, String(POINTS[i]))));
await setOdds(FIELD.map((d, i) => makeEv(d.id, ODDS[i])));
});
it("gives the leader ~100% with 2 of 17 races left", async () => {
await setRaceCounts(15, 2);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.99);
});
it("without a calendar it can only echo the stale odds — the shape of the bug", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
// Nowhere near the truth, which is exactly why the no-calendar warning
// above exists. Power devig keeps the -300 favourite well clear of the
// 55% that proportional devig produced, but odds alone cannot see a
// 121-point lead.
expect(leader.probabilities.probFirst).toBeLessThan(0.9);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("still gives the leader a commanding lead with 5 races left", async () => {
await setRaceCounts(12, 5);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.9);
});
});
});
describe("race points tables", () => {
it("IndyCar pays 50 for a win and scores down to P26", () => {
expect(INDYCAR_RACE_POINTS[1]).toBe(50);
expect(INDYCAR_RACE_POINTS[2]).toBe(40);
expect(INDYCAR_RACE_POINTS[25]).toBe(5);
expect(INDYCAR_RACE_POINTS[26]).toBe(5);
expect(INDYCAR_RACE_POINTS[27]).toBeUndefined();
});
it("F1 pays 25 for a win and scores down to P10", () => {
expect(F1_RACE_POINTS[1]).toBe(25);
expect(F1_RACE_POINTS[10]).toBe(1);
expect(F1_RACE_POINTS[11]).toBeUndefined();
});
it("both tables decrease monotonically so the points loop never truncates early", () => {
for (const table of [F1_RACE_POINTS, INDYCAR_RACE_POINTS]) {
const positions = Object.keys(table).map(Number).toSorted((a, b) => a - b);
// Contiguous from P1, no gaps — the award loop breaks at the first 0.
positions.forEach((pos, i) => expect(pos).toBe(i + 1));
for (let i = 1; i < positions.length; i++) {
expect(table[positions[i]]).toBeLessThanOrEqual(table[positions[i - 1]]);
}
}
});
});