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:
parent
cf817cc9ff
commit
eefe407e7f
4 changed files with 86 additions and 38 deletions
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue