From c88ae6b745f8817bd50d57d0544ff2b12ecaf46c Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 12 May 2026 22:26:25 -0700 Subject: [PATCH] Upgrade NCAA preseason simulators (#419) --- app/models/__tests__/simulator-inputs.test.ts | 75 ++ app/models/participant-expected-value.ts | 10 +- app/models/simulator.ts | 75 +- .../admin.sports-seasons.$id.simulator.tsx | 114 ++- .../__tests__/input-policy.test.ts | 65 ++ .../__tests__/ncaam-simulator.test.ts | 68 +- .../__tests__/ncaaw-simulator.test.ts | 81 +- app/services/simulations/input-policy.ts | 71 +- app/services/simulations/manifest.ts | 4 +- .../simulations/ncaa-basketball-simulator.ts | 659 ++++++++++++++++ app/services/simulations/ncaam-simulator.ts | 710 +----------------- app/services/simulations/ncaaw-simulator.ts | 659 +--------------- app/services/simulations/registry.ts | 4 +- 13 files changed, 1061 insertions(+), 1534 deletions(-) create mode 100644 app/models/__tests__/simulator-inputs.test.ts create mode 100644 app/services/simulations/ncaa-basketball-simulator.ts diff --git a/app/models/__tests__/simulator-inputs.test.ts b/app/models/__tests__/simulator-inputs.test.ts new file mode 100644 index 0000000..ca3b5fd --- /dev/null +++ b/app/models/__tests__/simulator-inputs.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockDb = vi.hoisted(() => ({ + query: { + seasonParticipants: { findMany: vi.fn() }, + seasonParticipantSimulatorInputs: { findMany: vi.fn() }, + seasonParticipantExpectedValues: { findMany: vi.fn() }, + }, +})); + +vi.mock("~/database/context", () => ({ + database: () => mockDb, +})); + +describe("simulator input model", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not treat previously generated ratings as direct rating inputs", async () => { + const { getParticipantSimulatorInputs } = await import("../simulator"); + + mockDb.query.seasonParticipants.findMany.mockResolvedValue([ + { id: "direct-rating" }, + { id: "generated-rating" }, + { id: "legacy-direct-rating" }, + ]); + mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([ + { + participantId: "direct-rating", + sourceOdds: 750, + sourceElo: null, + worldRanking: null, + rating: "31.2500", + projectedWins: null, + projectedTablePoints: null, + seed: null, + region: null, + metadata: { ratingMethod: "direct" }, + }, + { + participantId: "generated-rating", + sourceOdds: 750, + sourceElo: null, + worldRanking: null, + rating: "35.0000", + projectedWins: null, + projectedTablePoints: null, + seed: null, + region: null, + metadata: { ratingMethod: "sourceOdds" }, + }, + { + participantId: "legacy-direct-rating", + sourceOdds: null, + sourceElo: null, + worldRanking: null, + rating: "27.5000", + projectedWins: null, + projectedTablePoints: null, + seed: null, + region: null, + metadata: null, + }, + ]); + mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]); + + const inputs = await getParticipantSimulatorInputs("season-1"); + const byParticipant = new Map(inputs.map((input) => [input.participantId, input])); + + expect(byParticipant.get("direct-rating")?.rating).toBe(31.25); + expect(byParticipant.get("generated-rating")?.rating).toBeNull(); + expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5); + }); +}); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index c139993..0400df1 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -10,7 +10,7 @@ import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/ import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; -import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; +import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; @@ -418,13 +418,7 @@ export async function batchSaveSourceOdds( } }); - await batchUpsertParticipantSimulatorInputs( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - })) - ); + await batchSaveParticipantSimulatorSourceOdds(inputs); } /** diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 75b0656..57aa08c 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -1,4 +1,4 @@ -import { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { @@ -122,7 +122,17 @@ function hasInputValue(input: ParticipantSimulatorInput, key: SimulatorInputKey) } function isFallbackMethod(method: string): boolean { - return method === "fallbackElo" || method === "averageKnown" || method === "worstKnownMinus"; + return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus"; +} + +const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; + +function isGeneratedRatingMethod(method: unknown): boolean { + return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]); +} + +function isGeneratedRatingInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean { + return isGeneratedRatingMethod(input?.metadata?.ratingMethod); } function inputRequirementLabel( @@ -269,7 +279,7 @@ export async function getParticipantSimulatorInputs( sourceOdds: input?.sourceOdds ?? legacy?.sourceOdds ?? null, sourceElo: input?.sourceElo ?? legacy?.sourceElo ?? null, worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null, - rating: parseDecimal(input?.rating), + rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.rating), projectedWins: parseDecimal(input?.projectedWins), projectedTablePoints: parseDecimal(input?.projectedTablePoints), seed: input?.seed ?? null, @@ -279,6 +289,59 @@ export async function getParticipantSimulatorInputs( }); } +function generatedRatingMethodSql() { + return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`; +} + +export async function batchSaveParticipantSimulatorSourceOdds( + inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))]; + + await db.transaction(async (tx) => { + await tx + .update(schema.seasonParticipantSimulatorInputs) + .set({ + rating: null, + metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, + updatedAt: now, + }) + .where( + and( + inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), + generatedRatingMethodSql() + ) + ); + + await tx + .insert(schema.seasonParticipantSimulatorInputs) + .values( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceOdds: input.sourceOdds, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [ + schema.seasonParticipantSimulatorInputs.participantId, + schema.seasonParticipantSimulatorInputs.sportsSeasonId, + ], + set: { + sourceOdds: sql`excluded.source_odds`, + rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`, + metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`, + updatedAt: now, + }, + }); + }); +} + export async function batchUpsertParticipantSimulatorInputs( inputs: UpsertParticipantSimulatorInput[] ): Promise { @@ -403,7 +466,9 @@ export async function validateSimulatorReadiness( derivedInputCount = [...resolvedSourceElos.values()].filter((input) => input.method !== "direct").length + [...resolvedRatings.values()].filter((input) => input.method !== "direct").length; - fallbackInputCount = [...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length; + fallbackInputCount = + [...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length + + [...resolvedRatings.values()].filter((input) => isFallbackMethod(input.method)).length; function hasRequiredInput(input: ParticipantSimulatorInput, key: SimulatorInputKey): boolean { if (hasInputValue(input, key)) return true; @@ -429,7 +494,7 @@ export async function validateSimulatorReadiness( warnings.push(`Derived simulator inputs will be used for ${derivedInputCount} participant(s).`); } if (fallbackInputCount > 0) { - warnings.push(`Configured fallback Elo will be used for ${fallbackInputCount} participant(s).`); + warnings.push(`Configured fallback simulator inputs will be used for ${fallbackInputCount} participant(s).`); } if (config.simulatorType === "cs2_major_qualifying_points") { const missingRankCount = inputs.filter((input) => !hasInputValue(input, "worldRanking")).length; diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index f59d4c3..8a8e4c2 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -25,7 +25,7 @@ import { type UpsertParticipantSimulatorInput, } from "~/models/simulator"; import { normalizeName } from "~/lib/fuzzy-match"; -import { getSimulatorInputPolicy, type MissingEloStrategy } from "~/services/simulations/input-policy"; +import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy } from "~/services/simulations/input-policy"; import { runSportsSeasonSimulation } from "~/services/simulations/runner"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { @@ -84,6 +84,12 @@ function parseMissingEloStrategy(value: FormDataEntryValue | null): MissingEloSt : "block"; } +function parseMissingRatingStrategy(value: FormDataEntryValue | null): MissingRatingStrategy { + return value === "fallbackRating" || value === "averageKnown" || value === "worstKnownMinus" + ? value + : "block"; +} + function findParticipantId(name: string, participants: Array<{ id: string; name: string }>): string | null { const normalizedInput = normalizeName(name); const normalized = participants.map((participant) => ({ @@ -198,10 +204,13 @@ export async function action({ request, params }: Route.ActionArgs): Promise @@ -335,50 +347,80 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone - {config.profile.requiredInputs.includes("sourceElo") && ( + {showsInputPolicy && ( Input Policy - Direct inputs win. When present, this simulator can derive Elo from{" "} - {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate inputs"}. - Rating-based simulators may also derive preseason ratings from futures odds. Tail fallbacks are explicit - so low-impact missing participants do not silently get invented ratings. + Direct inputs win. This simulator can derive Elo from{" "} + {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"} + {ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}. + Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ {config.profile.requiredInputs.includes("sourceElo") && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + )} + {config.profile.requiredInputs.includes("rating") && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )}
diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 28aa184..706d0d9 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -65,4 +65,69 @@ describe("simulator input policy", () => { expect(resolved.get("longshot")?.method).toBe("sourceOdds"); expect(resolved.get("favorite")?.rating).toBeGreaterThan(resolved.get("longshot")?.rating ?? 999); }); + + it("blocks missing ratings unless a rating fallback strategy is configured", () => { + const inputs = [ + { participantId: "known", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, + { participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, + { participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + ]; + const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick; + + expect(resolveRatings(inputs, ratingProfile, { + inputPolicy: { ratingMin: -10, ratingMax: 35 }, + }).has("tail")).toBe(false); + + expect(resolveRatings(inputs, ratingProfile, { + inputPolicy: { + missingRatingStrategy: "fallbackRating", + fallbackRating: -4, + ratingMin: -10, + ratingMax: 35, + }, + }).get("tail")).toMatchObject({ rating: -4, method: "fallbackRating" }); + }); + + it("supports worst-known-minus rating fallback with clamping", () => { + const resolved = resolveRatings( + [ + { participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + { participantId: "known-tail", sourceElo: null, rating: -8, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + { participantId: "missing-tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + ], + { derivableInputs: { rating: ["sourceOdds"] } }, + { + inputPolicy: { + missingRatingStrategy: "worstKnownMinus", + fallbackRatingDelta: 5, + ratingMin: -10, + ratingMax: 35, + }, + } + ); + + expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" }); + }); + + it("uses NCAAW-style rating scale when deriving and falling back", () => { + const resolved = resolveRatings( + [ + { participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, + { participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, + { participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + ], + { derivableInputs: { rating: ["sourceOdds"] } }, + { + inputPolicy: { + missingRatingStrategy: "worstKnownMinus", + fallbackRatingDelta: 0.05, + ratingMin: 0.15, + ratingMax: 0.99, + }, + } + ); + + expect(resolved.get("favorite")?.rating).toBeLessThanOrEqual(0.99); + expect(resolved.get("tail")).toMatchObject({ rating: 0.15, method: "worstKnownMinus" }); + }); }); diff --git a/app/services/simulations/__tests__/ncaam-simulator.test.ts b/app/services/simulations/__tests__/ncaam-simulator.test.ts index 53ebe9c..7bb9d57 100644 --- a/app/services/simulations/__tests__/ncaam-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaam-simulator.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { kenpomWinProbability, getNetRating, normalizeTeamName } from "../ncaam-simulator"; +import { kenpomWinProbability } from "../ncaam-simulator"; +import { buildFirstFourMappings } from "../ncaa-basketball-simulator"; // ─── KenPom win probability formula ────────────────────────────────────────── @@ -46,62 +47,6 @@ describe("kenpomWinProbability", () => { }); }); -// ─── Team name normalization ────────────────────────────────────────────────── - -describe("normalizeTeamName", () => { - it("converts to lowercase", () => { - expect(normalizeTeamName("Duke")).toBe("duke"); - }); - - it("trims leading/trailing whitespace", () => { - expect(normalizeTeamName(" Duke ")).toBe("duke"); - }); - - it("collapses internal whitespace", () => { - expect(normalizeTeamName("North Carolina")).toBe("north carolina"); - }); - - it("preserves punctuation", () => { - expect(normalizeTeamName("St. John's")).toBe("st. john's"); - }); -}); - -// ─── KenPom data lookup ─────────────────────────────────────────────────────── - -describe("getNetRating", () => { - it("returns correct netrtg for exact known name (lowercase)", () => { - expect(getNetRating("duke")).toBeCloseTo(38.90, 2); - }); - - it("is case-insensitive", () => { - expect(getNetRating("Duke")).toBeCloseTo(38.90, 2); - expect(getNetRating("DUKE")).toBeCloseTo(38.90, 2); - }); - - it("handles abbreviated name variants", () => { - // Both 'michigan st.' and 'michigan state' should resolve - expect(getNetRating("Michigan St.")).toBeCloseTo(28.31, 2); - expect(getNetRating("Michigan State")).toBeCloseTo(28.31, 2); - }); - - it("handles name with apostrophe", () => { - expect(getNetRating("St. John's")).toBeCloseTo(25.91, 2); - }); - - it("returns 0.0 for an unknown team name", () => { - expect(getNetRating("Fictional University")).toBe(0.0); - }); - - it("returns 0.0 for empty string", () => { - expect(getNetRating("")).toBe(0.0); - }); - - it("handles negative netrtg (below-average teams)", () => { - expect(getNetRating("Idaho")).toBeCloseTo(1.53, 2); - expect(getNetRating("Eastern Washington")).toBeCloseTo(0.00, 2); - }); -}); - // ─── Bracket path logic ─────────────────────────────────────────────────────── describe("NCAAM bracket advancement path", () => { @@ -159,6 +104,15 @@ describe("NCAAM bracket advancement path", () => { expect(p2).toBeLessThan(4); } }); + + it("maps First Four winners into the canonical NCAA 68 Round of 64 slots", () => { + expect(buildFirstFourMappings()).toEqual([ + { firstFourMatchNumber: 1, r64MatchNumber: 9, seedSlot: 16, regionName: "South" }, + { firstFourMatchNumber: 2, r64MatchNumber: 21, seedSlot: 11, regionName: "West" }, + { firstFourMatchNumber: 3, r64MatchNumber: 29, seedSlot: 11, regionName: "Midwest" }, + { firstFourMatchNumber: 4, r64MatchNumber: 25, seedSlot: 16, regionName: "Midwest" }, + ]); + }); }); // ─── Probability bucket math ────────────────────────────────────────────────── diff --git a/app/services/simulations/__tests__/ncaaw-simulator.test.ts b/app/services/simulations/__tests__/ncaaw-simulator.test.ts index 677e839..a2b675b 100644 --- a/app/services/simulations/__tests__/ncaaw-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaaw-simulator.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { barthagWinProbability, getBarthagRating, normalizeTeamName } from "../ncaaw-simulator"; +import { barthagWinProbability } from "../ncaaw-simulator"; +import { buildFirstFourMappings } from "../ncaa-basketball-simulator"; // ─── Log5 win probability formula ──────────────────────────────────────────── @@ -48,75 +49,6 @@ describe("barthagWinProbability", () => { }); }); -// ─── Team name normalization ────────────────────────────────────────────────── - -describe("normalizeTeamName", () => { - it("converts to lowercase", () => { - expect(normalizeTeamName("Connecticut")).toBe("connecticut"); - }); - - it("trims leading/trailing whitespace", () => { - expect(normalizeTeamName(" UCLA ")).toBe("ucla"); - }); - - it("collapses internal whitespace", () => { - expect(normalizeTeamName("South Carolina")).toBe("south carolina"); - }); - - it("preserves punctuation", () => { - expect(normalizeTeamName("N.C. State")).toBe("n.c. state"); - }); -}); - -// ─── Barthag data lookup ────────────────────────────────────────────────────── - -describe("getBarthagRating", () => { - it("returns correct Barthag for exact known name (lowercase)", () => { - expect(getBarthagRating("connecticut")).toBeCloseTo(0.9996, 4); - }); - - it("is case-insensitive", () => { - expect(getBarthagRating("Connecticut")).toBeCloseTo(0.9996, 4); - expect(getBarthagRating("CONNECTICUT")).toBeCloseTo(0.9996, 4); - }); - - it("resolves uconn alias", () => { - expect(getBarthagRating("UConn")).toBeCloseTo(0.9996, 4); - }); - - it("returns correct Barthag for #2 team (UCLA)", () => { - expect(getBarthagRating("UCLA")).toBeCloseTo(0.9991, 4); - }); - - it("handles abbreviated name variants", () => { - // Both 'michigan st.' and 'michigan state' should resolve - expect(getBarthagRating("Michigan St.")).toBeCloseTo(0.9817, 4); - expect(getBarthagRating("Michigan State")).toBeCloseTo(0.9817, 4); - }); - - it("handles 'n.c. state' and 'nc state' aliases", () => { - expect(getBarthagRating("N.C. State")).toBeCloseTo(0.9766, 4); - expect(getBarthagRating("nc state")).toBeCloseTo(0.9766, 4); - }); - - it("handles ole miss alias", () => { - expect(getBarthagRating("Ole Miss")).toBeCloseTo(0.9821, 4); - }); - - it("returns 0.5 for an unknown team name (BARTHAG_FALLBACK = average)", () => { - expect(getBarthagRating("Fictional University")).toBe(0.5); - }); - - it("returns 0.5 for empty string", () => { - expect(getBarthagRating("")).toBe(0.5); - }); - - it("returns correct Barthag for a low-ranked team (Norfolk St.)", () => { - expect(getBarthagRating("Norfolk St.")).toBeCloseTo(0.3468, 4); - expect(getBarthagRating("Norfolk State")).toBeCloseTo(0.3468, 4); - }); -}); - // ─── Bracket path logic ─────────────────────────────────────────────────────── describe("NCAAW bracket advancement path", () => { @@ -174,6 +106,15 @@ describe("NCAAW bracket advancement path", () => { expect(p2).toBeLessThan(4); } }); + + it("maps First Four winners into the canonical NCAA 68 Round of 64 slots", () => { + expect(buildFirstFourMappings()).toEqual([ + { firstFourMatchNumber: 1, r64MatchNumber: 9, seedSlot: 16, regionName: "South" }, + { firstFourMatchNumber: 2, r64MatchNumber: 21, seedSlot: 11, regionName: "West" }, + { firstFourMatchNumber: 3, r64MatchNumber: 29, seedSlot: 11, regionName: "Midwest" }, + { firstFourMatchNumber: 4, r64MatchNumber: 25, seedSlot: 16, regionName: "Midwest" }, + ]); + }); }); // ─── Probability bucket math ────────────────────────────────────────────────── diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index 6af8104..aa0512e 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -2,13 +2,17 @@ import { convertFuturesToElo } from "~/services/probability-engine"; import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; +export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; export interface SimulatorInputPolicy { missingEloStrategy: MissingEloStrategy; + missingRatingStrategy: MissingRatingStrategy; fallbackElo: number; fallbackEloDelta: number; eloMin: number; eloMax: number; + fallbackRating: number; + fallbackRatingDelta: number; ratingMin: number; ratingMax: number; } @@ -31,15 +35,18 @@ export interface ResolvedSourceElo { export interface ResolvedRating { participantId: string; rating: number; - method: "direct" | "sourceOdds"; + method: "direct" | "sourceOdds" | MissingRatingStrategy; } const DEFAULT_POLICY: SimulatorInputPolicy = { missingEloStrategy: "block", + missingRatingStrategy: "block", fallbackElo: 1400, fallbackEloDelta: 25, eloMin: 1100, eloMax: 1900, + fallbackRating: 0, + fallbackRatingDelta: 5, ratingMin: -20, ratingMax: 40, }; @@ -74,17 +81,24 @@ function projectionToElo( export function getSimulatorInputPolicy(config: Record): SimulatorInputPolicy { const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {}; - const strategy = rawPolicy.missingEloStrategy; + const eloStrategy = rawPolicy.missingEloStrategy; + const ratingStrategy = rawPolicy.missingRatingStrategy; return { missingEloStrategy: - strategy === "fallbackElo" || strategy === "averageKnown" || strategy === "worstKnownMinus" - ? strategy + eloStrategy === "fallbackElo" || eloStrategy === "averageKnown" || eloStrategy === "worstKnownMinus" + ? eloStrategy + : "block", + missingRatingStrategy: + ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus" + ? ratingStrategy : "block", fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo), fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta), eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin), eloMax: optionalNumber(rawPolicy.eloMax, DEFAULT_POLICY.eloMax), + fallbackRating: optionalNumber(rawPolicy.fallbackRating, DEFAULT_POLICY.fallbackRating), + fallbackRatingDelta: optionalNumber(rawPolicy.fallbackRatingDelta, DEFAULT_POLICY.fallbackRatingDelta), ratingMin: optionalNumber(rawPolicy.ratingMin, DEFAULT_POLICY.ratingMin), ratingMax: optionalNumber(rawPolicy.ratingMax, DEFAULT_POLICY.ratingMax), }; @@ -213,19 +227,48 @@ export function resolveRatings( const oddsInputs = inputs .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); - const converted = convertFuturesToElo(oddsInputs, "american", { - exponent: 0.33, - eloMin: policy.ratingMin, - eloMax: policy.ratingMax, - }); - for (const [participantId, rating] of converted) { - resolved.set(participantId, { - participantId, - rating, - method: "sourceOdds", + if (oddsInputs.length >= 2) { + const converted = convertFuturesToElo(oddsInputs, "american", { + exponent: 0.33, + eloMin: policy.ratingMin, + eloMax: policy.ratingMax, }); + for (const [participantId, rating] of converted) { + resolved.set(participantId, { + participantId, + rating: clamp(rating, policy.ratingMin, policy.ratingMax), + method: "sourceOdds", + }); + } } } + const knownRatings = [...resolved.values()].map((value) => value.rating); + const averageKnown = knownRatings.length > 0 + ? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length + : policy.fallbackRating; + const worstKnown = knownRatings.length > 0 ? Math.min(...knownRatings) : policy.fallbackRating; + + for (const input of inputs) { + if (resolved.has(input.participantId) || policy.missingRatingStrategy === "block") continue; + + let rating: number | null; + if (policy.missingRatingStrategy === "averageKnown") { + rating = averageKnown; + } else if (policy.missingRatingStrategy === "worstKnownMinus") { + rating = knownRatings.length > 0 ? worstKnown - policy.fallbackRatingDelta : null; + } else { + rating = policy.fallbackRating; + } + + if (rating === null) continue; + + resolved.set(input.participantId, { + participantId: input.participantId, + rating: clamp(rating, policy.ratingMin, policy.ratingMax), + method: policy.missingRatingStrategy, + }); + } + return resolved; } diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 28a7e21..c0dcfef 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -72,14 +72,14 @@ const PROFILES: Record; + +interface Team { + participantId: string; + name: string; + rating: number; + sourceOdds: number | null; +} + +interface PlacementCounts { + champion: number; + finalist: number; + finalFourLoser: number; + eliteEightLoser: number; +} + +interface NCAARegionAssignment { + directSeeds: Map; + playIns: Map; +} + +interface NCAABasketballSimulatorOptions { + source: string; + missingRatingName: string; + selectionRatingDivisor: number; + winProbability: (ratingA: number, ratingB: number) => number; + getWinProbability?: (config: Record) => (ratingA: number, ratingB: number) => number; +} + +export interface FirstFourMapping { + firstFourMatchNumber: number; + r64MatchNumber: number; + seedSlot: number; + regionName: string; +} + +export function buildFirstFourMappings(regions: BracketRegion[] = NCAA_68.regions ?? []): FirstFourMapping[] { + const slotMap = buildNCAA68SlotMap(regions); + return slotMap.playInOffsets.map((playIn, index) => ({ + firstFourMatchNumber: index + 1, + r64MatchNumber: playIn.regionIndex * 8 + matchIndexForSeedSlot(playIn.seedSlot) + 1, + seedSlot: playIn.seedSlot, + regionName: regions[playIn.regionIndex]?.name ?? `Region ${playIn.regionIndex + 1}`, + })); +} + +function sortByMatchNumber(matches: T[]): T[] { + return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function getSeasonSimulatorConfig(db: Db, sportsSeasonId: string): Promise> { + const season = await db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, sportsSeasonId), + with: { + sport: true, + simulatorConfig: true, + }, + }); + const simulatorType = season?.simulatorConfig?.simulatorType ?? season?.sport?.simulatorType; + if (!simulatorType) return {}; + + const profile = await db.query.simulatorProfiles.findFirst({ + where: eq(schema.simulatorProfiles.simulatorType, simulatorType), + columns: { defaultConfig: true }, + }); + + return { + ...(isRecord(profile?.defaultConfig) ? profile.defaultConfig : {}), + ...(isRecord(season?.simulatorConfig?.config) ? season.simulatorConfig.config : {}), + }; +} + +function bump(counts: Map, participantId: string | null | undefined, key: keyof PlacementCounts): void { + if (!participantId) return; + const entry = counts.get(participantId); + if (entry) entry[key]++; +} + +function makeCounts(participantIds: string[]): Map { + return new Map( + participantIds.map((participantId) => [ + participantId, + { champion: 0, finalist: 0, finalFourLoser: 0, eliteEightLoser: 0 }, + ]) + ); +} + +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 normalizeOdds(teams: Team[]): Map { + const raw = new Map(); + for (const team of teams) { + if (team.sourceOdds !== null) { + raw.set(team.participantId, convertAmericanOddsToProbability(team.sourceOdds)); + } + } + + const total = [...raw.values()].reduce((sum, value) => sum + value, 0); + const normalized = new Map(); + if (total <= 0) return normalized; + + for (const [participantId, probability] of raw) { + normalized.set(participantId, probability / total); + } + return normalized; +} + +function buildSelectionWeights(teams: Team[], selectionRatingDivisor: number): Map { + const normalizedOdds = normalizeOdds(teams); + const hasOdds = normalizedOdds.size > 0; + const maxRating = Math.max(...teams.map((team) => team.rating)); + const divisor = Math.max(selectionRatingDivisor, 0.0001); + + const ratingWeights = new Map(); + for (const team of teams) { + ratingWeights.set(team.participantId, Math.max(Math.exp((team.rating - maxRating) / divisor), 0.000001)); + } + + const ratingTotal = [...ratingWeights.values()].reduce((sum, value) => sum + value, 0); + const weights = new Map(); + for (const team of teams) { + const ratingShare = (ratingWeights.get(team.participantId) ?? 0.000001) / ratingTotal; + const weight = hasOdds + ? (normalizedOdds.get(team.participantId) ?? 0) + ODDS_SELECTION_BONUS * ratingShare + : ratingShare; + weights.set(team.participantId, Math.max(weight, 0.000001)); + } + + return weights; +} + +function sampleField(teams: Team[], fieldSize: number, selectionRatingDivisor: number): Team[] { + const weights = buildSelectionWeights(teams, selectionRatingDivisor); + const remaining = [...teams]; + const selected: Team[] = []; + + for (let i = 0; i < fieldSize; i++) { + const totalWeight = remaining.reduce((sum, team) => sum + (weights.get(team.participantId) ?? 0), 0); + let cursor = Math.random() * totalWeight; + let index = 0; + for (; index < remaining.length - 1; index++) { + cursor -= weights.get(remaining[index].participantId) ?? 0; + if (cursor <= 0) break; + } + selected.push(remaining[index]); + remaining.splice(index, 1); + } + + return selected; +} + +export function assignFieldToNCAA68Regions( + field: Team[], + regions: BracketRegion[] = NCAA_68.regions ?? [] +): NCAARegionAssignment[] { + if (field.length !== FIELD_SIZE) { + throw new Error(`Expected ${FIELD_SIZE} teams for NCAA preseason field, received ${field.length}.`); + } + + const assignments = regions.map((): NCAARegionAssignment => ({ + directSeeds: new Map(), + playIns: new Map(), + })); + + const sorted = field + .map((team) => ({ team, tiebreaker: Math.random() })) + .toSorted((a, b) => b.team.rating - a.team.rating || b.tiebreaker - a.tiebreaker) + .map((entry) => entry.team); + + let cursor = 0; + for (const seed of Array.from({ length: 16 }, (_, index) => index + 1)) { + for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) { + const region = regions[regionIndex]; + const playIn = region.playIns.find((entry) => entry.seedSlot === seed); + if (playIn) { + assignments[regionIndex].playIns.set(seed, [sorted[cursor], sorted[cursor + 1]]); + cursor += 2; + } else { + assignments[regionIndex].directSeeds.set(seed, sorted[cursor]); + cursor++; + } + } + } + + return assignments; +} + +function simulateGame( + team1: Team, + team2: Team, + winProbability: (ratingA: number, ratingB: number) => number +): { winner: Team; loser: Team } { + const winner = Math.random() < winProbability(team1.rating, team2.rating) ? team1 : team2; + return { winner, loser: winner === team1 ? team2 : team1 }; +} + +function buildPreseasonRoundOf64( + assignments: NCAARegionAssignment[], + winProbability: (ratingA: number, ratingB: number) => number +): Array<[Team, Team]> { + const r64: Array<[Team, Team]> = []; + + for (const region of assignments) { + for (const [seedA, seedB] of STANDARD_BRACKET_SEEDING) { + const directA = region.directSeeds.get(seedA); + const directB = region.directSeeds.get(seedB); + const playInA = region.playIns.get(seedA); + const playInB = region.playIns.get(seedB); + const teamA = directA ?? (playInA ? simulateGame(playInA[0], playInA[1], winProbability).winner : null); + const teamB = directB ?? (playInB ? simulateGame(playInB[0], playInB[1], winProbability).winner : null); + + if (!teamA || !teamB) { + throw new Error(`NCAA preseason field is missing a Round of 64 team for ${seedA} vs ${seedB}.`); + } + r64.push([teamA, teamB]); + } + } + + return r64; +} + +function simulateRoundOf64Bracket( + r64Pairs: Array<[Team, Team]>, + counts: Map, + winProbability: (ratingA: number, ratingB: number) => number +): void { + const r64Winners = r64Pairs.map(([team1, team2]) => simulateGame(team1, team2, winProbability).winner); + + const r32Winners: Team[] = []; + for (let i = 0; i < 16; i++) { + r32Winners.push(simulateGame(r64Winners[i * 2], r64Winners[i * 2 + 1], winProbability).winner); + } + + const s16Winners: Team[] = []; + for (let i = 0; i < 8; i++) { + s16Winners.push(simulateGame(r32Winners[i * 2], r32Winners[i * 2 + 1], winProbability).winner); + } + + const e8Winners: Team[] = []; + for (let i = 0; i < 4; i++) { + const result = simulateGame(s16Winners[i * 2], s16Winners[i * 2 + 1], winProbability); + e8Winners.push(result.winner); + bump(counts, result.loser.participantId, "eliteEightLoser"); + } + + const ffWinners: Team[] = []; + for (let i = 0; i < 2; i++) { + const result = simulateGame(e8Winners[i * 2], e8Winners[i * 2 + 1], winProbability); + ffWinners.push(result.winner); + bump(counts, result.loser.participantId, "finalFourLoser"); + } + + const championship = simulateGame(ffWinners[0], ffWinners[1], winProbability); + bump(counts, championship.winner.participantId, "champion"); + bump(counts, championship.loser.participantId, "finalist"); +} + +function countsToResults(participantIds: string[], counts: Map, source: string): SimulationResult[] { + const N = NUM_SIMULATIONS; + return participantIds.map((participantId) => { + const count = counts.get(participantId) ?? { champion: 0, finalist: 0, finalFourLoser: 0, eliteEightLoser: 0 }; + return { + participantId, + probabilities: { + probFirst: count.champion / N, + probSecond: count.finalist / N, + probThird: count.finalFourLoser / (2 * N), + probFourth: count.finalFourLoser / (2 * N), + probFifth: count.eliteEightLoser / (4 * N), + probSixth: count.eliteEightLoser / (4 * N), + probSeventh: count.eliteEightLoser / (4 * N), + probEighth: count.eliteEightLoser / (4 * N), + }, + source, + }; + }); +} + +async function loadTeams( + db: Db, + sportsSeasonId: string, + participantIds?: string[], + missingRatingName = "rating" +): Promise { + const participantRows = participantIds + ? await db + .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) + .from(schema.seasonParticipants) + .where(inArray(schema.seasonParticipants.id, participantIds)) + : await db + .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) + .from(schema.seasonParticipants) + .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); + + const simulatorInputs = await db + .select({ + participantId: schema.seasonParticipantSimulatorInputs.participantId, + rating: schema.seasonParticipantSimulatorInputs.rating, + sourceOdds: schema.seasonParticipantSimulatorInputs.sourceOdds, + }) + .from(schema.seasonParticipantSimulatorInputs) + .where(eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId)); + + const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input])); + const foundIds = new Set(participantRows.map((row) => row.id)); + const missingParticipants = participantIds?.filter((participantId) => !foundIds.has(participantId)) ?? []; + if (missingParticipants.length > 0) { + throw new Error(`Bracket includes ${missingParticipants.length} participant(s) that are not in this sports season.`); + } + + const teams = participantRows.map((participant): Team => { + const input = inputById.get(participant.id); + const rating = input?.rating !== null && input?.rating !== undefined ? Number(input.rating) : null; + if (rating === null || !Number.isFinite(rating)) { + throw new Error( + `${participant.name} is missing a prepared ${missingRatingName}. ` + + `Enter direct ratings, futures odds, or configure an explicit rating fallback before running this simulator.` + ); + } + + return { + participantId: participant.id, + name: participant.name, + rating, + sourceOdds: input?.sourceOdds ?? null, + }; + }); + + return participantIds + ? participantIds.map((participantId) => { + const team = teams.find((candidate) => candidate.participantId === participantId); + if (!team) throw new Error(`Participant ${participantId} is missing from the prepared team map.`); + return team; + }) + : teams; +} + +function groupMatchesByRound(matches: PlayoffMatch[]): { + firstFourMatches: PlayoffMatch[]; + r64Matches: PlayoffMatch[]; + r32Matches: PlayoffMatch[]; + s16Matches: PlayoffMatch[]; + e8Matches: PlayoffMatch[]; + ffMatches: PlayoffMatch[]; + champMatch: PlayoffMatch; +} { + const byRound = new Map(); + for (const match of matches) { + if (!byRound.has(match.round)) byRound.set(match.round, []); + byRound.get(match.round)?.push(match); + } + + const firstFourMatches = sortByMatchNumber(byRound.get("First Four") ?? []); + const r64Matches = sortByMatchNumber(byRound.get("Round of 64") ?? []); + const r32Matches = sortByMatchNumber(byRound.get("Round of 32") ?? []); + const s16Matches = sortByMatchNumber(byRound.get("Sweet Sixteen") ?? []); + const e8Matches = sortByMatchNumber(byRound.get("Elite Eight") ?? []); + const ffMatches = sortByMatchNumber(byRound.get("Final Four") ?? []); + const champMatches = sortByMatchNumber(byRound.get("Championship") ?? []); + + if ( + r64Matches.length !== 32 || + r32Matches.length !== 16 || + s16Matches.length !== 8 || + e8Matches.length !== 4 || + ffMatches.length !== 2 || + champMatches.length !== 1 + ) { + const found = [...byRound.keys()].join(", "); + throw new Error( + `Missing expected NCAA rounds. Found: [${found}]. ` + + `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` + ); + } + + return { firstFourMatches, r64Matches, r32Matches, s16Matches, e8Matches, ffMatches, champMatch: champMatches[0] }; +} + +function validateFirstFour( + firstFourMatches: PlayoffMatch[], + r64Matches: PlayoffMatch[], + regions: BracketRegion[] +): Map { + const ffToR64 = new Map(); + const r64NullSlots = new Set( + r64Matches.filter((match) => !match.participant2Id).map((match) => match.matchNumber) + ); + + if (firstFourMatches.length === 0) { + for (const match of r64Matches) { + if (!match.participant1Id || !match.participant2Id) { + throw new Error( + `Round of 64 match ${match.matchNumber} is missing participants. ` + + `Assign all 64 teams to the bracket before running simulation.` + ); + } + } + return ffToR64; + } + + for (const mapping of buildFirstFourMappings(regions)) { + ffToR64.set(mapping.firstFourMatchNumber, mapping.r64MatchNumber); + } + + for (const match of firstFourMatches) { + if (!match.participant1Id || !match.participant2Id) { + throw new Error(`First Four match ${match.matchNumber} is missing participants. Assign both teams before running simulation.`); + } + } + + const r64SlotsCoveredByFF = new Set(ffToR64.values()); + for (const nullSlot of r64NullSlots) { + if (!r64SlotsCoveredByFF.has(nullSlot)) { + throw new Error(`Round of 64 match ${nullSlot} has no participant2 but is not covered by any First Four mapping.`); + } + } + + for (const [ffMatchNumber, r64MatchNumber] of ffToR64) { + if (!r64NullSlots.has(r64MatchNumber)) { + const ffMatch = firstFourMatches.find((match) => match.matchNumber === ffMatchNumber); + if (!ffMatch?.isComplete) { + throw new Error( + `First Four maps to Round of 64 match ${r64MatchNumber}, but that slot is already filled. Check the bracket configuration.` + ); + } + } + } + + for (const match of r64Matches) { + if (!match.participant1Id) { + throw new Error(`Round of 64 match ${match.matchNumber} is missing participant1. Check the bracket configuration.`); + } + } + + return ffToR64; +} + +function getTeam(teamById: Map, participantId: string): Team { + const team = teamById.get(participantId); + if (!team) throw new Error(`Participant ${participantId} is missing a prepared rating row.`); + return team; +} + +function simulateMatchFromIds( + participant1Id: string, + participant2Id: string, + match: PlayoffMatch | undefined, + teamById: Map, + winProbability: (ratingA: number, ratingB: number) => number +): { winner: Team; loser: Team } { + if (match?.isComplete && match.winnerId) { + return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, completedLoser(match)) }; + } + return simulateGame(getTeam(teamById, participant1Id), getTeam(teamById, participant2Id), winProbability); +} + +export class NCAABasketballSimulator implements Simulator { + constructor(private readonly options: NCAABasketballSimulatorOptions) {} + + async simulate(sportsSeasonId: string): Promise { + const db = database(); + const config = await getSeasonSimulatorConfig(db, sportsSeasonId); + const winProbability = this.options.getWinProbability?.(config) ?? this.options.winProbability; + const bracketEvent = await db.query.scoringEvents.findFirst({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.eventType, "playoff_game") + ), + }); + + if (!bracketEvent) { + return this.simulatePreseason(db, sportsSeasonId, winProbability); + } + + const allMatches = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + orderBy: (match, { asc }) => [asc(match.matchNumber)], + }); + + if (allMatches.length === 0) { + return this.simulatePreseason(db, sportsSeasonId, winProbability); + } + + return this.simulateExistingBracket( + db, + sportsSeasonId, + bracketEvent.bracketRegionConfig as BracketRegion[] | null, + allMatches, + winProbability + ); + } + + private async simulatePreseason( + db: Db, + sportsSeasonId: string, + winProbability: (ratingA: number, ratingB: number) => number + ): Promise { + const teams = await loadTeams(db, sportsSeasonId, undefined, this.options.missingRatingName); + if (teams.length < FIELD_SIZE) { + throw new Error(`NCAA preseason mode requires at least ${FIELD_SIZE} draftable participants, found ${teams.length}.`); + } + + const participantIds = teams.map((team) => team.participantId); + const counts = makeCounts(participantIds); + + for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { + const field = sampleField(teams, FIELD_SIZE, this.options.selectionRatingDivisor); + const assignments = assignFieldToNCAA68Regions(field); + const r64Pairs = buildPreseasonRoundOf64(assignments, winProbability); + simulateRoundOf64Bracket(r64Pairs, counts, winProbability); + } + + return countsToResults(participantIds, counts, `${this.options.source}_preseason`); + } + + private async simulateExistingBracket( + db: Db, + sportsSeasonId: string, + bracketRegions: BracketRegion[] | null, + allMatches: PlayoffMatch[], + winProbability: (ratingA: number, ratingB: number) => number + ): Promise { + const rounds = groupMatchesByRound(allMatches); + const regions = bracketRegions ?? NCAA_68.regions ?? []; + const ffToR64 = validateFirstFour(rounds.firstFourMatches, rounds.r64Matches, regions); + + const participantIdSet = new Set(); + for (const match of rounds.r64Matches) { + if (match.participant1Id) participantIdSet.add(match.participant1Id); + if (match.participant2Id) participantIdSet.add(match.participant2Id); + } + for (const match of rounds.firstFourMatches) { + if (match.participant1Id) participantIdSet.add(match.participant1Id); + if (match.participant2Id) participantIdSet.add(match.participant2Id); + } + const participantIds = [...participantIdSet]; + const teams = await loadTeams(db, sportsSeasonId, participantIds, this.options.missingRatingName); + const teamById = new Map(teams.map((team) => [team.participantId, team])); + const counts = makeCounts(participantIds); + + const firstFourByNum = new Map(rounds.firstFourMatches.map((match) => [match.matchNumber, match])); + const r64ByNum = new Map(rounds.r64Matches.map((match) => [match.matchNumber, match])); + const r32ByNum = new Map(rounds.r32Matches.map((match) => [match.matchNumber, match])); + const s16ByNum = new Map(rounds.s16Matches.map((match) => [match.matchNumber, match])); + const e8ByNum = new Map(rounds.e8Matches.map((match) => [match.matchNumber, match])); + const ffByNum = new Map(rounds.ffMatches.map((match) => [match.matchNumber, match])); + + for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { + const ffSimWinners = new Map(); + for (const [ffMatchNumber, r64MatchNumber] of ffToR64) { + const match = firstFourByNum.get(ffMatchNumber); + if (!match?.participant1Id || !match.participant2Id) continue; + const winner = match.isComplete && match.winnerId + ? match.winnerId + : simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, winProbability).winner.participantId; + ffSimWinners.set(r64MatchNumber, winner); + } + + const r64Winners: string[] = []; + for (let i = 1; i <= 32; i++) { + const match = r64ByNum.get(i); + if (!match?.participant1Id) continue; + const participant2Id = match.participant2Id ?? ffSimWinners.get(i); + if (!participant2Id) throw new Error(`Round of 64 match ${i} is missing its second participant.`); + const result = simulateMatchFromIds(match.participant1Id, participant2Id, match, teamById, winProbability); + r64Winners.push(result.winner.participantId); + } + + const r32Winners: string[] = []; + for (let i = 1; i <= 16; i++) { + const result = simulateMatchFromIds( + r64Winners[(i - 1) * 2], + r64Winners[(i - 1) * 2 + 1], + r32ByNum.get(i), + teamById, + winProbability + ); + r32Winners.push(result.winner.participantId); + } + + const s16Winners: string[] = []; + for (let i = 1; i <= 8; i++) { + const result = simulateMatchFromIds( + r32Winners[(i - 1) * 2], + r32Winners[(i - 1) * 2 + 1], + s16ByNum.get(i), + teamById, + winProbability + ); + s16Winners.push(result.winner.participantId); + } + + const e8Winners: string[] = []; + for (let i = 1; i <= 4; i++) { + const result = simulateMatchFromIds( + s16Winners[(i - 1) * 2], + s16Winners[(i - 1) * 2 + 1], + e8ByNum.get(i), + teamById, + winProbability + ); + e8Winners.push(result.winner.participantId); + bump(counts, result.loser.participantId, "eliteEightLoser"); + } + + const ffWinners: string[] = []; + for (let i = 1; i <= 2; i++) { + const result = simulateMatchFromIds( + e8Winners[(i - 1) * 2], + e8Winners[(i - 1) * 2 + 1], + ffByNum.get(i), + teamById, + winProbability + ); + ffWinners.push(result.winner.participantId); + bump(counts, result.loser.participantId, "finalFourLoser"); + } + + const final = simulateMatchFromIds( + ffWinners[0], + ffWinners[1], + rounds.champMatch, + teamById, + winProbability + ); + bump(counts, final.winner.participantId, "champion"); + bump(counts, final.loser.participantId, "finalist"); + } + + return countsToResults(participantIds, counts, this.options.source); + } +} diff --git a/app/services/simulations/ncaam-simulator.ts b/app/services/simulations/ncaam-simulator.ts index 870e74f..560ad96 100644 --- a/app/services/simulations/ncaam-simulator.ts +++ b/app/services/simulations/ncaam-simulator.ts @@ -1,709 +1,35 @@ -/** - * NCAAM Tournament Bracket Simulator - * - * Monte Carlo simulation of the NCAA Men's Basketball Tournament (64-team bracket). - * Win probability is derived from KenPom Adjusted Efficiency Margin (AEM). - * - * Algorithm: - * 1. Load the bracket scoring event and all playoff matches from DB - * (6 rounds: R64, R32, S16, E8, FF, Final — 63 total matches) - * 2. Load participant names from DB; look up KenPom net rating by name - * from the hardcoded KENPOM_NET_RATINGS map (updated each season) - * 3. Per-match win probability = 1 / (1 + exp(-(netrtgA - netrtgB) / 7.5)) - * (neutral-court logistic formula, calibrated to KenPom AEM scale) - * 4. Simulate 50,000 tournaments, honoring completed match results - * 5. Track placements only for point-scoring rounds: - * - Champion (1st) - * - Finalist (2nd) - * - Final Four losers (3rd/4th) — 2 per sim - * - Elite Eight losers (5th–8th) — 4 per sim - * R64 / R32 / S16 exits score 0 points → not tracked - * - * Probability output (8 slots — same SimulationProbabilities type as UCL): - * probFirst = champion / N - * probSecond = finalist / N - * probThird/Fourth = ffLoser / (2 * N) — 2 FF losers per sim - * probFifth–Eighth = e8Loser / (4 * N) — 4 E8 losers per sim - * All pre-E8 exits → 0 (no points scored) - * - * Column sums are guaranteed to equal 1.0 by construction: - * probFirst/Second — 1 per sim, N total → sums to 1 - * probThird/Fourth — 2 per sim, each column = total/2N → sum = 1 - * probFifth–Eighth — 4 per sim, each column = total/4N → sum = 1 - * - * KenPom net ratings are hardcoded in KENPOM_NET_RATINGS below (2025-26 season). - * Update this map each season. Participant names must match DB records - * (lookup is case-insensitive, whitespace-normalized). - * Unknown teams fall back to netrtg = 0.0 (near-average strength). - * - * Bracket advancement path (same as UCL): - * nextMatchNumber = Math.ceil(matchNumber / 2) - * i.e. R64 matches 1+2 → R32 match 1, R64 matches 3+4 → R32 match 2, … - */ - -import { database } from "~/database/context"; -import { eq, and, inArray } from "drizzle-orm"; -import * as schema from "~/database/schema"; -import type { BracketRegion } from "~/lib/bracket-templates"; -import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates"; +import { NCAABasketballSimulator } from "./ncaa-basketball-simulator"; import type { Simulator, SimulationResult } from "./types"; -import { logger } from "~/lib/logger"; -// ─── Simulation parameters ──────────────────────────────────────────────────── - -const NUM_SIMULATIONS = 50_000; - -/** - * KenPom scale factor for the neutral-court logistic win probability formula. - * A difference of 7.5 AEM points crosses the logit 50% mark. - * Source: KenPom documentation; widely used in academic NCAAM models. - */ const KENPOM_SCALE_FACTOR = 7.5; -// ─── KenPom net rating data (2025-26 season) ───────────────────────────────── -// -// Update this map at the start of each tournament season with current -// KenPom Adjusted Efficiency Margin values. -// Source: kenpom.com, data through March 15, 2026 (6,195 games). -// -// Keys: lowercase, whitespace-normalized team names (normalizeTeamName output). -// Multiple aliases are included for common abbreviation variants. -// If a participant name is not found, it falls back to 0.0 (average strength). - -const KENPOM_NET_RATINGS: Record = { - // ── 1–10 ──────────────────────────────────────────────────────────────────── - "duke": 38.90, - "arizona": 37.66, - "michigan": 37.59, - "florida": 33.79, - "houston": 33.43, - "iowa st.": 32.42, "iowa state": 32.42, - "illinois": 32.10, - "purdue": 31.20, - "michigan st.": 28.31, "michigan state": 28.31, - "gonzaga": 28.10, - - // ── 11–30 ─────────────────────────────────────────────────────────────────── - "connecticut": 27.87, "uconn": 27.87, - "vanderbilt": 27.51, - "virginia": 26.71, - "nebraska": 26.16, - "arkansas": 26.05, - "tennessee": 26.02, - "st. john's": 25.91, "st johns": 25.91, - "alabama": 25.72, - "louisville": 25.42, - "texas tech": 25.22, - "kansas": 24.44, - "wisconsin": 23.39, - "byu": 23.25, - "saint mary's": 23.07, - "iowa": 22.44, - "ohio st.": 22.24, "ohio state": 22.24, - "ucla": 21.67, - "kentucky": 21.48, - "north carolina": 20.84, - - // ── 30–50 ─────────────────────────────────────────────────────────────────── - "utah st.": 20.76, "utah state": 20.76, - "miami fl": 20.68, "miami (fl)": 20.68, "miami": 20.68, - "georgia": 20.48, - "villanova": 19.97, - "nc state": 19.60, "n.c. state": 19.60, - "santa clara": 19.40, - "clemson": 19.24, - "texas": 19.03, - "auburn": 19.02, - "texas a&m": 18.67, - "oklahoma": 18.37, - "saint louis": 18.32, - "smu": 18.09, - "tcu": 17.59, - "cincinnati": 17.49, - "vcu": 17.21, - "indiana": 17.18, - "south florida": 16.39, "usf": 16.39, - "san diego st.": 16.39, "san diego state": 16.39, - "baylor": 16.00, - - // ── 50–80 ─────────────────────────────────────────────────────────────────── - "new mexico": 15.81, - "seton hall": 15.71, - "missouri": 15.39, - "washington": 15.12, - "ucf": 15.04, - "virginia tech": 13.69, - "florida st.": 13.48, "florida state": 13.48, - "northwestern": 13.41, - "stanford": 13.37, - "west virginia": 13.27, - "lsu": 13.23, - "grand canyon": 13.19, - "boise st.": 13.18, "boise state": 13.18, - "tulsa": 12.97, - "akron": 12.80, - "ole miss": 12.62, "mississippi": 12.62, - "oklahoma st.": 12.58, "oklahoma state": 12.58, - "arizona st.": 12.52, "arizona state": 12.52, - "mcneese": 12.48, "mcneese st.": 12.48, "mcneese state": 12.48, - "belmont": 12.26, - "colorado": 12.11, - "providence": 11.81, - "northern iowa": 11.81, - "california": 11.43, "cal": 11.43, - "wake forest": 11.39, - "nevada": 11.30, - "creighton": 11.01, - "minnesota": 10.91, - "dayton": 10.91, - "georgetown": 10.89, - "usc": 10.81, - - // ── 81–120 ────────────────────────────────────────────────────────────────── - "yale": 10.65, - "wichita st.": 9.78, "wichita state": 9.78, - "syracuse": 9.74, - "marquette": 9.66, - "george washington": 9.64, "gwu": 9.64, - "butler": 9.49, - "hofstra": 9.49, - "colorado st.": 9.49, "colorado state": 9.49, - "notre dame": 8.88, - "utah valley": 8.82, - "stephen f. austin": 8.43, "sf austin": 8.43, - "high point": 8.40, - "miami oh": 8.26, "miami (oh)": 8.26, - "pittsburgh": 7.60, "pitt": 7.60, - "south carolina": 7.54, - "george mason": 7.50, - "xavier": 7.47, - "wyoming": 7.40, - "oregon": 7.03, - "mississippi st.": 7.00, "mississippi state": 7.00, - "kansas st.": 6.98, "kansas state": 6.98, - "depaul": 6.83, - "illinois st.": 6.66, "illinois state": 6.66, - "uc irvine": 6.21, - "illinois chicago": 6.16, "uic": 6.16, - "cal baptist": 5.99, - "unlv": 5.97, - "hawaii": 5.97, "hawai'i": 5.97, - "st. thomas": 5.88, - "unc wilmington": 5.79, "uncw": 5.79, "nc wilmington": 5.79, - "sam houston": 5.56, "sam houston st.": 5.56, "sam houston state": 5.56, - "pacific": 5.42, - "north dakota st.": 5.13, "north dakota state": 5.13, - "davidson": 4.94, - "saint joseph's": 4.56, - "southern illinois": 4.55, - "uc san diego": 4.53, "ucsd": 4.53, - "seattle": 4.49, - "maryland": 4.25, - "san francisco": 4.20, - "murray st.": 4.12, "murray state": 4.12, - "bradley": 3.96, - "rutgers": 3.95, - "liberty": 3.90, - "utah": 3.65, - "uab": 3.46, - "duquesne": 3.45, - "florida atlantic": 3.31, - "uc santa barbara": 3.17, "ucsb": 3.17, - "toledo": 3.15, - "fresno st.": 3.09, "fresno state": 3.09, - "montana st.": 3.00, "montana state": 3.00, - - // ── 134–170 (tournament bubble / auto-bid range) ───────────────────────── - "memphis": 2.52, - "rhode island": 2.47, - "north texas": 2.46, - "washington st.": 2.32, "washington state": 2.32, - "penn st.": 2.18, "penn state": 2.18, - "st. bonaventure": 2.17, - "wright st.": 2.04, "wright state": 2.04, - "northern colorado": 1.87, - "navy": 1.84, - "troy": 1.72, - "robert morris": 1.69, - "idaho": 1.53, - "portland st.": 1.52, "portland state": 1.52, - "bowling green": 1.51, - "kent st.": 1.50, "kent state": 1.50, - "william & mary": 1.49, - "penn": 1.47, - "arkansas st.": 1.39, "arkansas state": 1.39, - "harvard": 1.26, - "central arkansas": 1.25, "c arkansas": 1.25, - "winthrop": 1.13, - "western kentucky": 0.02, "western ky.": 0.02, - "kennesaw st.": 0.57, "kennesaw state": 0.57, - "cornell": 0.51, - "east tennessee st.": 0.44, "etsu": 0.44, - "eastern washington": 0.00, "east washington": 0.00, - "charleston": -0.19, - "austin peay": -0.28, - "merrimack": -0.83, - - // ── 170–220 (low seeds / play-in range) ───────────────────────────────── - "montana": -1.72, - "umbc": -1.67, - "tennessee st.": -1.83, "tennessee state": -1.83, - "furman": -1.98, - "siena": -2.10, - "appalachian state": -2.33, "app state": -2.33, - "south alabama": -3.14, - "howard": -3.19, - "marshall": -3.19, - "samford": -3.93, - "liu": -3.95, - "lipscomb": -2.84, - "queens": -1.44, - "quinnipiac": -4.25, - "south dakota st.": -4.31, "south dakota state": -4.31, - "se missouri st.": -5.84, "southeast missouri state": -5.84, - "tennessee martin": -4.81, "ut martin": -4.81, - "vermont": -6.45, - "bethune": -6.60, "bethune-cookman": -6.60, - "colgate": -7.02, - "saint peter's": -7.51, "st. peter's": -7.51, "st peters": -7.51, - "mercer": -1.97, - "morehead st.": -10.35, "morehead state": -10.35, - "lehigh": -10.37, - "prairie view a&m": -10.69, "prairie view": -10.69, - "grambling": -11.78, "grambling st.": -11.78, - "central connecticut": -12.71, "c connecticut": -12.71, - "wagner": -12.68, - "norfolk st.": -15.38, "norfolk state": -15.38, -}; - -// ─── Public helpers (exported for unit testing) ─────────────────────────────── - -/** Normalize a team name for KenPom map lookup. */ -export function normalizeTeamName(name: string): string { - return name.toLowerCase().trim().replace(/\s+/g, " "); -} - -/** Look up KenPom AEM for a participant name. Returns 0.0 if not found. */ -export function getNetRating(name: string): number { - return KENPOM_NET_RATINGS[normalizeTeamName(name)] ?? 0.0; -} - /** - * NCAAM neutral-court win probability using KenPom Adjusted Efficiency Margin. - * P(A beats B) = 1 / (1 + exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)) - * Exported for unit testing. + * NCAAM neutral-court win probability using a KenPom-like adjusted efficiency + * margin. Ratings are season-scoped simulator inputs, not hardcoded data. */ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number { return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)); } -function sortByMatchNumber(matches: T[]): T[] { - return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); +function optionalPositiveNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } -// ─── Simulator ──────────────────────────────────────────────────────────────── +function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number { + return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor)); +} export class NCAAMSimulator implements Simulator { - async simulate(sportsSeasonId: string): Promise { - const db = database(); + private readonly simulator = new NCAABasketballSimulator({ + source: "ncaam_bracket_rating", + missingRatingName: "KenPom-like rating", + selectionRatingDivisor: 8, + winProbability: kenpomWinProbability, + getWinProbability: (config) => + kenpomWinProbabilityWithScale(optionalPositiveNumber(config.ratingScaleFactor, KENPOM_SCALE_FACTOR)), + }); - // 1. Find the bracket scoring event (playoff_game) for this sports season. - const bracketEvent = await db.query.scoringEvents.findFirst({ - where: and( - eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), - eq(schema.scoringEvents.eventType, "playoff_game") - ), - }); - - if (!bracketEvent) { - throw new Error( - `No bracket event found for sports season ${sportsSeasonId}. ` + - `Create a playoff_game scoring event and set up the 64-team bracket first.` - ); - } - - // 2. Load all playoff matches for this bracket event. - const allMatches = await db.query.playoffMatches.findMany({ - where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), - orderBy: (m, { asc }) => [asc(m.matchNumber)], - }); - - if (allMatches.length === 0) { - throw new Error( - `No playoff matches found for the bracket event. ` + - `Generate the 64-team bracket from the admin panel first.` - ); - } - - // 3. Group matches by round name. - // Rounds: "First Four" (optional, 4) | "Round of 64" (32) | "Round of 32" (16) - // | "Sweet Sixteen" (8) | "Elite Eight" (4) | "Final Four" (2) | "Championship" (1) - // - // We look up by name rather than sorting by count to avoid the ambiguity between - // "First Four" and "Elite Eight" (both 4 matches). - const byRound = new Map(); - for (const m of allMatches) { - if (!byRound.has(m.round)) byRound.set(m.round, []); - byRound.get(m.round)?.push(m); - } - - const rawFirstFour = byRound.get("First Four"); - const rawR64 = byRound.get("Round of 64"); - const rawR32 = byRound.get("Round of 32"); - const rawS16 = byRound.get("Sweet Sixteen"); - const rawE8 = byRound.get("Elite Eight"); - const rawFF = byRound.get("Final Four"); - const rawChamp = byRound.get("Championship"); - - const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : []; - const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null; - const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null; - const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null; - const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null; - const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null; - const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null; - - if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { - const found = [...byRound.keys()].join(", "); - throw new Error( - `Missing expected rounds. Found: [${found}]. ` + - `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` - ); - } - - if (r64Matches.length !== 32) { - throw new Error( - `Expected 32 Round of 64 matches, found ${r64Matches.length}. ` + - `This simulator only supports the standard 64-team NCAAM format.` - ); - } - if (r32Matches.length !== 16) { - throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`); - } - if (s16Matches.length !== 8) { - throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`); - } - if (e8Matches.length !== 4) { - throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`); - } - if (ffMatches.length !== 2) { - throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`); - } - if (champMatches.length !== 1) { - throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`); - } - - // 4. Build the First Four → Round of 64 slot mapping (if First Four games exist). - // - // First Four match N feeds into R64 match: - // regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1 - // This mirrors the advanceFirstFourWinner() logic in playoff-match.ts. - // - // The region config is stored on the scoring event (bracketRegionConfig) so - // per-event overrides are respected. Falls back to the ncaa_68 template default. - // - // ffToR64: Map - // r64NullSlots: Set of R64 match numbers whose participant2Id is null (pending FF) - const ffToR64 = new Map(); - const r64NullSlots = new Set( - r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber) - ); - - if (firstFourMatches.length > 0) { - const regions = - (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? - // Fall back to the template's built-in regions — single source of truth - NCAA_68.regions ?? []; - - const slotMap = buildNCAA68SlotMap(regions); - for (let i = 0; i < slotMap.playInOffsets.length; i++) { - const { regionIndex, seedSlot } = slotMap.playInOffsets[i]; - const r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1; - ffToR64.set(i + 1, r64MatchNumber); // First Four matchNumbers are 1-based - } - - // Validate First Four matches have participants before simulation - for (const m of firstFourMatches) { - if (!m.participant1Id || !m.participant2Id) { - throw new Error( - `First Four match ${m.matchNumber} is missing participants. ` + - `Assign both teams before running simulation.` - ); - } - } - - // Validate First Four → R64 mapping covers all null R64 slots. - // Note: FF-mapped slots may already be filled if the First Four game was completed - // and the winner was advanced — that's expected and handled in the sim loop. - const r64SlotsCoveredByFF = new Set(ffToR64.values()); - for (const nullSlot of r64NullSlots) { - if (!r64SlotsCoveredByFF.has(nullSlot)) { - throw new Error( - `Round of 64 match ${nullSlot} has no participant2 but is not covered ` + - `by any First Four mapping. Check the bracket configuration.` - ); - } - } - for (const [ffMatchNum, ffR64Slot] of ffToR64) { - if (!r64NullSlots.has(ffR64Slot)) { - // Slot is already filled — only valid if the FF game is complete - const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum); - if (!ffMatch?.isComplete) { - throw new Error( - `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + - `already filled. Check the bracket configuration.` - ); - } - } - } - // In the First Four path, participant1Id must always be set; participant2Id - // may be null only for FF-pending slots (already validated above). - for (const m of r64Matches) { - if (!m.participant1Id) { - throw new Error( - `Round of 64 match ${m.matchNumber} is missing participant1. ` + - `Check the bracket configuration.` - ); - } - } - } else { - // No First Four — all R64 slots must be directly filled - for (const m of r64Matches) { - if (!m.participant1Id || !m.participant2Id) { - throw new Error( - `Round of 64 match ${m.matchNumber} is missing participants. ` + - `Assign all 64 teams to the bracket before running simulation.` - ); - } - } - } - - // 5. Collect all participant IDs (up to 68 when First Four is present). - // R64 direct slots (60 or 64) + First Four teams (8) — First Four losers get - // all-zero probabilities since they don't enter the scored rounds. - const participantIdSet = new Set(); - for (const m of r64Matches) { - if (m.participant1Id) participantIdSet.add(m.participant1Id); - if (m.participant2Id) participantIdSet.add(m.participant2Id); - } - for (const m of firstFourMatches) { - if (m.participant1Id) participantIdSet.add(m.participant1Id); - if (m.participant2Id) participantIdSet.add(m.participant2Id); - } - const participantIds = [...participantIdSet]; - - // 6. Load participant names + admin-managed ratings from DB; build net rating lookup by ID. - const [participantRows, simulatorInputs] = await Promise.all([ - db - .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) - .from(schema.seasonParticipants) - .where(inArray(schema.seasonParticipants.id, participantIds)), - db - .select({ participantId: schema.seasonParticipantSimulatorInputs.participantId, rating: schema.seasonParticipantSimulatorInputs.rating }) - .from(schema.seasonParticipantSimulatorInputs) - .where(eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId)), - ]); - - if (participantRows.length < participantIds.length) { - const foundIds = new Set(participantRows.map((r) => r.id)); - const missing = participantIds.filter((id) => !foundIds.has(id)); - logger.warn( - `[NCAAMSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + - `They will be treated as average strength (KenPom 0.0).` - ); - } - - const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input])); - const netRatingById = new Map(); - for (const { id, name } of participantRows) { - const rawRating = inputById.get(id)?.rating; - const rating = rawRating !== null && rawRating !== undefined ? Number(rawRating) : null; - netRatingById.set(id, (rating !== null && Number.isFinite(rating) ? rating : null) ?? getNetRating(name)); - } - - // 7. Build per-round O(1) lookup maps keyed by matchNumber. - const firstFourByNum = new Map(firstFourMatches.map((m) => [m.matchNumber, m])); - const r64ByNum = new Map(r64Matches.map((m) => [m.matchNumber, m])); - const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m])); - const s16ByNum = new Map(s16Matches.map((m) => [m.matchNumber, m])); - const e8ByNum = new Map(e8Matches.map((m) => [m.matchNumber, m])); - const ffByNum = new Map(ffMatches.map((m) => [m.matchNumber, m])); - const champMatch = champMatches[0]; - - // ─── Helpers ────────────────────────────────────────────────────────────── - - const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { - const r1 = netRatingById.get(p1) ?? 0; - const r2 = netRatingById.get(p2) ?? 0; - const w = Math.random() < kenpomWinProbability(r1, r2) ? p1 : p2; - return { winner: w, loser: w === p1 ? p2 : p1 }; - }; - - // 8. Integer placement count maps — initialized to 0 for all participants. - const championCounts = new Map(participantIds.map((id) => [id, 0])); - const finalistCounts = new Map(participantIds.map((id) => [id, 0])); - const ffLoserCounts = new Map(participantIds.map((id) => [id, 0])); - const e8LoserCounts = new Map(participantIds.map((id) => [id, 0])); - - // 9. Monte Carlo simulation loop. - for (let s = 0; s < NUM_SIMULATIONS; s++) { - // ── First Four (4 play-in games → 4 winners placed into R64 slots) ─── - // ffSimWinners: Map for the null participant2Id slots - const ffSimWinners = new Map(); - for (const [ffMatchNum, r64MatchNum] of ffToR64) { - const m = firstFourByNum.get(ffMatchNum); - if (!m) continue; - const winner = m.isComplete && m.winnerId - ? m.winnerId - : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner; - ffSimWinners.set(r64MatchNum, winner); - } - - // ── Round of 64 (32 matches → 32 winners) ──────────────────────────── - const r64Winners: string[] = []; - for (let i = 1; i <= 32; i++) { - const m = r64ByNum.get(i); - if (!m) continue; - // participant2Id may be null if this slot is filled by a First Four winner - const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; - if (m.isComplete && m.winnerId) { - r64Winners.push(m.winnerId); - } else { - r64Winners.push(simMatch(m.participant1Id ?? "", p2 ?? "").winner); - } - } - - // ── Round of 32 (16 matches → 16 winners) ──────────────────────────── - // Match i uses r64Winners[(i-1)*2] and [(i-1)*2+1] - const r32Winners: string[] = []; - for (let i = 1; i <= 16; i++) { - const dbMatch = r32ByNum.get(i); - if (dbMatch?.isComplete && dbMatch.winnerId) { - r32Winners.push(dbMatch.winnerId); - } else { - const p1 = r64Winners[(i - 1) * 2]; - const p2 = r64Winners[(i - 1) * 2 + 1]; - r32Winners.push(simMatch(p1, p2).winner); - } - } - - // ── Sweet 16 (8 matches → 8 winners) ───────────────────────────────── - const s16Winners: string[] = []; - for (let i = 1; i <= 8; i++) { - const dbMatch = s16ByNum.get(i); - if (dbMatch?.isComplete && dbMatch.winnerId) { - s16Winners.push(dbMatch.winnerId); - } else { - const p1 = r32Winners[(i - 1) * 2]; - const p2 = r32Winners[(i - 1) * 2 + 1]; - s16Winners.push(simMatch(p1, p2).winner); - } - } - - // ── Elite Eight (4 matches → 4 winners + 4 tracked losers) ─────────── - const e8Winners: string[] = []; - for (let i = 1; i <= 4; i++) { - const dbMatch = e8ByNum.get(i); - let winner: string; - let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId) { - winner = dbMatch.winnerId; - // Derive loser from stored participants if loserId is missing - loser = dbMatch.loserId ?? - (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id ?? "" - : dbMatch.participant1Id ?? ""); - } else { - const p1 = s16Winners[(i - 1) * 2]; - const p2 = s16Winners[(i - 1) * 2 + 1]; - ({ winner, loser } = simMatch(p1, p2)); - } - e8Winners.push(winner); - e8LoserCounts.set(loser, (e8LoserCounts.get(loser) ?? 0) + 1); - } - - // ── Final Four (2 matches → 2 winners + 2 tracked losers) ──────────── - const ffWinners: string[] = []; - for (let i = 1; i <= 2; i++) { - const dbMatch = ffByNum.get(i); - let winner: string; - let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId) { - winner = dbMatch.winnerId; - loser = dbMatch.loserId ?? - (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id ?? "" - : dbMatch.participant1Id ?? ""); - } else { - const p1 = e8Winners[(i - 1) * 2]; - const p2 = e8Winners[(i - 1) * 2 + 1]; - ({ winner, loser } = simMatch(p1, p2)); - } - ffWinners.push(winner); - ffLoserCounts.set(loser, (ffLoserCounts.get(loser) ?? 0) + 1); - } - - // ── Championship ────────────────────────────────────────────────────── - let champion: string; - let finalist: string; - if (champMatch?.isComplete && champMatch.winnerId) { - champion = champMatch.winnerId; - finalist = champMatch.loserId ?? - (champMatch.participant1Id === champMatch.winnerId - ? champMatch.participant2Id ?? "" - : champMatch.participant1Id ?? ""); - } else { - ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); - } - championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); - finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); - } - - // 10. Convert integer counts to probability distributions. - // Exact denominators guarantee column sums of 1.0 by construction: - // probFirst/Second → N total per sim → sum = 1 - // probThird/Fourth → ffLoserCounts / (2*N) → 2 losers * 1/(2N) = 1 - // probFifth–Eighth → e8LoserCounts / (4*N) → 4 losers * 1/(4N) = 1 - const N = NUM_SIMULATIONS; - const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId) ?? 0; - const f = finalistCounts.get(participantId) ?? 0; - const ff = ffLoserCounts.get(participantId) ?? 0; - const e8 = e8LoserCounts.get(participantId) ?? 0; - return { - participantId, - probabilities: { - probFirst: c / N, - probSecond: f / N, - probThird: ff / (2 * N), - probFourth: ff / (2 * N), - probFifth: e8 / (4 * N), - probSixth: e8 / (4 * N), - probSeventh: e8 / (4 * N), - probEighth: e8 / (4 * N), - }, - source: "ncaam_bracket_kenpom", - }; - }); - - // 10. Per-position normalization — belt-and-suspenders guard against - // floating-point residuals. Column sums are already near-exactly 1.0 - // after step 9, but this guarantees the invariant before persisting. - 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; + simulate(sportsSeasonId: string): Promise { + return this.simulator.simulate(sportsSeasonId); } } diff --git a/app/services/simulations/ncaaw-simulator.ts b/app/services/simulations/ncaaw-simulator.ts index d1977c5..c371583 100644 --- a/app/services/simulations/ncaaw-simulator.ts +++ b/app/services/simulations/ncaaw-simulator.ts @@ -1,664 +1,27 @@ -/** - * NCAAW Tournament Bracket Simulator - * - * Monte Carlo simulation of the NCAA Women's Basketball Tournament (64-team bracket). - * Win probability is derived from Barttorvik Barthag ratings using the Log5 formula. - * - * Algorithm: - * 1. Load the bracket scoring event and all playoff matches from DB - * (6 rounds: R64, R32, S16, E8, FF, Final — 63 total matches) - * 2. Load participant names from DB; look up Barthag rating by name - * from the hardcoded BARTHAG_RATINGS map (updated each season) - * 3. Per-match win probability = Log5: A*(1-B) / (A*(1-B) + B*(1-A)) - * (Barthag is on a 0–1 scale: probability of beating an average D-I opponent) - * 4. Simulate 50,000 tournaments, honoring completed match results - * 5. Track placements only for point-scoring rounds: - * - Champion (1st) - * - Finalist (2nd) - * - Final Four losers (3rd/4th) — 2 per sim - * - Elite Eight losers (5th–8th) — 4 per sim - * R64 / R32 / S16 exits score 0 points → not tracked - * - * Probability output (8 slots — same SimulationProbabilities type as NCAAM/UCL): - * probFirst = champion / N - * probSecond = finalist / N - * probThird/Fourth = ffLoser / (2 * N) — 2 FF losers per sim - * probFifth–Eighth = e8Loser / (4 * N) — 4 E8 losers per sim - * All pre-E8 exits → 0 (no points scored) - * - * Column sums are guaranteed to equal 1.0 by construction (same invariant as NCAAM). - * - * Barthag ratings are hardcoded in BARTHAG_RATINGS below (2025-26 season). - * Source: barttorvik.com/ncaaw — update each season before running simulations. - * Keys are lowercase, whitespace-normalized team names. - * Unknown teams fall back to BARTHAG_FALLBACK = 0.5 (average D-I strength). - * - * Win probability formula — Log5 (Bill James): - * P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A)) - * Property: barthagWinProbability(x, 0.5) === x (definition of Barthag) - * Property: barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1 - */ - -import { database } from "~/database/context"; -import { eq, and, inArray } from "drizzle-orm"; -import * as schema from "~/database/schema"; -import type { BracketRegion } from "~/lib/bracket-templates"; -import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates"; +import { NCAABasketballSimulator } from "./ncaa-basketball-simulator"; import type { Simulator, SimulationResult } from "./types"; -import { logger } from "~/lib/logger"; - -// ─── Simulation parameters ──────────────────────────────────────────────────── - -const NUM_SIMULATIONS = 50_000; /** - * Fallback Barthag for unknown team names. - * 0.5 = exactly average D-I opponent (unlike KenPom AEM where 0 = average). - */ -const BARTHAG_FALLBACK = 0.5; - -// ─── Barttorvik Barthag ratings (2025-26 NCAAW season) ──────────────────────── -// -// Source: barttorvik.com/ncaaw, data through March 15, 2026. -// Update this map at the start of each tournament season. -// -// Barthag is on a 0–1 scale: the probability of beating an average D-I team. -// Strong teams are near 1.0; weak teams near 0.0; average is 0.5. -// -// Keys: lowercase, whitespace-normalized (same as normalizeTeamName output). -// Multiple aliases for common abbreviation variants. - -const BARTHAG_RATINGS: Record = { - // ── 1–10 ──────────────────────────────────────────────────────────────────── - "connecticut": 0.9996, "uconn": 0.9996, "connecticut (uconn)": 0.9996, - "ucla": 0.9991, - "texas": 0.9988, - "south carolina": 0.9986, - "lsu": 0.9977, - "michigan": 0.9952, - "duke": 0.9936, - "minnesota": 0.9914, - "iowa": 0.9907, - "vanderbilt": 0.9906, - - // ── 11–30 ─────────────────────────────────────────────────────────────────── - "louisville": 0.9904, - "maryland": 0.9893, - "ohio st.": 0.9892, "ohio state": 0.9892, - "oklahoma": 0.9886, - "tcu": 0.9885, - "west virginia": 0.9882, - "kentucky": 0.9880, - "north carolina": 0.9864, - "washington": 0.9834, - "usc": 0.9824, - "mississippi": 0.9821, "ole miss": 0.9821, - "michigan st.": 0.9817, "michigan state": 0.9817, - "notre dame": 0.9803, - "tennessee": 0.9799, - "n.c. state": 0.9766, "nc state": 0.9766, - "nebraska": 0.9751, - "villanova": 0.9745, - "oregon": 0.9741, - "alabama": 0.9720, - "texas tech": 0.9717, - - // ── 31–50 ─────────────────────────────────────────────────────────────────── - "illinois": 0.9710, - "georgia": 0.9710, - "oklahoma st.": 0.9672, "oklahoma state": 0.9672, - "iowa st.": 0.9666, "iowa state": 0.9666, - "baylor": 0.9639, - "colorado": 0.9615, - "virginia tech": 0.9589, - "florida": 0.9519, - "syracuse": 0.9502, - "virginia": 0.9437, - "stanford": 0.9407, - "mississippi st.": 0.9407, "mississippi state": 0.9407, - "james madison": 0.9374, - "richmond": 0.9341, - "arizona st.": 0.9331, "arizona state": 0.9331, - "indiana": 0.9309, - "california": 0.9268, "cal": 0.9268, - "kansas": 0.9259, - "clemson": 0.9219, - "princeton": 0.9202, - - // ── 51–70 ─────────────────────────────────────────────────────────────────── - "texas a&m": 0.9201, - "south dakota st.": 0.9187, "south dakota state": 0.9187, - "kansas st.": 0.9122, "kansas state": 0.9122, - "utah": 0.9076, - "byu": 0.8991, - "seton hall": 0.8978, - "marquette": 0.8922, - "rhode island": 0.8912, - "fairfield": 0.8902, - "georgia tech": 0.8887, - "columbia": 0.8884, - "gonzaga": 0.8849, - "george mason": 0.8834, - "north dakota st.": 0.8796, "north dakota state": 0.8796, - "miami fl": 0.8772, "miami (fl)": 0.8772, "miami": 0.8772, - "montana st.": 0.8643, "montana state": 0.8643, - "harvard": 0.8604, - "creighton": 0.8594, - "wisconsin": 0.8585, - "colorado st.": 0.8563, "colorado state": 0.8563, - - // ── 71–100 ────────────────────────────────────────────────────────────────── - "penn st.": 0.8471, "penn state": 0.8471, - "miami oh": 0.8420, "miami (oh)": 0.8420, - "san diego st.": 0.8362, "san diego state": 0.8362, - "purdue": 0.8357, - "south florida": 0.8343, "usf": 0.8343, - "georgetown": 0.8325, - "missouri": 0.8318, - "georgia southern": 0.8275, - "oregon st.": 0.8200, "oregon state": 0.8200, - "unlv": 0.8117, - "rice": 0.8109, - "st. john's": 0.8095, "st johns": 0.8095, - "davidson": 0.8093, - "ball st.": 0.8071, "ball state": 0.8071, - "auburn": 0.7978, - "louisiana tech": 0.7977, - "troy": 0.7932, - "green bay": 0.7872, "wi-green bay": 0.7872, - "idaho": 0.7859, - "santa clara": 0.7824, - - // ── 101–130 ───────────────────────────────────────────────────────────────── - "uc irvine": 0.7788, - "mcneese st.": 0.7716, "mcneese state": 0.7716, "mcneese": 0.7716, - "cincinnati": 0.7677, - "vermont": 0.7658, - "quinnipiac": 0.7633, - "saint joseph's": 0.7627, - "loyola marymount": 0.7600, "lmu": 0.7600, - "north texas": 0.7509, - "florida st.": 0.7498, "florida state": 0.7498, - "massachusetts": 0.7479, "umass": 0.7479, - "murray st.": 0.7431, "murray state": 0.7431, - "arkansas st.": 0.7412, "arkansas state": 0.7412, - "arkansas": 0.7353, - "lindenwood": 0.7312, - "butler": 0.7254, - "western illinois": 0.7238, - "arizona": 0.7023, - "abilene christian": 0.7016, - "hawaii": 0.6995, "hawai'i": 0.6995, - "new mexico": 0.6988, - - // ── 131–160 ───────────────────────────────────────────────────────────────── - "northwestern": 0.6979, - "uc san diego": 0.6846, "ucsd": 0.6846, - "cal baptist": 0.6822, - "boise st.": 0.6809, "boise state": 0.6809, - "central arkansas": 0.6775, - "providence": 0.6717, - "southern indiana": 0.6702, - "belmont": 0.6688, - "portland": 0.6683, - "pepperdine": 0.6626, - "eastern kentucky": 0.6587, - "wake forest": 0.6571, - "charleston": 0.6484, - "marshall": 0.6442, - "utsa": 0.6431, - "navy": 0.6349, - "missouri st.": 0.6208, "missouri state": 0.6208, - "fairleigh dickinson": 0.6201, - "penn": 0.6159, - "east carolina": 0.6154, - - // ── 161–200 ───────────────────────────────────────────────────────────────── - "south dakota": 0.6144, - "purdue fort wayne": 0.6117, - "grand canyon": 0.6115, - "northern colorado": 0.6111, - "rutgers": 0.6040, - "liberty": 0.5964, - "northern iowa": 0.5920, - "temple": 0.5911, - "uc santa barbara": 0.5869, "ucsb": 0.5869, - "brown": 0.5869, - "ohio": 0.5867, - "xavier": 0.5837, - "central michigan": 0.5804, - "idaho st.": 0.5792, "idaho state": 0.5792, - "army": 0.5724, - "jacksonville": 0.5719, - "cleveland st.": 0.5710, "cleveland state": 0.5710, - "ucf": 0.5702, - "old dominion": 0.5666, - "youngstown st.": 0.5643, "youngstown state": 0.5643, - - // ── 200+ (low seeds / play-in range) ──────────────────────────────────── - "holy cross": 0.5153, - "high point": 0.5091, - "howard": 0.4623, - "stephen f. austin": 0.4435, "sf austin": 0.4435, - "norfolk st.": 0.3468, "norfolk state": 0.3468, -}; - -// ─── Public helpers (exported for unit testing) ─────────────────────────────── - -/** Normalize a team name for Barthag map lookup. */ -export function normalizeTeamName(name: string): string { - return name.toLowerCase().trim().replace(/\s+/g, " "); -} - -/** - * Look up Barttorvik Barthag rating for a participant name. - * Returns BARTHAG_FALLBACK (0.5 = average strength) if not found. - */ -export function getBarthagRating(name: string): number { - return BARTHAG_RATINGS[normalizeTeamName(name)] ?? BARTHAG_FALLBACK; -} - -/** - * NCAAW neutral-court win probability using Barttorvik Barthag (Log5 formula). - * P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A)) - * - * Key properties: - * barthagWinProbability(x, 0.5) === x (Barthag definition) - * barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1 (symmetric) - * - * Exported for unit testing. + * NCAAW neutral-court win probability using a Barthag-style Log5 formula. + * Ratings are season-scoped simulator inputs, not hardcoded data. */ export function barthagWinProbability(barthagA: number, barthagB: number): number { const pA = barthagA * (1 - barthagB); const pB = barthagB * (1 - barthagA); const total = pA + pB; - // Degenerate case: both teams identical at 0 or 1 → coin flip if (total === 0) return 0.5; return pA / total; } -function sortByMatchNumber(matches: T[]): T[] { - return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); -} - -// ─── Simulator ──────────────────────────────────────────────────────────────── - export class NCAAWSimulator implements Simulator { - async simulate(sportsSeasonId: string): Promise { - const db = database(); + private readonly simulator = new NCAABasketballSimulator({ + source: "ncaaw_bracket_rating", + missingRatingName: "Barthag-like rating", + selectionRatingDivisor: 0.08, + winProbability: barthagWinProbability, + }); - // 1. Find the bracket scoring event (playoff_game) for this sports season. - const bracketEvent = await db.query.scoringEvents.findFirst({ - where: and( - eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), - eq(schema.scoringEvents.eventType, "playoff_game") - ), - }); - - if (!bracketEvent) { - throw new Error( - `No bracket event found for sports season ${sportsSeasonId}. ` + - `Create a playoff_game scoring event and set up the 64-team bracket first.` - ); - } - - // 2. Load all playoff matches for this bracket event. - const allMatches = await db.query.playoffMatches.findMany({ - where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), - orderBy: (m, { asc }) => [asc(m.matchNumber)], - }); - - if (allMatches.length === 0) { - throw new Error( - `No playoff matches found for the bracket event. ` + - `Generate the 64-team bracket from the admin panel first.` - ); - } - - // 3. Group matches by round name. - // Rounds: "First Four" (optional, 4) | "Round of 64" (32) | "Round of 32" (16) - // | "Sweet Sixteen" (8) | "Elite Eight" (4) | "Final Four" (2) | "Championship" (1) - const byRound = new Map(); - for (const m of allMatches) { - if (!byRound.has(m.round)) byRound.set(m.round, []); - byRound.get(m.round)?.push(m); - } - - const rawFirstFour = byRound.get("First Four"); - const rawR64 = byRound.get("Round of 64"); - const rawR32 = byRound.get("Round of 32"); - const rawS16 = byRound.get("Sweet Sixteen"); - const rawE8 = byRound.get("Elite Eight"); - const rawFF = byRound.get("Final Four"); - const rawChamp = byRound.get("Championship"); - - const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : []; - const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null; - const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null; - const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null; - const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null; - const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null; - const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null; - - if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { - const found = [...byRound.keys()].join(", "); - throw new Error( - `Missing expected rounds. Found: [${found}]. ` + - `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` - ); - } - - if (r64Matches.length !== 32) { - throw new Error( - `Expected 32 Round of 64 matches, found ${r64Matches.length}. ` + - `This simulator only supports the standard 64-team NCAAW format.` - ); - } - if (r32Matches.length !== 16) { - throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`); - } - if (s16Matches.length !== 8) { - throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`); - } - if (e8Matches.length !== 4) { - throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`); - } - if (ffMatches.length !== 2) { - throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`); - } - if (champMatches.length !== 1) { - throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`); - } - - // 4. Build First Four → Round of 64 slot mapping (if First Four games exist). - const ffToR64 = new Map(); - const r64NullSlots = new Set( - r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber) - ); - - if (firstFourMatches.length > 0) { - const regions = - (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? - // Fall back to the template's built-in regions — single source of truth - NCAA_68.regions ?? []; - - const slotMap = buildNCAA68SlotMap(regions); - for (let i = 0; i < slotMap.playInOffsets.length; i++) { - const { regionIndex, seedSlot } = slotMap.playInOffsets[i]; - const r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1; - ffToR64.set(i + 1, r64MatchNumber); - } - - for (const m of firstFourMatches) { - if (!m.participant1Id || !m.participant2Id) { - throw new Error( - `First Four match ${m.matchNumber} is missing participants. ` + - `Assign both teams before running simulation.` - ); - } - } - - // Validate First Four → R64 mapping covers all null R64 slots. - // Note: FF-mapped slots may already be filled if the First Four game was completed - // and the winner was advanced — that's expected and handled in the sim loop. - const r64SlotsCoveredByFF = new Set(ffToR64.values()); - for (const nullSlot of r64NullSlots) { - if (!r64SlotsCoveredByFF.has(nullSlot)) { - throw new Error( - `Round of 64 match ${nullSlot} has no participant2 but is not covered ` + - `by any First Four mapping. Check the bracket configuration.` - ); - } - } - for (const [ffMatchNum, ffR64Slot] of ffToR64) { - if (!r64NullSlots.has(ffR64Slot)) { - // Slot is already filled — only valid if the FF game is complete - const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum); - if (!ffMatch?.isComplete) { - throw new Error( - `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + - `already filled. Check the bracket configuration.` - ); - } - } - } - // In the First Four path, participant1Id must always be set; participant2Id - // may be null only for FF-pending slots (already validated above). - for (const m of r64Matches) { - if (!m.participant1Id) { - throw new Error( - `Round of 64 match ${m.matchNumber} is missing participant1. ` + - `Check the bracket configuration.` - ); - } - } - } else { - for (const m of r64Matches) { - if (!m.participant1Id || !m.participant2Id) { - throw new Error( - `Round of 64 match ${m.matchNumber} is missing participants. ` + - `Assign all 64 teams to the bracket before running simulation.` - ); - } - } - } - - // 5. Collect all participant IDs (up to 68 when First Four is present). - const participantIdSet = new Set(); - for (const m of r64Matches) { - if (m.participant1Id) participantIdSet.add(m.participant1Id); - if (m.participant2Id) participantIdSet.add(m.participant2Id); - } - for (const m of firstFourMatches) { - if (m.participant1Id) participantIdSet.add(m.participant1Id); - if (m.participant2Id) participantIdSet.add(m.participant2Id); - } - const participantIds = [...participantIdSet]; - - // 6. Load participant names + admin-managed ratings from DB; build Barthag lookup by ID. - const [participantRows, simulatorInputs] = await Promise.all([ - db - .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) - .from(schema.seasonParticipants) - .where(inArray(schema.seasonParticipants.id, participantIds)), - db - .select({ participantId: schema.seasonParticipantSimulatorInputs.participantId, rating: schema.seasonParticipantSimulatorInputs.rating }) - .from(schema.seasonParticipantSimulatorInputs) - .where(eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId)), - ]); - - if (participantRows.length < participantIds.length) { - const foundIds = new Set(participantRows.map((r) => r.id)); - const missing = participantIds.filter((id) => !foundIds.has(id)); - logger.warn( - `[NCAAWSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + - `They will be treated as average strength (Barthag ${BARTHAG_FALLBACK}).` - ); - } - - const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input])); - const barthagById = new Map(); - for (const { id, name } of participantRows) { - const rawRating = inputById.get(id)?.rating; - const rating = rawRating !== null && rawRating !== undefined ? Number(rawRating) : null; - barthagById.set(id, (rating !== null && Number.isFinite(rating) ? rating : null) ?? getBarthagRating(name)); - } - - // 7. Build per-round O(1) lookup maps keyed by matchNumber. - const firstFourByNum = new Map(firstFourMatches.map((m) => [m.matchNumber, m])); - const r64ByNum = new Map(r64Matches.map((m) => [m.matchNumber, m])); - const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m])); - const s16ByNum = new Map(s16Matches.map((m) => [m.matchNumber, m])); - const e8ByNum = new Map(e8Matches.map((m) => [m.matchNumber, m])); - const ffByNum = new Map(ffMatches.map((m) => [m.matchNumber, m])); - const champMatch = champMatches[0]; - - // ─── Helpers ────────────────────────────────────────────────────────────── - - const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { - const b1 = barthagById.get(p1) ?? BARTHAG_FALLBACK; - const b2 = barthagById.get(p2) ?? BARTHAG_FALLBACK; - const w = Math.random() < barthagWinProbability(b1, b2) ? p1 : p2; - return { winner: w, loser: w === p1 ? p2 : p1 }; - }; - - // 8. Integer placement count maps — initialized to 0 for all participants. - const championCounts = new Map(participantIds.map((id) => [id, 0])); - const finalistCounts = new Map(participantIds.map((id) => [id, 0])); - const ffLoserCounts = new Map(participantIds.map((id) => [id, 0])); - const e8LoserCounts = new Map(participantIds.map((id) => [id, 0])); - - // 9. Monte Carlo simulation loop. - for (let s = 0; s < NUM_SIMULATIONS; s++) { - // ── First Four ──────────────────────────────────────────────────────── - const ffSimWinners = new Map(); - for (const [ffMatchNum, r64MatchNum] of ffToR64) { - const m = firstFourByNum.get(ffMatchNum); - if (!m) continue; - const winner = m.isComplete && m.winnerId - ? m.winnerId - : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner; - ffSimWinners.set(r64MatchNum, winner); - } - - // ── Round of 64 ─────────────────────────────────────────────────────── - const r64Winners: string[] = []; - for (let i = 1; i <= 32; i++) { - const m = r64ByNum.get(i); - if (!m) continue; - const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; - if (m.isComplete && m.winnerId) { - r64Winners.push(m.winnerId); - } else { - r64Winners.push(simMatch(m.participant1Id ?? "", p2 ?? "").winner); - } - } - - // ── Round of 32 ─────────────────────────────────────────────────────── - const r32Winners: string[] = []; - for (let i = 1; i <= 16; i++) { - const dbMatch = r32ByNum.get(i); - if (dbMatch?.isComplete && dbMatch.winnerId) { - r32Winners.push(dbMatch.winnerId); - } else { - const p1 = r64Winners[(i - 1) * 2]; - const p2 = r64Winners[(i - 1) * 2 + 1]; - r32Winners.push(simMatch(p1, p2).winner); - } - } - - // ── Sweet 16 ────────────────────────────────────────────────────────── - const s16Winners: string[] = []; - for (let i = 1; i <= 8; i++) { - const dbMatch = s16ByNum.get(i); - if (dbMatch?.isComplete && dbMatch.winnerId) { - s16Winners.push(dbMatch.winnerId); - } else { - const p1 = r32Winners[(i - 1) * 2]; - const p2 = r32Winners[(i - 1) * 2 + 1]; - s16Winners.push(simMatch(p1, p2).winner); - } - } - - // ── Elite Eight (tracked losers → 5th–8th) ──────────────────────────── - const e8Winners: string[] = []; - for (let i = 1; i <= 4; i++) { - const dbMatch = e8ByNum.get(i); - let winner: string; - let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId) { - winner = dbMatch.winnerId; - // Derive loser from stored participants if loserId is missing - loser = dbMatch.loserId ?? - (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id ?? "" - : dbMatch.participant1Id ?? ""); - } else { - const p1 = s16Winners[(i - 1) * 2]; - const p2 = s16Winners[(i - 1) * 2 + 1]; - ({ winner, loser } = simMatch(p1, p2)); - } - e8Winners.push(winner); - e8LoserCounts.set(loser, (e8LoserCounts.get(loser) ?? 0) + 1); - } - - // ── Final Four (tracked losers → 3rd/4th) ───────────────────────────── - const ffWinners: string[] = []; - for (let i = 1; i <= 2; i++) { - const dbMatch = ffByNum.get(i); - let winner: string; - let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId) { - winner = dbMatch.winnerId; - loser = dbMatch.loserId ?? - (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id ?? "" - : dbMatch.participant1Id ?? ""); - } else { - const p1 = e8Winners[(i - 1) * 2]; - const p2 = e8Winners[(i - 1) * 2 + 1]; - ({ winner, loser } = simMatch(p1, p2)); - } - ffWinners.push(winner); - ffLoserCounts.set(loser, (ffLoserCounts.get(loser) ?? 0) + 1); - } - - // ── Championship ────────────────────────────────────────────────────── - let champion: string; - let finalist: string; - if (champMatch?.isComplete && champMatch.winnerId) { - champion = champMatch.winnerId; - finalist = champMatch.loserId ?? - (champMatch.participant1Id === champMatch.winnerId - ? champMatch.participant2Id ?? "" - : champMatch.participant1Id ?? ""); - } else { - ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); - } - championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); - finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); - } - - // 10. Convert integer counts to probability distributions. - const N = NUM_SIMULATIONS; - const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId) ?? 0; - const f = finalistCounts.get(participantId) ?? 0; - const ff = ffLoserCounts.get(participantId) ?? 0; - const e8 = e8LoserCounts.get(participantId) ?? 0; - return { - participantId, - probabilities: { - probFirst: c / N, - probSecond: f / N, - probThird: ff / (2 * N), - probFourth: ff / (2 * N), - probFifth: e8 / (4 * N), - probSixth: e8 / (4 * N), - probSeventh: e8 / (4 * N), - probEighth: e8 / (4 * N), - }, - source: "ncaaw_bracket_barthag", - }; - }); - - // 11. Per-position normalization — belt-and-suspenders guard against - // floating-point residuals. Column sums are already near-exactly 1.0. - 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; + simulate(sportsSeasonId: string): Promise { + return this.simulator.simulate(sportsSeasonId); } } diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index 8fd06b0..331ced4 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -101,11 +101,11 @@ const REGISTRY: Record Simul create: () => new UCLSimulator(), }, ncaam_bracket: { - info: { name: "NCAAM Bracket Monte Carlo", description: "Simulates the NCAA Men's Basketball Tournament 64-team bracket using KenPom net ratings" }, + info: { name: "NCAAM Bracket Monte Carlo", description: "Simulates preseason field selection or the NCAA Men's Basketball Tournament bracket using season-scoped KenPom-like ratings" }, create: () => new NCAAMSimulator(), }, ncaaw_bracket: { - info: { name: "NCAAW Bracket Monte Carlo", description: "Simulates the NCAA Women's Basketball Tournament 64-team bracket using Barttorvik Barthag ratings" }, + info: { name: "NCAAW Bracket Monte Carlo", description: "Simulates preseason field selection or the NCAA Women's Basketball Tournament bracket using season-scoped Barthag-like ratings" }, create: () => new NCAAWSimulator(), }, nba_bracket: {