brackt/app/services/simulations/nll-simulator.ts

645 lines
25 KiB
TypeScript
Raw Normal View History

/**
* NLL Season + Playoffs Simulator
*
* Monte Carlo simulation of the NLL regular season and playoffs.
*
* Three modes, auto-detected at runtime:
*
* Mode 1: Bracket-Aware
* Used when a playoff_game scoring event with generated matches exists.
* Reads completed QF/SF/Finals results; simulates remaining games and series.
* Best-of-3 series respect completed playoff_match_games rows.
*
* Mode 2: Known-Seed
* Used when no bracket exists but all 8 seeds 1-8 are set via the seed input.
* Simulates the playoff bracket directly from those seeds.
*
* Mode 3: Regular-Season Projection (default)
* Simulates remaining regular season games per team using Elo vs average (1500),
* blending with projectedWins when provided. Top 8 teams qualify. Then simulates
* the NLL playoff bracket.
*
* NLL playoff bracket format:
* Quarterfinals (single game): 1v8, 2v7, 3v6, 4v5
* Semifinal arms: (1/8 winner vs 4/5 winner), (2/7 winner vs 3/6 winner)
* Semifinals (best-of-3) and Finals (best-of-3)
*
* Probability mapping:
* probFirst = NLL champion (1 per sim)
* probSecond = Finals loser (1 per sim)
* probThird/Fourth = Semifinal losers (2 per sim split evenly)
* probFifthEighth = Quarterfinal losers (4 per sim split evenly)
* Missed playoffs all 0
*
* Round name constants (ROUND_*) must match the bracket template used to
* generate playoff matches. If the template changes round names, update these.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;
/** Maximum preseason weight given to projectedWins in the blend. Decays to 0 by season end. */
const PROJECTED_WINS_WEIGHT = 0.65;
/** Fallback Elo used when a bracket participant has no sourceElo on record. */
const FALLBACK_ELO = 1400;
// ─── Round name constants ─────────────────────────────────────────────────────
// These must match the round strings generated by the nll_bracket bracket template.
export const ROUND_QUARTERFINALS = "Quarterfinals" as const;
export const ROUND_SEMIFINALS = "Semifinals" as const;
export const ROUND_FINALS = "Finals" as const;
// ─── Structural types used by resolve helpers ─────────────────────────────────
/** Minimal match fields needed by the resolve helpers. */
export interface MatchSnapshot {
id: string;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
/** Minimal game result needed by the resolve helpers. */
export interface GameSnapshot {
winnerId: string | null;
}
// ─── Public helpers (exported for testability) ────────────────────────────────
export function nllGameWinProbability(
eloA: number,
eloB: number,
parityFactor = PARITY_FACTOR
): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
export interface SimTeam {
participantId: string;
elo: number;
seed?: number;
}
export function simulateNllGame(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR
): { winner: SimTeam; loser: SimTeam } {
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
if (Math.random() < pA) return { winner: teamA, loser: teamB };
return { winner: teamB, loser: teamA };
}
export interface BestOfThreeState {
winsA: number;
winsB: number;
}
/** Simulate a best-of-3 series from an optional existing state. First to 2 wins. */
export function simulateBestOfThree(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR,
existing: BestOfThreeState = { winsA: 0, winsB: 0 }
): { winner: SimTeam; loser: SimTeam; gamesPlayed: number } {
let wA = existing.winsA;
let wB = existing.winsB;
let gamesPlayed = 0;
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
while (wA < 2 && wB < 2) {
if (Math.random() < pA) wA++; else wB++;
gamesPlayed++;
}
return wA === 2
? { winner: teamA, loser: teamB, gamesPlayed }
: { winner: teamB, loser: teamA, gamesPlayed };
}
export interface SeedEntry {
participantId: string;
elo: number;
seed: number;
}
/**
* Build the 4 NLL Quarterfinal matchups from seeds 1-8.
* Bracket arms are fixed:
* QF1: 1 vs 8 SF arm A
* QF2: 4 vs 5 SF arm A
* QF3: 2 vs 7 SF arm B
* QF4: 3 vs 6 SF arm B
*/
export function buildNllBracketFromSeeds(seeds: SeedEntry[]): [
[SeedEntry, SeedEntry], // QF1: 1 vs 8
[SeedEntry, SeedEntry], // QF2: 4 vs 5
[SeedEntry, SeedEntry], // QF3: 2 vs 7
[SeedEntry, SeedEntry], // QF4: 3 vs 6
] {
const bySeeds = new Map(seeds.map((s) => [s.seed, s]));
const s = (n: number) => {
const entry = bySeeds.get(n);
if (!entry) throw new Error(`Seed ${n} not found in bracket seeds.`);
return entry;
};
return [
[s(1), s(8)],
[s(4), s(5)],
[s(2), s(7)],
[s(3), s(6)],
];
}
export interface NllPlayoffResult {
champion: string;
finalist: string;
sfLosers: [string, string];
qfLosers: [string, string, string, string];
}
/** Simulate the full NLL playoff bracket from 8 seeded teams. */
export function simulateNllPlayoffs(
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR
): NllPlayoffResult {
const [[qf1a, qf1b], [qf2a, qf2b], [qf3a, qf3b], [qf4a, qf4b]] =
buildNllBracketFromSeeds(seeds);
const qf1 = simulateNllGame(qf1a, qf1b, parityFactor);
const qf2 = simulateNllGame(qf2a, qf2b, parityFactor);
const qf3 = simulateNllGame(qf3a, qf3b, parityFactor);
const qf4 = simulateNllGame(qf4a, qf4b, parityFactor);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5)
const sfA = simulateBestOfThree(qf1.winner, qf2.winner, parityFactor);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6)
const sfB = simulateBestOfThree(qf3.winner, qf4.winner, parityFactor);
const final = simulateBestOfThree(sfA.winner, sfB.winner, parityFactor);
return {
champion: final.winner.participantId,
finalist: final.loser.participantId,
sfLosers: [sfA.loser.participantId, sfB.loser.participantId],
qfLosers: [
qf1.loser.participantId,
qf2.loser.participantId,
qf3.loser.participantId,
qf4.loser.participantId,
],
};
}
// ─── Bracket-aware resolve helpers (exported for testability) ─────────────────
/**
* Resolve a single-game QF match.
* Returns the stored result deterministically when the match is complete;
* simulates a single game otherwise.
*/
export function resolveQfMatch(
match: MatchSnapshot | undefined,
eloMap: Map<string, number>
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id;
const p2 = match?.participant2Id;
if (!p1 || !p2) {
throw new Error(
`NLL QF match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
);
return { winner: result.winner.participantId, loser: result.loser.participantId };
}
/**
* Resolve a best-of-3 SF or Finals match.
* Returns the stored result deterministically when the match is complete.
* Counts completed game rows (GameSnapshot[]) for partial series state; if a
* team already has 2 wins in the game rows, treats the series as done even if
* playoffMatches.isComplete has not been toggled yet.
* Simulates remaining games from the current series score otherwise.
*/
export function resolveBo3Match(
match: MatchSnapshot | undefined,
games: GameSnapshot[],
eloMap: Map<string, number>,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`NLL series match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
let winsP1 = 0;
let winsP2 = 0;
for (const g of games) {
if (g.winnerId === null) continue;
if (g.winnerId === p1) winsP1++;
else if (g.winnerId === p2) winsP2++;
}
// A team with 2 game wins has clinched the series.
if (winsP1 >= 2) return { winner: p1, loser: p2 };
if (winsP2 >= 2) return { winner: p2, loser: p1 };
const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
PARITY_FACTOR,
{ winsA: winsP1, winsB: winsP2 }
);
return { winner: winner.participantId, loser: loser.participantId };
}
// ─── Regular-season projection helper ────────────────────────────────────────
export interface TeamProjection {
participantId: string;
elo: number;
currentWins: number;
gamesPlayed: number;
projectedWins: number | null;
}
/** Simulate remaining regular season games and return the top-8 seeds. */
export function simulateRegularSeasonSeeds(
teams: TeamProjection[],
parityFactor = PARITY_FACTOR
): SeedEntry[] {
// Pre-compute tiebreaker jitter once per simulation for sort stability.
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
const projected = teams.map((t) => {
const remaining = Math.max(0, NLL_REGULAR_SEASON_GAMES - t.gamesPlayed);
const eloRate = eloWinProbabilityWithParity(t.elo, AVERAGE_OPPONENT_ELO, parityFactor);
// Decay the preseason projectedWins prior linearly as the season progresses.
// At gamesPlayed=0 it carries PROJECTED_WINS_WEIGHT (65%); by gamesPlayed=18
// it carries 0% so late-season projections rely purely on Elo. This prevents
// a stale preseason estimate from dominating when actual standings are available.
//
// The prior rate is computed over *remaining* games (not the full season) so
// that wins already accumulated are subtracted from the preseason expectation.
// If currentWins already meets or exceeds the projection, the prior is clamped
// to 0 and Elo takes over fully.
const completionFraction = t.gamesPlayed / NLL_REGULAR_SEASON_GAMES;
const projectedBlend =
t.projectedWins !== null ? PROJECTED_WINS_WEIGHT * (1 - completionFraction) : 0;
const priorRate =
t.projectedWins !== null && remaining > 0
? Math.min(1, Math.max(0, t.projectedWins - t.currentWins) / remaining)
: 0;
const winRate =
projectedBlend > 0 && priorRate > 0
? projectedBlend * priorRate + (1 - projectedBlend) * eloRate
: eloRate;
let additionalWins = 0;
for (let g = 0; g < remaining; g++) {
if (Math.random() < winRate) additionalWins++;
}
return {
participantId: t.participantId,
elo: t.elo,
finalWins: t.currentWins + additionalWins,
};
});
const sorted = projected.toSorted(
(a, b) =>
b.finalWins - a.finalWins ||
(jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0)
);
return sorted.slice(0, NLL_PLAYOFF_TEAMS).map((t, i) => ({
participantId: t.participantId,
elo: t.elo,
seed: i + 1,
}));
}
// ─── Simulator class ──────────────────────────────────────────────────────────
export class NLLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// ── Load Elo ratings from EV table (populated by prepareSimulatorInputsForRun) ──
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
}
}
if (eloMap.size === 0) {
throw new Error(
`No Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
);
}
// ── Load simulator inputs (projectedWins + seed) ──────────────────────────
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map<string, number | null>();
const seedMap = new Map<string, number>();
for (const input of simInputs) {
projectedWinsMap.set(input.participantId, input.projectedWins);
if (input.seed !== null && input.seed !== undefined) {
seedMap.set(input.participantId, input.seed);
}
}
// ── All participant IDs for counting ─────────────────────────────────────
const allIds = participants.map((p) => p.id);
// ── Mode detection ────────────────────────────────────────────────────────
// Mode 1: bracket-aware — a playoff_game scoring event with matches exists.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
if (bracketEvent) {
const bracketMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
if (bracketMatches.length > 0) {
return this.simulateBracketAware(allIds, eloMap, bracketMatches);
}
}
// Mode 2: known-seed — all 8 seeds 1-8 are explicitly set.
const knownSeeds: SeedEntry[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
const seed = seedMap.get(p.id);
if (elo !== undefined && seed !== undefined && seed >= 1 && seed <= 8) {
knownSeeds.push({ participantId: p.id, elo, seed });
}
}
if (
knownSeeds.length === NLL_PLAYOFF_TEAMS &&
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
) {
return this.simulateKnownSeeds(allIds, knownSeeds);
}
// Mode 3: regular-season projection (default, including preseason).
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
const teams: TeamProjection[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
if (elo === undefined) continue;
const standing = standingsMap.get(p.id);
teams.push({
participantId: p.id,
elo,
currentWins: standing?.wins ?? 0,
gamesPlayed: standing?.gamesPlayed ?? 0,
projectedWins: projectedWinsMap.get(p.id) ?? null,
});
}
if (teams.length === 0) {
throw new Error(
`No participants with Elo ratings found for season ${sportsSeasonId}.`
);
}
return this.simulateRegularSeason(allIds, teams);
}
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
private async simulateBracketAware(
allIds: string[],
eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[]
): Promise<SimulationResult[]> {
const db = database();
const qfMatches = allMatches
.filter((m) => m.round === ROUND_QUARTERFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const sfMatches = allMatches
.filter((m) => m.round === ROUND_SEMIFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const finalMatches = allMatches.filter((m) => m.round === ROUND_FINALS);
if (qfMatches.length !== 4 || sfMatches.length !== 2 || finalMatches.length !== 1) {
throw new Error(
`NLL bracket has unexpected structure. Expected QF×4, SF×2, Finals×1. ` +
`Got QF×${qfMatches.length}, SF×${sfMatches.length}, Finals×${finalMatches.length}. ` +
`Ensure the bracket was generated with the nll_bracket template.`
);
}
// Warn about any bracket participants without Elo ratings before the hot loop.
const bracketParticipantIds = new Set<string>();
for (const m of allMatches) {
if (m.participant1Id) bracketParticipantIds.add(m.participant1Id);
if (m.participant2Id) bracketParticipantIds.add(m.participant2Id);
if (m.winnerId) bracketParticipantIds.add(m.winnerId);
if (m.loserId) bracketParticipantIds.add(m.loserId);
}
for (const id of bracketParticipantIds) {
if (!eloMap.has(id)) {
logger.warn(
`NLL bracket simulator: no Elo rating for participant ${id}. ` +
`Using fallback Elo ${FALLBACK_ELO}. Enter sourceElo via Admin → Elo Ratings.`
);
}
}
// Load playoff_match_games for best-of-3 series state.
const sfAndFinalIds = [...sfMatches, ...finalMatches].map((m) => m.id);
const allGames = sfAndFinalIds.length > 0
? await db.query.playoffMatchGames.findMany({
where: (g, { inArray }) => inArray(g.playoffMatchId, sfAndFinalIds),
})
: [];
const gamesByMatch = new Map<string, typeof schema.playoffMatchGames.$inferSelect[]>();
for (const game of allGames) {
const list = gamesByMatch.get(game.playoffMatchId) ?? [];
list.push(game);
gamesByMatch.set(game.playoffMatchId, list);
}
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const [qf1, qf2, qf3, qf4] = qfMatches;
const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0];
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap);
const r_qf2 = resolveQfMatch(qf2, eloMap);
const r_qf3 = resolveQfMatch(qf3, eloMap);
const r_qf4 = resolveQfMatch(qf4, eloMap);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner);
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf1.loser, (sfLoserCounts.get(r_sf1.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf2.loser, (sfLoserCounts.get(r_sf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf1.loser, (qfLoserCounts.get(r_qf1.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf2.loser, (qfLoserCounts.get(r_qf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf3.loser, (qfLoserCounts.get(r_qf3.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
seeds: SeedEntry[]
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: TeamProjection[]
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const seeds = simulateRegularSeasonSeeds(teams);
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Shared result builder ─────────────────────────────────────────────────
private buildResults(
allIds: string[],
championCounts: Map<string, number>,
finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>
): SimulationResult[] {
const N = NUM_SIMULATIONS;
return allIds.map((id) => ({
participantId: id,
probabilities: {
probFirst: (championCounts.get(id) ?? 0) / N,
probSecond: (finalistCounts.get(id) ?? 0) / N,
// 2 SF losers per sim — split evenly across 3rd/4th
probThird: (sfLoserCounts.get(id) ?? 0) / N / 2,
probFourth: (sfLoserCounts.get(id) ?? 0) / N / 2,
// 4 QF losers per sim — split evenly across 5th8th
probFifth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSixth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSeventh: (qfLoserCounts.get(id) ?? 0) / N / 4,
probEighth: (qfLoserCounts.get(id) ?? 0) / N / 4,
},
source: "nll_bracket_monte_carlo",
}));
}
}