/** * 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 { 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 { 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 { 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; }