Address code review findings in AutoRacingSimulator
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 50s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- Extract VOLATILITY_DECAY_FACTOR named constant (was magic 0.7) with docstring
- Replace two complementary allEvents.filter() calls with a single for-loop pass
- Move blendedProbs construction inside the in-season else branch (was built
  unconditionally even in pre-season where it was never read)
- Flatten nested totalCurrentPoints/pts conditions to a single ternary
- Emit a console.warn when participants are missing from standings data, so
  degraded blending is visible rather than silent
- Add AutoRacingSimulator test suite covering pre-season path, in-season
  standings-dominant blending, zero-points fallback, missing-standings warning,
  schedule_event exclusion, and column normalization

https://claude.ai/code/session_01FyG28zxsjSrgr8aKYuy6xY
This commit is contained in:
Claude 2026-06-15 01:31:43 +00:00
parent e507f82508
commit 06a011f0ed
No known key found for this signature in database
2 changed files with 303 additions and 29 deletions

View file

@ -0,0 +1,260 @@
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 110) ─────────────────────────────────────────
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.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")!;
// 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")!;
// 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.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);
});
});
});

View file

@ -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,16 +125,17 @@ export class AutoRacingSimulator implements Simulator {
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
);
// 3. Count remaining and completed races (exclude 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;
const completedRaces = 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;
@ -166,27 +175,6 @@ export class AutoRacingSimulator implements Simulator {
}
}
// 7. Build blended probability weights that combine futures-odds strength
// with standings-based strength, weighted by season progress.
// - Pre-season (seasonProgress=0): pure 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;
let standingsW: number;
if (totalCurrentPoints > 0) {
const pts = currentPointsMap.get(id) ?? 0;
// Drivers with 0 pts (new entry, early DNF) fall back to odds strength
standingsW = pts > 0 ? pts / totalCurrentPoints : oddsW;
} else {
standingsW = oddsW;
}
blendedProbs.set(id, (1 - seasonProgress) * oddsW + seasonProgress * standingsW);
}
// Accumulate finish counts across simulations
// rankCounts[id][0..7] = number of times driver finished 1st..8th
const rankCounts = new Map<string, number[]>();
@ -207,9 +195,35 @@ 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) {
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 * 0.7);
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// 7a. Season-long performance multiplier per driver (uses blended strength)
const seasonWeights = new Map<string, number>();