2025-11-21 22:05:50 -08:00
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
|
import {
|
|
|
|
|
updateProbabilitiesAfterResult,
|
|
|
|
|
} from "../probability-updater";
|
|
|
|
|
import * as participantResultModel from "~/models/participant-result";
|
|
|
|
|
import * as participantEVModel from "~/models/participant-expected-value";
|
|
|
|
|
|
|
|
|
|
// Mock the dependencies
|
|
|
|
|
vi.mock("~/models/participant-result");
|
|
|
|
|
vi.mock("~/models/participant-expected-value");
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
vi.mock("~/models/simulator");
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
vi.mock("~/models/sports-season");
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
vi.mock("~/services/simulations/runner");
|
2025-11-21 22:05:50 -08:00
|
|
|
vi.mock("~/database/context", () => ({
|
|
|
|
|
database: () => ({
|
|
|
|
|
query: {
|
|
|
|
|
participantExpectedValues: {
|
|
|
|
|
findMany: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
|
|
|
|
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
|
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
describe("probability-updater", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("updateProbabilitiesAfterResult", () => {
|
|
|
|
|
it("should set 100% probability for finished participants at their placement", async () => {
|
|
|
|
|
// Mock data: One participant finished 1st
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 1,
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: false,
|
2025-11-21 22:05:50 -08:00
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-21 09:44:05 -07:00
|
|
|
participant: null,
|
2025-11-21 22:05:50 -08:00
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "ev-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
probFirst: "0.1500",
|
|
|
|
|
probSecond: "0.1300",
|
|
|
|
|
probThird: "0.1200",
|
|
|
|
|
probFourth: "0.1100",
|
|
|
|
|
probFifth: "0.1000",
|
|
|
|
|
probSixth: "0.0900",
|
|
|
|
|
probSeventh: "0.0800",
|
|
|
|
|
probEighth: "0.0700",
|
|
|
|
|
expectedValue: "50.00",
|
|
|
|
|
source: "futures_odds",
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({
|
|
|
|
|
id: "ev-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
probFirst: "1.0000",
|
|
|
|
|
probSecond: "0.0000",
|
|
|
|
|
probThird: "0.0000",
|
|
|
|
|
probFourth: "0.0000",
|
|
|
|
|
probFifth: "0.0000",
|
|
|
|
|
probSixth: "0.0000",
|
|
|
|
|
probSeventh: "0.0000",
|
|
|
|
|
probEighth: "0.0000",
|
|
|
|
|
expectedValue: "100.00",
|
|
|
|
|
source: "manual",
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
|
|
|
expect(result.updated).toBe(1);
|
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
|
|
|
|
|
|
// Verify upsert was called with correct probabilities
|
|
|
|
|
expect(upsertSpy).toHaveBeenCalled();
|
|
|
|
|
const callArgs = upsertSpy.mock.calls[0][0];
|
|
|
|
|
expect(callArgs.participantId).toBe("participant-1");
|
|
|
|
|
expect(callArgs.sportsSeasonId).toBe("season-1");
|
|
|
|
|
expect(callArgs.probabilities.probFirst).toBe(1);
|
|
|
|
|
expect(callArgs.probabilities.probSecond).toBe(0);
|
|
|
|
|
expect(callArgs.source).toBe("manual");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should handle multiple finished participants", async () => {
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 1,
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: false,
|
2025-11-21 22:05:50 -08:00
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-21 09:44:05 -07:00
|
|
|
participant: null,
|
2025-11-21 22:05:50 -08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "result-2",
|
|
|
|
|
participantId: "participant-2",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 2,
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: false,
|
2025-11-21 22:05:50 -08:00
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-21 09:44:05 -07:00
|
|
|
participant: null,
|
2025-11-21 22:05:50 -08:00
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "ev-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
probFirst: "0.2000",
|
|
|
|
|
probSecond: "0.1500",
|
|
|
|
|
probThird: "0.1000",
|
|
|
|
|
probFourth: "0.0800",
|
|
|
|
|
probFifth: "0.0700",
|
|
|
|
|
probSixth: "0.0600",
|
|
|
|
|
probSeventh: "0.0500",
|
|
|
|
|
probEighth: "0.0400",
|
|
|
|
|
expectedValue: "60.00",
|
|
|
|
|
source: "futures_odds",
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "ev-2",
|
|
|
|
|
participantId: "participant-2",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
probFirst: "0.1500",
|
|
|
|
|
probSecond: "0.1500",
|
|
|
|
|
probThird: "0.1200",
|
|
|
|
|
probFourth: "0.1000",
|
|
|
|
|
probFifth: "0.0900",
|
|
|
|
|
probSixth: "0.0800",
|
|
|
|
|
probSeventh: "0.0700",
|
|
|
|
|
probEighth: "0.0600",
|
|
|
|
|
expectedValue: "55.00",
|
|
|
|
|
source: "futures_odds",
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(2);
|
|
|
|
|
expect(result.updated).toBe(2);
|
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
|
expect(upsertSpy).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should handle empty results", async () => {
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([]);
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1");
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
|
|
|
expect(result.unfishedParticipants).toBe(0);
|
|
|
|
|
expect(result.updated).toBe(0);
|
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should handle results without final position", async () => {
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: null, // No position set yet
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: false,
|
2025-11-21 22:05:50 -08:00
|
|
|
qualifyingPoints: "50.00",
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-21 09:44:05 -07:00
|
|
|
participant: null,
|
2025-11-21 22:05:50 -08:00
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1");
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
|
|
|
expect(result.updated).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should set all probabilities to 0% for eliminated participants (finalPosition = 0)", async () => {
|
|
|
|
|
// Mock: Participant eliminated (didn't make playoffs)
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 0, // Eliminated
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: false,
|
2025-11-21 22:05:50 -08:00
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-21 09:44:05 -07:00
|
|
|
participant: null,
|
2025-11-21 22:05:50 -08:00
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "ev-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
probFirst: "0.0500",
|
|
|
|
|
probSecond: "0.0800",
|
|
|
|
|
probThird: "0.1200",
|
|
|
|
|
probFourth: "0.1500",
|
|
|
|
|
probFifth: "0.1600",
|
|
|
|
|
probSixth: "0.1500",
|
|
|
|
|
probSeventh: "0.1400",
|
|
|
|
|
probEighth: "0.1500",
|
|
|
|
|
expectedValue: "20.00",
|
|
|
|
|
source: "futures_odds",
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
|
|
|
expect(result.updated).toBe(1);
|
|
|
|
|
|
|
|
|
|
// Verify all probabilities set to 0
|
|
|
|
|
const callArgs = upsertSpy.mock.calls[0][0];
|
|
|
|
|
expect(callArgs.probabilities.probFirst).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probSecond).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probThird).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probFourth).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probFifth).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probSixth).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probSeventh).toBe(0);
|
|
|
|
|
expect(callArgs.probabilities.probEighth).toBe(0);
|
|
|
|
|
});
|
2026-08-24 17:13:45 +00:00
|
|
|
|
|
|
|
|
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
|
|
|
|
|
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
|
|
|
|
|
// to 100% at 5th would erase their championship odds before they have played.
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 5,
|
|
|
|
|
isPartialScore: true,
|
|
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
participant: null,
|
|
|
|
|
},
|
|
|
|
|
] as never);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(0);
|
|
|
|
|
expect(upsertSpy).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
|
|
|
|
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
|
|
|
|
{
|
|
|
|
|
id: "result-1",
|
|
|
|
|
participantId: "participant-1",
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
finalPosition: 0,
|
|
|
|
|
isPartialScore: false,
|
|
|
|
|
qualifyingPoints: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
participant: null,
|
|
|
|
|
},
|
|
|
|
|
] as never);
|
|
|
|
|
|
|
|
|
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
|
|
|
|
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
|
|
|
|
|
|
|
|
|
const result = await updateProbabilitiesAfterResult("season-1", false);
|
|
|
|
|
|
|
|
|
|
expect(result.finishedParticipants).toBe(1);
|
|
|
|
|
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
|
|
|
|
|
});
|
2025-11-21 22:05:50 -08:00
|
|
|
});
|
|
|
|
|
});
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
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
2026-08-29 02:49:17 +00:00
|
|
|
// 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.
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
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<typeof finishedResult>[];
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
seasonStatus?: string;
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
}) {
|
|
|
|
|
const simulatorModel = await import("~/models/simulator");
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
const sportsSeasonModel = await import("~/models/sports-season");
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
const runner = await import("~/services/simulations/runner");
|
|
|
|
|
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
|
|
|
|
|
id: "season-1",
|
|
|
|
|
status: opts.seasonStatus ?? "active",
|
|
|
|
|
} as never);
|
|
|
|
|
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
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);
|
|
|
|
|
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
expect(icmWrites()).toHaveLength(0);
|
|
|
|
|
expect(result.errors).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
|
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
2026-08-29 02:49:17 +00:00
|
|
|
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" });
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
await updateProbabilitiesAfterResult("season-1", true);
|
|
|
|
|
|
Stop the simulator re-run from trampling its caller's side effects
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
|
|
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
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
2026-08-29 02:49:17 +00:00
|
|
|
expect(icmWrites()).toHaveLength(0);
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
});
|
|
|
|
|
|
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
2026-08-29 02:49:17 +00:00
|
|
|
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",
|
|
|
|
|
});
|
Make the AFL simulator read the bracket that was actually drawn
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|