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
This commit is contained in:
Claude 2026-08-29 02:49:17 +00:00
parent cf817cc9ff
commit eefe407e7f
No known key found for this signature in database
4 changed files with 86 additions and 38 deletions

View file

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

View file

@ -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<boolean> {
if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) return false;
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
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

View file

@ -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];

View file

@ -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<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
@ -88,6 +98,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
@ -95,6 +106,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
@ -102,6 +114,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
@ -109,6 +122,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
@ -148,6 +162,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
bracketAware: true,
},
tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
@ -175,18 +190,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
bracketAware: true,
},
darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"],
bracketAware: true,
},
cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
bracketAware: true,
},
ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
@ -215,6 +233,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
bracketAware: true,
},
brackt: {
defaultConfig: { iterations: 20_000 },
@ -238,6 +257,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
mls_bracket: {
defaultConfig: {