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
363 lines
13 KiB
TypeScript
363 lines
13 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 { 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> {
|
|
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");
|
|
await runSportsSeasonSimulation(sportsSeasonId);
|
|
updated += unfinishedEVs.length;
|
|
} catch (error) {
|
|
// runSportsSeasonSimulation throws on a completed season, on a run already in
|
|
// flight, and on failed readiness. Leave the existing probabilities alone rather
|
|
// than falling back to ICM: for these seasons ICM is the thing being replaced, and
|
|
// a completed season has nothing unfinished left to recalculate anyway.
|
|
logger.error(
|
|
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
|
`leaving existing probabilities in place:`,
|
|
error
|
|
);
|
|
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
|
}
|
|
} else if (unfinishedEVs.length > 0) {
|
|
// Get their current championship probabilities (use existing P(1st) as proxy)
|
|
const unfinishedOdds = unfinishedEVs.map(ev => {
|
|
const pFirst = parseFloat(ev.probFirst);
|
|
// 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;
|
|
}
|