From 95f895a715aac04512d972d7c81ea27a4ac203dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:50:30 +0000 Subject: [PATCH] Fix three defects found reviewing the auto racing sim change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt --- app/models/__tests__/season-races.test.ts | 32 ++++++- app/models/season-races.ts | 15 +++- .../__tests__/auto-racing-simulator.test.ts | 75 ++++++++++++---- .../simulations/auto-racing-simulator.ts | 86 +++++++------------ 4 files changed, 129 insertions(+), 79 deletions(-) diff --git a/app/models/__tests__/season-races.test.ts b/app/models/__tests__/season-races.test.ts index ef71810..4274c10 100644 --- a/app/models/__tests__/season-races.test.ts +++ b/app/models/__tests__/season-races.test.ts @@ -52,13 +52,13 @@ describe("hasRaceRun", () => { }); 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( hasRaceRun( { isComplete: false, - eventDate: TODAY, - eventStartsAt: new Date("2026-08-17T09:00:00.000Z"), + eventDate: null, + eventStartsAt: new Date("2026-08-16T18:00:00.000Z"), }, NOW, TODAY @@ -78,6 +78,29 @@ describe("hasRaceRun", () => { ).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", () => { expect( hasRaceRun( @@ -154,8 +177,9 @@ describe("countSeasonRaces", () => { ...Array.from({ length: 15 }, (_, i) => 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-09-13T18:00:00.000Z") }), makeEvent({ eventType: "final_standings" }), ]; await mockEvents(calendar); diff --git a/app/models/season-races.ts b/app/models/season-races.ts index 8d15c5c..4493aa8 100644 --- a/app/models/season-races.ts +++ b/app/models/season-races.ts @@ -17,6 +17,17 @@ export interface SeasonRaceCounts { 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? * @@ -39,7 +50,9 @@ export function hasRaceRun( today: string ): boolean { 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; return false; } diff --git a/app/services/simulations/__tests__/auto-racing-simulator.test.ts b/app/services/simulations/__tests__/auto-racing-simulator.test.ts index b818bbb..ff7f5ba 100644 --- a/app/services/simulations/__tests__/auto-racing-simulator.test.ts +++ b/app/services/simulations/__tests__/auto-racing-simulator.test.ts @@ -136,9 +136,9 @@ describe("AutoRacingSimulator", () => { } }); - it("floors an unpriced driver at the bottom of the priced market", async () => { - // d5 has no odds at all; d2–d4 are +1000 long shots. Before power devig - // an unpriced driver was handed 1/N, which rated them above the field. + it("prices an unpriced driver at the longest price in the book", async () => { + // d5 has no odds; d2–d4 are +1000 long shots. An unpriced driver used to + // be handed 1/N, which rated them above most of the priced field. await setOdds([ makeEv("d1", -500), makeEv("d2", 1000), @@ -146,14 +146,24 @@ describe("AutoRacingSimulator", () => { 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 - ); + const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst])); + const unpriced = byId.get("d5") ?? 0; + const longShot = byId.get("d2") ?? 0; + expect(unpriced).toBeCloseTo(longShot, 1); + expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3); + }); + + 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); }); - it("warns and falls back to odds when there are no standings rows", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("ranks the whole field, not just the drivers with standings rows", async () => { + // 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 setStandings([]); 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. const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 6); - warnSpy.mockRestore(); }); }); diff --git a/app/services/simulations/auto-racing-simulator.ts b/app/services/simulations/auto-racing-simulator.ts index f9ff560..c0b1988 100644 --- a/app/services/simulations/auto-racing-simulator.ts +++ b/app/services/simulations/auto-racing-simulator.ts @@ -9,15 +9,15 @@ * 1. Load participants + current championship points from DB * 2. Count completed/remaining races (see `countSeasonRaces`) * 3. Convert sourceOdds → vig-removed probability weights - * 4. Three paths: - * a. season complete (no races left, some run): standings are the answer - * b. pre-season (no races at all yet): pure weighted draws from odds - * c. in-season: simulate each remaining race, starting from real standings, - * awarding series-specific points per finish + * 4. Two paths: + * a. pre-season (no races run yet): pure weighted draws from odds + * b. otherwise: simulate each remaining race, starting from real standings, + * awarding series-specific points per finish. With zero races left this + * awards nothing and simply ranks the final standings. * 5. Convert finish counts → probability distributions + normalize columns * * 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 */ @@ -157,40 +157,32 @@ export class AutoRacingSimulator implements Simulator { // 5. Build raw implied championship win probabilities from odds. // americanToImpliedProb includes vig (the field sums well over 1.0), so the - // priced field is devigged with a power transform rather than proportional - // division — see devigPower. Only drivers who actually have odds go into the - // 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 - // above most of the real longshots. + // field is devigged with a power transform rather than proportional division + // — see devigPower. + // + // Unpriced drivers are priced at the longest price in the book before the + // 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 pricedIds: string[] = []; - const pricedImplied: number[] = []; + const pricedImplied = new Map(); for (const p of participants) { const odds = evMap.get(p.id)?.sourceOdds; if (odds !== null && odds !== undefined) { - pricedIds.push(p.id); - pricedImplied.push(americanToImpliedProb(odds)); + pricedImplied.set(p.id, americanToImpliedProb(odds)); } } - const rawProbs = new Map(); - if (pricedIds.length === 0) { - for (const p of participants) rawProbs.set(p.id, fallbackProb); - } else { - const devigged = devigPower(pricedImplied); - pricedIds.forEach((id, i) => rawProbs.set(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); - } - } + const unpricedImplied = + pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb; + const devigged = devigPower( + participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied) + ); + const rawProbs = new Map( + participants.map((p, i) => [p.id, devigged[i]]) + ); // 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0) const baseProbs = new Map(); @@ -210,30 +202,12 @@ export class AutoRacingSimulator implements Simulator { rankCounts.set(id, Array.from({ length: 8 }, () => 0)); } - // Every race has run and at least one result is in: the championship is - // decided, so the standings *are* the answer — there is nothing to simulate. - // getSeasonResults already sorts by currentPosition (nulls last), then points - // descending. - const settledOrder = - remainingRaces === 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.` - ); - } + // Pre-season only: no races run *and* none left, so there are no standings + // to build on and the odds are all there is. When races have already been + // run the in-season path below handles it — with zero races left it awards + // no points, so it just ranks the current standings, which is exactly the + // right answer for a finished season. + if (totalRaces === 0 || completedRaces === 0) { const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb); for (let sim = 0; sim < numSimulations; sim++) { const finishOrder = weightedDrawWithoutReplacement(ids, weights);