From 4142b21c55e942d9f80b55d8ef1e11b0295c1e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 22:00:33 +0000 Subject: [PATCH 1/2] Honor engine knobs across simulators, de-dupe odds, unify config UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems addressed: 1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied). - The NHL simulator hardcoded its parity factor (1000) and ignored the season config's parityFactor, so the knob meant to flatten the distribution did nothing. It also re-blended raw futures odds into every game on top of the odds->Elo conversion, double-counting the same signal. - NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config and no longer re-blends odds per game (odds enter once, via the central odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to ~13% title odds. 2. "Season Config" and "Input Policy" were two forms over the same stored object that didn't reflect each other, and the engine-knob half was inert for many simulators. - Every simulator now reads its engine knobs (iterations everywhere; parityFactor for all Elo-based sims) from the merged config, passed in by the runner via the Simulator interface. Defaults equal the former hardcoded constants, so behavior is unchanged unless a season overrides. - The admin simulator page is now a single "Simulator Configuration" card with structured Engine and Input-derivation sections (profile-driven, so each sport shows only the knobs it honors) plus an Advanced raw-JSON escape hatch — all writing the same config. Also: centralized the duplicated configNumber helpers into config-access.ts; the central odds->Elo resolver now maps onto the configured Elo floor/ceiling so those bounds set the odds-derived spread (a real flattening dial). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf --- .../admin.sports-seasons.$id.simulator.tsx | 326 ++++++++++-------- .../__tests__/nhl-simulator.test.ts | 7 + app/services/simulations/afl-simulator.ts | 29 +- .../simulations/auto-racing-simulator.ts | 26 +- .../simulations/college-hockey-simulator.ts | 60 ++-- app/services/simulations/config-access.ts | 43 +++ .../simulations/cs-major-simulator.ts | 24 +- app/services/simulations/darts-simulator.ts | 22 +- app/services/simulations/epl-simulator.ts | 24 +- app/services/simulations/golf-simulator.ts | 24 +- app/services/simulations/input-policy.ts | 9 +- app/services/simulations/llws-simulator.ts | 16 +- app/services/simulations/manifest.ts | 6 +- app/services/simulations/mlb-simulator.ts | 8 +- app/services/simulations/mls-simulator.ts | 7 +- app/services/simulations/nba-simulator.ts | 202 ++++++----- .../simulations/ncaa-football-simulator.ts | 68 ++-- app/services/simulations/ncaam-simulator.ts | 5 +- app/services/simulations/nfl-simulator.ts | 63 ++-- app/services/simulations/nhl-simulator.ts | 138 +++----- app/services/simulations/nll-simulator.ts | 75 ++-- app/services/simulations/runner.ts | 2 +- app/services/simulations/snooker-simulator.ts | 24 +- app/services/simulations/tennis-simulator.ts | 24 +- app/services/simulations/types.ts | 7 +- app/services/simulations/ucl-simulator.ts | 36 +- app/services/simulations/wnba-simulator.ts | 58 ++-- .../simulations/world-cup-simulator.ts | 8 +- 28 files changed, 748 insertions(+), 593 deletions(-) create mode 100644 app/services/simulations/config-access.ts diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 578315c..f623c01 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -232,33 +232,48 @@ export async function action({ request, params }: Route.ActionArgs): Promise = { ...currentConfig.config }; + + // Engine knobs: every numeric field rendered as `engine.`. + for (const [field, value] of formData.entries()) { + if (typeof value !== "string" || !field.startsWith("engine.")) continue; + const key = field.slice("engine.".length); + const parsed = Number(value); + if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed; + } + + // Input-derivation policy (only when the simulator consumes Elo/ratings). + if (formData.get("hasInputPolicy") === "1") { + const currentPolicy = getSimulatorInputPolicy(currentConfig.config); + next.inputPolicy = { + ...currentPolicy, + missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), + missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), + // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. + oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), + 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), + }; + } + await upsertSportsSeasonSimulatorConfig({ sportsSeasonId, simulatorType: currentConfig.simulatorType, - config: { - ...currentConfig.config, - inputPolicy: { - missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), - missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), - // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. - oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), - 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), - }, - }, + config: next, }); - return { success: true, message: "Simulator input policy saved." }; + return { success: true, message: "Simulator configuration saved." }; } if (intent === "save-inputs") { @@ -316,6 +331,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone const showsInputPolicy = config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); + // Structured engine knobs: every top-level numeric config key (inputPolicy is a + // nested object edited in its own section). Driving the fields from the merged + // config means each simulator shows exactly the knobs it actually reads. + const engineEntries = Object.entries(config.config) + .filter(([key, value]) => key !== "inputPolicy" && typeof value === "number") + .map(([key, value]) => [key, value as unknown as number] as [string, number]); + return (
@@ -407,130 +429,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone - {showsInputPolicy && ( - - - Input Policy - - 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. - - - -
- -
- - -

- Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into - the single Elo that feeds the simulator. This is the weight given to futures odds: - 0 = Elo / projections only, 1 = futures fully override, - in between = blend (e.g. 0.3 = 70% Elo / 30% futures). -

-
- {config.profile.requiredInputs.includes("sourceElo") && ( - <> -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - )} - {config.profile.requiredInputs.includes("rating") && ( - <> -
- - -
-
- - -
-
- - -
- - )} -
- - -
-
- - -
-
- -
-
-
-
- )} - - Season Config + Simulator Configuration - JSON overrides for this specific sports season. Defaults come from the simulator profile. + One place for this season's settings. Engine controls how the Monte Carlo + runs; Input derivation controls how raw inputs become the single Elo/rating + the engine consumes. Defaults come from the simulator profile; values set here override them + for this season only. Both sections write the same stored config. - -
- -