Fix: don't clamp directly entered Elo/rating in the blend resolver

Code review caught a regression in the single-Elo blend: resolveSourceElos /
resolveRatings clamped every resolved value to [eloMin, eloMax] /
[ratingMin, ratingMax], including a directly entered base Elo. The original
resolver only clamped derived values (projections, odds, fallbacks) and passed
direct inputs through untouched. With the default 1900 Elo ceiling this
truncated manual Elos for sports that enter higher ratings (e.g. snooker ~2450),
collapsing their spread.

Clamp only odds-derived / blended / fallback values now; direct Elo and rating
inputs pass through as entered. Adds a regression test, and fixes two comments
that still referenced the removed `sourceEloPriority` field (now `oddsWeight`).

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 04:09:48 +00:00
parent 24de966b3b
commit 8fff8d2715
No known key found for this signature in database
4 changed files with 45 additions and 19 deletions

View file

@ -394,10 +394,10 @@ export async function batchSaveSourceOdds(
await tx
.update(seasonParticipantExpectedValues)
// Persist the odds and label the source as odds-driven for display.
// We no longer null a stored Elo here: whether these odds override it
// is decided at run time by the configurable source priority
// (SimulatorInputPolicy.sourceEloPriority), and prepareSimulatorInputsForRun
// overwrites the derived Elo before the simulator reads it.
// We no longer null a stored Elo here: how much these odds move it is
// decided at run time by the season's blend weight
// (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun
// overwrites the resolved Elo before the simulator reads it.
.set({ sourceOdds, source: "futures_odds", updatedAt: now })
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else {

View file

@ -367,10 +367,10 @@ export async function batchSaveFuturesOddsForSimulator(
const db = database();
const now = new Date();
// Persist the odds non-destructively. Whether these odds override a stored
// Elo/rating is now governed by the season's configurable source priority
// (see resolveSourceElos / SimulatorInputPolicy.sourceEloPriority), so we no
// longer null out manually entered Elo here — that policy decides at run time.
// Persist the odds non-destructively. How much these odds move a stored
// Elo/rating is now governed by the season's blend weight (see resolveSourceElos
// / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered
// Elo here — that policy decides at run time.
await db
.insert(schema.seasonParticipantSimulatorInputs)
.values(

View file

@ -188,6 +188,23 @@ describe("simulator input policy", () => {
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
});
it("does not clamp a directly entered Elo above the policy ceiling", () => {
// Snooker/darts enter manual Elos well above the default 1900 ceiling and
// have no odds source — the direct value must pass through unclamped.
const eloOnlyProfile = {} as Pick<SimulatorManifestProfile, "derivableInputs">;
const resolved = resolveSourceElos(
[
{ participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
{ participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
eloOnlyProfile,
{}
);
expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" });
expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" });
});
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
const inputs = [
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },

View file

@ -240,35 +240,41 @@ 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;
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 elo: number;
let sourceElo: number;
let method: ResolvedSourceElo["method"];
if (base !== undefined && odds !== undefined) {
if (w <= 0) {
elo = base.elo;
sourceElo = base.elo;
method = base.method;
} else if (w >= 1) {
elo = odds;
sourceElo = clampDerived(odds);
method = "sourceOdds";
} else {
elo = (1 - w) * base.elo + w * odds;
sourceElo = clampDerived((1 - w) * base.elo + w * odds);
method = "blend";
}
} else if (base !== undefined) {
elo = base.elo;
sourceElo = base.elo;
method = base.method;
} else if (odds !== undefined) {
elo = odds;
sourceElo = clampDerived(odds);
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
sourceElo,
method,
});
}
@ -343,7 +349,10 @@ 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;
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);
@ -354,24 +363,24 @@ export function resolveRatings(
rating = base;
method = "direct";
} else if (w >= 1) {
rating = odds;
rating = clampDerived(odds);
method = "sourceOdds";
} else {
rating = (1 - w) * base + w * odds;
rating = clampDerived((1 - w) * base + w * odds);
method = "blend";
}
} else if (base !== undefined) {
rating = base;
method = "direct";
} else if (odds !== undefined) {
rating = odds;
rating = clampDerived(odds);
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
rating,
method,
});
}