brackt/app/services/simulations/college-hockey-simulator.ts
chrisp 3e50619629
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
claude/probability-config-review-1tdolw (#119)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00

429 lines
16 KiB
TypeScript

/**
* NCAA Men's College Hockey Tournament Simulator
*
* Supports two modes with admin-editable inputs only:
* 1. Pre-bracket mode: samples a 16-team tournament field from all season
* participants using futures odds, NPI rank, and/or Elo entered in Admin.
* 2. Bracket-aware mode: when a 16-team playoff bracket exists, simulates the
* actual bracket and honors completed match results.
*
* No team ratings are hardcoded. Strength comes from season_participant_expected_values:
* - sourceElo: optional direct Elo / admin power rating
* - worldRanking: optional NPI rank (1 = best)
* - sourceOdds: optional American championship futures odds
*/
import { database } from "~/database/context";
import { and, eq, inArray } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
convertAmericanOddsToProbability,
convertFuturesToElo,
eloWinProbabilityWithParity,
} from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 16;
const HOCKEY_PARITY_FACTOR = 850;
const DEFAULT_ELO = 1500;
const RANK_ELO_SCALE = 125;
const RANK_ZIPF_EXPONENT = 1.1;
const ODDS_ELO_WEIGHT = 0.25;
const DIRECT_ELO_WEIGHT = 1 - ODDS_ELO_WEIGHT;
const COLLEGE_HOCKEY_TEMPLATE_ID = "college_hockey_16";
const ROUND_OF_16_NAMES = new Set(["Round of 16", "Regional Semifinals"]);
const QUARTERFINAL_NAMES = new Set(["Quarterfinals", "Regional Finals"]);
const SEMIFINAL_NAMES = new Set(["Semifinals", "Frozen Four Semifinals"]);
const FINAL_NAMES = new Set(["Finals", "National Championship"]);
interface Team {
participantId: string;
elo: number;
seedScore: number;
oddsProb: number;
selectionWeight: number;
}
interface EvInputRow {
participantId: string;
sourceElo: number | null;
sourceOdds: number | null;
worldRanking: number | null;
}
interface PlacementCounts {
champion: number;
finalist: number;
semifinalLoser: number;
quarterfinalLoser: number;
}
type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export function npiRankToElo(rank: number, fieldSize: number): number {
if (!Number.isFinite(rank) || rank <= 0) return DEFAULT_ELO;
const safeFieldSize = Math.max(fieldSize, BRACKET_SIZE);
return Math.round(DEFAULT_ELO + RANK_ELO_SCALE * Math.log(safeFieldSize / rank));
}
export function npiRankSelectionWeight(rank: number | null): number {
if (!rank || rank <= 0) return 0;
return 1 / Math.pow(rank, RANK_ZIPF_EXPONENT);
}
function normalizeOdds(evRows: EvInputRow[]): Map<string, number> {
const raw = new Map<string, number>();
for (const row of evRows) {
if (row.sourceOdds !== null) {
raw.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
const sum = [...raw.values()].reduce((acc, p) => acc + p, 0);
const normalized = new Map<string, number>();
if (sum <= 0) return normalized;
for (const [id, prob] of raw) normalized.set(id, prob / sum);
return normalized;
}
function buildOddsEloMap(evRows: EvInputRow[]): Map<string, number> {
const oddsInput = evRows
.filter((row) => row.sourceOdds !== null)
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds ?? 0 }));
if (oddsInput.length < 2) return new Map();
return convertFuturesToElo(oddsInput, "american", {
exponent: 0.33,
eloMin: 1325,
eloMax: 1725,
});
}
export function buildCollegeHockeyTeams(
participantIds: string[],
evRows: EvInputRow[]
): Team[] {
const evById = new Map(evRows.map((row) => [row.participantId, row]));
const normalizedOdds = normalizeOdds(evRows);
const oddsEloMap = buildOddsEloMap(evRows);
const hasOdds = normalizedOdds.size > 0;
const hasAnyRank = evRows.some((row) => row.worldRanking !== null);
const hasAnyDirectElo = evRows.some((row) => row.sourceElo !== null);
const teams: Team[] = participantIds.map((participantId) => {
const ev = evById.get(participantId);
const rankElo = ev?.worldRanking ? npiRankToElo(ev.worldRanking, participantIds.length) : null;
const baseElo = ev?.sourceElo ?? rankElo ?? oddsEloMap.get(participantId) ?? DEFAULT_ELO;
const oddsElo = oddsEloMap.get(participantId);
const elo = oddsElo !== undefined && ((ev?.sourceElo !== null && ev?.sourceElo !== undefined) || rankElo !== null)
? Math.round(DIRECT_ELO_WEIGHT * baseElo + ODDS_ELO_WEIGHT * oddsElo)
: baseElo;
return {
participantId,
elo,
seedScore: rankElo ?? ev?.sourceElo ?? elo,
oddsProb: normalizedOdds.get(participantId) ?? 0,
selectionWeight: 0,
};
});
const maxElo = Math.max(...teams.map((team) => team.elo));
const fallbackWeights = new Map<string, number>();
for (const team of teams) {
const rank = evById.get(team.participantId)?.worldRanking ?? null;
const fallbackWeight = hasAnyRank
? npiRankSelectionWeight(rank)
: hasAnyDirectElo
? Math.exp((team.elo - maxElo) / 140)
: 1;
fallbackWeights.set(team.participantId, fallbackWeight > 0 ? fallbackWeight : 0.01);
}
if (hasOdds) {
const fallbackTotal = [...fallbackWeights.values()].reduce((sum, weight) => sum + weight, 0);
for (const team of teams) {
const fallbackShare = (fallbackWeights.get(team.participantId) ?? 0.01) / fallbackTotal;
team.selectionWeight = team.oddsProb + 0.1 * fallbackShare;
}
} else {
for (const team of teams) team.selectionWeight = fallbackWeights.get(team.participantId) ?? 1;
}
return teams;
}
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
}
function bump(counts: Map<string, PlacementCounts>, id: string, key: keyof PlacementCounts): void {
const entry = counts.get(id);
if (entry) entry[key]++;
}
function sampleField(pool: Team[], n: number): Team[] {
const remaining = [...pool];
const selected: Team[] = [];
for (let i = 0; i < n; i++) {
const totalWeight = remaining.reduce((sum, team) => sum + team.selectionWeight, 0);
let r = Math.random() * totalWeight;
let index = 0;
for (; index < remaining.length - 1; index++) {
r -= remaining[index].selectionWeight;
if (r <= 0) break;
}
selected.push(remaining[index]);
remaining.splice(index, 1);
}
return seedField(selected);
}
function seedField(teams: Team[]): Team[] {
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
}
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = HOCKEY_PARITY_FACTOR): void {
const r16Pairs: Array<[number, number]> = [
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
];
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner);
const qfWinners: Team[] = [];
for (let i = 0; i < 4; i++) {
const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]);
qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
const sf1 = game(qfWinners[0], qfWinners[1]);
const sf2 = game(qfWinners[2], qfWinners[3]);
bump(counts, sf1.loser.participantId, "semifinalLoser");
bump(counts, sf2.loser.participantId, "semifinalLoser");
const final = game(sf1.winner, sf2.winner);
bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist");
}
function findRound(matches: PlayoffMatch[], names: Set<string>): PlayoffMatch[] {
return matches
.filter((match) => names.has(match.round) || (match.templateRound ? names.has(match.templateRound) : false))
.toSorted((a, b) => a.matchNumber - b.matchNumber);
}
function completedLoser(match: PlayoffMatch): string {
if (match.loserId) return match.loserId;
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
throw new Error(`Completed ${match.round} match ${match.matchNumber} is missing a loser.`);
}
function getTeam(teamById: Map<string, Team>, participantId: string): Team {
const team = teamById.get(participantId);
if (!team) throw new Error(`Participant ${participantId} is missing a saved rating row.`);
return team;
}
function simulateMatchFromIds(
p1: string,
p2: string,
match: PlayoffMatch | undefined,
teamById: Map<string, Team>,
parityFactor = HOCKEY_PARITY_FACTOR
): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
}
return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor);
}
function simulateExistingBracket(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>,
counts: Map<string, PlacementCounts>,
parityFactor = HOCKEY_PARITY_FACTOR
): void {
const r16Winners: Team[] = [];
for (let i = 0; i < 8; i++) {
const match = rounds.r16[i];
if (!match.participant1Id || !match.participant2Id) {
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
}
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner);
}
const qfWinners: Team[] = [];
for (let i = 0; i < 4; i++) {
const match = rounds.qf[i];
const p1 = r16Winners[i * 2]?.participantId;
const p2 = r16Winners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
const sfWinners: Team[] = [];
for (let i = 0; i < 2; i++) {
const match = rounds.sf[i];
const p1 = qfWinners[i * 2]?.participantId;
const p2 = qfWinners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
sfWinners.push(result.winner);
bump(counts, result.loser.participantId, "semifinalLoser");
}
const p1 = sfWinners[0]?.participantId;
const p2 = sfWinners[1]?.participantId;
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor);
bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist");
}
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>, numSimulations: number): SimulationResult[] {
const results = participantIds.map((participantId) => {
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * numSimulations);
const qf = c.quarterfinalLoser / (4 * numSimulations);
return {
participantId,
probabilities: {
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probThird: sf,
probFourth: sf,
probFifth: qf,
probSixth: qf,
probSeventh: qf,
probEighth: qf,
},
source: "college_hockey_monte_carlo",
};
});
for (const key of ["probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth"] as const) {
const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0);
const residual = 1 - colSum;
if (Math.abs(residual) > 0 && Math.abs(residual) < 1e-9) {
const maxResult = results.reduce((best, result) =>
result.probabilities[key] > best.probabilities[key] ? result : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
export class CollegeHockeySimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
const bracketMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
})
: [];
const existingBracket = this.getExistingBracketRounds(bracketMatches);
const participantIds = existingBracket
? this.getParticipantIdsFromBracket(existingBracket)
: (await db
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId))).map((row) => row.id);
if (participantIds.length < BRACKET_SIZE) {
throw new Error(
`College hockey simulator requires at least ${BRACKET_SIZE} participants, found ${participantIds.length}.`
);
}
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
})
.from(schema.seasonParticipantExpectedValues)
.where(inArray(schema.seasonParticipantExpectedValues.participantId, participantIds));
const teams = buildCollegeHockeyTeams(participantIds, evRows);
const teamById = new Map(teams.map((team) => [team.participantId, team]));
const counts = new Map<string, PlacementCounts>(
participantIds.map((id) => [id, { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }])
);
if (existingBracket) {
for (let i = 0; i < numSimulations; i++) {
simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
}
} else {
const preBracketMode = participantIds.length > BRACKET_SIZE;
const deterministicField = preBracketMode ? null : seedField(teams);
for (let i = 0; i < numSimulations; i++) {
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
simulateSeeded16TeamBracket(field, counts, parityFactor);
}
}
return toResults(participantIds, counts, numSimulations);
}
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {
if (matches.length === 0) return null;
const r16 = findRound(matches, ROUND_OF_16_NAMES);
const qf = findRound(matches, QUARTERFINAL_NAMES);
const sf = findRound(matches, SEMIFINAL_NAMES);
const final = findRound(matches, FINAL_NAMES);
if (r16.length === 0 && qf.length === 0 && sf.length === 0 && final.length === 0) return null;
if (r16.length !== 8 || qf.length !== 4 || sf.length !== 2 || final.length !== 1) {
throw new Error(
`College hockey bracket must have 8 Round of 16, 4 Quarterfinal, 2 Semifinal, and 1 Final match. ` +
`Found ${r16.length}/${qf.length}/${sf.length}/${final.length}.`
);
}
return { r16, qf, sf, final: final[0] };
}
private getParticipantIdsFromBracket(rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }): string[] {
const ids = new Set<string>();
const allMatches = [...rounds.r16, ...rounds.qf, ...rounds.sf, rounds.final];
for (const match of allMatches) {
if (match.participant1Id) ids.add(match.participant1Id);
if (match.participant2Id) ids.add(match.participant2Id);
if (match.winnerId) ids.add(match.winnerId);
if (match.loserId) ids.add(match.loserId);
}
return [...ids];
}
}
export { COLLEGE_HOCKEY_TEMPLATE_ID };