brackt/app/services/simulations/manifest.ts
chrisp 03726f1a66
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m27s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 47s
🚀 Deploy / 🐳 Build (push) Successful in 15m5s
🚀 Deploy / 🚀 Deploy (push) Successful in 32s
Fix simulator correctness bugs and reduce test iteration counts (#61)
## Summary

- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`

## Test plan

- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
2026-05-31 17:39:43 +00:00

275 lines
9.9 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, eloWeight: 0.7, oddsWeight: 0.3 },
requiredInputs: ["sourceOdds"],
optionalInputs: ["sourceElo"],
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, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
wnba_bracket: {
defaultConfig: { ...BASE_CONFIG, 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, eloWeight: 0.7, 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, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4 },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
},
llws_bracket: {
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
requiredInputs: ["sourceOdds"],
optionalInputs: ["metadata"],
setupSections: ["participants", "futuresOdds"],
},
college_hockey_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850 },
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];
}