brackt/app/services/__tests__/probability-updater.test.ts

494 lines
18 KiB
TypeScript
Raw Normal View History

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");
vi.mock("~/services/simulations/runner");
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);
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,
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
]);
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,
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
{
id: "result-2",
participantId: "participant-2",
sportsSeasonId: "season-1",
finalPosition: 2,
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
]);
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
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: "50.00",
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
]);
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
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
]);
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);
});
Fix four issues found reviewing the entry-floor change - Provisional rows were being treated as finished by updateProbabilitiesAfterResult, whose finishedMap filtered on finalPosition alone. Entry floors made that fire for the whole seeded field: on the first match result, AFL seeds 1-6 would each be pinned to 100% at their floor position and dropped from the ICM recalc, zeroing the championship odds of six teams that had not played. Filter partial rows out of finishedMap so they stay in the unfinished set. Finalized 0-position eliminations still finalize as before. - generate-bracket recalculated standings only inside markEliminatedAndAnnounce, which no-ops when nothing was eliminated. A season whose participants exactly equal the bracket field would never surface the floors in teamStandings.totalPoints. Recalculate explicitly in that case (skipDiscord: seeding is not a result). - applyBracketEntryFloors upserted unconditionally, so regenerating a bracket mid-tournament could downgrade a team already sitting on a better placement. Read existing placements first and only write when the floor improves on what a participant already has; position 0 is eliminated, not a placement, so it never blocks a floor. - Relaxing the reprocess guard to matches.length made the season-wide deleteParticipantResultsBySportsSeasonId reachable with zero completed matches, wiping other events' placements with no replay able to rebuild them. Skip the wipe when there is nothing to replay; entry floors and elimination marking are additive and need no wipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
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);
});
});
});
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>[];
}) {
const simulatorModel = await import("~/models/simulator");
const runner = await import("~/services/simulations/runner");
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
opts.results ?? []
);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
evRow("alive-1", opts.evSource),
evRow("alive-2", opts.evSource),
] as never);
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
);
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
runSim.mockResolvedValue({} as never);
return { runner, runSim };
}
/** The ICM branch is the only thing that writes unfinished rows with this source. */
const icmWrites = () =>
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
beforeEach(() => {
vi.clearAllMocks();
});
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
const result = await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
expect(icmWrites()).toHaveLength(0);
expect(result.errors).toEqual([]);
});
it("still pins finished participants before re-running the simulator", async () => {
const { runner } = await setup({
evSource: "elo_simulation",
simulatorType: "afl_bracket",
results: [finishedResult("done-1", 2)],
});
await updateProbabilitiesAfterResult("season-1", true);
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
});
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
// included. A finalized placement is a fact, not a projection, so it has to land last.
const { runSim } = await setup({
evSource: "elo_simulation",
simulatorType: "afl_bracket",
results: [finishedResult("done-1", 0)],
});
await updateProbabilitiesAfterResult("season-1", true);
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
expect(pinIndex).toBeGreaterThanOrEqual(0);
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
runSim.mock.invocationCallOrder[0]
);
});
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
new Error("A simulation is already running for this sports season.")
);
const result = await updateProbabilitiesAfterResult("season-1", true);
expect(icmWrites()).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
});
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);
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(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
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);
});
});