Fix three defects found reviewing the auto racing sim change
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m3s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- The settled-season branch only ranked drivers that had a standings row, so
  with fewer than eight rows the trailing placement columns were empty and
  step 9's residual normalization dumped a full 1.0 onto whichever driver came
  first. Ten drivers with five standings rows gave d1 probSecond, probSixth,
  probSeventh and probEighth all at 1. Removed the branch: the in-season path
  awards no points when no races remain, so it already ranks the final
  standings, and it ranks the whole field.

- The unpriced-driver floor was taken from a distribution normalized over the
  priced subset alone. With one priced driver devigPower returns [1], so every
  unpriced driver was seeded at 1 and the field came back flat — a lone -500
  favourite fell to 0.117. Floor on the implied scale and devig the whole
  field instead, keeping the 1/N fallback when a book has a single price and
  no tail to anchor to.

- hasRaceRun treated a race as run at the green flag, so remainingRaces hit 0
  the moment the finale started and the pre-race leader was published as
  champion at 100% from standings that did not include that race. Require the
  race to have had time to finish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
This commit is contained in:
Claude 2026-08-17 00:50:30 +00:00
parent adceda3aca
commit 95f895a715
No known key found for this signature in database
4 changed files with 129 additions and 79 deletions

View file

@ -52,13 +52,13 @@ describe("hasRaceRun", () => {
}); });
it("prefers eventStartsAt over eventDate", () => { it("prefers eventStartsAt over eventDate", () => {
// Same day, but the green flag already dropped this morning. // Started yesterday and long finished, even though eventDate is unset.
expect( expect(
hasRaceRun( hasRaceRun(
{ {
isComplete: false, isComplete: false,
eventDate: TODAY, eventDate: null,
eventStartsAt: new Date("2026-08-17T09:00:00.000Z"), eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
}, },
NOW, NOW,
TODAY TODAY
@ -78,6 +78,29 @@ describe("hasRaceRun", () => {
).toBe(false); ).toBe(false);
}); });
it("does not call a race run the moment it goes green", () => {
// Declaring the finale finished at the green flag would publish the
// pre-race leader as champion at 100%, from standings without that race.
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(false);
});
it("counts a race run once it has had time to finish", () => {
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("still honours isComplete for a race that just started", () => {
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("treats a past date as run even when nobody marked it complete", () => { it("treats a past date as run even when nobody marked it complete", () => {
expect( expect(
hasRaceRun( hasRaceRun(
@ -154,8 +177,9 @@ describe("countSeasonRaces", () => {
...Array.from({ length: 15 }, (_, i) => ...Array.from({ length: 15 }, (_, i) =>
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` }) makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
), ),
// The next race goes green in a few hours — still remaining.
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }), makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
makeEvent({ eventStartsAt: new Date("2026-09-13T18:00:00.000Z") }),
makeEvent({ eventType: "final_standings" }), makeEvent({ eventType: "final_standings" }),
]; ];
await mockEvents(calendar); await mockEvents(calendar);

View file

@ -17,6 +17,17 @@ export interface SeasonRaceCounts {
total: number; total: number;
} }
/**
* How long after the green flag a race is assumed to have finished.
*
* `event_starts_at` is a start time, so treating it as "already run" would
* declare the season over the moment the finale goes green and the simulator
* would publish the pre-race leader as champion at 100%, from standings that do
* not yet include the race being run. No race in these series comes close to
* six hours, and the standings feed updates within hours of a finish.
*/
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
/** /**
* Has this race already been run? * Has this race already been run?
* *
@ -39,7 +50,9 @@ export function hasRaceRun(
today: string today: string
): boolean { ): boolean {
if (event.isComplete) return true; if (event.isComplete) return true;
if (event.eventStartsAt) return new Date(event.eventStartsAt) < now; if (event.eventStartsAt) {
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
}
if (event.eventDate) return event.eventDate < today; if (event.eventDate) return event.eventDate < today;
return false; return false;
} }

View file

@ -136,9 +136,9 @@ describe("AutoRacingSimulator", () => {
} }
}); });
it("floors an unpriced driver at the bottom of the priced market", async () => { it("prices an unpriced driver at the longest price in the book", async () => {
// d5 has no odds at all; d2d4 are +1000 long shots. Before power devig // d5 has no odds; d2d4 are +1000 long shots. An unpriced driver used to
// an unpriced driver was handed 1/N, which rated them above the field. // be handed 1/N, which rated them above most of the priced field.
await setOdds([ await setOdds([
makeEv("d1", -500), makeEv("d1", -500),
makeEv("d2", 1000), makeEv("d2", 1000),
@ -146,14 +146,24 @@ describe("AutoRacingSimulator", () => {
makeEv("d4", 1000), makeEv("d4", 1000),
]); ]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1"); const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const unpriced = results.find((r) => r.participantId === "d5"); const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
const longShot = results.find((r) => r.participantId === "d2"); const unpriced = byId.get("d5") ?? 0;
expect(unpriced).toBeDefined(); const longShot = byId.get("d2") ?? 0;
expect(longShot).toBeDefined(); expect(unpriced).toBeCloseTo(longShot, 1);
if (!unpriced || !longShot) return; expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
expect(unpriced.probabilities.probFirst).toBeLessThanOrEqual( });
longShot.probabilities.probFirst + 0.02
); it("does not let a thinly priced book flatten the favourite", async () => {
// Only one driver is priced. Anchoring the rest to "the longest price"
// would make that price the whole book and hand out a uniform field, so
// a single-price book keeps the 1/N fallback for the others.
// (Readiness requires odds for every participant, so this is a fallback
// path rather than a supported configuration.)
await setOdds([makeEv("d1", -500)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
expect(byId.get("d1") ?? 0).toBeGreaterThan(0.4);
expect(byId.get("d2") ?? 0).toBeLessThan(0.2);
}); });
}); });
@ -182,21 +192,50 @@ describe("AutoRacingSimulator", () => {
expect(byId.get("d4")?.probFifth).toBe(1); expect(byId.get("d4")?.probFifth).toBe(1);
}); });
it("warns and falls back to odds when there are no standings rows", async () => { it("ranks the whole field, not just the drivers with standings rows", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); // The settled season still has to fill all eight placement columns. Only
// ranking the drivers who have a standings row leaves the trailing
// columns empty, and the residual normalization then dumps a full 1.0
// onto whichever driver happens to be first in the list.
await setRaceCounts(17, 0);
await useDrivers(Array.from({ length: 10 }, (_, i) => ({ id: `d${i + 1}` })));
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
// No driver may hold two placements at once.
for (const probs of byId.values()) {
const held = PROB_KEYS.filter((key) => probs[key] > 0.5);
expect(held.length).toBeLessThanOrEqual(1);
}
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("falls back to a points-ranked field when there are no standings rows", async () => {
await setRaceCounts(17, 0); await setRaceCounts(17, 0);
await setStandings([]); await setStandings([]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]); await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1"); const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("no races left but no standings rows")
);
// Still produces a usable distribution rather than all zeroes. // Still produces a usable distribution rather than all zeroes.
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 6); expect(total).toBeCloseTo(1.0, 6);
warnSpy.mockRestore();
}); });
}); });

View file

@ -9,15 +9,15 @@
* 1. Load participants + current championship points from DB * 1. Load participants + current championship points from DB
* 2. Count completed/remaining races (see `countSeasonRaces`) * 2. Count completed/remaining races (see `countSeasonRaces`)
* 3. Convert sourceOdds vig-removed probability weights * 3. Convert sourceOdds vig-removed probability weights
* 4. Three paths: * 4. Two paths:
* a. season complete (no races left, some run): standings are the answer * a. pre-season (no races run yet): pure weighted draws from odds
* b. pre-season (no races at all yet): pure weighted draws from odds * b. otherwise: simulate each remaining race, starting from real standings,
* c. in-season: simulate each remaining race, starting from real standings, * awarding series-specific points per finish. With zero races left this
* awarding series-specific points per finish * awards nothing and simply ranks the final standings.
* 5. Convert finish counts probability distributions + normalize columns * 5. Convert finish counts probability distributions + normalize columns
* *
* Notes: * Notes:
* - Drivers without odds are floored at the bottom of the priced market * - Drivers without odds are priced at the longest price in the book
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path * - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/ */
@ -157,40 +157,32 @@ export class AutoRacingSimulator implements Simulator {
// 5. Build raw implied championship win probabilities from odds. // 5. Build raw implied championship win probabilities from odds.
// americanToImpliedProb includes vig (the field sums well over 1.0), so the // americanToImpliedProb includes vig (the field sums well over 1.0), so the
// priced field is devigged with a power transform rather than proportional // field is devigged with a power transform rather than proportional division
// division — see devigPower. Only drivers who actually have odds go into the // — see devigPower.
// devig: mixing in a 1/N placeholder for unpriced drivers would both inflate //
// the book sum (distorting the solved exponent) and rate an unpriced driver // Unpriced drivers are priced at the longest price in the book before the
// above most of the real longshots. // devig, not at 1/N: the market left them out because it did not rate them,
// and in a 27-car field 1/N (3.7%) rates them above most of the real
// longshots (+50000 is 0.2%). A book with a single price has no tail to
// anchor to, so that case keeps the 1/N fallback.
const fallbackProb = 1 / participants.length; const fallbackProb = 1 / participants.length;
const pricedIds: string[] = []; const pricedImplied = new Map<string, number>();
const pricedImplied: number[] = [];
for (const p of participants) { for (const p of participants) {
const odds = evMap.get(p.id)?.sourceOdds; const odds = evMap.get(p.id)?.sourceOdds;
if (odds !== null && odds !== undefined) { if (odds !== null && odds !== undefined) {
pricedIds.push(p.id); pricedImplied.set(p.id, americanToImpliedProb(odds));
pricedImplied.push(americanToImpliedProb(odds));
} }
} }
const rawProbs = new Map<string, number>(); const unpricedImplied =
if (pricedIds.length === 0) { pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
for (const p of participants) rawProbs.set(p.id, fallbackProb); const devigged = devigPower(
} else { participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
const devigged = devigPower(pricedImplied); );
pricedIds.forEach((id, i) => rawProbs.set(id, devigged[i])); const rawProbs = new Map<string, number>(
participants.map((p, i) => [p.id, devigged[i]])
// Unpriced drivers sit at the bottom of the market, then renormalize. );
const marketFloor = Math.min(...devigged);
for (const p of participants) {
if (!rawProbs.has(p.id)) rawProbs.set(p.id, marketFloor);
}
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawProbs) {
rawProbs.set(id, prob / rawSum);
}
}
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0) // 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>(); const baseProbs = new Map<string, number>();
@ -210,30 +202,12 @@ export class AutoRacingSimulator implements Simulator {
rankCounts.set(id, Array.from({ length: 8 }, () => 0)); rankCounts.set(id, Array.from({ length: 8 }, () => 0));
} }
// Every race has run and at least one result is in: the championship is // Pre-season only: no races run *and* none left, so there are no standings
// decided, so the standings *are* the answer — there is nothing to simulate. // to build on and the odds are all there is. When races have already been
// getSeasonResults already sorts by currentPosition (nulls last), then points // run the in-season path below handles it — with zero races left it awards
// descending. // no points, so it just ranks the current standings, which is exactly the
const settledOrder = // right answer for a finished season.
remainingRaces === 0 && completedRaces > 0 if (totalRaces === 0 || completedRaces === 0) {
? seasonResults.map((r) => r.participant.id).filter((id) => rankCounts.has(id))
: [];
if (settledOrder.length > 0) {
for (let rank = 0; rank < Math.min(8, settledOrder.length); rank++) {
const counts = rankCounts.get(settledOrder[rank]);
if (counts) counts[rank] = numSimulations;
}
} else if (remainingRaces === 0) {
// Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws.
if (completedRaces > 0) {
// eslint-disable-next-line no-console
console.warn(
`[AutoRacingSimulator] Season ${sportsSeasonId} has no races left but no standings rows — ` +
`falling back to futures odds instead of the final championship order.`
);
}
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb); const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights); const finishOrder = weightedDrawWithoutReplacement(ids, weights);