brackt/app/services/bracket-simulator.ts
Chris Parsons 618bc57ec1
Replace console.* with structured logger, fix no-inferrable-types (closes #98) (#199)
- Add app/lib/logger.ts: dev passes through to console; prod routes errors
  to Sentry.captureException and warnings to Sentry.captureMessage, with
  extra context preserved. Uses captureMessage (not captureException) for
  string-only args to avoid fabricated stack traces.
- Add server/logger.ts: dev passes through; prod silences log/info but
  keeps warn/error on stderr (Sentry not initialized in that process).
- Replace all console.* calls across 44 app files and 4 server files.
- Upgrade no-console from warn → error in oxlint; exempt logger files and
  scripts/** via overrides.
- Add typescript/no-inferrable-types rule; fix violations in services and
  simulators. Exempt test files (intentional string widening for switch/if
  tests would break under literal type inference).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:41:39 -07:00

345 lines
10 KiB
TypeScript

/**
* Bracket Simulator
*
* Monte Carlo simulation of single-elimination playoff brackets.
* Supports NHL (8 teams) and NFL (14 teams) formats.
*
* The simulator:
* 1. Takes teams with Elo ratings
* 2. Simulates the bracket many times (typically 100,000)
* 3. Tracks placement frequencies (1st, 2nd, 3-4, 5-8, etc.)
* 4. Returns probability distribution for each team
*/
import { eloWinProbability } from './probability-engine';
/**
* Team with Elo rating for simulation
*/
export interface TeamForSimulation {
participantId: string;
elo: number;
}
/**
* Bracket format configurations
*/
export type BracketFormat = 'nhl-8' | 'nfl-14';
/**
* Result of a single simulation
* Maps participantId to their placement (1 = champion, 2 = runner-up, etc.)
*/
export type SimulationResult = Map<string, number>;
/**
* Aggregated results across all simulations
* For each participant, tracks how many times they finished in each placement
*/
export interface PlacementCounts {
[participantId: string]: {
1: number; // Champion
2: number; // Runner-up
3: number; // Semifinal loser (tied 3-4)
4: number; // Semifinal loser (tied 3-4)
5: number; // Quarterfinal loser (tied 5-8)
6: number; // Quarterfinal loser (tied 5-8)
7: number; // Quarterfinal loser (tied 5-8)
8: number; // Quarterfinal loser (tied 5-8)
};
}
/**
* Probability distribution for a single participant
* Array of 8 probabilities [P(1st), P(2nd), P(3rd), P(4th), P(5th), P(6th), P(7th), P(8th)]
*/
export type ProbabilityDistribution = [number, number, number, number, number, number, number, number];
/**
* Final simulation results
* Maps participantId to their probability distribution
*/
export type SimulationResults = Map<string, ProbabilityDistribution>;
/**
* Simulate a single head-to-head matchup
*
* @param team1 First team
* @param team2 Second team
* @returns Winner of the matchup
*/
function simulateMatchup(team1: TeamForSimulation, team2: TeamForSimulation): TeamForSimulation {
const team1WinProb = eloWinProbability(team1.elo, team2.elo);
const random = Math.random();
return random < team1WinProb ? team1 : team2;
}
/**
* Simulate a single round of the bracket
*
* @param teams Teams in this round (must be even number)
* @returns Winners advancing to next round
*/
function simulateRound(teams: TeamForSimulation[]): TeamForSimulation[] {
if (teams.length % 2 !== 0) {
throw new Error('Number of teams must be even for bracket round');
}
const winners: TeamForSimulation[] = [];
// Simulate each matchup
for (let i = 0; i < teams.length; i += 2) {
const winner = simulateMatchup(teams[i], teams[i + 1]);
winners.push(winner);
}
return winners;
}
/**
* Simulate an entire 8-team bracket
*
* Placement determination:
* - 1st: Finals winner
* - 2nd: Finals loser
* - 3-4: Semifinal losers (tied)
* - 5-8: Quarterfinal losers (tied)
*
* @param teams 8 teams in bracket order
* @returns Map of participantId to placement
*/
function simulate8TeamBracket(teams: TeamForSimulation[]): SimulationResult {
if (teams.length !== 8) {
throw new Error('8-team bracket requires exactly 8 teams');
}
const placements = new Map<string, number>();
// Quarterfinals: 8 → 4
const semifinalists = simulateRound(teams);
const quarterfinalsLosers = teams.filter(t => !semifinalists.some(s => s.participantId === t.participantId));
// Quarterfinal losers tie for 5-8
quarterfinalsLosers.forEach(team => placements.set(team.participantId, 5));
// Semifinals: 4 → 2
const finalists = simulateRound(semifinalists);
const semifinalsLosers = semifinalists.filter(t => !finalists.some(f => f.participantId === t.participantId));
// Semifinal losers tie for 3-4
semifinalsLosers.forEach(team => placements.set(team.participantId, 3));
// Finals: 2 → 1
const champion = simulateMatchup(finalists[0], finalists[1]);
const runnerUp = finalists.find(t => t.participantId !== champion.participantId);
if (!runnerUp) throw new Error("Runner-up not found in finalists");
placements.set(champion.participantId, 1);
placements.set(runnerUp.participantId, 2);
return placements;
}
/**
* Initialize placement counts for participants
*
* @param participantIds List of participant IDs
* @returns Placement counts initialized to 0
*/
function initializePlacementCounts(participantIds: string[]): PlacementCounts {
const counts: PlacementCounts = {};
participantIds.forEach(id => {
counts[id] = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 };
});
return counts;
}
/**
* Convert placement counts to probability distributions
*
* @param counts Placement counts from simulations
* @param totalSimulations Total number of simulations run
* @returns Probability distribution for each participant
*/
function countsToDistributions(
counts: PlacementCounts,
totalSimulations: number
): SimulationResults {
const distributions = new Map<string, ProbabilityDistribution>();
Object.entries(counts).forEach(([participantId, placementCounts]) => {
const distribution: ProbabilityDistribution = [
placementCounts[1] / totalSimulations,
placementCounts[2] / totalSimulations,
placementCounts[3] / totalSimulations,
placementCounts[4] / totalSimulations,
placementCounts[5] / totalSimulations,
placementCounts[6] / totalSimulations,
placementCounts[7] / totalSimulations,
placementCounts[8] / totalSimulations,
];
distributions.set(participantId, distribution);
});
return distributions;
}
/**
* Run Monte Carlo simulation of playoff bracket
*
* @param teams Teams with Elo ratings
* @param format Bracket format ('nhl-8' or 'nfl-14')
* @param simulations Number of simulations to run (default: 100,000)
* @param onProgress Optional progress callback (called every 10,000 simulations)
* @returns Probability distribution for each team
*
* @example
* const teams = [
* { participantId: '1', elo: 1650 },
* { participantId: '2', elo: 1600 },
* // ... 6 more teams
* ];
* const results = await simulateBracket(teams, 'nhl-8', 100000);
* // Map { '1' => [0.25, 0.18, 0.15, ...], '2' => [0.18, 0.20, ...], ... }
*/
export async function simulateBracket(
teams: TeamForSimulation[],
format: BracketFormat = 'nhl-8',
simulations = 100000,
onProgress?: (current: number, total: number) => void
): Promise<SimulationResults> {
// Validate input
if (format === 'nhl-8' && teams.length !== 8) {
throw new Error('NHL format requires exactly 8 teams');
}
if (simulations <= 0) {
throw new Error('Number of simulations must be positive');
}
// Initialize placement counters
const participantIds = teams.map(t => t.participantId);
const placementCounts = initializePlacementCounts(participantIds);
// Run simulations
for (let i = 0; i < simulations; i++) {
// Simulate bracket (currently only 8-team supported)
const result = simulate8TeamBracket(teams);
// Record placements
result.forEach((placement, participantId) => {
// Handle ties: placement 3 or 4 both count as tied-3rd
// placement 5-8 all count as tied-5th
if (placement >= 3 && placement <= 4) {
placementCounts[participantId][3] += 0.5; // Split tied placements
placementCounts[participantId][4] += 0.5;
} else if (placement >= 5 && placement <= 8) {
// Split across all 5-8 placements
placementCounts[participantId][5] += 0.25;
placementCounts[participantId][6] += 0.25;
placementCounts[participantId][7] += 0.25;
placementCounts[participantId][8] += 0.25;
} else {
// 1st or 2nd place - no ties
placementCounts[participantId][placement as 1 | 2] += 1;
}
});
// Report progress every 10,000 simulations
if (onProgress && (i + 1) % 10000 === 0) {
onProgress(i + 1, simulations);
}
}
// Convert counts to probabilities
const results = countsToDistributions(placementCounts, simulations);
// Final progress callback
if (onProgress) {
onProgress(simulations, simulations);
}
return results;
}
/**
* Simulate bracket synchronously (blocking)
*
* Use this for smaller simulation counts or when you don't need progress updates.
* For large simulations (100k+), prefer the async version with progress callbacks.
*
* @param teams Teams with Elo ratings
* @param format Bracket format
* @param simulations Number of simulations to run
* @returns Probability distribution for each team
*/
export function simulateBracketSync(
teams: TeamForSimulation[],
format: BracketFormat = 'nhl-8',
simulations = 100000
): SimulationResults {
// Validate input
if (format === 'nhl-8' && teams.length !== 8) {
throw new Error('NHL format requires exactly 8 teams');
}
if (simulations <= 0) {
throw new Error('Number of simulations must be positive');
}
// Initialize placement counters
const participantIds = teams.map(t => t.participantId);
const placementCounts = initializePlacementCounts(participantIds);
// Run simulations
for (let i = 0; i < simulations; i++) {
const result = simulate8TeamBracket(teams);
// Record placements
result.forEach((placement, participantId) => {
if (placement >= 3 && placement <= 4) {
placementCounts[participantId][3] += 0.5;
placementCounts[participantId][4] += 0.5;
} else if (placement >= 5 && placement <= 8) {
placementCounts[participantId][5] += 0.25;
placementCounts[participantId][6] += 0.25;
placementCounts[participantId][7] += 0.25;
placementCounts[participantId][8] += 0.25;
} else {
placementCounts[participantId][placement as 1 | 2] += 1;
}
});
}
// Convert counts to probabilities
return countsToDistributions(placementCounts, simulations);
}
/**
* Get expected placement counts from probability distribution
*
* Useful for debugging/validation
*
* @param distribution Probability distribution
* @param simulations Number of simulations that produced this distribution
* @returns Expected count for each placement
*/
export function getExpectedCounts(
distribution: ProbabilityDistribution,
simulations: number
): Record<number, number> {
return {
1: Math.round(distribution[0] * simulations),
2: Math.round(distribution[1] * simulations),
3: Math.round(distribution[2] * simulations),
4: Math.round(distribution[3] * simulations),
5: Math.round(distribution[4] * simulations),
6: Math.round(distribution[5] * simulations),
7: Math.round(distribution[6] * simulations),
8: Math.round(distribution[7] * simulations),
};
}