diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 2927145..152f24c 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -280,9 +280,10 @@ describe("simulator input policy", () => { expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY); expect(defaults.oddsWeight).toBe(0.3); - // oddsWeight can be sourced from the top-level config or the input policy, - // and is clamped to [0, 1]; junk base-priority entries are dropped. - expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6); + // oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level + // config.oddsWeight is ignored (single home); junk base-priority entries drop. + expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6); + expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0); expect( diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index c6800ee..8e08b89 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -142,13 +142,8 @@ export function getSimulatorInputPolicy(config: Record): Simula ? ratingStrategy : "block", baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority), - // Allow oddsWeight to live either in inputPolicy (the unified knob) or at the - // top level of config (legacy per-profile defaultConfig), preferring the policy. - oddsWeight: clamp( - optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)), - 0, - 1 - ), + // The futures blend weight lives only under inputPolicy (single home). + oddsWeight: clamp(optionalNumber(rawPolicy.oddsWeight, DEFAULT_POLICY.oddsWeight), 0, 1), fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo), fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta), eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin), @@ -173,6 +168,32 @@ export function ratingRequirementLabel(profile: SimulatorManifestProfile): strin return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / "); } +/** + * Combine a participant's base strength value and its odds-derived value into the + * single value that feeds the simulator, by `oddsWeight`: 0 = base only, + * 1 = odds only, in between = weighted blend. Odds-derived and blended results + * pass through `clampDerived` (they are mapped onto the configured band by + * design); a base value is returned untouched (a directly entered Elo/rating is + * trusted as-is, a projection-derived Elo was already clamped upstream). Returns + * undefined when neither source is present (the caller falls back to its + * missing-input strategy). Shared by Elo and rating resolution. + */ +function blendBaseAndOdds( + base: { value: number; method: M } | undefined, + odds: number | undefined, + oddsWeight: number, + clampDerived: (value: number) => number +): { value: number; method: M | "sourceOdds" | "blend" } | undefined { + if (base !== undefined && odds !== undefined) { + if (oddsWeight <= 0) return { value: base.value, method: base.method }; + if (oddsWeight >= 1) return { value: clampDerived(odds), method: "sourceOdds" }; + return { value: clampDerived((1 - oddsWeight) * base.value + oddsWeight * odds), method: "blend" }; + } + if (base !== undefined) return { value: base.value, method: base.method }; + if (odds !== undefined) return { value: clampDerived(odds), method: "sourceOdds" }; + return undefined; +} + export function resolveSourceElos( inputs: ParticipantInputForPolicy[], profile: Pick, @@ -240,42 +261,23 @@ export function resolveSourceElos( } // 3. Blend base + odds into the single Elo that feeds the simulator. - // Only odds-derived / blended / fallback values are clamped+rounded onto the - // [eloMin, eloMax] band (they are mapped onto it by design). A base Elo is - // passed through untouched: a directly entered Elo is trusted as-is (some - // sports use ratings above the default ceiling, e.g. snooker ~2450) and a - // projection-derived Elo was already clamped by projectionToElo. - const w = policy.oddsWeight; + // Directly entered Elos (some sports use ratings above the default ceiling, + // e.g. snooker ~2450) pass through unclamped; only derived values are + // clamped onto [eloMin, eloMax]. const clampDerived = (elo: number): number => clamp(Math.round(elo), policy.eloMin, policy.eloMax); for (const input of inputs) { const base = baseElo.get(input.participantId); - const odds = oddsElo.get(input.participantId); - let sourceElo: number; - let method: ResolvedSourceElo["method"]; - if (base !== undefined && odds !== undefined) { - if (w <= 0) { - sourceElo = base.elo; - method = base.method; - } else if (w >= 1) { - sourceElo = clampDerived(odds); - method = "sourceOdds"; - } else { - sourceElo = clampDerived((1 - w) * base.elo + w * odds); - method = "blend"; - } - } else if (base !== undefined) { - sourceElo = base.elo; - method = base.method; - } else if (odds !== undefined) { - sourceElo = clampDerived(odds); - method = "sourceOdds"; - } else { - continue; - } + const blended = blendBaseAndOdds( + base ? { value: base.elo, method: base.method } : undefined, + oddsElo.get(input.participantId), + policy.oddsWeight, + clampDerived + ); + if (!blended) continue; resolved.set(input.participantId, { participantId: input.participantId, - sourceElo, - method, + sourceElo: blended.value, + method: blended.method, }); } @@ -349,39 +351,22 @@ export function resolveRatings( } } - // As with Elo: only odds-derived / blended values are clamped onto the rating - // band; a directly entered rating is trusted as-is (matching prior behavior). - const w = policy.oddsWeight; + // As with Elo: a directly entered rating passes through untouched; only + // derived values are clamped onto the rating band. const clampDerived = (rating: number): number => clamp(rating, policy.ratingMin, policy.ratingMax); for (const input of inputs) { const base = baseRating.get(input.participantId); - const odds = oddsRating.get(input.participantId); - let rating: number; - let method: ResolvedRating["method"]; - if (base !== undefined && odds !== undefined) { - if (w <= 0) { - rating = base; - method = "direct"; - } else if (w >= 1) { - rating = clampDerived(odds); - method = "sourceOdds"; - } else { - rating = clampDerived((1 - w) * base + w * odds); - method = "blend"; - } - } else if (base !== undefined) { - rating = base; - method = "direct"; - } else if (odds !== undefined) { - rating = clampDerived(odds); - method = "sourceOdds"; - } else { - continue; - } + const blended = blendBaseAndOdds( + base !== undefined ? { value: base, method: "direct" as const } : undefined, + oddsRating.get(input.participantId), + policy.oddsWeight, + clampDerived + ); + if (!blended) continue; resolved.set(input.participantId, { participantId: input.participantId, - rating, - method, + rating: blended.value, + method: blended.method, }); } diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 4a18e68..8d9c862 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -66,7 +66,7 @@ const PROFILES: Record