From cf817cc9ff0f1256c68a4ca0ea88865717707097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:42:31 +0000 Subject: [PATCH 1/3] Make the AFL simulator read the bracket that was actually drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS --- .../__tests__/probability-updater.test.ts | 165 ++++++++ app/services/probability-updater.ts | 122 ++++-- .../__tests__/afl-simulator.test.ts | 271 ++++++++++++- app/services/simulations/afl-simulator.ts | 373 ++++++++++++++---- app/services/simulations/manifest.ts | 16 +- 5 files changed, 831 insertions(+), 116 deletions(-) diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index 21faa64..8e5e4b1 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -8,6 +8,8 @@ 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: { @@ -18,6 +20,9 @@ vi.mock("~/database/context", () => ({ }), })); +// 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(); @@ -320,3 +325,163 @@ 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. + +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[]; + }) { + 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("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" }); + + await updateProbabilitiesAfterResult("season-1", true); + + expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); + expect(icmWrites().length).toBeGreaterThan(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" }); + + 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); + }); +}); diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index 51cf8d5..e8af9a5 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -21,6 +21,9 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; +import { getSportsSeasonSimulatorConfig } from "~/models/simulator"; +import { getManifestSimulatorProfile } from "~/services/simulations/manifest"; +import { logger } from "~/lib/logger"; /** * Result of probability update operation @@ -97,6 +100,36 @@ 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 + * simulator instead of by the ICM recalculation below. + * + * Two conditions, and both matter: + * + * - 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. + */ +async function shouldRerunSimulator( + sportsSeasonId: string, + unfinishedEVs: ParticipantEV[] +): Promise { + if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) return false; + + const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); + if (!simulatorConfig) return false; + + return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true; +} + /** * Update probabilities for a sports season after results come in * @@ -104,7 +137,8 @@ function createFinishedProbabilities(finalPosition: number): number[] { * 1. Get all participant results (finished participants) * 2. Get all existing participant EVs * 3. For finished participants: set 100% at their placement - * 4. For unfinished participants: recalculate using ICM with remaining participants + * 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one, + * otherwise recalculate using ICM with remaining participants * * @param sportsSeasonId Sports season to update * @param recalculateUnfinished Whether to recalculate unfinished participants (default true) @@ -138,39 +172,39 @@ export async function updateProbabilitiesAfterResult( .map(r => [r.participantId, r.finalPosition ?? 0]) ); - // Update finished participants. The shared default table is used because we only - // care about setting probabilities here, not the EV — each league re-derives its own - // EV from the stored probabilities in calculateTeamProjectedScore. - - // Sequential: upsertParticipantEV calls syncVorpForSeason internally, which - // reads and rewrites every seasonParticipants row for this sportsSeasonId. - // Running these in parallel would race on that shared state. - for (const [participantId, finalPosition] of finishedMap.entries()) { - try { - const probs = createFinishedProbabilities(finalPosition); - const probabilities = arrayToProbabilityDistribution(probs); - - await upsertParticipantEV({ - participantId, - sportsSeasonId, - probabilities, - scoringRules: DEFAULT_SCORING_RULES, - source: 'manual', // Result is from actual outcome - }); - - updated++; - } catch (error) { - errors.push(`Failed to update participant ${participantId}: ${error}`); - } - } - // Recalculate unfinished participants if requested if (recalculateUnfinished) { const unfinishedEVs = existingEVs.filter( ev => !finishedMap.has(ev.participantId) ); - if (unfinishedEVs.length > 0) { + if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId, unfinishedEVs))) { + // 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 + // floors a bracket entry or a non-scoring-round win has already banked, which the ICM + // branch below cannot see and would value below points the league has paid out. + // + // Imported lazily: probability-updater → runner → scoring-calculator → + // probability-updater is a module cycle, and a static import leaves the binding + // undefined at module-init time. + try { + const { runSportsSeasonSimulation } = await import("~/services/simulations/runner"); + await runSportsSeasonSimulation(sportsSeasonId); + updated += unfinishedEVs.length; + } catch (error) { + // runSportsSeasonSimulation throws on a completed season, on a run already in + // flight, and on failed readiness. Leave the existing probabilities alone rather + // than falling back to ICM: for these seasons ICM is the thing being replaced, and + // a completed season has nothing unfinished left to recalculate anyway. + logger.error( + `[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` + + `leaving existing probabilities in place:`, + error + ); + errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`); + } + } else if (unfinishedEVs.length > 0) { // Get their current championship probabilities (use existing P(1st) as proxy) const unfinishedOdds = unfinishedEVs.map(ev => { const pFirst = parseFloat(ev.probFirst); @@ -220,6 +254,38 @@ export async function updateProbabilitiesAfterResult( } } + // Update finished participants. The shared default table is used because we only + // care about setting probabilities here, not the EV — each league re-derives its own + // EV from the stored probabilities in calculateTeamProjectedScore. + // + // This runs *after* the recalculation above, not before, because re-running a simulator + // rewrites every participant in the season — the finalized ones included. A finalized + // placement is a fact, not a projection, so it is written last and wins: if a simulator + // ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has + // since been cleared and not re-seeded, say), the pin still zeroes them. + + // Sequential: upsertParticipantEV calls syncVorpForSeason internally, which + // reads and rewrites every seasonParticipants row for this sportsSeasonId. + // Running these in parallel would race on that shared state. + for (const [participantId, finalPosition] of finishedMap.entries()) { + try { + const probs = createFinishedProbabilities(finalPosition); + const probabilities = arrayToProbabilityDistribution(probs); + + await upsertParticipantEV({ + participantId, + sportsSeasonId, + probabilities, + scoringRules: DEFAULT_SCORING_RULES, + source: 'manual', // Result is from actual outcome + }); + + updated++; + } catch (error) { + errors.push(`Failed to update participant ${participantId}: ${error}`); + } + } + return { finishedParticipants: finishedMap.size, unfishedParticipants: existingEVs.length - finishedMap.size, diff --git a/app/services/simulations/__tests__/afl-simulator.test.ts b/app/services/simulations/__tests__/afl-simulator.test.ts index 56d7025..0b63307 100644 --- a/app/services/simulations/__tests__/afl-simulator.test.ts +++ b/app/services/simulations/__tests__/afl-simulator.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { normalizeTeamName } from "~/lib/normalize-team-name"; -import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator"; +import { + getTeamData, + eloWinProbability, + AFLSimulator, + readAflBracketSeeds, + type BracketMatch, +} from "../afl-simulator"; +import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; +import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator"; // ─── normalizeTeamName ──────────────────────────────────────────────────────── @@ -125,8 +133,82 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({ const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id); +/** + * Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in + * ladder order (index 0 = minor premier). `completed` overrides individual matches with a + * recorded result. + */ +function aflBracketMatches( + seedIds: string[], + completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = [] +): BracketMatch[] { + const seed = (n: number) => seedIds[n - 1] ?? null; + const rows: BracketMatch[] = [ + { round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) }, + { round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) }, + { round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) }, + { round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) }, + // participant2 is TBD until a Wildcard winner advances into it. + { round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null }, + { round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null }, + { round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null }, + { round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null }, + { round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null }, + { round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null }, + { round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null }, + ].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false })); + + for (const done of completed) { + const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber); + if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`); + row.isComplete = true; + row.winnerId = done.winnerId; + row.loserId = done.loserId; + // A Wildcard winner is advanced into the Elimination Final it feeds. + if (done.round === "Wildcard Round") { + const ef = rows.find( + (r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1) + ); + if (ef) ef.participant2Id = done.winnerId; + } + } + + return rows; +} + +/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */ +function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch { + const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber); + if (!found) throw new Error(`no such match: ${round} #${matchNumber}`); + return found; +} + +/** Look up one participant's result, failing loudly rather than silently passing on undefined. */ +function resultFor(results: T[], participantId: string): T { + const found = results.find((r) => r.participantId === participantId); + if (!found) throw new Error(`no simulation result for ${participantId}`); + return found; +} + +/** EV on the reference scale the runner persists with. */ +function evOf(result: { probabilities: ProbabilityDistribution }): number { + return calculateEV(result.probabilities, DEFAULT_SCORING_RULES); +} + describe("AFLSimulator.simulate()", () => { - let mockDb: { select: MockInstance }; + let mockDb: { + select: MockInstance; + query: { + scoringEvents: { findMany: MockInstance }; + playoffMatches: { findMany: MockInstance }; + }; + }; + + /** Put a seeded afl_10 bracket in front of the simulator. */ + function seedBracket(matches: BracketMatch[]) { + mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]); + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + } beforeEach(async () => { const { database } = await import("~/database/context"); @@ -136,6 +218,11 @@ describe("AFLSimulator.simulate()", () => { let selectCallCount = 0; mockDb = { + // Default: no bracket generated yet, so the ladder-projection path runs. + query: { + scoringEvents: { findMany: vi.fn().mockResolvedValue([]) }, + playoffMatches: { findMany: vi.fn().mockResolvedValue([]) }, + }, select: vi.fn().mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { @@ -355,4 +442,184 @@ describe("AFLSimulator.simulate()", () => { // Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst); }); + + // ─── Bracket-aware mode ───────────────────────────────────────────────────── + // + // afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and + // on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a + // Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that + // re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or + // out of the finals, where it scores 0 — reports an EV below points already awarded. Each + // EV assertion below is that floor. + + describe("bracket-aware mode", () => { + /** + * Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the + * strongest ten would let the ladder-projection path produce much the same field by + * accident, so the floor assertions below would pass even with the bracket ignored. + */ + const SEEDS = PARTICIPANT_IDS.slice(8); + + it("never values a seed below the entry floor its seeding already banked", async () => { + seedBracket(aflBracketMatches(SEEDS)); + const results = await new AFLSimulator().simulate("season-1"); + + // Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25). + for (const seed of [1, 2, 3, 4]) { + expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25); + } + // Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15). + for (const seed of [5, 6]) { + expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15); + } + }); + + it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => { + seedBracket(aflBracketMatches(SEEDS)); + const results = await new AFLSimulator().simulate("season-1"); + + // A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The + // 7th-8th tier is reachable only by losing an Elimination Final. + for (const seed of [1, 2, 3, 4]) { + expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0); + } + // Seeds 5-10 all reach an Elimination Final only by playing one, so they can. + expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0); + }); + + it("uses the bracket's draw rather than a re-projected ladder", async () => { + // Deliberately inverted: the weakest club is the minor premier and the strongest + // scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only + // holds if the bracket's own slots are being read. + const inverted = [ + "team-18", "team-17", "team-16", "team-15", "team-14", + "team-13", "team-12", "team-11", "team-10", "team-1", + ]; + seedBracket(aflBracketMatches(inverted)); + const results = await new AFLSimulator().simulate("season-1"); + + // West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never + // finish 7th-8th, and its EV clears the seed 1-4 floor. + expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0); + expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25); + + // Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round + // with nothing banked and can be knocked out for 0. + expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0); + }); + + it("zeroes every participant outside the bracket", async () => { + seedBracket(aflBracketMatches(SEEDS)); + const results = await new AFLSimulator().simulate("season-1"); + + for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) { + expect(evOf(r), r.participantId).toBe(0); + } + expect(results).toHaveLength(18); + }); + + it("still normalizes every column to 1.0 and the field to 340 total EV", async () => { + seedBracket(aflBracketMatches(SEEDS)); + const results = await new AFLSimulator().simulate("season-1"); + + const keys = [ + "probFirst", "probSecond", "probThird", "probFourth", + "probFifth", "probSixth", "probSeventh", "probEighth", + ] as const; + for (const key of keys) { + const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); + expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6); + } + expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4); + }); + + it("replays a completed Wildcard Round instead of re-simulating it", async () => { + // Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points). + seedBracket( + aflBracketMatches(SEEDS, [ + { round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] }, + ]) + ); + const results = await new AFLSimulator().simulate("season-1"); + + expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15); + // The loser is out with nothing, in every iteration. + expect(evOf(resultFor(results, SEEDS[6]))).toBe(0); + }); + + it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => { + // Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points) + // and the loser drops into a Semi-Final (floor 5th, 25 points). + seedBracket( + aflBracketMatches(SEEDS, [ + { round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] }, + ]) + ); + const results = await new AFLSimulator().simulate("season-1"); + + const winner = resultFor(results, SEEDS[0]); + expect(evOf(winner)).toBeGreaterThanOrEqual(45); + // Already through to a Preliminary Final, so the 5th-6th tier is behind it. + expect(winner.probabilities.probFifth).toBe(0); + + expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25); + }); + + it("falls back to the ladder projection when the bracket carries no seeds", async () => { + seedBracket(aflBracketMatches([])); + const results = await new AFLSimulator().simulate("season-1"); + + // Every club is back in contention, so nobody is structurally zeroed. + expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10); + }); + }); +}); + +// ─── readAflBracketSeeds ────────────────────────────────────────────────────── + +describe("readAflBracketSeeds", () => { + const teamsById = new Map( + PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }]) + ); + const SEEDS = PARTICIPANT_IDS.slice(0, 10); + + it("returns null when there is no bracket at all", () => { + expect(readAflBracketSeeds([], teamsById as never)).toBeNull(); + }); + + it("returns null for a generated but unseeded bracket", () => { + expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull(); + }); + + it("reads the 10 seeds in ladder order", () => { + const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never); + expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS); + }); + + it("does not treat the TBD Elimination Final slots as missing seeds", () => { + const matches = aflBracketMatches(SEEDS); + for (const m of matches.filter((r) => r.round === "Elimination Finals")) { + expect(m.participant2Id).toBeNull(); + } + expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull(); + }); + + it("throws on a partially seeded bracket rather than discarding the draw", () => { + const matches = aflBracketMatches(SEEDS); + // ON DELETE SET NULL empties a slot when a participant is removed and re-added. + matchIn(matches, "Qualifying Finals", 1).participant2Id = null; + expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s); + }); + + it("throws when one participant holds two slots", () => { + const matches = aflBracketMatches(SEEDS); + matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0]; + expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/); + }); + + it("throws when the bracket references a participant outside the season", () => { + const matches = aflBracketMatches(SEEDS); + matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost"; + expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/); + }); }); diff --git a/app/services/simulations/afl-simulator.ts b/app/services/simulations/afl-simulator.ts index 195d394..174e91a 100644 --- a/app/services/simulations/afl-simulator.ts +++ b/app/services/simulations/afl-simulator.ts @@ -3,18 +3,36 @@ * * Monte Carlo simulation of the AFL regular season and finals for 2026. * + * Two modes: + * 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is + * re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is + * modelled as still uncertain. + * 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed + * across every iteration, and games already played are replayed from their recorded + * result instead of being re-simulated. + * + * Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that + * awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a + * simulator that re-draws the ladder every iteration puts those teams back in the Wildcard + * Round — or out of the finals entirely — where they score 0, pulling EV below points the + * league has already paid out. Reading the real draw removes that by construction: a team + * seeded into an Elimination Final is in that game in 100% of iterations, so its worst + * outcome is the 7th-8th tier. + * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained) * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set. * 3. Load current regular season standings (wins, gamesPlayed) — if available - * 4. For each simulation: - * a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed) - * using Elo win probability vs. an average opponent (Elo 1500) - * → projectedPoints = currentWins*4 + simulatedRemainingWins*4 - * b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder - * → Top 10 advance to the AFL Finals Series - * c. Simulate AFL Finals Series (AFL_10 bracket): + * 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far + * 5. For each simulation: + * a. Pre-bracket mode only: for each team, simulate remaining regular season games + * (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent + * (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4 + * b. Pre-bracket mode only: sort all 18 teams by projected points desc + random + * tiebreaker → final ladder → top 10 advance to the AFL Finals Series. + * In bracket-aware mode the bracket's own 10 seeds are used as-is. + * c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match: * * Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts) * Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye) @@ -24,8 +42,8 @@ * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th) * Grand Final: PF1w vs PF2w → winner 1st, loser 2nd * - * 5. Track placement counts per scoring tier - * 6. Convert counts to probability distributions + * 6. Track placement counts per scoring tier + * 7. Convert counts to probability distributions * * Win probability (Elo, PARITY_FACTOR = 450): * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450)) @@ -53,7 +71,7 @@ * probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly) * probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly) * Wildcard losers → all 0 (score 0 points, same as 9th/10th) - * Missed finals → all 0 + * Missed finals → all 0 (in bracket-aware mode, every team outside the bracket) * * NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two * separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts @@ -62,7 +80,7 @@ */ import { database } from "~/database/context"; -import { eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { normalizeTeamName } from "~/lib/normalize-team-name"; @@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access"; const DEFAULT_NUM_SIMULATIONS = 10_000; +/** The bracket template the AFL finals are scored against. */ +const AFL_TEMPLATE_ID = "afl_10"; + /** * Elo parity factor for AFL single-game win probability. * 450 reflects moderate variance — lower than NHL (1000) to account for @@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number { return entry.currentWins + extra; } +/** The playoff_matches columns the simulator actually reads. */ +export type BracketMatch = Pick< + typeof schema.playoffMatches.$inferSelect, + "round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete" +>; + +interface LoadedBracket { + /** The 10 finalists in seed order — index 0 is the minor premier. */ + seeds: TeamEntry[]; + /** Every bracket match, keyed by `${round}#${matchNumber}`. */ + matches: Map; +} + +/** + * Plays one finals game. `round`/`matchNumber` identify it within the bracket so an + * already-played result can be looked up; `t1`/`t2` are the teams routed into it. + */ +type PlayGame = ( + round: string, + matchNumber: number, + t1: TeamEntry, + t2: TeamEntry +) => { winner: TeamEntry; loser: TeamEntry }; + +function matchKey(round: string, matchNumber: number): string { + return `${round}#${matchNumber}`; +} + +function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } { + return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor) + ? { winner: t1, loser: t2 } + : { winner: t2, loser: t1 }; +} + +/** + * Where generateAFL10Bracket (models/playoff-match.ts) writes each seed. + * + * The two Elimination Final participant2 slots are deliberately absent: they are TBD by + * design until a Wildcard winner advances into them, so they are never a missing seed. + * That leaves exactly 10 named slots for the 10 finalists. + */ +const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [ + { round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 }, + { round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 }, + { round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 }, + { round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 }, + { round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 }, + { round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 }, + { round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 }, + { round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 }, + { round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 }, + { round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 }, +]; + +/** + * Read the seeded afl_10 bracket for this season, if there is one. + * + * Returns null only when the bracket carries no draw at all — no matches, or a freshly + * generated bracket with every slot still empty — in which case the caller falls back to + * projecting the ladder. + * + * A *partially* seeded bracket is an error rather than a fallback. Falling back there would + * throw away the real draw and every recorded result with it, putting eliminated teams back + * in contention; and it is reachable in practice, because playoff_matches.participant1Id / + * participant2Id are ON DELETE SET NULL, so removing and re-adding one participant + * mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same + * reason. + */ +export function readAflBracketSeeds( + matches: BracketMatch[], + teamsById: Map +): LoadedBracket | null { + if (matches.length === 0) return null; + + const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m])); + + const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => { + const match = byKey.get(matchKey(round, matchNumber)); + if (!match) return null; + return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null; + }); + + const seededCount = drawn.filter((id) => id !== null).length; + + // Generated but not yet filled in — no draw to honor. + if (seededCount === 0) return null; + + if (seededCount < drawn.length) { + const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null) + .map((s) => s.seed) + .toSorted((a, b) => a - b) + .join(", "); + throw new Error( + `AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` + + `missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` + + `simulating around the gap would discard the draw and every recorded result.` + ); + } + + // Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each. + const seeds: TeamEntry[] = []; + const seen = new Set(); + + for (let i = 0; i < SEED_SLOTS.length; i++) { + const participantId = drawn[i] as string; + if (seen.has(participantId)) { + throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`); + } + seen.add(participantId); + + const team = teamsById.get(participantId); + if (!team) { + throw new Error( + `AFL bracket references participant ${participantId}, which is not in this sports season.` + ); + } + seeds[SEED_SLOTS[i].seed - 1] = team; + } + + return { seeds, matches: byKey }; +} + +/** + * The recorded loser of a completed match. loserId is written by the scoring flow, but fall + * back to "whichever slot isn't the winner" for older rows. + */ +function completedLoser(match: BracketMatch): string | null { + if (match.loserId) return match.loserId; + if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; + if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; + return null; +} + +/** + * Build the game-playing function for a bracket. + * + * When the bracket has a completed result for a game AND that result is between the two teams + * the simulation routed into it, the recorded winner is used verbatim — that is what makes an + * already-played result stick across all iterations, and what stops a banked floor from being + * re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or + * out-of-order row from desynchronising the rest of the bracket. + */ +export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame { + if (!bracket) { + return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor); + } + + return (round, matchNumber, t1, t2) => { + const match = bracket.matches.get(matchKey(round, matchNumber)); + if (match?.isComplete && match.winnerId) { + const loserId = completedLoser(match); + const arrived = [t1.id, t2.id]; + if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { + return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; + } + } + return simGame(t1, t2, parityFactor); + }; +} + +/** + * Simulate the AFL Finals Series from a seeded list of 10 teams. + * + * Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a + * recorded result is looked up against the game it was actually played in: + * SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner, + * PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner. + * + * Returns the placement for each team: + * "gf_winner" → 1st + * "gf_loser" → 2nd + * "pf_loser" → 3rd/4th (two teams per sim) + * "sf_loser" → 5th/6th (two teams per sim) + * "ef_loser" → 7th/8th (two teams per sim) + * "wc_loser" → 9th/10th (zero scoring points) + */ +export function simAFLFinals( + finalists: TeamEntry[], + play: PlayGame +): { + gfWinner: TeamEntry; + gfLoser: TeamEntry; + pfLosers: [TeamEntry, TeamEntry]; + sfLosers: [TeamEntry, TeamEntry]; + efLosers: [TeamEntry, TeamEntry]; +} { + const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists; + + // Wildcard Round: #7 vs #10, #8 vs #9 + const wc1 = play("Wildcard Round", 1, s7, s10); + const wc2 = play("Wildcard Round", 2, s8, s9); + + // Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF) + const qf1 = play("Qualifying Finals", 1, s1, s4); + const qf2 = play("Qualifying Finals", 2, s2, s3); + + // Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner + const ef1 = play("Elimination Finals", 1, s5, wc2.winner); + const ef2 = play("Elimination Finals", 2, s6, wc1.winner); + + // Semi-Finals: QF losers (second chance) vs EF winners + const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner); + const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner); + + // Preliminary Finals: QF winners vs SF winners + const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner); + const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner); + + // Grand Final + const gf = play("Grand Final", 1, pf1.winner, pf2.winner); + + return { + gfWinner: gf.winner, + gfLoser: gf.loser, + pfLosers: [pf1.loser, pf2.loser], + sfLosers: [sf1.loser, sf2.loser], + efLosers: [ef1.loser, ef2.loser], + }; +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class AFLSimulator implements Simulator { @@ -270,11 +511,34 @@ export class AFLSimulator implements Simulator { }; }); - // ─── Helpers (defined once, outside the hot loop) ───────────────────────── + const teamsById = new Map(teams.map((t) => [t.id, t])); - /** Simulate a single AFL game. Returns the winner. */ - const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => - Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b; + // 4. Load the real bracket (draw + results so far), if one has been generated. + // Events are filtered on bracketTemplateId rather than eventType and taken most + // recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own + // several events, and landing on a stale or template-less row would silently + // discard the real draw and every recorded result. createdAt can tie when a bracket + // is generated alongside a sibling event, so id breaks the tie. + const playoffEvents = await db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID) + ), + columns: { id: true }, + orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)], + }); + const bracketEvent = playoffEvents[0]; + + const bracketMatches = bracketEvent + ? await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + }) + : []; + + const bracket = readAflBracketSeeds(bracketMatches, teamsById); + const play = makePlayGame(bracket, parityFactor); + + // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** * Project end-of-season ladder and return the top 10 finalists seeded 1–10. @@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator { return projected.slice(0, 10).map((x) => x.team); }; - /** - * Simulate the AFL Finals Series from a seeded list of 10 teams. - * - * Returns the placement for each team: - * "gf_winner" → 1st - * "gf_loser" → 2nd - * "pf_loser" → 3rd/4th (two teams per sim) - * "sf_loser" → 5th/6th (two teams per sim) - * "ef_loser" → 7th/8th (two teams per sim) - * "wc_loser" → 9th/10th (zero scoring points) - */ - const simAFLFinals = ( - finalists: TeamEntry[] - ): { - gfWinner: TeamEntry; - gfLoser: TeamEntry; - pfLosers: [TeamEntry, TeamEntry]; - sfLosers: [TeamEntry, TeamEntry]; - efLosers: [TeamEntry, TeamEntry]; - } => { - const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists; - - // Wildcard Round: #7 vs #10, #8 vs #9 - const wc1Winner = simGame(s7, s10); - const wc2Winner = simGame(s8, s9); - - // Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF) - const qf1Winner = simGame(s1, s4); - const qf1Loser = qf1Winner === s1 ? s4 : s1; - const qf2Winner = simGame(s2, s3); - const qf2Loser = qf2Winner === s2 ? s3 : s2; - - // Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner - const ef1Winner = simGame(s5, wc2Winner); - const ef1Loser = ef1Winner === s5 ? wc2Winner : s5; - const ef2Winner = simGame(s6, wc1Winner); - const ef2Loser = ef2Winner === s6 ? wc1Winner : s6; - - // Semi-Finals: QF losers (2nd chance) vs EF winners - const sf1Winner = simGame(qf1Loser, ef2Winner); - const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser; - const sf2Winner = simGame(qf2Loser, ef1Winner); - const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser; - - // Preliminary Finals: QF winners vs SF winners - const pf1Winner = simGame(qf1Winner, sf2Winner); - const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner; - const pf2Winner = simGame(qf2Winner, sf1Winner); - const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner; - - // Grand Final - const gfWinner = simGame(pf1Winner, pf2Winner); - const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner; - - return { - gfWinner, - gfLoser, - pfLosers: [pf1Loser, pf2Loser ], - sfLosers: [sf1Loser, sf2Loser ], - efLosers: [ef1Loser, ef2Loser ], - }; - }; - - // 3. Integer placement count maps — initialized to 0 for all participants. + // 5. Integer placement count maps — initialized to 0 for all participants. // // AFL scoring uses the AFL_10 bracket template which splits 5–8 into two // separate pairs: Semi-Finals losers share 5th/6th (higher value), and @@ -368,10 +569,12 @@ export class AFLSimulator implements Simulator { const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map(participantIds.map((id) => [id, 0])); - // 4. Monte Carlo simulation loop. + // 6. Monte Carlo simulation loop. for (let s = 0; s < numSimulations; s++) { - const finalists = buildFinalsList(); - const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists); + // With a real bracket the draw is fixed and its played games are replayed from their + // recorded result; without one the ladder is re-projected every iteration. + const finalists = bracket ? bracket.seeds : buildFinalsList(); + const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play); championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1); finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1); @@ -388,7 +591,7 @@ export class AFLSimulator implements Simulator { // Wildcard losers and non-finalists are not counted (0 points per scoring rules). } - // 5. Convert integer counts to probability distributions. + // 7. Convert integer counts to probability distributions. // // Exact denominators guarantee column sums of 1.0 by construction: // probFirst/Second → / NUM_SIMULATIONS (1 per sim) @@ -421,8 +624,8 @@ export class AFLSimulator implements Simulator { }; }); - // 6. Per-position normalization — belt-and-suspenders guard against floating-point - // division residuals. Columns are already near-exactly 1.0 after step 5. + // 8. Per-position normalization — belt-and-suspenders guard against floating-point + // division residuals. Columns are already near-exactly 1.0 after step 7. const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 0a6f801..9e29aab 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -34,6 +34,16 @@ export interface SimulatorManifestProfile { derivableInputs?: Partial>; 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. + * + * 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. + */ + bracketAware?: boolean; } const BASE_CONFIG = { @@ -112,7 +122,10 @@ const PROFILES: Record Date: Sat, 29 Aug 2026 02:49:17 +0000 Subject: [PATCH 2/3] Route every bracket-aware sport away from ICM, not just AFL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS --- .../__tests__/probability-updater.test.ts | 30 +++++++++------- app/services/probability-updater.ts | 35 ++++++++----------- .../simulations/__tests__/manifest.test.ts | 27 ++++++++++++++ app/services/simulations/manifest.ts | 32 +++++++++++++---- 4 files changed, 86 insertions(+), 38 deletions(-) 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 Date: Sat, 29 Aug 2026 03:46:12 +0000 Subject: [PATCH 3/3] Stop the simulator re-run from trampling its caller's side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of this branch found four problems, all downstream of one decision: calling runSportsSeasonSimulation from inside the result path. That function does three jobs — recompute probabilities, recalculate standings, write the daily EV snapshot — and the result path wants only the first. 1. The Discord standings post was silently suppressed on every scored match, for all 13 bracket-aware sports. recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then diffing; changedTeamIds gates the notification. But processMatchResult runs updateProbabilitiesAfterResult first, which now reached the runner's own recalculateStandings. The new totals were therefore already written when the "before" snapshot was taken, the diff came back empty, and the post never fired. previousRank went the same way: recalculateStandings rolls it forward on every call, so the extra one erased rank movement. runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and the probability updater passes both. The snapshot is skipped because it is a per-day series keyed by snapshotDate — writing it per match result just overwrites the day's row with intra-day values. 2. finalizeQualifyingPoints marks the season completed immediately before calling the updater, and the runner rejects a completed season outright. With the ICM fallback gone that failed every time, stranding anyone still in the unfinished set on permanently stale probabilities — reachable for cs2_major_qualifying_points, the one bracket-aware qualifying-points sport. A completed season is not this branch's case rather than a failure: every placement is final and the floor it protects can no longer be contradicted. shouldRerunSimulator now excludes it and it falls through to ICM as before. The genuine failure modes still leave probabilities alone rather than falling back to the path being replaced. 3. match-sync calls processMatchResult in a per-match loop with no skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite, snapshot and standings recalc. processMatchResult gains skipProbabilities — mirroring the option processPlayoffEvent already takes, and narrower than skipSideEffects — which match-sync passes in the loop before refreshing once at the end. Per-match standings and Discord posts are unchanged. 4. A partially-seeded afl_10 bracket now throws from inside the result path. The throw is correct and stays; the concern was that it was silent, which the error surfacing in 2 covers. Two claims from the review did not hold up and were left alone: the batch bracket route already passes skipSideEffects per match and refreshes once after the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there is no double run per round completion. Tests: the runner honors both skip flags and still writes EVs; the updater asks for probabilities only; a completed season takes the ICM path without erroring; processMatchResult skips the refresh but still announces. The two behavioral ones were confirmed to fail against the previous behavior. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS --- .../__tests__/process-match-result.test.ts | 16 +++++ app/models/scoring-calculator.ts | 31 +++++--- .../__tests__/probability-updater.test.ts | 44 +++++++++++- app/services/match-sync/index.ts | 15 ++++ app/services/probability-updater.ts | 30 ++++++-- .../simulations/__tests__/runner.test.ts | 38 ++++++++++ app/services/simulations/runner.ts | 72 +++++++++++++------ 7 files changed, 209 insertions(+), 37 deletions(-) diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index 8967180..86b42d8 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -319,6 +319,22 @@ describe("processMatchResult", () => { expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true); }); + it("skips only the probability refresh when asked, still announcing", async () => { + // For a caller scoring several matches in a loop: the refresh is season-wide and, for a + // bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather + // than once per match. Standings and the announcement still happen per match. + const { db } = makeDb(); + + await processMatchResult( + { ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true }, + db + ); + + expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled(); + // recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports. + expect(db.query.seasonSports.findMany).toHaveBeenCalled(); + }); + it("does not throw even if probability update fails", async () => { (updateProbabilitiesAfterResult as ReturnType).mockRejectedValueOnce( new Error("network error") diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 431b467..f0a3302 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -547,6 +547,19 @@ export async function processMatchResult( /** When set, Discord notification only shows this match (not all completed matches for the event). */ matchId?: string; skipSideEffects?: boolean; + /** + * Skip only the probability refresh, still recalculating standings and announcing. + * + * For a caller scoring several matches in a loop: the refresh is season-wide and + * idempotent, so running it per match repeats the whole thing needlessly — and for a + * bracket-aware sport that now means a full Monte Carlo run each time. Set this in the + * loop and call updateProbabilitiesAfterResult once when it finishes. Per-match + * announcements then project from the previous probabilities until that final call. + * + * Distinct from skipSideEffects, which also suppresses the standings recalculation and + * the announcement. + */ + skipProbabilities?: boolean; /** * When true, the loser of this non-scoring round advances to another match * (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be @@ -557,7 +570,7 @@ export async function processMatchResult( providedDb?: ReturnType ): Promise { const db = providedDb || database(); - const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params; + const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params; if (!isScoring) { // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts), @@ -637,13 +650,15 @@ export async function processMatchResult( : undefined; // Update probabilities first so the standings recalc reads fresh EVs and // projected points reflect the new result. - try { - await updateProbabilitiesAfterResult(sportsSeasonId, true); - } catch (error) { - logger.error( - `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, - error - ); + if (!skipProbabilities) { + try { + await updateProbabilitiesAfterResult(sportsSeasonId, true); + } catch (error) { + logger.error( + `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, + error + ); + } } await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); } diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index e9639a5..c890ccf 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -9,6 +9,7 @@ import * as participantEVModel from "~/models/participant-expected-value"; vi.mock("~/models/participant-result"); vi.mock("~/models/participant-expected-value"); vi.mock("~/models/simulator"); +vi.mock("~/models/sports-season"); vi.mock("~/services/simulations/runner"); vi.mock("~/database/context", () => ({ database: () => ({ @@ -373,10 +374,17 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => { evSource: string; simulatorType: string | null; results?: ReturnType[]; + seasonStatus?: string; }) { const simulatorModel = await import("~/models/simulator"); + const sportsSeasonModel = await import("~/models/sports-season"); const runner = await import("~/services/simulations/runner"); + vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({ + id: "season-1", + status: opts.seasonStatus ?? "active", + } as never); + vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue( opts.results ?? [] ); @@ -407,11 +415,43 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => { const result = await updateProbabilitiesAfterResult("season-1", true); - expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1"); + expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything()); expect(icmWrites()).toHaveLength(0); expect(result.errors).toEqual([]); }); + it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => { + // recalculateAffectedLeagues detects change by diffing teamStandings across its own + // recalculation, and that diff gates the Discord standings post. A recalculation in here + // runs before it takes its "before" snapshot, so the diff comes back empty and the post is + // silently dropped — and previousRank gets rolled forward twice, erasing rank movement. + const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" }); + + await updateProbabilitiesAfterResult("season-1", true); + + expect(runSim).toHaveBeenCalledWith("season-1", { + skipStandingsRecalc: true, + skipSnapshots: true, + }); + }); + + it("falls through to ICM on a completed season rather than failing every time", async () => { + // finalizeQualifyingPoints marks the season completed immediately before calling here, and + // runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure + // would strand anyone still unfinished on stale probabilities forever. + const { runner } = await setup({ + evSource: "elo_simulation", + simulatorType: "cs2_major_qualifying_points", + seasonStatus: "completed", + }); + + const result = await updateProbabilitiesAfterResult("season-1", true); + + expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); + expect(icmWrites().length).toBeGreaterThan(0); + expect(result.errors).toEqual([]); + }); + it("still pins finished participants before re-running the simulator", async () => { const { runner } = await setup({ evSource: "elo_simulation", @@ -464,7 +504,7 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => { await updateProbabilitiesAfterResult("season-1", true); - expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1"); + expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything()); expect(icmWrites()).toHaveLength(0); }); diff --git a/app/services/match-sync/index.ts b/app/services/match-sync/index.ts index 891739d..a958f60 100644 --- a/app/services/match-sync/index.ts +++ b/app/services/match-sync/index.ts @@ -22,6 +22,7 @@ import { processQualifyingBracketEvent, recalculateAffectedLeagues, } from "~/models/scoring-calculator"; +import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results"; import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server"; import { @@ -283,6 +284,11 @@ export async function syncMatches(sportsSeasonId: string): Promise 0) { + try { + await updateProbabilitiesAfterResult(sportsSeasonId, true); + } catch (err) { + logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err); + } + } } return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors }; diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index a239e4d..b2caabb 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -22,6 +22,7 @@ import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import { getSportsSeasonSimulatorConfig } from "~/models/simulator"; +import { findSportsSeasonById } from "~/models/sports-season"; import { getManifestSimulatorProfile } from "~/services/simulations/manifest"; import { logger } from "~/lib/logger"; @@ -119,6 +120,15 @@ function createFinishedProbabilities(finalPosition: number): number[] { * simulator would re-draw the field and hand equity back to teams already knocked out. */ async function shouldRerunSimulator(sportsSeasonId: string): Promise { + // A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright — + // and finalizeQualifyingPoints marks the season completed immediately before calling here, + // so taking this branch there would fail every single time and leave anyone still in the + // unfinished set on permanently stale probabilities. It is not a failure, it is not this + // branch's case: the season is over, every placement is final, and the floor this branch + // exists to protect can no longer be contradicted. Fall through to ICM as before. + const sportsSeason = await findSportsSeasonById(sportsSeasonId); + if (sportsSeason?.status === "completed") return false; + const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); if (!simulatorConfig) return false; @@ -185,13 +195,23 @@ export async function updateProbabilitiesAfterResult( // undefined at module-init time. try { const { runSportsSeasonSimulation } = await import("~/services/simulations/runner"); - await runSportsSeasonSimulation(sportsSeasonId); + // Probabilities only. Our callers recalculate standings themselves right after this, + // and recalculateAffectedLeagues detects change by diffing teamStandings across its + // own recalculation — a recalculation slipped in here empties that diff and silently + // suppresses the Discord standings post, and rolls previousRank forward a second time + // so rank movement disappears. The daily EV snapshot is not ours to write either: it + // is keyed by date, so writing it per result overwrites the day with intra-day values. + await runSportsSeasonSimulation(sportsSeasonId, { + skipStandingsRecalc: true, + skipSnapshots: true, + }); updated += unfinishedEVs.length; } catch (error) { - // runSportsSeasonSimulation throws on a completed season, on a run already in - // flight, and on failed readiness. Leave the existing probabilities alone rather - // than falling back to ICM: for these seasons ICM is the thing being replaced, and - // a completed season has nothing unfinished left to recalculate anyway. + // A run already in flight, failed readiness, or a bracket the simulator refuses to + // read (afl_10 seeded into only some of its slots). Leave the existing probabilities + // alone rather than falling back to ICM — for these seasons ICM is precisely the + // thing being replaced, and reintroducing it here would reintroduce sub-floor EVs. + // Completed seasons never reach this: shouldRerunSimulator excludes them. logger.error( `[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` + `leaving existing probabilities in place:`, diff --git a/app/services/simulations/__tests__/runner.test.ts b/app/services/simulations/__tests__/runner.test.ts index 0c4f9b4..9d50a7d 100644 --- a/app/services/simulations/__tests__/runner.test.ts +++ b/app/services/simulations/__tests__/runner.test.ts @@ -50,6 +50,8 @@ import { import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; import { batchUpsertParticipantEVs } from "~/models/participant-expected-value"; import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot"; +import { recalculateStandings } from "~/models/scoring-calculator"; +import { database } from "~/database/context"; import { getSimulator } from "~/services/simulations/registry"; import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities"; @@ -126,6 +128,42 @@ describe("runSportsSeasonSimulation", () => { expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]); }); + /** The default mock has no linked leagues, so nothing to recalculate. Give it one. */ + function withLinkedLeague() { + vi.mocked(database).mockReturnValue({ + query: { + seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) }, + seasons: { findFirst: vi.fn() }, + }, + } as never); + } + + it("recalculates standings and writes the daily snapshot by default", async () => { + withLinkedLeague(); + + await runSportsSeasonSimulation("season-1"); + + expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1"); + expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled(); + }); + + it("skips standings and snapshots when the caller owns them", async () => { + withLinkedLeague(); + + // updateProbabilitiesAfterResult runs inside the result path, where the caller + // recalculates standings straight afterwards. A recalculation here lands before + // recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the + // Discord standings post and rolling previousRank forward twice. EVs are still written. + await runSportsSeasonSimulation("season-1", { + skipStandingsRecalc: true, + skipSnapshots: true, + }); + + expect(recalculateStandings).not.toHaveBeenCalled(); + expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled(); + expect(batchUpsertParticipantEVs).toHaveBeenCalled(); + }); + it("throws when the sports season is not found", async () => { vi.mocked(findSportsSeasonById).mockResolvedValue(undefined); diff --git a/app/services/simulations/runner.ts b/app/services/simulations/runner.ts index 3c4fa45..cc41968 100644 --- a/app/services/simulations/runner.ts +++ b/app/services/simulations/runner.ts @@ -62,6 +62,29 @@ async function getPersistenceContext( }; } +/** + * Side effects a caller can opt out of. + * + * A simulation run does three jobs — recompute probabilities, recalculate standings, and record + * the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside + * the result path, where the caller recalculates standings itself immediately afterwards. + * + * Letting the run recalculate there is not merely redundant, it is wrong. + * recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then + * diffing, and that diff gates the Discord standings post; a recalculation slipped in + * beforehand makes the diff empty and silently suppresses the notification. recalculateStandings + * also rolls previousRank forward on every call, so an extra one erases rank movement. + */ +export interface RunSportsSeasonSimulationOptions { + /** Leave standings to the caller. */ + skipStandingsRecalc?: boolean; + /** + * Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so + * writing it on every match result just overwrites the day's row with intra-day values. + */ + skipSnapshots?: boolean; +} + export interface RunSportsSeasonSimulationResult { sportsSeasonId: string; simulatorType: SimulatorType; @@ -71,7 +94,8 @@ export interface RunSportsSeasonSimulationResult { } export async function runSportsSeasonSimulation( - sportsSeasonId: string + sportsSeasonId: string, + options: RunSportsSeasonSimulationOptions = {} ): Promise { const sportsSeason = await findSportsSeasonById(sportsSeasonId); if (!sportsSeason) { @@ -135,29 +159,33 @@ export async function runSportsSeasonSimulation( })), ]); - const seasonSports = await database().query.seasonSports.findMany({ - where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), - }); - await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId))); + if (!options.skipStandingsRecalc) { + const seasonSports = await database().query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), + }); + await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId))); + } const snapshotDate = new Date().toISOString().slice(0, 10); - await batchUpsertParticipantEvSnapshots( - results.map((r) => ({ - participantId: r.participantId, - sportsSeasonId, - snapshotDate, - probFirst: r.probabilities.probFirst, - probSecond: r.probabilities.probSecond, - probThird: r.probabilities.probThird, - probFourth: r.probabilities.probFourth, - probFifth: r.probabilities.probFifth, - probSixth: r.probabilities.probSixth, - probSeventh: r.probabilities.probSeventh, - probEighth: r.probabilities.probEighth, - calculatedEV: calculateEV(r.probabilities, persistence.scoringRules), - source: r.source, - })) - ); + if (!options.skipSnapshots) { + await batchUpsertParticipantEvSnapshots( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + snapshotDate, + probFirst: r.probabilities.probFirst, + probSecond: r.probabilities.probSecond, + probThird: r.probabilities.probThird, + probFourth: r.probabilities.probFourth, + probFifth: r.probabilities.probFifth, + probSixth: r.probabilities.probSixth, + probSeventh: r.probabilities.probSeventh, + probEighth: r.probabilities.probEighth, + calculatedEV: calculateEV(r.probabilities, persistence.scoringRules), + source: r.source, + })) + ); + } await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });