The previous commit gated the simulator re-run on a `bracketAware` flag set only on afl_bracket and llws_bracket, on the claim that every other simulator was bracket-blind and would resurrect eliminated teams if re-run. That claim was wrong. Eleven more read playoff_matches and honor isComplete/winnerId already: ucl, ncaam, ncaaw (both via ncaa-basketball), nba, nhl, snooker, world_cup, darts, cs2_major, college_hockey and nll. All thirteen are now flagged, so any sport with a bracket the simulator can read absorbs a result by re-running that simulator rather than through ICM. Two simulators are deliberately left off. playoff_bracket and ncaa_football_bracket declare a "bracket" setup section but never read playoff_matches, so re-running them really would re-draw the field. That mismatch runs the other way too — world_cup, darts_bracket and cs2_major_qualifying_points read the bracket without declaring the section — so setupSections is not a usable signal here and the flag stays separate from it, with both facts written down on the flag. The EV-source condition is also gone. It only asked whether the existing EVs came from a simulation, which protected nothing: the alternative to re-running was never leaving them alone, it was the ICM branch overwriting them anyway. Given two overwrites, the bracket-aware one wins regardless of what wrote them. Tests: a bracket-blind simulator still goes through ICM, futures-odds EVs no longer divert a bracket-aware season away from the re-run, and the bracket-aware set is pinned in the manifest test so a new simulator is an explicit decision rather than a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import {
|
|
updateProbabilitiesAfterResult,
|
|
} from "../probability-updater";
|
|
import * as participantResultModel from "~/models/participant-result";
|
|
import * as participantEVModel from "~/models/participant-expected-value";
|
|
|
|
// Mock the dependencies
|
|
vi.mock("~/models/participant-result");
|
|
vi.mock("~/models/participant-expected-value");
|
|
vi.mock("~/models/simulator");
|
|
vi.mock("~/services/simulations/runner");
|
|
vi.mock("~/database/context", () => ({
|
|
database: () => ({
|
|
query: {
|
|
participantExpectedValues: {
|
|
findMany: vi.fn(),
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
|
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
|
|
|
describe("probability-updater", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("updateProbabilitiesAfterResult", () => {
|
|
it("should set 100% probability for finished participants at their placement", async () => {
|
|
// Mock data: One participant finished 1st
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 1,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
{
|
|
id: "ev-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "0.1500",
|
|
probSecond: "0.1300",
|
|
probThird: "0.1200",
|
|
probFourth: "0.1100",
|
|
probFifth: "0.1000",
|
|
probSixth: "0.0900",
|
|
probSeventh: "0.0800",
|
|
probEighth: "0.0700",
|
|
expectedValue: "50.00",
|
|
source: "futures_odds",
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({
|
|
id: "ev-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "1.0000",
|
|
probSecond: "0.0000",
|
|
probThird: "0.0000",
|
|
probFourth: "0.0000",
|
|
probFifth: "0.0000",
|
|
probSixth: "0.0000",
|
|
probSeventh: "0.0000",
|
|
probEighth: "0.0000",
|
|
expectedValue: "100.00",
|
|
source: "manual",
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
expect(result.updated).toBe(1);
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
// Verify upsert was called with correct probabilities
|
|
expect(upsertSpy).toHaveBeenCalled();
|
|
const callArgs = upsertSpy.mock.calls[0][0];
|
|
expect(callArgs.participantId).toBe("participant-1");
|
|
expect(callArgs.sportsSeasonId).toBe("season-1");
|
|
expect(callArgs.probabilities.probFirst).toBe(1);
|
|
expect(callArgs.probabilities.probSecond).toBe(0);
|
|
expect(callArgs.source).toBe("manual");
|
|
});
|
|
|
|
it("should handle multiple finished participants", async () => {
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 1,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
{
|
|
id: "result-2",
|
|
participantId: "participant-2",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 2,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
{
|
|
id: "ev-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "0.2000",
|
|
probSecond: "0.1500",
|
|
probThird: "0.1000",
|
|
probFourth: "0.0800",
|
|
probFifth: "0.0700",
|
|
probSixth: "0.0600",
|
|
probSeventh: "0.0500",
|
|
probEighth: "0.0400",
|
|
expectedValue: "60.00",
|
|
source: "futures_odds",
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: "ev-2",
|
|
participantId: "participant-2",
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "0.1500",
|
|
probSecond: "0.1500",
|
|
probThird: "0.1200",
|
|
probFourth: "0.1000",
|
|
probFifth: "0.0900",
|
|
probSixth: "0.0800",
|
|
probSeventh: "0.0700",
|
|
probEighth: "0.0600",
|
|
expectedValue: "55.00",
|
|
source: "futures_odds",
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
expect(result.finishedParticipants).toBe(2);
|
|
expect(result.updated).toBe(2);
|
|
expect(result.errors).toHaveLength(0);
|
|
expect(upsertSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should handle empty results", async () => {
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([]);
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1");
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
expect(result.unfishedParticipants).toBe(0);
|
|
expect(result.updated).toBe(0);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("should handle results without final position", async () => {
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: null, // No position set yet
|
|
isPartialScore: false,
|
|
qualifyingPoints: "50.00",
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1");
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it("should set all probabilities to 0% for eliminated participants (finalPosition = 0)", async () => {
|
|
// Mock: Participant eliminated (didn't make playoffs)
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 0, // Eliminated
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
{
|
|
id: "ev-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "0.0500",
|
|
probSecond: "0.0800",
|
|
probThird: "0.1200",
|
|
probFourth: "0.1500",
|
|
probFifth: "0.1600",
|
|
probSixth: "0.1500",
|
|
probSeventh: "0.1400",
|
|
probEighth: "0.1500",
|
|
expectedValue: "20.00",
|
|
source: "futures_odds",
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
expect(result.updated).toBe(1);
|
|
|
|
// Verify all probabilities set to 0
|
|
const callArgs = upsertSpy.mock.calls[0][0];
|
|
expect(callArgs.probabilities.probFirst).toBe(0);
|
|
expect(callArgs.probabilities.probSecond).toBe(0);
|
|
expect(callArgs.probabilities.probThird).toBe(0);
|
|
expect(callArgs.probabilities.probFourth).toBe(0);
|
|
expect(callArgs.probabilities.probFifth).toBe(0);
|
|
expect(callArgs.probabilities.probSixth).toBe(0);
|
|
expect(callArgs.probabilities.probSeventh).toBe(0);
|
|
expect(callArgs.probabilities.probEighth).toBe(0);
|
|
});
|
|
|
|
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
|
|
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
|
|
// to 100% at 5th would erase their championship odds before they have played.
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 5,
|
|
isPartialScore: true,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
] as never);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
expect(upsertSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
{
|
|
id: "result-1",
|
|
participantId: "participant-1",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 0,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
},
|
|
] as never);
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
|
//
|
|
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
|
// who is playing whom or what has already been decided, so it cannot see the placement floors
|
|
// an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
|
|
// team below points the league has paid out. Whenever the season has a simulator that reads
|
|
// its bracket, that simulator is the better answer and is re-run instead. Only a season whose
|
|
// simulator is bracket-blind (or has none) still goes through ICM.
|
|
|
|
const evRow = (participantId: string, source: string) => ({
|
|
id: `ev-${participantId}`,
|
|
participantId,
|
|
sportsSeasonId: "season-1",
|
|
probFirst: "0.1000",
|
|
probSecond: "0.1000",
|
|
probThird: "0.1000",
|
|
probFourth: "0.1000",
|
|
probFifth: "0.1000",
|
|
probSixth: "0.1000",
|
|
probSeventh: "0.1000",
|
|
probEighth: "0.1000",
|
|
expectedValue: "34.00",
|
|
source,
|
|
sourceOdds: null,
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const finishedResult = (participantId: string, finalPosition: number) => ({
|
|
id: `result-${participantId}`,
|
|
participantId,
|
|
sportsSeasonId: "season-1",
|
|
finalPosition,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
participant: null,
|
|
});
|
|
|
|
describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
|
/** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */
|
|
async function setup(opts: {
|
|
evSource: string;
|
|
simulatorType: string | null;
|
|
results?: ReturnType<typeof finishedResult>[];
|
|
}) {
|
|
const simulatorModel = await import("~/models/simulator");
|
|
const runner = await import("~/services/simulations/runner");
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
|
opts.results ?? []
|
|
);
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
evRow("alive-1", opts.evSource),
|
|
evRow("alive-2", opts.evSource),
|
|
] as never);
|
|
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
|
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
|
|
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
|
|
);
|
|
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
|
|
runSim.mockResolvedValue({} as never);
|
|
|
|
return { runner, runSim };
|
|
}
|
|
|
|
/** The ICM branch is the only thing that writes unfinished rows with this source. */
|
|
const icmWrites = () =>
|
|
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
|
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
|
expect(icmWrites()).toHaveLength(0);
|
|
expect(result.errors).toEqual([]);
|
|
});
|
|
|
|
it("still pins finished participants before re-running the simulator", async () => {
|
|
const { runner } = await setup({
|
|
evSource: "elo_simulation",
|
|
simulatorType: "afl_bracket",
|
|
results: [finishedResult("done-1", 2)],
|
|
});
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
|
|
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
|
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
|
|
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
|
|
// included. A finalized placement is a fact, not a projection, so it has to land last.
|
|
const { runSim } = await setup({
|
|
evSource: "elo_simulation",
|
|
simulatorType: "afl_bracket",
|
|
results: [finishedResult("done-1", 0)],
|
|
});
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
|
|
expect(pinIndex).toBeGreaterThanOrEqual(0);
|
|
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
|
|
runSim.mock.invocationCallOrder[0]
|
|
);
|
|
});
|
|
|
|
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
|
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
|
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
|
|
new Error("A simulation is already running for this sports season.")
|
|
);
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
expect(icmWrites()).toHaveLength(0);
|
|
expect(result.errors).toHaveLength(1);
|
|
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
|
|
});
|
|
|
|
it("re-runs the simulator whatever wrote the EVs originally", async () => {
|
|
// The alternative is not leaving them alone — ICM would overwrite them either way — so
|
|
// futures-odds EVs are no reason to prefer the bracket-blind overwrite.
|
|
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
|
expect(icmWrites()).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps the ICM path for a bracket-blind simulator", async () => {
|
|
// ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches,
|
|
// so re-running it would re-draw the field and hand equity back to eliminated teams.
|
|
const { runner } = await setup({
|
|
evSource: "elo_simulation",
|
|
simulatorType: "ncaa_football_bracket",
|
|
});
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
|
expect(icmWrites().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps the ICM path when the season has no simulator configured", async () => {
|
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null });
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
|
expect(icmWrites().length).toBeGreaterThan(0);
|
|
});
|
|
});
|