diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index f623c01..e6d2f87 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -69,6 +69,25 @@ interface ActionData { message: string; } +/** + * Engine knobs that a simulator (or the shared input-policy resolver) actually + * reads from config. The structured Engine fields are limited to these so the UI + * never shows a control that silently does nothing — bespoke per-sim constants + * (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile + * but are not read from config stay editable only via the raw-JSON escape hatch. + */ +const HONORED_ENGINE_KNOBS = new Set([ + "iterations", + "parityFactor", + "seasonGames", + "overtimeRate", + "matchParityFactor", + "averageOpponentElo", + "baseDrawRate", + "drawDecay", + "ratingScaleFactor", +]); + function parseOptionalNumber(value: string | undefined): number | null { if (value === undefined || value.trim() === "") return null; const parsed = Number(value); @@ -335,7 +354,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone // 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") + .filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number") .map(([key, value]) => [key, value as unknown as number] as [string, number]); return ( diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index d230b07..b2b8acf 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -254,14 +254,11 @@ export function resolveSourceElos( ); } if (oddsInputs.length >= 2) { - // Map onto the configured Elo band so the policy's floor/ceiling set the - // odds-derived *spread* (the real flattening dial), not just a post-hoc clamp. - const converted = convertFuturesToElo(oddsInputs, "american", { - exponent: 0.33, - eloMin: policy.eloMin, - eloMax: policy.eloMax, - }); - for (const [participantId, elo] of converted) { + // Odds map onto the calibrated band (probability-engine DEFAULT_CALIBRATION); + // the policy's eloMin/eloMax then clamp the result via clampDerived below, so + // a season's Elo floor/ceiling still narrows the odds-derived spread without + // widening it (and without clamping a high directly-entered base Elo's blend). + for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) { oddsElo.set(participantId, elo); } }