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
383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
/**
|
|
* Probability Updater Service
|
|
*
|
|
* Updates probability distributions when real results come in.
|
|
*
|
|
* Key behaviors:
|
|
* - Finished participants: Set to 100% at their placement, 0% elsewhere
|
|
* - Unfinished participants: Re-run ICM calculation with remaining participants
|
|
* - Handles partial results correctly
|
|
*/
|
|
|
|
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
import {
|
|
getAllParticipantEVsForSeason,
|
|
upsertParticipantEV,
|
|
type ParticipantEV,
|
|
} from "~/models/participant-expected-value";
|
|
import { calculateICMFromOdds } from "./icm-calculator";
|
|
import type { ProbabilityDistribution } from "./ev-calculator";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
|
import { logger } from "~/lib/logger";
|
|
|
|
/**
|
|
* Result of probability update operation
|
|
*/
|
|
export interface ProbabilityUpdateResult {
|
|
finishedParticipants: number;
|
|
unfishedParticipants: number;
|
|
updated: number;
|
|
errors: string[];
|
|
}
|
|
|
|
/**
|
|
* Before/after probabilities for display
|
|
*/
|
|
export interface ProbabilityComparison {
|
|
participantId: string;
|
|
participantName: string;
|
|
before: number[]; // [P(1st), P(2nd), ..., P(8th)]
|
|
after: number[]; // [P(1st), P(2nd), ..., P(8th)]
|
|
status: 'finished' | 'recalculated' | 'unchanged';
|
|
}
|
|
|
|
/**
|
|
* Convert probability array to ProbabilityDistribution
|
|
*/
|
|
function arrayToProbabilityDistribution(probs: number[]): ProbabilityDistribution {
|
|
return {
|
|
probFirst: probs[0],
|
|
probSecond: probs[1],
|
|
probThird: probs[2],
|
|
probFourth: probs[3],
|
|
probFifth: probs[4],
|
|
probSixth: probs[5],
|
|
probSeventh: probs[6],
|
|
probEighth: probs[7],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert ParticipantEV to probability array
|
|
*/
|
|
function evToProbabilityArray(ev: ParticipantEV): number[] {
|
|
return [
|
|
parseFloat(ev.probFirst),
|
|
parseFloat(ev.probSecond),
|
|
parseFloat(ev.probThird),
|
|
parseFloat(ev.probFourth),
|
|
parseFloat(ev.probFifth),
|
|
parseFloat(ev.probSixth),
|
|
parseFloat(ev.probSeventh),
|
|
parseFloat(ev.probEighth),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Create a probability distribution where participant finished at a specific position
|
|
*
|
|
* @param finalPosition The position where participant finished
|
|
* - 1-8: 100% at that position, 0% elsewhere
|
|
* - 0: Eliminated (didn't make playoffs) - 0% for all positions
|
|
* - >8: Finished outside scoring - 0% for all positions
|
|
* @returns Array of probabilities with 100% at finalPosition (if 1-8), or all 0%
|
|
*/
|
|
function createFinishedProbabilities(finalPosition: number): number[] {
|
|
const probs = [0, 0, 0, 0, 0, 0, 0, 0];
|
|
|
|
// Handle positions 1-8
|
|
if (finalPosition >= 1 && finalPosition <= 8) {
|
|
probs[finalPosition - 1] = 1.0; // 100% at their position
|
|
}
|
|
// finalPosition = 0 (eliminated) or > 8 (finished outside top 8)
|
|
// Keep all probabilities at 0%
|
|
|
|
return probs;
|
|
}
|
|
|
|
/**
|
|
* Whether this season's still-alive participants should be refreshed by re-running its
|
|
* simulator instead of by the ICM recalculation below.
|
|
*
|
|
* If the season has a simulator that reads its bracket, that simulator is simply a better
|
|
* answer than ICM to "what happens from here": it seeds from the real draw and replays every
|
|
* completed match, where ICM re-derives a whole distribution from P(1st) alone and knows
|
|
* nothing about who is playing whom or what has already been decided. That blindness is what
|
|
* makes ICM report a placement floor the league has already paid out as worth less than its
|
|
* awarded points.
|
|
*
|
|
* Where the EVs originally came from is not consulted, because the alternative here is not
|
|
* leaving them alone — the ICM branch overwrites them either way. Given the choice between
|
|
* two overwrites, the bracket-aware one wins.
|
|
*
|
|
* The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
|
|
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
|
*/
|
|
async function shouldRerunSimulator(sportsSeasonId: string): 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;
|
|
|
|
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
|
|
}
|
|
|
|
/**
|
|
* Update probabilities for a sports season after results come in
|
|
*
|
|
* Process:
|
|
* 1. Get all participant results (finished participants)
|
|
* 2. Get all existing participant EVs
|
|
* 3. For finished participants: set 100% at their placement
|
|
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
|
|
* otherwise recalculate using ICM with remaining participants
|
|
*
|
|
* @param sportsSeasonId Sports season to update
|
|
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
|
* @returns Update result summary
|
|
*/
|
|
export async function updateProbabilitiesAfterResult(
|
|
sportsSeasonId: string,
|
|
recalculateUnfinished = true
|
|
): Promise<ProbabilityUpdateResult> {
|
|
const errors: string[] = [];
|
|
let updated = 0;
|
|
|
|
try {
|
|
// Get all results (finished participants)
|
|
const results = await findParticipantResultsBySportsSeasonId(sportsSeasonId);
|
|
|
|
// Get all existing EVs
|
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
|
|
|
// Create map of participantId -> finalPosition.
|
|
//
|
|
// Provisional rows (isPartialScore) are NOT finished: they are the guaranteed
|
|
// minimum for someone still alive — a bracket entry floor, or the floor banked
|
|
// by winning a round. Treating them as finished pins the participant to 100% at
|
|
// that floor and drops them from the ICM recalculation below, which would zero
|
|
// the championship odds of every team still playing. They belong in the
|
|
// unfinished set until a real result lands.
|
|
const finishedMap = new Map(
|
|
results
|
|
.filter(r => r.finalPosition !== null && !r.isPartialScore)
|
|
.map(r => [r.participantId, r.finalPosition ?? 0])
|
|
);
|
|
|
|
// Recalculate unfinished participants if requested
|
|
if (recalculateUnfinished) {
|
|
const unfinishedEVs = existingEVs.filter(
|
|
ev => !finishedMap.has(ev.participantId)
|
|
);
|
|
|
|
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
|
|
// The simulator reads the bracket, so it already knows this result: it seeds from the
|
|
// real draw and replays every completed match. Re-running it keeps each participant's
|
|
// distribution consistent with the games actually played — including the placement
|
|
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
|
|
// branch below cannot see and would value below points the league has paid out.
|
|
//
|
|
// Imported lazily: probability-updater → runner → scoring-calculator →
|
|
// probability-updater is a module cycle, and a static import leaves the binding
|
|
// undefined at module-init time.
|
|
try {
|
|
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
|
// 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) {
|
|
// 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:`,
|
|
error
|
|
);
|
|
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
|
}
|
|
} else if (unfinishedEVs.length > 0) {
|
|
// Get their current championship probabilities (use existing P(1st) as proxy)
|
|
const unfinishedOdds = unfinishedEVs.map(ev => {
|
|
const pFirst = parseFloat(ev.probFirst);
|
|
// Convert probability back to odds (approximate)
|
|
// probability = 100 / (odds + 100) => odds = (100 / probability) - 100
|
|
const odds = pFirst > 0 ? Math.round((100 / pFirst) - 100) : 100000;
|
|
|
|
return {
|
|
participantId: ev.participantId,
|
|
odds: odds,
|
|
};
|
|
});
|
|
|
|
// Recalculate ICM for unfinished participants
|
|
const icmResults = calculateICMFromOdds(unfinishedOdds);
|
|
|
|
// Sequential for the same reason as the finished loop above:
|
|
// upsertParticipantEV rewrites shared per-season state via syncVorpForSeason.
|
|
for (const [participantId, icmResult] of icmResults.entries()) {
|
|
try {
|
|
const probs = [
|
|
icmResult.probabilities.first,
|
|
icmResult.probabilities.second,
|
|
icmResult.probabilities.third,
|
|
icmResult.probabilities.fourth,
|
|
icmResult.probabilities.fifth,
|
|
icmResult.probabilities.sixth,
|
|
icmResult.probabilities.seventh,
|
|
icmResult.probabilities.eighth,
|
|
];
|
|
|
|
const probabilities = arrayToProbabilityDistribution(probs);
|
|
|
|
await upsertParticipantEV({
|
|
participantId,
|
|
sportsSeasonId,
|
|
probabilities,
|
|
scoringRules: DEFAULT_SCORING_RULES,
|
|
source: 'futures_odds', // Recalculated from remaining odds
|
|
});
|
|
|
|
updated++;
|
|
} catch (error) {
|
|
errors.push(`Failed to recalculate participant ${participantId}: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update finished participants. The shared default table is used because we only
|
|
// care about setting probabilities here, not the EV — each league re-derives its own
|
|
// EV from the stored probabilities in calculateTeamProjectedScore.
|
|
//
|
|
// This runs *after* the recalculation above, not before, because re-running a simulator
|
|
// rewrites every participant in the season — the finalized ones included. A finalized
|
|
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
|
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
|
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
|
|
|
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
|
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
|
// Running these in parallel would race on that shared state.
|
|
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
|
try {
|
|
const probs = createFinishedProbabilities(finalPosition);
|
|
const probabilities = arrayToProbabilityDistribution(probs);
|
|
|
|
await upsertParticipantEV({
|
|
participantId,
|
|
sportsSeasonId,
|
|
probabilities,
|
|
scoringRules: DEFAULT_SCORING_RULES,
|
|
source: 'manual', // Result is from actual outcome
|
|
});
|
|
|
|
updated++;
|
|
} catch (error) {
|
|
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
finishedParticipants: finishedMap.size,
|
|
unfishedParticipants: existingEVs.length - finishedMap.size,
|
|
updated,
|
|
errors,
|
|
};
|
|
} catch (error) {
|
|
errors.push(`Failed to update probabilities: ${error}`);
|
|
return {
|
|
finishedParticipants: 0,
|
|
unfishedParticipants: 0,
|
|
updated,
|
|
errors,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get before/after comparison of probabilities for display
|
|
*
|
|
* Shows what will change when updateProbabilitiesAfterResult runs.
|
|
* Useful for preview before committing changes.
|
|
*
|
|
* @param sportsSeasonId Sports season to preview
|
|
* @returns Array of probability comparisons
|
|
*/
|
|
export async function previewProbabilityUpdate(
|
|
sportsSeasonId: string
|
|
): Promise<ProbabilityComparison[]> {
|
|
const db = database();
|
|
|
|
// Get all results (finished participants)
|
|
const results = await findParticipantResultsBySportsSeasonId(sportsSeasonId);
|
|
|
|
// Get all existing EVs with participant names
|
|
const existingEVs = await db.query.seasonParticipantExpectedValues.findMany({
|
|
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
|
|
with: {
|
|
participant: true,
|
|
},
|
|
});
|
|
|
|
// Create map of participantId -> finalPosition
|
|
const finishedMap = new Map(
|
|
results
|
|
.filter(r => r.finalPosition !== null)
|
|
.map(r => [r.participantId, r.finalPosition ?? 0])
|
|
);
|
|
|
|
const comparisons: ProbabilityComparison[] = [];
|
|
|
|
for (const ev of existingEVs) {
|
|
const before = evToProbabilityArray(ev);
|
|
const finalPosition = finishedMap.get(ev.participantId);
|
|
|
|
let after: number[];
|
|
let status: 'finished' | 'recalculated' | 'unchanged';
|
|
|
|
if (finalPosition !== undefined) {
|
|
// Finished participant
|
|
after = createFinishedProbabilities(finalPosition);
|
|
status = 'finished';
|
|
} else {
|
|
// For preview, we'd need to recalculate - for now just show unchanged
|
|
// In a real implementation, we'd run the ICM calculation here too
|
|
after = before;
|
|
status = 'unchanged';
|
|
}
|
|
|
|
comparisons.push({
|
|
participantId: ev.participantId,
|
|
participantName: ev.participant?.name || 'Unknown',
|
|
before,
|
|
after,
|
|
status,
|
|
});
|
|
}
|
|
|
|
return comparisons;
|
|
}
|