Upgrade NCAA preseason simulators
This commit is contained in:
parent
ce0ed4f485
commit
be3e017178
13 changed files with 1061 additions and 1534 deletions
75
app/models/__tests__/simulator-inputs.test.ts
Normal file
75
app/models/__tests__/simulator-inputs.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Act
|
|||
...currentConfig.config,
|
||||
inputPolicy: {
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
||||
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
|
||||
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
|
||||
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
|
||||
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
|
||||
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
|
||||
},
|
||||
|
|
@ -242,6 +251,9 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
const isSubmitting = navigation.state === "submitting";
|
||||
const setupSections = config.profile.setupSections;
|
||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
||||
const showsInputPolicy =
|
||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
|
|
@ -335,50 +347,80 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
{showsInputPolicy && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input Policy</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||
<input type="hidden" name="intent" value="save-input-policy" />
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||||
<select
|
||||
id="missingEloStrategy"
|
||||
name="missingEloStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingEloStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||||
<option value="averageKnown">Use average known Elo</option>
|
||||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackEloDelta">Worst Delta</Label>
|
||||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||||
</div>
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||||
<select
|
||||
id="missingEloStrategy"
|
||||
name="missingEloStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingEloStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||||
<option value="averageKnown">Use average known Elo</option>
|
||||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
|
||||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{config.profile.requiredInputs.includes("rating") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
|
||||
<select
|
||||
id="missingRatingStrategy"
|
||||
name="missingRatingStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingRatingStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackRating">Use fixed fallback rating</option>
|
||||
<option value="averageKnown">Use average known rating</option>
|
||||
<option value="worstKnownMinus">Use worst known rating minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackRating">Fallback Rating</Label>
|
||||
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
|
||||
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMin">Rating Floor</Label>
|
||||
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
|
||||
|
|
|
|||
|
|
@ -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<SimulatorManifestProfile, "derivableInputs">;
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,14 +72,14 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
},
|
||||
ncaam_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35 } },
|
||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
||||
requiredInputs: ["rating"],
|
||||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
},
|
||||
ncaaw_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingType: "barthag", inputPolicy: { ratingMin: 0.15, ratingMax: 0.99 } },
|
||||
defaultConfig: { ...BASE_CONFIG, ratingType: "barthag", inputPolicy: { ratingMin: 0.15, ratingMax: 0.99, fallbackRatingDelta: 0.05 } },
|
||||
requiredInputs: ["rating"],
|
||||
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
|
|
|
|||
659
app/services/simulations/ncaa-basketball-simulator.ts
Normal file
659
app/services/simulations/ncaa-basketball-simulator.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import {
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
NCAA_68,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
type BracketRegion,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
const NUM_SIMULATIONS = 50_000;
|
||||
const FIELD_SIZE = 68;
|
||||
const ODDS_SELECTION_BONUS = 0.1;
|
||||
|
||||
type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
type Db = ReturnType<typeof database>;
|
||||
|
||||
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<number, Team>;
|
||||
playIns: Map<number, [Team, Team]>;
|
||||
}
|
||||
|
||||
interface NCAABasketballSimulatorOptions {
|
||||
source: string;
|
||||
missingRatingName: string;
|
||||
selectionRatingDivisor: number;
|
||||
winProbability: (ratingA: number, ratingB: number) => number;
|
||||
getWinProbability?: (config: Record<string, unknown>) => (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<T extends { matchNumber: number }>(matches: T[]): T[] {
|
||||
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function getSeasonSimulatorConfig(db: Db, sportsSeasonId: string): Promise<Record<string, unknown>> {
|
||||
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<string, PlacementCounts>, 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<string, PlacementCounts> {
|
||||
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<string, number> {
|
||||
const raw = new Map<string, number>();
|
||||
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<string, number>();
|
||||
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<string, number> {
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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<number, Team>(),
|
||||
playIns: new Map<number, [Team, Team]>(),
|
||||
}));
|
||||
|
||||
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<string, PlacementCounts>,
|
||||
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<string, PlacementCounts>, 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<Team[]> {
|
||||
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<string, PlayoffMatch[]>();
|
||||
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<number, number> {
|
||||
const ffToR64 = new Map<number, number>();
|
||||
const r64NullSlots = new Set<number>(
|
||||
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<string, Team>, 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<string, Team>,
|
||||
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<SimulationResult[]> {
|
||||
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<SimulationResult[]> {
|
||||
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<SimulationResult[]> {
|
||||
const rounds = groupMatchesByRound(allMatches);
|
||||
const regions = bracketRegions ?? NCAA_68.regions ?? [];
|
||||
const ffToR64 = validateFirstFour(rounds.firstFourMatches, rounds.r64Matches, regions);
|
||||
|
||||
const participantIdSet = new Set<string>();
|
||||
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<number, string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, number> = {
|
||||
// ── 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<T extends { matchNumber: number }>(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<SimulationResult[]> {
|
||||
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<string, typeof allMatches>();
|
||||
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<firstFourMatchNumber, r64MatchNumber>
|
||||
// r64NullSlots: Set of R64 match numbers whose participant2Id is null (pending FF)
|
||||
const ffToR64 = new Map<number, number>();
|
||||
const r64NullSlots = new Set<number>(
|
||||
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<string>();
|
||||
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<string, number>();
|
||||
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<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const ffLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const e8LoserCounts = new Map<string, number>(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<r64MatchNumber, winnerId> for the null participant2Id slots
|
||||
const ffSimWinners = new Map<number, string>();
|
||||
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<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
];
|
||||
for (const key of positionKeys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
const residual = 1.0 - colSum;
|
||||
if (residual !== 0) {
|
||||
const maxResult = results.reduce((best, r) =>
|
||||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||||
);
|
||||
maxResult.probabilities[key] += residual;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
return this.simulator.simulate(sportsSeasonId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {
|
||||
// ── 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<T extends { matchNumber: number }>(matches: T[]): T[] {
|
||||
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class NCAAWSimulator implements Simulator {
|
||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
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<string, typeof allMatches>();
|
||||
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<number, number>();
|
||||
const r64NullSlots = new Set<number>(
|
||||
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<string>();
|
||||
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<string, number>();
|
||||
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<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const ffLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const e8LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
|
||||
// 9. Monte Carlo simulation loop.
|
||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||
// ── First Four ────────────────────────────────────────────────────────
|
||||
const ffSimWinners = new Map<number, string>();
|
||||
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<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
];
|
||||
for (const key of positionKeys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
const residual = 1.0 - colSum;
|
||||
if (residual !== 0) {
|
||||
const maxResult = results.reduce((best, r) =>
|
||||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||||
);
|
||||
maxResult.probabilities[key] += residual;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
return this.simulator.simulate(sportsSeasonId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,11 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => 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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue