brackt/app/services/simulations/manifest.ts
Claude 3fae78c521
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Address review findings in LLWS simulator
Four fixes from code review of the previous commit.

1. Preserve the futures board's dispersion (llws-simulator.ts).
   convertFuturesToElo finishes by rescaling any field onto a fixed
   1250-1750 Elo span, discarding how spread out the board actually is: a
   board with a 22%-priced favorite and one with a 6%-priced favorite both
   came out 500 Elo wide. On a tight board that inflated the favorite from
   6% to 13% -- worse than the raw-futures model it replaced (RMSE 0.025 vs
   0.005), so the previous commit was a regression in that regime.

   buildLLWSElos now maps decompressed strengths by their log-ratio to the
   field's geometric mean, so Elo span tracks real dispersion. Re-swept the
   parity factor across three board shapes rather than one: 550 minimizes
   total error. Simulated vs priced favorite, with RMSE:
     wide       21.8% -> 20.7%  (0.0055), elo span 1355-1682
     top-heavy  28.2% -> 25.6%  (0.0085), elo span 1354-1733
     tight       6.0% ->  5.8%  (0.0026), elo span 1484-1518

2. Rate an unpriced team at the field's median, not 1500.
   DEFAULT_ELO is the midpoint of the Elo output range, not of the field;
   on a typical board it ranked an unpriced team ~6th of 20, so blanking a
   team's odds promoted it. buildLLWSElos now returns the priced field's
   median alongside the ratings (ranks 11th of 20 on the same board).

3. Pick the bracket event deterministically.
   scoringEvents.findFirst with no ordering returned an arbitrary row when a
   season had more than one llws_20 playoff event; landing on a stale one
   silently reverted to a randomized draw that ignored all recorded results.
   Now takes the most recent, matching world-cup-simulator.ts.

4. Fail on a partially seeded bracket instead of discarding it.
   readBracketSlots returned null on any single missing participant, throwing
   away the draw and every completed result with no warning. Since
   playoff_matches participant columns are ON DELETE SET NULL, removing and
   re-adding one participant mid-tournament was enough to put eliminated
   teams back in contention. It now distinguishes "generated but not seeded"
   (0 slots filled -> randomized draw) from "partially seeded" (throws).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
2026-08-21 22:08:04 +00:00

283 lines
11 KiB
TypeScript

import { getSimulatorInfo, SIMULATOR_TYPES, type SimulatorType } from "./registry";
export type SimulatorInputKey =
| "sourceOdds"
| "sourceElo"
| "worldRanking"
| "rating"
| "projectedWins"
| "projectedTablePoints"
| "seed"
| "region"
| "metadata";
export type SimulatorSetupSection =
| "futuresOdds"
| "eloRatings"
| "rankings"
| "ratings"
| "regularStandings"
| "bracket"
| "events"
| "golfSkills"
| "surfaceElo"
| "cs2Setup"
| "participants";
export interface SimulatorManifestProfile {
simulatorType: SimulatorType;
displayName: string;
description: string;
defaultConfig: Record<string, unknown>;
requiredInputs: SimulatorInputKey[];
optionalInputs: SimulatorInputKey[];
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
setupSections: SimulatorSetupSection[];
minParticipantInputs?: number;
}
const BASE_CONFIG = {
iterations: 50_000,
};
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
f1_standings: {
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
requiredInputs: ["sourceOdds"],
optionalInputs: [],
setupSections: ["participants", "futuresOdds", "events"],
},
indycar_standings: {
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
requiredInputs: ["sourceOdds"],
optionalInputs: [],
setupSections: ["participants", "futuresOdds", "events"],
},
golf_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 156, plBeta: 1.5 },
requiredInputs: [],
optionalInputs: ["sourceOdds", "rating"],
setupSections: ["participants", "events", "golfSkills"],
},
playoff_bracket: {
defaultConfig: { ...BASE_CONFIG },
requiredInputs: ["sourceOdds"],
optionalInputs: ["sourceElo"],
setupSections: ["participants", "futuresOdds", "bracket"],
},
ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"],
},
ncaam_bracket: {
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, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
},
nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
},
nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
},
nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
afl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 450, seasonGames: 23 },
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins"],
derivableInputs: { sourceElo: ["projectedWins"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
epl_standings: {
defaultConfig: {
...BASE_CONFIG,
iterations: 10_000,
seasonGames: 38,
parityFactor: 400,
matchParityFactor: 400,
averageOpponentElo: 1500,
baseDrawRate: 0.26,
drawDecay: 0.002,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedTablePoints"],
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
snooker_bracket: {
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
},
tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
requiredInputs: [],
optionalInputs: ["worldRanking"],
setupSections: ["participants", "surfaceElo", "events"],
},
mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
wnba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 44, srsEloScale: 30 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
world_cup: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
},
darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"],
},
cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
},
ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
},
llws_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
requiredInputs: ["sourceOdds"],
optionalInputs: ["metadata"],
// The bracket is optional — without one the draw is randomized — but once it
// exists the simulator reads the real draw and honors completed results from it.
setupSections: ["participants", "futuresOdds", "bracket"],
},
college_hockey_bracket: {
// College hockey blends odds into Elo internally (and also uses NPI rank,
// which the central resolver can't represent). oddsWeight 0 makes the central
// resolver leave a present base Elo untouched (no double-count), while still
// letting odds resolve a participant's Elo when they are the only source — so
// readiness and the sim's own internal odds blend both keep working.
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { oddsWeight: 0 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
},
brackt: {
defaultConfig: { iterations: 20_000 },
requiredInputs: [],
optionalInputs: [],
setupSections: ["participants"],
},
nll_bracket: {
defaultConfig: {
...BASE_CONFIG,
seasonGames: 18,
parityFactor: 400,
bracketSize: 8,
playoffTeams: 8,
regularSeasonTeamCount: 14,
homeFieldElo: 0,
regularSeasonMode: "project_remaining_games",
regularSeasonNoise: 0.9,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
},
mls_bracket: {
defaultConfig: {
...BASE_CONFIG,
seasonGames: 34,
parityFactor: 400,
matchParityFactor: 400,
averageOpponentElo: 1500,
baseDrawRate: 0.26,
drawDecay: 0.002,
playoffTeamsPerConference: 9,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedTablePoints", "seed", "region"],
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
};
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =
Object.fromEntries(
SIMULATOR_TYPES.map((simulatorType) => {
const info = getSimulatorInfo(simulatorType);
const profile = PROFILES[simulatorType];
return [
simulatorType,
{
simulatorType,
displayName: info?.name ?? simulatorType,
description: info?.description ?? "",
...profile,
minParticipantInputs: profile.minParticipantInputs ?? 1,
},
];
})
) as Record<SimulatorType, SimulatorManifestProfile>;
export function getManifestSimulatorProfile(
simulatorType: SimulatorType
): SimulatorManifestProfile {
return SIMULATOR_MANIFEST[simulatorType];
}
export function simulatorInputLabel(key: SimulatorInputKey): string {
const labels: Record<SimulatorInputKey, string> = {
sourceOdds: "futures odds",
sourceElo: "Elo rating",
worldRanking: "ranking",
rating: "rating",
projectedWins: "projected wins",
projectedTablePoints: "projected table points",
seed: "seed",
region: "region",
metadata: "metadata",
};
return labels[key];
}