brackt/app/services/probability-updater.ts
Claude 21422a7e92
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix four issues found reviewing the entry-floor change
- 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
2026-08-24 17:13:45 +00:00

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;
}