/** * MLB Playoff Simulator * * Monte Carlo simulation of the MLB season and playoffs including seeding * projection for the current season (2026). * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings * 3. Load sourceElo ratings from seasonParticipantExpectedValues * 4. Match participant names to hardcoded team data (RDif + league/division) * 5. For each simulation: * a. For each league (AL/NL), simulate remaining regular season games for * every team using Binomial sampling, giving final projected wins. * b. Division winner = best record in each division (3 per league). * Wild card = next 3 best records among non-division-winners per league. * c. Seed division winners 1–3 by final wins (best = seed 1); * WC teams 4–6 by final wins. * d. Run the playoff bracket per league: * - Wildcard Round (best-of-3): 3 vs 6, 4 vs 5 (seeds 1 & 2 get byes) * - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other * - League Championship Series (best-of-7) * e. World Series (best-of-7): AL champ vs NL champ * 6. Track placement counts per scoring tier * 7. Convert counts to probability distributions * * Win probability (log5 formula): * Step 1 — convert projected RDif to win rate for playoff matchups: * winRate = clamp(0.5 + rdif / RDIF_DIVISOR, 0.01, 0.99) * RDIF_DIVISOR compresses team strengths toward .500 for playoff parity. * Step 2 — Bill James log5 head-to-head probability: * P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB) * * Regular season simulation (seeding): * Each team's raw per-game win rate is derived from sourceElo (if set) or * from the hardcoded RDif using SEEDING_RDIF_SCALE ≈ 10 runs/win × 162 games. * Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a * Binomial distribution. This makes playoff seeding respond to both current * standings and user-entered projected wins. * * Futures blending: * If sourceOdds are stored in participantExpectedValues for this season, * the per-game win probability for playoff series is blended: * P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb * RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3. * * Placement tiers → SimulationProbabilities mapping: * probFirst = World Series champion (1 per sim) * probSecond = World Series loser (1 per sim) * probThird / probFourth = LCS losers (2 per sim — AL + NL, split evenly) * probFifth–probEighth = Division Series losers (4 per sim, split evenly) * Wildcard Round losers → all 0 (score 0 points, same as non-playoff teams) * Missed playoffs → all 0 * * Team data keys: * rdif: FanGraphs Depth Charts projected run differential for 2026. * Used as fallback for seeding win rate and for playoff series win probability. * league/division: Static MLB structure — does not change season-to-season. * * Sources: * rdif: https://www.fangraphs.com/standings/projected-standings * Update rdif at the start of each season. * * Divisions: * AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays * AL Central: Royals, Guardians, Twins, Tigers, White Sox * AL West: Astros, Mariners, Rangers, Angels, Athletics * NL East: Phillies, Braves, Mets, Nationals, Marlins * NL Central: Brewers, Cubs, Cardinals, Reds, Pirates * NL West: Dodgers, Padres, Diamondbacks, Giants, Rockies */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; import { logger } from "~/lib/logger"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; // ─── Simulation parameters ──────────────────────────────────────────────────── const DEFAULT_NUM_SIMULATIONS = 50_000; const TOTAL_SEASON_GAMES = 162; /** * Controls how much projected run differential spreads teams away from .500 * for single-game playoff matchups. * * Effect on Dodgers (RDif +137): * 1620 → win rate 0.585 (raw Pythagorean — too dominant for playoff matchups) * 8000 → win rate 0.517 (current — near coin-flip vs any playoff team) */ const RDIF_DIVISOR = 8000; /** * Scale for converting RDif to a raw per-game win rate for regular-season * seeding simulation. Unlike RDIF_DIVISOR (which compresses for playoff * parity), this uses ~10 runs/win × 162 games to give realistic season win%. */ const SEEDING_RDIF_SCALE = 1620; // ─── Team data (2026 pre-season — FanGraphs Depth Charts) ──────────────────── // // rdif: Projected run differential from FanGraphs Depth Charts. // Source: https://www.fangraphs.com/standings/projected-standings // // league/division: Static MLB structure — update only if teams change leagues. // // Update rdif at the start of each season. interface MlbTeamData { league: "AL" | "NL"; division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West"; rdif: number; // 2026 FanGraphs Depth Charts projected run differential } const TEAMS_DATA: Record = { // ── American League East ─────────────────────────────────────────────────── "New York Yankees": { league: "AL", division: "AL East", rdif: 67 }, "Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23 }, "Boston Red Sox": { league: "AL", division: "AL East", rdif: 48 }, "Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2 }, "Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37 }, // ── American League Central ──────────────────────────────────────────────── "Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8 }, "Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38 }, "Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15 }, "Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26 }, "Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112 }, // ── American League West ─────────────────────────────────────────────────── "Houston Astros": { league: "AL", division: "AL West", rdif: -1 }, "Seattle Mariners": { league: "AL", division: "AL West", rdif: 66 }, "Texas Rangers": { league: "AL", division: "AL West", rdif: 20 }, "Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65 }, "Athletics": { league: "AL", division: "AL West", rdif: -17 }, // ── National League East ─────────────────────────────────────────────────── "Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51 }, "Atlanta Braves": { league: "NL", division: "NL East", rdif: 67 }, "New York Mets": { league: "NL", division: "NL East", rdif: 71 }, "Washington Nationals": { league: "NL", division: "NL East", rdif: -113 }, "Miami Marlins": { league: "NL", division: "NL East", rdif: -48 }, // ── National League Central ──────────────────────────────────────────────── "Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9 }, "Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23 }, "St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55 }, "Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31 }, "Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13 }, // ── National League West ─────────────────────────────────────────────────── "Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137 }, "San Diego Padres": { league: "NL", division: "NL West", rdif: -9 }, "Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5 }, "San Francisco Giants": { league: "NL", division: "NL West", rdif: 3 }, "Colorado Rockies": { league: "NL", division: "NL West", rdif: -173 }, }; // ─── Public helpers (exported for unit testing) ─────────────────────────────── /** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */ export function normalizeTeamName(name: string): string { return name.toLowerCase().trim().replace(/\s+/g, " "); } /** Look up team data by participant name (case-insensitive). */ export function getTeamData(name: string): MlbTeamData | undefined { const normalized = normalizeTeamName(name); for (const [teamName, data] of Object.entries(TEAMS_DATA)) { if (normalizeTeamName(teamName) === normalized) return data; } return undefined; } /** * Convert projected run differential to a compressed win rate for playoff matchup probability. * Uses RDIF_DIVISOR to control how much team strength spreads away from .500. * Clamped to [0.01, 0.99] to avoid degenerate log5 values. * Exported for unit testing. */ export function winRateFromRDif(rdif: number): number { return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR)); } /** * Convert projected run differential to a raw per-game win rate for regular-season * seeding simulation. Uses SEEDING_RDIF_SCALE (~10 runs/win × 162 games) which * gives a realistic season win percentage rather than the playoff-compressed value. * Exported for unit testing. */ export function rawWinRateFromRDif(rdif: number): number { return Math.min(0.99, Math.max(0.01, 0.5 + rdif / SEEDING_RDIF_SCALE)); } /** * Derive a raw per-game win rate directly from an Elo rating. * This is the inverse Elo formula, returning the same win probability * that was originally used to compute the Elo from projected wins. * Exported for unit testing. */ export function rawWinRateFromElo(elo: number): number { return 1 / (1 + Math.pow(10, (1500 - elo) / 400)); } /** * Convert an Elo rating to an equivalent projected run differential. * Uses the standard Elo win probability formula (parity factor 400, average Elo 1500), * then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR. * Exported for unit testing. */ export function eloToRDif(elo: number): number { return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR; } /** * Bill James log5 head-to-head win probability for team A over team B, * given their projected run differentials. * P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB) * Exported for unit testing. */ export function rdifWinProbability(rdifA: number, rdifB: number): number { const wA = winRateFromRDif(rdifA); const wB = winRateFromRDif(rdifB); // Denominator is always > 0 when wA and wB are in (0,1). return (wA - wA * wB) / (wA + wB - 2 * wA * wB); } /** * Sample the number of wins from n independent Bernoulli trials each with * probability p. Used to project remaining regular-season wins per team. * * For n ≥ 30 (where CLT applies well: n·p ≥ 5 and n·(1-p) ≥ 5 for any * realistic win rate), uses a Box-Muller normal approximation — 2 Math.random() * calls per team instead of n, cutting the seeding phase from ~243M to ~3M * Math.random() calls per 50K-simulation run. For small n the exact Bernoulli * loop is used. Both paths produce integer output clamped to [0, n]. * Exported for unit testing. */ export function sampleBinomial(n: number, p: number): number { if (n <= 0) return 0; if (p <= 0) return 0; if (p >= 1) return n; if (n >= 30) { // Normal approximation via Box-Muller transform. // Guard u1 > 0 to avoid log(0) = -Infinity. const u1 = Math.max(Number.EPSILON, Math.random()); const u2 = Math.random(); const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); return Math.round(Math.min(n, Math.max(0, n * p + Math.sqrt(n * p * (1 - p)) * z))); } // Exact Bernoulli trials for small n. let wins = 0; for (let i = 0; i < n; i++) { if (Math.random() < p) wins++; } return wins; } // ─── Internal types ─────────────────────────────────────────────────────────── interface TeamEntry { id: string; name: string; data: MlbTeamData | undefined; originalSeed?: number; currentWins: number; // from regularSeasonStandings (0 pre-season) remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed } /** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */ function getEntryRDif(entry: TeamEntry): number { return entry.data?.rdif ?? 0; } // ─── Series simulators ───────────────────────────────────────────────────────── type SeriesResult = { winner: TeamEntry; loser: TeamEntry }; /** Simulate a series where the winner must reach `winsNeeded` wins. */ function simSeries( a: TeamEntry, b: TeamEntry, winsNeeded: number, gameWinProb: (a: TeamEntry, b: TeamEntry) => number ): SeriesResult { const prob = gameWinProb(a, b); let winsA = 0; let winsB = 0; while (winsA < winsNeeded && winsB < winsNeeded) { if (Math.random() < prob) winsA++; else winsB++; } return winsA === winsNeeded ? { winner: a, loser: b } : { winner: b, loser: a }; } /** Wildcard Round: best-of-3 (first to 2 wins). */ export function simBo3( a: TeamEntry, b: TeamEntry, gameWinProb: (a: TeamEntry, b: TeamEntry) => number ): SeriesResult { return simSeries(a, b, 2, gameWinProb); } /** Division Series: best-of-5 (first to 3 wins). */ export function simBo5( a: TeamEntry, b: TeamEntry, gameWinProb: (a: TeamEntry, b: TeamEntry) => number ): SeriesResult { return simSeries(a, b, 3, gameWinProb); } /** League Championship + World Series: best-of-7 (first to 4 wins). */ export function simBo7( a: TeamEntry, b: TeamEntry, gameWinProb: (a: TeamEntry, b: TeamEntry) => number ): SeriesResult { return simSeries(a, b, 4, gameWinProb); } // ─── League bracket builder ─────────────────────────────────────────────────── /** * Draws the 6-team playoff field for one league (AL or NL) by simulating the * remaining regular season for each team. * * Steps: * 1. For each team, sample remaining wins from Binomial(remainingGames, seedingWinRate). * A tiny uniform noise [0, 0.001) is added to break integer ties randomly. * 2. Division winner = team with highest final wins in each division (3 per league). * 3. Wild card = next 3 highest final wins among non-division-winners. * 4. Seed division winners 1–3 by final wins descending (best = seed 1). * 5. Seed WC teams 4–6 by final wins descending. * * Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated * with originalSeed, or undefined if the league has fewer than 3 eligible WC teams. */ function drawLeaguePlayoffField( leagueTeams: TeamEntry[], getSeedingWinRate: (t: TeamEntry) => number ): TeamEntry[] | undefined { // Group by division const divMap = new Map(); for (const t of leagueTeams) { const div = t.data?.division ?? "Unknown"; if (!divMap.has(div)) divMap.set(div, []); divMap.get(div)?.push(t); } // Simulate remaining games for each team; tiny noise breaks integer win ties const finalWins = new Map(); for (const t of leagueTeams) { finalWins.set( t.id, t.currentWins + sampleBinomial(t.remainingGames, getSeedingWinRate(t)) + Math.random() * 0.001 ); } // Division winners: best record per division const divisionWinners: TeamEntry[] = []; const divWinnerSet = new Set(); for (const divTeams of divMap.values()) { const winner = divTeams.reduce((best, t) => (finalWins.get(t.id) ?? 0) > (finalWins.get(best.id) ?? 0) ? t : best ); divisionWinners.push(winner); divWinnerSet.add(winner); } // Wild card: top 3 non-division-winners by final wins const wcTeams = leagueTeams .filter((t) => !divWinnerSet.has(t)) .toSorted((a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)) .slice(0, 3); if (wcTeams.length < 3) return undefined; // Seed: div winners 1–3 and WC teams 4–6, both by final wins descending const sortedDivWinners = divisionWinners.toSorted( (a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0) ); const sortedWcTeams = wcTeams.toSorted( (a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0) ); const seeds = [...sortedDivWinners, ...sortedWcTeams]; return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 })); } /** * Simulate the full playoff bracket for one league. * * Bracket structure: * Wildcard Round (best-of-3): seeds 3v6, 4v5 — seeds 1 & 2 get byes * Division Series (best-of-5): 1 vs lowest-seeded WC survivor; 2 vs other * League Championship Series (best-of-7) * * Returns { lcWinner, lcLoser, dsLosers[2], wcLosers[2] } */ function simLeagueBracket( seeds: TeamEntry[], gameWinProb: (a: TeamEntry, b: TeamEntry) => number ): { lcWinner: TeamEntry; lcLoser: TeamEntry; dsLosers: [TeamEntry, TeamEntry]; wcLosers: [TeamEntry, TeamEntry]; } { const [s1, s2, s3, s4, s5, s6] = seeds; // Wildcard Round (best-of-3) const wc1 = simBo3(s3, s6, gameWinProb); const wc2 = simBo3(s4, s5, gameWinProb); // Division Series: re-seed — seed 1 plays the worse WC survivor, seed 2 plays the better one. // Sort by originalSeed ascending: [0] = lower seed number = better team, [1] = worse team. const survivors = [wc1.winner, wc2.winner].toSorted( (a, b) => (a.originalSeed ?? 0) - (b.originalSeed ?? 0) ); const ds1 = simBo5(s1, survivors[1], gameWinProb); // 1 vs worse remaining (higher seed number) const ds2 = simBo5(s2, survivors[0], gameWinProb); // 2 vs better remaining (lower seed number) // League Championship Series (best-of-7) const lcs = simBo7(ds1.winner, ds2.winner, gameWinProb); return { lcWinner: lcs.winner, lcLoser: lcs.loser, dsLosers: [ds1.loser, ds2.loser], wcLosers: [wc1.loser, wc2.loser], }; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class MLBSimulator implements Simulator { async simulate(sportsSeasonId: string, config: Record = {}): Promise { const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const db = database(); // 1. Load all participants for this sports season. const participantRows = await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); if (participantRows.length === 0) { throw new Error( `No participants found for sports season ${sportsSeasonId}. ` + `Add MLB teams as participants before running simulation.` ); } const participantIds = participantRows.map((r) => r.id); const participantIdSet = new Set(participantIds); // 2. Load current standings (wins + games played) to seed the regular-season simulation. const standings = await getRegularSeasonStandings(sportsSeasonId); const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s])); const teams: TeamEntry[] = participantRows.map((r) => { const standing = standingsByParticipantId.get(r.id); const gamesPlayed = standing?.gamesPlayed ?? 0; return { id: r.id, name: r.name, data: getTeamData(r.name), currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed), }; }); // Warn about participants that don't match any hardcoded team. const unrecognized = teams.filter((t) => !t.data); if (unrecognized.length > 0) { logger.warn( `[MLBSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` + unrecognized.map((t) => t.name).join(", ") ); } // Warn when standings exist for some teams but not all recognized ones — this // usually means a partial sync. Missing teams fall back to 0 wins / 162 // remaining games (league-average strength), which distorts mid-season seeding. if (standings.length > 0) { const missingStandings = teams.filter( (t) => t.data && !standingsByParticipantId.has(t.id) ); if (missingStandings.length > 0) { logger.warn( `[MLBSimulator] ${missingStandings.length} recognized team(s) have no standings row — ` + `seeding will use 0 wins / 162 remaining games for: ` + missingStandings.map((t) => t.name).join(", ") ); } } const alTeams = teams.filter((t) => t.data?.league === "AL"); const nlTeams = teams.filter((t) => t.data?.league === "NL"); if (alTeams.length < 6 || nlTeams.length < 6) { throw new Error( `Each league needs at least 6 recognized participants (3 division winners + 3 wild cards) ` + `(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` + `Add MLB teams before running simulation.` ); } // ─── Strength from the resolved Elo ──────────────────────────────────────── // sourceElo is the single Elo produced by the input policy — already a blend // of any raw Elo / projections / futures odds — so the simulator just reads it // and no longer blends odds itself. const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif) // for playoff series win probability. const sourceEloRDifMap = new Map(); // Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding. const rawWinRateMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) { sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo)); rawWinRateMap.set(r.participantId, rawWinRateFromElo(r.sourceElo)); } } // ─── Helpers ────────────────────────────────────────────────────────────── /** Effective RDif for playoff series: prefer sourceElo-derived value over hardcoded rdif. */ const effectiveRDif = (entry: TeamEntry): number => sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry); /** * Raw per-game win rate for regular-season seeding simulation. * Uses sourceElo-derived rate if available; falls back to hardcoded rdif * with SEEDING_RDIF_SCALE (Pythagorean approximation). */ const seedingWinRate = (entry: TeamEntry): number => rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry)); /** * Per-game win probability for team A over team B in a playoff series, from * the (resolved-Elo-derived) run differential. */ const gameWinProb = (a: TeamEntry, b: TeamEntry): number => rdifWinProbability(effectiveRDif(a), effectiveRDif(b)); // ─── Placement count maps ────────────────────────────────────────────────── const championCounts = new Map(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const lcsLoserCounts = new Map(participantIds.map((id) => [id, 0])); const dsLoserCounts = new Map(participantIds.map((id) => [id, 0])); // WC losers are not tracked — they score 0 points // ─── Monte Carlo simulation loop ─────────────────────────────────────────── let effectiveN = 0; for (let s = 0; s < numSimulations; s++) { const alField = drawLeaguePlayoffField(alTeams, seedingWinRate); const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate); if (!alField || !nlField) continue; // degenerate draw — skip effectiveN++; // Simulate both league brackets const alResult = simLeagueBracket(alField, gameWinProb); const nlResult = simLeagueBracket(nlField, gameWinProb); // LCS losers (3rd/4th tier) lcsLoserCounts.set(alResult.lcLoser.id, (lcsLoserCounts.get(alResult.lcLoser.id) ?? 0) + 1); lcsLoserCounts.set(nlResult.lcLoser.id, (lcsLoserCounts.get(nlResult.lcLoser.id) ?? 0) + 1); // DS losers (5th–8th tier) for (const loser of [...alResult.dsLosers, ...nlResult.dsLosers]) { dsLoserCounts.set(loser.id, (dsLoserCounts.get(loser.id) ?? 0) + 1); } // World Series (best-of-7) const ws = simBo7(alResult.lcWinner, nlResult.lcWinner, gameWinProb); championCounts.set(ws.winner.id, (championCounts.get(ws.winner.id) ?? 0) + 1); finalistCounts.set(ws.loser.id, (finalistCounts.get(ws.loser.id) ?? 0) + 1); } if (effectiveN === 0) { throw new Error( "All simulations produced degenerate brackets. " + "Check that each division has teams with non-degenerate RDif values." ); } // ─── Convert counts to probability distributions ─────────────────────────── // // probFirst/Second → count / N (1 team per sim) // probThird/Fourth → count / (2 * N) (2 LCS losers per sim: AL + NL) // probFifth–Eighth → count / (4 * N) (4 DS losers per sim: 2 per league) // WC losers → 0 (all probs zero) const N = effectiveN; const results: SimulationResult[] = participantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const lcs = lcsLoserCounts.get(participantId) ?? 0; const ds = dsLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / N, probSecond: f / N, probThird: lcs / (2 * N), probFourth: lcs / (2 * N), probFifth: ds / (4 * N), probSixth: ds / (4 * N), probSeventh: ds / (4 * N), probEighth: ds / (4 * N), }, source: "mlb_bracket_monte_carlo", }; }); // ─── Per-position normalization ──────────────────────────────────────────── const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ]; for (const key of positionKeys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); const residual = 1.0 - colSum; if (residual !== 0) { const maxResult = results.reduce((best, r) => r.probabilities[key] > best.probabilities[key] ? r : best ); maxResult.probabilities[key] += residual; } } return results; } }