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:
parent
24de966b3b
commit
8fff8d2715
4 changed files with 45 additions and 19 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue