brackt/app/services/__tests__/probability-updater.test.ts
Claude eefe407e7f
Route every bracket-aware sport away from ICM, not just AFL
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
2026-08-29 02:49:17 +00:00

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);
});
});