brackt/app/services/simulations/nhl-simulator.ts
Chris Parsons 6f5ea35952
Add NHL playoff simulator and fix simulator dropdown (#166)
Closes #115

- Add `NHLSimulator` using NHL's divisional bracket format (top 3 per
  division + 2 wildcards per conference, best-of-7 series, parity
  factor 800). Elo ratings and seeding probabilities sourced from
  elo.harvitronix.com/nhl/2025-2026 as of March 18, 2026.
- Add `nhl_bracket` to `simulatorTypeEnum` with migration.
- Register `NHLSimulator` in the simulator registry.
- Fix simulator type dropdown in admin to auto-generate from the
  registry instead of a hardcoded list, so new simulators appear
  automatically without a separate UI change.

Code review fixes applied to the simulator:
- Narrow `weightedPick` key type to the five seeding probability fields
  (was `keyof NhlTeamData`, allowing `"elo"` which would silently
  corrupt results)
- Remove uniform-random fallback in `weightedPick` — zero-weight draws
  now return `undefined` and are skipped, preventing eliminated teams
  from becoming wildcards
- Exclude unrecognized participants entirely (with a console warning)
  instead of silently routing them into the Eastern wildcard pool
- Replace the confusing for-of loop over both conferences (with unused
  `champ` variable and `!`-suppressed unitialized vars) with two
  explicit sequential East/West blocks
- Track `effectiveN` for skipped degenerate draws so probability
  denominators stay accurate; throw if all iterations are skipped

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:23:53 -07:00

572 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NHL Playoff Simulator
*
* Monte Carlo simulation of the NHL playoffs including seeding projection
* for the current season (2025-26).
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities)
* 3. For each simulation:
* a. For each conference, draw division placements (1st/2nd/3rd per division)
* and two wildcard teams using weighted probability distributions
* b. Build the NHL bracket:
* - Higher-Elo division winner faces WC2; other division winner faces WC1
* - Each division's 2nd vs 3rd seeds play each other
* c. Simulate 4 rounds of best-of-7 series:
* Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1,
* m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd
* Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
* Round 3 (Conf Finals): two division-final winners per conference
* Stanley Cup Final: East champ vs West champ
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 800):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 800))
* NHL uses a higher parity factor than the standard 400 to dampen the Elo
* spread and reflect the high variance of hockey.
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = Stanley Cup champion (1 per sim)
* probSecond = Stanley Cup Final loser (1 per sim)
* probThird / probFourth = Conference Final losers (2 per sim — East + West)
* probFifthprobEighth = Second Round losers (4 per sim)
* First Round losers → all 0 (score 0 points)
* Missed playoffs → all 0
*
* Seeding probability keys per team:
* p_div1 = prob of winning own division
* p_div2 = prob of finishing 2nd in own division
* p_div3 = prob of finishing 3rd in own division
* p_wc1 = prob of being conference wildcard 1 (higher-ranked WC)
* p_wc2 = prob of being conference wildcard 2 (lower-ranked WC)
* Sum of all p_* ≈ team's overall playoff probability.
* Teams with no p_* entries always miss the playoffs.
*
* Elo ratings and seeding probabilities are hardcoded below (March 2026 data).
* Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration).
* Seeding probabilities are hand-derived from current standings as of March 18, 2026.
* Update at the start of each season.
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
/**
* Elo parity factor. NHL uses 800 (double the standard 400) to reflect the
* high game-to-game variance in hockey vs other sports.
* An 800-point Elo difference → ~90.9% win probability per game.
*/
const PARITY_FACTOR = 800;
// ─── Team data (2025-26 season, as of March 18, 2026) ────────────────────────
//
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
// p_div1: Probability of finishing 1st in own division.
// p_div2: Probability of finishing 2nd in own division.
// p_div3: Probability of finishing 3rd in own division.
// p_wc1: Probability of being conference wildcard 1 (higher seed).
// p_wc2: Probability of being conference wildcard 2 (lower seed).
//
// These are weights used in sequential weighted sampling (not strict distributions).
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site.
//
// Divisions:
// Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
// Eastern — Metropolitan: CAR, CBJ, NJD, NYI, NYR, PHI, PIT, WSH
// Western — Central: CHI, COL, DAL, MIN, NSH, STL, UTA, WPG
// Western — Pacific: ANA, CGY, EDM, LAK, SJS, SEA, VAN, VGK
interface NhlTeamData {
conference: "Eastern" | "Western";
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
elo: number;
p_div1?: number;
p_div2?: number;
p_div3?: number;
p_wc1?: number;
p_wc2?: number;
}
const TEAMS_DATA: Record<string, NhlTeamData> = {
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
// BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs.
// MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC.
// OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble.
// FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated.
"Tampa Bay Lightning": {
conference: "Eastern", division: "Atlantic", elo: 1587,
p_div1: 0.50, p_div2: 0.40, p_div3: 0.06,
},
"Buffalo Sabres": {
conference: "Eastern", division: "Atlantic", elo: 1572,
p_div1: 0.45, p_div2: 0.42, p_div3: 0.07,
},
"Montreal Canadiens": {
conference: "Eastern", division: "Atlantic", elo: 1527,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06,
},
"Ottawa Senators": {
conference: "Eastern", division: "Atlantic", elo: 1542,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Detroit Red Wings": {
conference: "Eastern", division: "Atlantic", elo: 1506,
p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22,
},
"Boston Bruins": {
conference: "Eastern", division: "Atlantic", elo: 1501,
p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Florida Panthers": {
conference: "Eastern", division: "Atlantic", elo: 1505,
p_wc2: 0.005,
},
"Toronto Maple Leafs": {
conference: "Eastern", division: "Atlantic", elo: 1479,
// 0% playoff probability — always miss
},
// ── Eastern Conference — Metropolitan ─────────────────────────────────────
// CAR 109 pts (99.9%) — clear 1st seed.
// CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race.
// WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots.
// NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated.
"Carolina Hurricanes": {
conference: "Eastern", division: "Metropolitan", elo: 1574,
p_div1: 0.98, p_div2: 0.01,
},
"Columbus Blue Jackets": {
conference: "Eastern", division: "Metropolitan", elo: 1530,
p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04,
},
"Pittsburgh Penguins": {
conference: "Eastern", division: "Metropolitan", elo: 1518,
p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"New York Islanders": {
conference: "Eastern", division: "Metropolitan", elo: 1513,
p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"Philadelphia Flyers": {
conference: "Eastern", division: "Metropolitan", elo: 1473,
p_wc1: 0.02, p_wc2: 0.05,
},
"Washington Capitals": {
conference: "Eastern", division: "Metropolitan", elo: 1506,
p_wc1: 0.01, p_wc2: 0.03,
},
"New Jersey Devils": {
conference: "Eastern", division: "Metropolitan", elo: 1488,
p_wc2: 0.005,
},
"New York Rangers": {
conference: "Eastern", division: "Metropolitan", elo: 1480,
// 0% playoff probability — always miss
},
// ── Western Conference — Central ───────────────────────────────────────────
// COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3.
// UTA 93 pts (92.8%) — leading WC candidate from Central.
// NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots.
// CHI 75 pts (0%) — eliminated.
"Colorado Avalanche": {
conference: "Western", division: "Central", elo: 1594,
p_div1: 0.90, p_div2: 0.09, p_div3: 0.01,
},
"Dallas Stars": {
conference: "Western", division: "Central", elo: 1581,
p_div1: 0.08, p_div2: 0.80, p_div3: 0.11,
},
"Minnesota Wild": {
conference: "Western", division: "Central", elo: 1548,
p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01,
},
"Utah Mammoth": {
conference: "Western", division: "Central", elo: 1529,
p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33,
},
"Nashville Predators": {
conference: "Western", division: "Central", elo: 1471,
p_wc1: 0.04, p_wc2: 0.17,
},
"Winnipeg Jets": {
conference: "Western", division: "Central", elo: 1483,
p_wc1: 0.01, p_wc2: 0.05,
},
"St. Louis Blues": {
conference: "Western", division: "Central", elo: 1473,
p_wc2: 0.02,
},
"Chicago Blackhawks": {
conference: "Western", division: "Central", elo: 1411,
// 0% playoff probability — always miss
},
// ── Western Conference — Pacific ───────────────────────────────────────────
// ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds.
// LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble.
// CGY 73 pts (0%), VAN 63 pts (0%) — eliminated.
"Anaheim Ducks": {
conference: "Western", division: "Pacific", elo: 1490,
p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01,
},
"Edmonton Oilers": {
conference: "Western", division: "Pacific", elo: 1531,
p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04,
},
"Vegas Golden Knights": {
conference: "Western", division: "Pacific", elo: 1519,
p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06,
},
"Los Angeles Kings": {
conference: "Western", division: "Pacific", elo: 1482,
p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12,
},
"San Jose Sharks": {
conference: "Western", division: "Pacific", elo: 1455,
p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12,
},
"Seattle Kraken": {
conference: "Western", division: "Pacific", elo: 1469,
p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10,
},
"Calgary Flames": {
conference: "Western", division: "Pacific", elo: 1436,
// 0% playoff probability — always miss
},
"Vancouver Canucks": {
conference: "Western", division: "Pacific", elo: 1403,
// 0% playoff probability — always miss
},
};
// ─── 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): NhlTeamData | undefined {
const normalized = normalizeTeamName(name);
for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
if (normalizeTeamName(teamName) === normalized) return data;
}
return undefined;
}
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: NhlTeamData | undefined;
}
type DivisionSeedings = {
first: TeamEntry;
second: TeamEntry;
third: TeamEntry;
};
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add NHL teams as participants before running simulation.`
);
}
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// Warn about participants that don't match any hardcoded team.
// These are excluded entirely rather than silently landing in the wildcard pool.
const unrecognized = teams.filter((t) => !t.data);
if (unrecognized.length > 0) {
console.warn(
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
unrecognized.map((t) => t.name).join(", ")
);
}
// Separate recognized teams by conference + division.
const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic");
const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan");
const westernCentral = teams.filter((t) => t.data?.division === "Central");
const westernPacific = teams.filter((t) => t.data?.division === "Pacific");
// Only recognized teams enter the conference wildcard pools.
const easternTeams = teams.filter((t) => t.data?.conference === "Eastern");
const westernTeams = teams.filter((t) => t.data?.conference === "Western");
if (easternTeams.length < 8 || westernTeams.length < 8) {
throw new Error(
`Each conference needs at least 8 recognized participants ` +
`(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` +
`Add all 32 NHL teams before running simulation.`
);
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400;
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(elo(a), elo(b));
let winsA = 0;
let winsB = 0;
while (winsA < 4 && winsB < 4) {
if (Math.random() < winProb) winsA++; else winsB++;
}
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
};
/**
* Weighted pick without replacement.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
* Returns undefined if no eligible team has positive weight — the caller should
* treat that as a degenerate draw and skip the iteration.
*/
const weightedPick = (
pool: TeamEntry[],
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
excluded: Set<TeamEntry>
): TeamEntry | undefined => {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
const total = weights.reduce((s, w) => s + w, 0);
if (total === 0) return undefined; // no team has positive weight for this slot
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
}
return eligible[eligible.length - 1];
};
/**
* Draw the top-3 playoff seeds for a single division.
* Returns undefined for each position if no team has weight for that slot
* (shouldn't happen with correct data, but handled gracefully).
*/
const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => {
const taken = new Set<TeamEntry>();
const first = weightedPick(divTeams, "p_div1", taken);
if (!first) return undefined;
taken.add(first);
const second = weightedPick(divTeams, "p_div2", taken);
if (!second) return undefined;
taken.add(second);
const third = weightedPick(divTeams, "p_div3", taken);
if (!third) return undefined;
return { first, second, third };
};
/**
* Build the 8-team playoff bracket for one conference.
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
*
* Bracket:
* m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard)
* m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard)
* m2: topDiv_2nd vs topDiv_3rd
* m3: otherDiv_2nd vs otherDiv_3rd
*
* "Top division" = whichever division winner has higher Elo. The actual NHL rule
* uses regular season points, but Elo is a reasonable per-simulation proxy since
* we don't simulate the regular season — it approximates the same ordering.
*/
const buildConferenceBracket = (
divATeams: TeamEntry[],
divBTeams: TeamEntry[],
allConfTeams: TeamEntry[]
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
const divA = drawDivisionSeedings(divATeams);
const divB = drawDivisionSeedings(divBTeams);
if (!divA || !divB) return undefined;
const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]);
const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t));
const wcTaken = new Set<TeamEntry>();
const wc1 = weightedPick(wcPool, "p_wc1", wcTaken);
if (!wc1) return undefined;
wcTaken.add(wc1);
const wc2 = weightedPick(wcPool, "p_wc2", wcTaken);
if (!wc2) return undefined;
// Higher-Elo division winner faces WC2 (the weaker wildcard).
const [topDiv, otherDiv] =
elo(divA.first) >= elo(divB.first)
? [divA, divB]
: [divB, divA];
return [
[topDiv.first, wc2], // m0
[otherDiv.first, wc1], // m1
[topDiv.second, topDiv.third], // m2
[otherDiv.second, otherDiv.third], // m3
];
};
// ─── Placement count maps ──────────────────────────────────────────────────
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const r2LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
// Track effective iterations separately: degenerate bracket draws are skipped
// and must not count toward the probability denominators.
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Build conference brackets.
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams);
const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams);
if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
effectiveN++;
// ── Eastern Conference ────────────────────────────────────────────────────
// Round 1: 4 series (Wild Card round).
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
// Round 2 (Division Finals):
// m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner)
// m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner)
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
// Conference Final.
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.winner;
confFinalLoserCounts.set(eastCF.loser.id, confFinalLoserCounts.get(eastCF.loser.id)! + 1);
// ── Western Conference ────────────────────────────────────────────────────
const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner);
const westR2m1 = simSeries(westR1Winners[0], westR1Winners[2]);
const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]);
const westCF = simSeries(westR2m1.winner, westR2m2.winner);
const westChamp = westCF.winner;
confFinalLoserCounts.set(westCF.loser.id, confFinalLoserCounts.get(westCF.loser.id)! + 1);
// ── Stanley Cup Final ─────────────────────────────────────────────────────
const final = simSeries(eastChamp, westChamp);
championCounts.set(final.winner.id, championCounts.get(final.winner.id)! + 1);
finalistCounts.set(final.loser.id, finalistCounts.get(final.loser.id)! + 1);
// Round 2 losers (4 per sim). Round 1 losers are not counted (0 points).
for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
r2LoserCounts.set(loser.id, r2LoserCounts.get(loser.id)! + 1);
}
}
if (effectiveN === 0) {
throw new Error(
"All simulations produced degenerate brackets. " +
"Check that each division has teams with positive seeding probabilities."
);
}
// ─── Convert counts to probability distributions ───────────────────────────
//
// probFirst/Second → count / N (1 team per sim)
// probThird/Fourth → count / (2 * N) (2 conf final losers per sim)
// probFifthEighth → count / (4 * N) (4 R2 losers per sim)
//
const N = effectiveN;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!;
const f = finalistCounts.get(participantId)!;
const cf = confFinalLoserCounts.get(participantId)!;
const r2 = r2LoserCounts.get(participantId)!;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: cf / (2 * N),
probFourth: cf / (2 * N),
probFifth: r2 / (4 * N),
probSixth: r2 / (4 * N),
probSeventh: r2 / (4 * N),
probEighth: r2 / (4 * N),
},
source: "nhl_bracket_monte_carlo",
};
});
// ─── Per-position normalization ────────────────────────────────────────────
//
// Belt-and-suspenders guard against floating-point residuals.
// Columns are already near-exactly 1.0 by construction above.
//
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"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;
}
}