diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index 8e5e4b1..e9639a5 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -329,10 +329,11 @@ describe("probability-updater", () => { // ─── Bracket-aware simulator seasons ────────────────────────────────────────── // // The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about -// the bracket, 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. -// For a season whose EVs came from a simulator that reads the bracket, re-running that -// simulator is the correct refresh; for every other season ICM stays exactly as it was. +// 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}`, @@ -456,19 +457,24 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => { expect(result.errors[0]).toMatch(/Failed to re-run simulator/); }); - it("keeps the ICM path for a bracket-blind simulator", async () => { - // Re-running one of these would re-draw the field and hand equity back to teams already - // knocked out, so nothing changes for them. - const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "nba_bracket" }); + 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).not.toHaveBeenCalled(); - expect(icmWrites().length).toBeGreaterThan(0); + expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1"); + expect(icmWrites()).toHaveLength(0); }); - it("keeps the ICM path when the EVs did not come from the simulator", async () => { - const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" }); + 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); diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index e8af9a5..a239e4d 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -100,30 +100,25 @@ function createFinishedProbabilities(finalPosition: number): number[] { return probs; } -/** EV sources written by a simulation run rather than by odds import or manual entry. */ -const SIMULATOR_EV_SOURCES = new Set(["elo_simulation", "performance_model"]); - /** - * Decide whether a season's still-alive participants should be refreshed by re-running its + * Whether this season's still-alive participants should be refreshed by re-running its * simulator instead of by the ICM recalculation below. * - * Two conditions, and both matter: + * If the season has a simulator that reads its bracket, that simulator is simply a better + * answer than ICM to "what happens from here": it seeds from the real draw and replays every + * completed match, where ICM re-derives a whole distribution from P(1st) alone and knows + * nothing about who is playing whom or what has already been decided. That blindness is what + * makes ICM report a placement floor the league has already paid out as worth less than its + * awarded points. * - * - The simulator must be bracket-aware (manifest `bracketAware`). Re-running a - * bracket-blind simulator after a result would re-draw the field and hand championship - * equity back to teams that have already been knocked out — strictly worse than ICM. - * Only AFL and LLWS read the real draw and replay completed matches. - * - The EVs must actually have come from that simulator. If an admin entered them by hand - * or imported them from futures odds, overwriting them with a simulation is not a refresh. - * The finished-participant loop below rewrites rows to `manual`, so only the unfinished - * rows — the ones about to be recalculated — are consulted. + * Where the EVs originally came from is not consulted, because the alternative here is not + * leaving them alone — the ICM branch overwrites them either way. Given the choice between + * two overwrites, the bracket-aware one wins. + * + * The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind + * simulator would re-draw the field and hand equity back to teams already knocked out. */ -async function shouldRerunSimulator( - sportsSeasonId: string, - unfinishedEVs: ParticipantEV[] -): Promise { - if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) return false; - +async function shouldRerunSimulator(sportsSeasonId: string): Promise { const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); if (!simulatorConfig) return false; @@ -178,7 +173,7 @@ export async function updateProbabilitiesAfterResult( ev => !finishedMap.has(ev.participantId) ); - if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId, unfinishedEVs))) { + if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) { // The simulator reads the bracket, so it already knows this result: it seeds from the // real draw and replays every completed match. Re-running it keeps each participant's // distribution consistent with the games actually played — including the placement diff --git a/app/services/simulations/__tests__/manifest.test.ts b/app/services/simulations/__tests__/manifest.test.ts index be36fa7..762a549 100644 --- a/app/services/simulations/__tests__/manifest.test.ts +++ b/app/services/simulations/__tests__/manifest.test.ts @@ -30,6 +30,33 @@ describe("simulator manifest", () => { } }); + // updateProbabilitiesAfterResult sends a season down the re-run path or the ICM path purely + // on this flag, and getting it wrong is silent in both directions: set it on a simulator + // that re-plays decided games and eliminated teams come back to life; leave it off a + // bracket-aware one and ICM keeps reporting placement floors as worth less than the points + // already awarded. Pinning the set makes a new simulator an explicit decision rather than a + // default. To add one, confirm it reads playoff_matches AND honors isComplete/winnerId. + it("pins which simulators are bracket-aware", () => { + const bracketAware = SIMULATOR_TYPES.filter((t) => SIMULATOR_MANIFEST[t].bracketAware); + expect(bracketAware.toSorted()).toEqual( + [ + "afl_bracket", + "college_hockey_bracket", + "cs2_major_qualifying_points", + "darts_bracket", + "llws_bracket", + "nba_bracket", + "ncaam_bracket", + "ncaaw_bracket", + "nhl_bracket", + "nll_bracket", + "snooker_bracket", + "ucl_bracket", + "world_cup", + ].toSorted() + ); + }); + it("only derives inputs from declared optional inputs", () => { for (const simulatorType of SIMULATOR_TYPES) { const profile = SIMULATOR_MANIFEST[simulatorType]; diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 9e29aab..b035804 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -35,13 +35,22 @@ export interface SimulatorManifestProfile { setupSections: SimulatorSetupSection[]; minParticipantInputs?: number; /** - * The simulator reads the season's generated bracket: it uses the real draw as its seeding - * and replays completed matches from their recorded result, instead of re-drawing the field - * every iteration. + * The simulator reads the season's generated bracket: it seeds from the real draw and + * replays completed matches from their recorded result, rather than re-drawing the field + * and re-playing decided games every iteration. * - * Only such a simulator can be safely re-run once results are in — a bracket-blind one puts - * knocked-out teams back in contention. updateProbabilitiesAfterResult reads this to decide - * whether to re-run the simulator or fall back to the generic ICM recalculation. + * updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed + * by re-running the simulator or by the generic ICM recalculation. Re-running is both more + * accurate and the only option that respects a banked placement floor, but it is only safe + * here: re-running a bracket-blind simulator would re-draw the field and hand equity back + * to teams already knocked out. + * + * Both halves are required. A simulator that reads the draw but re-simulates games already + * played is NOT bracket-aware for this purpose — it resurrects eliminated teams just the + * same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator. + * + * This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin + * links and a readiness warning and does not track this accurately in either direction. */ bracketAware?: boolean; } @@ -81,6 +90,7 @@ const PROFILES: Record