Cleanup: dedupe blend logic, single-home oddsWeight
Some checks failed
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m20s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 51s

Address code-review cleanup notes:
- Extract the base/odds blend ladder (duplicated between resolveSourceElos and
  resolveRatings) into one generic `blendBaseAndOdds` helper.
- Move the futures blend weight to a single home: oddsWeight now lives only under
  inputPolicy. Manifest per-profile defaults moved from top-level config into
  inputPolicy.oddsWeight, and getSimulatorInputPolicy no longer reads a legacy
  top-level config.oddsWeight. Drops the now-dead mlb rDiffWeight config knob.
- Clarify the college_hockey oddsWeight 0 rationale (it disables the central
  blend so its internal odds blend isn't double-counted, while still letting odds
  resolve an Elo when they're the only source — a separate centralBlend flag
  would wrongly drop that sole-source path and break readiness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
This commit is contained in:
Claude 2026-06-26 05:10:35 +00:00
parent 8fff8d2715
commit a77485d5a5
No known key found for this signature in database
3 changed files with 65 additions and 77 deletions

View file

@ -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(

View file

@ -142,13 +142,8 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): 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<M extends string>(
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<SimulatorManifestProfile, "derivableInputs">,
@ -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,
});
}

View file

@ -66,7 +66,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"],
},
ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, oddsWeight: 0.3 },
defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -143,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "surfaceElo", "events"],
},
mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -157,7 +157,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"],
},
world_cup: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, oddsWeight: 0.3 },
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -176,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
},
ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, oddsWeight: 0.4 },
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -189,10 +189,12 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds"],
},
college_hockey_bracket: {
// College hockey already blends odds into Elo internally (and also uses NPI
// rank, which the central resolver can't), so the central blend is disabled
// (oddsWeight 0) to avoid double-counting the odds.
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, oddsWeight: 0 },
// 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"] },