claude/trusting-heisenberg-r6xnfi (#90)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #90
This commit is contained in:
parent
d0a31f3883
commit
dc94403160
2 changed files with 324 additions and 8 deletions
271
app/services/simulations/__tests__/auto-racing-simulator.test.ts
Normal file
271
app/services/simulations/__tests__/auto-racing-simulator.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { AutoRacingSimulator } from "../auto-racing-simulator";
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
// ─── F1 race points (positions 1–10) ─────────────────────────────────────────
|
||||
const F1_RACE_POINTS: Record<number, number> = {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
|
||||
};
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
|
||||
|
||||
function makeEvent(isComplete: boolean, eventType = "race") {
|
||||
return { isComplete, eventType };
|
||||
}
|
||||
|
||||
function makeSeasonResult(participantId: string, currentPoints: string) {
|
||||
return { participant: { id: participantId }, currentPoints };
|
||||
}
|
||||
|
||||
function makeEv(participantId: string, sourceOdds: number | null) {
|
||||
return { participantId, sourceOdds };
|
||||
}
|
||||
|
||||
function mockDb(events: ReturnType<typeof makeEvent>[]) {
|
||||
return {
|
||||
query: {
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue(DRIVERS),
|
||||
},
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(events),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let db: ReturnType<typeof mockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
|
||||
db = mockDb([]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([]);
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AutoRacingSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
db.query.seasonParticipants.findMany.mockResolvedValue([]);
|
||||
await expect(
|
||||
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
|
||||
).rejects.toThrow(/No participants found/);
|
||||
});
|
||||
|
||||
describe("pre-season path (remainingRaces === 0)", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
// No scoring events → remainingRaces = 0
|
||||
db = mockDb([]);
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
// Heavy favourite: d1 at −500, all others at +1000
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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");
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of 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 () => {
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-season path (remainingRaces > 0)", () => {
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
// 10 completed races, 5 remaining
|
||||
db = mockDb([
|
||||
...Array.from({ length: 10 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 5 }, () => makeEvent(false)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
});
|
||||
|
||||
it("normalizes each position column to sum to 1.0", async () => {
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue(
|
||||
DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50)))
|
||||
);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of 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%
|
||||
const { database } = await import("~/database/context");
|
||||
db = mockDb([
|
||||
...Array.from({ length: 20 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 5 }, () => makeEvent(false)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
makeSeasonResult("d1", "400"),
|
||||
makeSeasonResult("d2", "50"),
|
||||
makeSeasonResult("d3", "40"),
|
||||
makeSeasonResult("d4", "30"),
|
||||
makeSeasonResult("d5", "20"),
|
||||
]);
|
||||
// Futures odds heavily favour d2 (pretend markets disagree)
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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 () => {
|
||||
// Use 2 completed / 20 remaining → early season, standings gap is small
|
||||
const { database } = await import("~/database/context");
|
||||
db = mockDb([
|
||||
...Array.from({ length: 2 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 20 }, () => makeEvent(false)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
makeSeasonResult("d1", "10"),
|
||||
makeSeasonResult("d2", "8"),
|
||||
makeSeasonResult("d3", "6"),
|
||||
makeSeasonResult("d4", "4"),
|
||||
// d5 intentionally absent → falls back to odds weight
|
||||
]);
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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(() => {});
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
// Only 3 of 5 drivers have standings rows
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
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();
|
||||
});
|
||||
|
||||
it("schedule_event entries are excluded from race counts", async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
// 5 real races + 3 schedule_events (should be ignored)
|
||||
db = mockDb([
|
||||
...Array.from({ length: 5 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 3 }, () => makeEvent(false, "schedule_event")),
|
||||
makeEvent(false), // 1 real remaining
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
// Should not throw and should use seasonProgress = 5/6
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
expect(results).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,14 @@ const RACE_NOISE = 0.50;
|
|||
*/
|
||||
const PARTICIPANT_VOLATILITY = 1.5;
|
||||
|
||||
/**
|
||||
* How much PARTICIPANT_VOLATILITY shrinks as the season progresses.
|
||||
* effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - progress * VOLATILITY_DECAY_FACTOR).
|
||||
* At 0.7, effective volatility reaches 30% of its baseline with one race left,
|
||||
* preventing large standing swings when the championship is nearly decided.
|
||||
*/
|
||||
const VOLATILITY_DECAY_FACTOR = 0.7;
|
||||
|
||||
/**
|
||||
* Optional smoothing toward the mean after vig removal.
|
||||
* 0.0 = use vig-removed market odds exactly (recommended).
|
||||
|
|
@ -117,13 +125,20 @@ export class AutoRacingSimulator implements Simulator {
|
|||
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
|
||||
);
|
||||
|
||||
// 3. Count remaining races: incomplete scoring events, excluding schedule_event entries
|
||||
// 3. Count remaining and completed races in a single pass (exclude schedule_event entries)
|
||||
const allEvents = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
const remainingRaces = allEvents.filter(
|
||||
(e) => !e.isComplete && e.eventType !== "schedule_event"
|
||||
).length;
|
||||
let remainingRaces = 0;
|
||||
let completedRaces = 0;
|
||||
for (const e of allEvents) {
|
||||
if (e.eventType === "schedule_event") continue;
|
||||
if (e.isComplete) completedRaces++;
|
||||
else remainingRaces++;
|
||||
}
|
||||
// 0.0 = pre-season, 1.0 = all races done
|
||||
const totalRaces = completedRaces + remainingRaces;
|
||||
const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0;
|
||||
|
||||
// 4. Load EV data for championship win probabilities
|
||||
const evs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
|
@ -160,7 +175,7 @@ export class AutoRacingSimulator implements Simulator {
|
|||
}
|
||||
}
|
||||
|
||||
// 7. Accumulate finish counts across simulations
|
||||
// Accumulate finish counts across simulations
|
||||
// rankCounts[id][0..7] = number of times driver finished 1st..8th
|
||||
const rankCounts = new Map<string, number[]>();
|
||||
for (const id of ids) {
|
||||
|
|
@ -180,14 +195,44 @@ export class AutoRacingSimulator implements Simulator {
|
|||
}
|
||||
} else {
|
||||
// In-season: simulate remaining races from current standings.
|
||||
|
||||
// Warn if standings data is incomplete — missing rows distort each driver's
|
||||
// share-of-points weight and silently degrade the blending accuracy.
|
||||
const participantsWithPoints = ids.filter((id) => currentPointsMap.has(id));
|
||||
if (participantsWithPoints.length < ids.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[AutoRacingSimulator] ${ids.length - participantsWithPoints.length} participant(s) missing from standings for season ${sportsSeasonId} — blending may be inaccurate`
|
||||
);
|
||||
}
|
||||
|
||||
// Build blended probability weights that combine futures-odds strength
|
||||
// with standings-based strength, weighted by season progress.
|
||||
// - Early season (low seasonProgress): mostly futures odds
|
||||
// - Mid/late season: standings dominate, reducing the distortion from
|
||||
// championship futures (which penalize 2nd-place drivers whose odds of
|
||||
// *winning* the title are weak, even though they'll likely finish top 3)
|
||||
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
|
||||
const blendedProbs = new Map<string, number>();
|
||||
for (const id of ids) {
|
||||
const oddsW = baseProbs.get(id) ?? fallbackProb;
|
||||
const pts = currentPointsMap.get(id) ?? 0;
|
||||
// Drivers with 0 pts (new entry, early DNF) fall back to odds strength
|
||||
const standingsW = totalCurrentPoints > 0 && pts > 0 ? pts / totalCurrentPoints : oddsW;
|
||||
blendedProbs.set(id, (1 - seasonProgress) * oddsW + seasonProgress * standingsW);
|
||||
}
|
||||
|
||||
// Volatility shrinks as the season progresses — late-season standings are
|
||||
// much more predictive than early-season odds.
|
||||
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
|
||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
||||
// 7a. Season-long performance multiplier per driver
|
||||
// 7a. Season-long performance multiplier per driver (uses blended strength)
|
||||
const seasonWeights = new Map<string, number>();
|
||||
for (const id of ids) {
|
||||
const base = baseProbs.get(id) ?? fallbackProb;
|
||||
const base = blendedProbs.get(id) ?? fallbackProb;
|
||||
const mult = Math.max(
|
||||
0.05,
|
||||
1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2
|
||||
1 - effectiveVolatility + Math.random() * effectiveVolatility * 2
|
||||
);
|
||||
seasonWeights.set(id, base * mult);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue