- 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
310 lines
9.7 KiB
TypeScript
310 lines
9.7 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";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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: 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])
|
|
);
|
|
|
|
// Update finished participants
|
|
// Use default scoring rules (we only care about setting probabilities, not EV for finished)
|
|
const defaultScoringRules = {
|
|
pointsFor1st: 100,
|
|
pointsFor2nd: 70,
|
|
pointsFor3rd: 50,
|
|
pointsFor4th: 40,
|
|
pointsFor5th: 25,
|
|
pointsFor6th: 25,
|
|
pointsFor7th: 15,
|
|
pointsFor8th: 15,
|
|
};
|
|
|
|
// 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: defaultScoringRules,
|
|
source: 'manual', // Result is from actual outcome
|
|
});
|
|
|
|
updated++;
|
|
} catch (error) {
|
|
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Recalculate unfinished participants if requested
|
|
if (recalculateUnfinished) {
|
|
const unfinishedEVs = existingEVs.filter(
|
|
ev => !finishedMap.has(ev.participantId)
|
|
);
|
|
|
|
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: defaultScoringRules,
|
|
source: 'futures_odds', // Recalculated from remaining odds
|
|
});
|
|
|
|
updated++;
|
|
} catch (error) {
|
|
errors.push(`Failed to recalculate 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;
|
|
}
|