claude/afl-ev-calc-issue-8kcmlh #145
7 changed files with 209 additions and 37 deletions
|
|
@ -319,6 +319,22 @@ describe("processMatchResult", () => {
|
|||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||
});
|
||||
|
||||
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||
// than once per match. Standings and the announcement still happen per match.
|
||||
const { db } = makeDb();
|
||||
|
||||
await processMatchResult(
|
||||
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||
db
|
||||
);
|
||||
|
||||
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw even if probability update fails", async () => {
|
||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("network error")
|
||||
|
|
|
|||
|
|
@ -547,6 +547,19 @@ export async function processMatchResult(
|
|||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||
matchId?: string;
|
||||
skipSideEffects?: boolean;
|
||||
/**
|
||||
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||
*
|
||||
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||
* announcements then project from the previous probabilities until that final call.
|
||||
*
|
||||
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||
* the announcement.
|
||||
*/
|
||||
skipProbabilities?: boolean;
|
||||
/**
|
||||
* When true, the loser of this non-scoring round advances to another match
|
||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||
|
|
@ -557,7 +570,7 @@ export async function processMatchResult(
|
|||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||
|
|
@ -637,6 +650,7 @@ export async function processMatchResult(
|
|||
: undefined;
|
||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||
// projected points reflect the new result.
|
||||
if (!skipProbabilities) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
|
|
@ -645,6 +659,7 @@ export async function processMatchResult(
|
|||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
|||
vi.mock("~/models/participant-result");
|
||||
vi.mock("~/models/participant-expected-value");
|
||||
vi.mock("~/models/simulator");
|
||||
vi.mock("~/models/sports-season");
|
||||
vi.mock("~/services/simulations/runner");
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
|
|
@ -373,10 +374,17 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
|||
evSource: string;
|
||||
simulatorType: string | null;
|
||||
results?: ReturnType<typeof finishedResult>[];
|
||||
seasonStatus?: string;
|
||||
}) {
|
||||
const simulatorModel = await import("~/models/simulator");
|
||||
const sportsSeasonModel = await import("~/models/sports-season");
|
||||
const runner = await import("~/services/simulations/runner");
|
||||
|
||||
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
|
||||
id: "season-1",
|
||||
status: opts.seasonStatus ?? "active",
|
||||
} as never);
|
||||
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||
opts.results ?? []
|
||||
);
|
||||
|
|
@ -407,11 +415,43 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
|||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => {
|
||||
// recalculateAffectedLeagues detects change by diffing teamStandings across its own
|
||||
// recalculation, and that diff gates the Discord standings post. A recalculation in here
|
||||
// runs before it takes its "before" snapshot, so the diff comes back empty and the post is
|
||||
// silently dropped — and previousRank gets rolled forward twice, erasing rank movement.
|
||||
const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runSim).toHaveBeenCalledWith("season-1", {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to ICM on a completed season rather than failing every time", async () => {
|
||||
// finalizeQualifyingPoints marks the season completed immediately before calling here, and
|
||||
// runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure
|
||||
// would strand anyone still unfinished on stale probabilities forever.
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "cs2_major_qualifying_points",
|
||||
seasonStatus: "completed",
|
||||
});
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("still pins finished participants before re-running the simulator", async () => {
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
|
|
@ -464,7 +504,7 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
|||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import {
|
||||
|
|
@ -283,6 +284,11 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId: playoffMatch.id,
|
||||
// The probability refresh is season-wide and idempotent, and for a bracket-aware
|
||||
// sport it is a full Monte Carlo run — doing it per match would repeat that for
|
||||
// every match in the sync. It runs once after the loop instead. Standings and the
|
||||
// per-match Discord post still happen here as before.
|
||||
skipProbabilities: true,
|
||||
loserAdvances: event.bracketTemplateId
|
||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||
: false,
|
||||
|
|
@ -300,6 +306,15 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refresh skipped inside the loop, run once for the whole sync.
|
||||
if (playoffUpdated > 0) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (err) {
|
||||
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import * as schema from "~/database/schema";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
|
|
@ -119,6 +120,15 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
|||
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
||||
*/
|
||||
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
||||
// A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright —
|
||||
// and finalizeQualifyingPoints marks the season completed immediately before calling here,
|
||||
// so taking this branch there would fail every single time and leave anyone still in the
|
||||
// unfinished set on permanently stale probabilities. It is not a failure, it is not this
|
||||
// branch's case: the season is over, every placement is final, and the floor this branch
|
||||
// exists to protect can no longer be contradicted. Fall through to ICM as before.
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (sportsSeason?.status === "completed") return false;
|
||||
|
||||
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!simulatorConfig) return false;
|
||||
|
||||
|
|
@ -185,13 +195,23 @@ export async function updateProbabilitiesAfterResult(
|
|||
// undefined at module-init time.
|
||||
try {
|
||||
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
// Probabilities only. Our callers recalculate standings themselves right after this,
|
||||
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
|
||||
// own recalculation — a recalculation slipped in here empties that diff and silently
|
||||
// suppresses the Discord standings post, and rolls previousRank forward a second time
|
||||
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
|
||||
// is keyed by date, so writing it per result overwrites the day with intra-day values.
|
||||
await runSportsSeasonSimulation(sportsSeasonId, {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
updated += unfinishedEVs.length;
|
||||
} catch (error) {
|
||||
// runSportsSeasonSimulation throws on a completed season, on a run already in
|
||||
// flight, and on failed readiness. Leave the existing probabilities alone rather
|
||||
// than falling back to ICM: for these seasons ICM is the thing being replaced, and
|
||||
// a completed season has nothing unfinished left to recalculate anyway.
|
||||
// A run already in flight, failed readiness, or a bracket the simulator refuses to
|
||||
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
|
||||
// alone rather than falling back to ICM — for these seasons ICM is precisely the
|
||||
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
|
||||
// Completed seasons never reach this: shouldRerunSimulator excludes them.
|
||||
logger.error(
|
||||
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||
`leaving existing probabilities in place:`,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import {
|
|||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import { getSimulator } from "~/services/simulations/registry";
|
||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||
|
||||
|
|
@ -126,6 +128,42 @@ describe("runSportsSeasonSimulation", () => {
|
|||
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
||||
});
|
||||
|
||||
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
|
||||
function withLinkedLeague() {
|
||||
vi.mocked(database).mockReturnValue({
|
||||
query: {
|
||||
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
|
||||
seasons: { findFirst: vi.fn() },
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("recalculates standings and writes the daily snapshot by default", async () => {
|
||||
withLinkedLeague();
|
||||
|
||||
await runSportsSeasonSimulation("season-1");
|
||||
|
||||
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
|
||||
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips standings and snapshots when the caller owns them", async () => {
|
||||
withLinkedLeague();
|
||||
|
||||
// updateProbabilitiesAfterResult runs inside the result path, where the caller
|
||||
// recalculates standings straight afterwards. A recalculation here lands before
|
||||
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
|
||||
// Discord standings post and rolling previousRank forward twice. EVs are still written.
|
||||
await runSportsSeasonSimulation("season-1", {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
|
||||
expect(recalculateStandings).not.toHaveBeenCalled();
|
||||
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
|
||||
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the sports season is not found", async () => {
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,29 @@ async function getPersistenceContext(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Side effects a caller can opt out of.
|
||||
*
|
||||
* A simulation run does three jobs — recompute probabilities, recalculate standings, and record
|
||||
* the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside
|
||||
* the result path, where the caller recalculates standings itself immediately afterwards.
|
||||
*
|
||||
* Letting the run recalculate there is not merely redundant, it is wrong.
|
||||
* recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then
|
||||
* diffing, and that diff gates the Discord standings post; a recalculation slipped in
|
||||
* beforehand makes the diff empty and silently suppresses the notification. recalculateStandings
|
||||
* also rolls previousRank forward on every call, so an extra one erases rank movement.
|
||||
*/
|
||||
export interface RunSportsSeasonSimulationOptions {
|
||||
/** Leave standings to the caller. */
|
||||
skipStandingsRecalc?: boolean;
|
||||
/**
|
||||
* Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so
|
||||
* writing it on every match result just overwrites the day's row with intra-day values.
|
||||
*/
|
||||
skipSnapshots?: boolean;
|
||||
}
|
||||
|
||||
export interface RunSportsSeasonSimulationResult {
|
||||
sportsSeasonId: string;
|
||||
simulatorType: SimulatorType;
|
||||
|
|
@ -71,7 +94,8 @@ export interface RunSportsSeasonSimulationResult {
|
|||
}
|
||||
|
||||
export async function runSportsSeasonSimulation(
|
||||
sportsSeasonId: string
|
||||
sportsSeasonId: string,
|
||||
options: RunSportsSeasonSimulationOptions = {}
|
||||
): Promise<RunSportsSeasonSimulationResult> {
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (!sportsSeason) {
|
||||
|
|
@ -135,12 +159,15 @@ export async function runSportsSeasonSimulation(
|
|||
})),
|
||||
]);
|
||||
|
||||
if (!options.skipStandingsRecalc) {
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
}
|
||||
|
||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||
if (!options.skipSnapshots) {
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
|
|
@ -158,6 +185,7 @@ export async function runSportsSeasonSimulation(
|
|||
source: r.source,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue