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 ────────────────────────────────────────── // ─── Bracket-aware simulator seasons ──────────────────────────────────────────
// //
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about // 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 // who is playing whom or what has already been decided, so it cannot see the placement floors
// win has already banked — it will happily value a team below points the league has paid out. // an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
// For a season whose EVs came from a simulator that reads the bracket, re-running that // team below points the league has paid out. Whenever the season has a simulator that reads
// simulator is the correct refresh; for every other season ICM stays exactly as it was. // 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) => ({ const evRow = (participantId: string, source: string) => ({
id: `ev-${participantId}`, id: `ev-${participantId}`,
@ -456,19 +457,24 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
expect(result.errors[0]).toMatch(/Failed to re-run simulator/); expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
}); });
it("keeps the ICM path for a bracket-blind simulator", async () => { it("re-runs the simulator whatever wrote the EVs originally", async () => {
// Re-running one of these would re-draw the field and hand equity back to teams already // The alternative is not leaving them alone — ICM would overwrite them either way — so
// knocked out, so nothing changes for them. // futures-odds EVs are no reason to prefer the bracket-blind overwrite.
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "nba_bracket" }); const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
await updateProbabilitiesAfterResult("season-1", true); await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
expect(icmWrites().length).toBeGreaterThan(0); expect(icmWrites()).toHaveLength(0);
}); });
it("keeps the ICM path when the EVs did not come from the simulator", async () => { it("keeps the ICM path for a bracket-blind simulator", async () => {
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" }); // 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); await updateProbabilitiesAfterResult("season-1", true);

View file

@ -100,30 +100,25 @@ function createFinishedProbabilities(finalPosition: number): number[] {
return probs; 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. * 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 * Where the EVs originally came from is not consulted, because the alternative here is not
* bracket-blind simulator after a result would re-draw the field and hand championship * leaving them alone the ICM branch overwrites them either way. Given the choice between
* equity back to teams that have already been knocked out strictly worse than ICM. * two overwrites, the bracket-aware one wins.
* 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 * The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
* or imported them from futures odds, overwriting them with a simulation is not a refresh. * simulator would re-draw the field and hand equity back to teams already knocked out.
* The finished-participant loop below rewrites rows to `manual`, so only the unfinished
* rows the ones about to be recalculated are consulted.
*/ */
async function shouldRerunSimulator( async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
sportsSeasonId: string,
unfinishedEVs: ParticipantEV[]
): Promise<boolean> {
if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) return false;
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!simulatorConfig) return false; if (!simulatorConfig) return false;
@ -178,7 +173,7 @@ export async function updateProbabilitiesAfterResult(
ev => !finishedMap.has(ev.participantId) 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 // 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 // real draw and replays every completed match. Re-running it keeps each participant's
// distribution consistent with the games actually played — including the placement // 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", () => { it("only derives inputs from declared optional inputs", () => {
for (const simulatorType of SIMULATOR_TYPES) { for (const simulatorType of SIMULATOR_TYPES) {
const profile = SIMULATOR_MANIFEST[simulatorType]; const profile = SIMULATOR_MANIFEST[simulatorType];

View file

@ -35,13 +35,22 @@ export interface SimulatorManifestProfile {
setupSections: SimulatorSetupSection[]; setupSections: SimulatorSetupSection[];
minParticipantInputs?: number; minParticipantInputs?: number;
/** /**
* The simulator reads the season's generated bracket: it uses the real draw as its seeding * The simulator reads the season's generated bracket: it seeds from the real draw and
* and replays completed matches from their recorded result, instead of re-drawing the field * replays completed matches from their recorded result, rather than re-drawing the field
* every iteration. * 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 * updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
* knocked-out teams back in contention. updateProbabilitiesAfterResult reads this to decide * by re-running the simulator or by the generic ICM recalculation. Re-running is both more
* whether to re-run the simulator or fall back to the generic ICM recalculation. * 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; bracketAware?: boolean;
} }
@ -81,6 +90,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds"], optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
}, },
ncaam_bracket: { ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } }, 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"], optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
ncaaw_bracket: { ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } }, 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"], optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
nba_bracket: { nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
@ -102,6 +114,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
nhl_bracket: { nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
@ -109,6 +122,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
nfl_bracket: { nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
@ -148,6 +162,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"], optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"], setupSections: ["participants", "eloRatings", "rankings", "bracket"],
bracketAware: true,
}, },
tennis_qualifying_points: { tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 }, defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
@ -175,18 +190,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"], setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
bracketAware: true,
}, },
darts_bracket: { darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 }, defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"], requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"], optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"], setupSections: ["participants", "eloRatings", "rankings"],
bracketAware: true,
}, },
cs2_major_qualifying_points: { cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 }, defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"], optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"], setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
bracketAware: true,
}, },
ncaa_football_bracket: { ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } }, 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"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"], setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
brackt: { brackt: {
defaultConfig: { iterations: 20_000 }, defaultConfig: { iterations: 20_000 },
@ -238,6 +257,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["projectedWins", "sourceOdds", "seed"], optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
mls_bracket: { mls_bracket: {
defaultConfig: { defaultConfig: {