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
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
getSimulatorInputPolicy,
|
|
resolveRatings,
|
|
resolveSourceElos,
|
|
DEFAULT_BASE_ELO_PRIORITY,
|
|
} from "../input-policy";
|
|
import type { SimulatorManifestProfile } from "../manifest";
|
|
|
|
const profile = {
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
const tablePointsProfile = {
|
|
derivableInputs: { sourceElo: ["projectedTablePoints"] },
|
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
describe("simulator input policy", () => {
|
|
it("keeps direct Elo ahead of derived values", () => {
|
|
const resolved = resolveSourceElos(
|
|
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: 2000, projectedWins: 20, projectedTablePoints: null }],
|
|
profile,
|
|
{ seasonGames: 82 }
|
|
);
|
|
|
|
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
|
});
|
|
|
|
it("derives Elo from projected wins when Elo is missing", () => {
|
|
const resolved = resolveSourceElos(
|
|
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
|
profile,
|
|
{ seasonGames: 82, parityFactor: 400 }
|
|
);
|
|
|
|
expect(resolved.get("team-1")?.method).toBe("projectedWins");
|
|
expect(resolved.get("team-1")?.sourceElo).toBeGreaterThan(1500);
|
|
});
|
|
|
|
it("uses parityFactor as the projected table points to Elo spread", () => {
|
|
const input = {
|
|
participantId: "team-1",
|
|
sourceElo: null,
|
|
rating: null,
|
|
sourceOdds: null,
|
|
projectedWins: null,
|
|
projectedTablePoints: 76,
|
|
};
|
|
|
|
const compressed = resolveSourceElos([input], tablePointsProfile, {
|
|
seasonGames: 38,
|
|
parityFactor: 250,
|
|
});
|
|
const wider = resolveSourceElos([input], tablePointsProfile, {
|
|
seasonGames: 38,
|
|
parityFactor: 1000,
|
|
});
|
|
|
|
expect(compressed.get("team-1")?.sourceElo).toBeLessThan(wider.get("team-1")?.sourceElo ?? 0);
|
|
});
|
|
|
|
it("blocks missing Elo unless a fallback strategy is configured", () => {
|
|
const inputs = [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }];
|
|
|
|
expect(resolveSourceElos(inputs, profile, {}).has("team-1")).toBe(false);
|
|
expect(resolveSourceElos(inputs, profile, {
|
|
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1375 },
|
|
}).get("team-1")).toMatchObject({ sourceElo: 1375, method: "fallbackElo" });
|
|
});
|
|
|
|
it("supports worst-known-minus tail fallback", () => {
|
|
const resolved = resolveSourceElos(
|
|
[
|
|
{ participantId: "known-1", sourceElo: 1500, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "known-2", sourceElo: 1430, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
profile,
|
|
{ inputPolicy: { missingEloStrategy: "worstKnownMinus", fallbackEloDelta: 30 } }
|
|
);
|
|
|
|
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
|
|
});
|
|
|
|
it("derives Elo from futures odds when no direct Elo is present", () => {
|
|
const resolved = resolveSourceElos(
|
|
[
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
profile,
|
|
{}
|
|
);
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
expect(resolved.get("favorite")?.sourceElo).toBeGreaterThan(resolved.get("longshot")?.sourceElo ?? Infinity);
|
|
});
|
|
|
|
it("ignores a lone sourceOdds participant rather than assigning a flat Elo", () => {
|
|
const inputs = [
|
|
{ participantId: "only", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
];
|
|
|
|
// Needs at least 2 odds to derive a spread; with block strategy it stays unresolved.
|
|
expect(resolveSourceElos(inputs, profile, {}).has("only")).toBe(false);
|
|
|
|
// With a fallback strategy it resolves via that method, not "sourceOdds".
|
|
expect(
|
|
resolveSourceElos(inputs, profile, {
|
|
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1400 },
|
|
}).get("only")
|
|
).toMatchObject({ sourceElo: 1400, method: "fallbackElo" });
|
|
});
|
|
|
|
it("derives Elo from odds once a prior direct Elo has been cleared", () => {
|
|
// Regression: after entering futures odds the simulator inputs row keeps
|
|
// sourceOdds but has its sourceElo nulled. The resolver must pick "sourceOdds",
|
|
// not resurrect a "direct" Elo.
|
|
const resolved = resolveSourceElos(
|
|
[
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
profile,
|
|
{}
|
|
);
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
});
|
|
|
|
it("derives ratings from futures odds when the profile allows it", () => {
|
|
const resolved = resolveRatings(
|
|
[
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
{ inputPolicy: { ratingMin: -10, ratingMax: 35 } }
|
|
);
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
expect(resolved.get("favorite")?.rating).toBeGreaterThan(resolved.get("longshot")?.rating ?? 999);
|
|
});
|
|
|
|
it("blocks missing ratings unless a rating fallback strategy is configured", () => {
|
|
const inputs = [
|
|
{ participantId: "known", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
];
|
|
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
expect(resolveRatings(inputs, ratingProfile, {
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35 },
|
|
}).has("tail")).toBe(false);
|
|
|
|
expect(resolveRatings(inputs, ratingProfile, {
|
|
inputPolicy: {
|
|
missingRatingStrategy: "fallbackRating",
|
|
fallbackRating: -4,
|
|
ratingMin: -10,
|
|
ratingMax: 35,
|
|
},
|
|
}).get("tail")).toMatchObject({ rating: -4, method: "fallbackRating" });
|
|
});
|
|
|
|
it("supports worst-known-minus rating fallback with clamping", () => {
|
|
const resolved = resolveRatings(
|
|
[
|
|
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "known-tail", sourceElo: null, rating: -8, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "missing-tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
{
|
|
inputPolicy: {
|
|
missingRatingStrategy: "worstKnownMinus",
|
|
fallbackRatingDelta: 5,
|
|
ratingMin: -10,
|
|
ratingMax: 35,
|
|
},
|
|
}
|
|
);
|
|
|
|
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 },
|
|
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
];
|
|
|
|
// oddsWeight 0: the stored base Elo wins outright.
|
|
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
|
|
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
|
|
|
|
// oddsWeight 1: futures fully override the base Elo.
|
|
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
|
|
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
|
|
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
|
|
|
|
// Between: a blend that sits strictly between the base and the odds-derived Elo.
|
|
const oddsElo = oddsOnly.get("favorite")!.sourceElo;
|
|
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
|
|
expect(blended.get("favorite")?.method).toBe("blend");
|
|
const blendedElo = blended.get("favorite")!.sourceElo;
|
|
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
|
|
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
|
|
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
|
|
});
|
|
|
|
it("blends projected wins with futures odds when both are present", () => {
|
|
const inputs = [
|
|
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
|
|
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
|
|
];
|
|
|
|
// oddsWeight 0: projectedWins is the base.
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
|
|
.toBe("projectedWins");
|
|
|
|
// oddsWeight 1: futures override the projection.
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
|
|
.toBe("sourceOdds");
|
|
|
|
// Between: blended.
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
|
|
.toBe("blend");
|
|
});
|
|
|
|
it("blends a direct rating with futures odds by oddsWeight", () => {
|
|
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
const inputs = [
|
|
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
];
|
|
|
|
// oddsWeight 0: direct rating wins.
|
|
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
|
|
.toMatchObject({ rating: 30, method: "direct" });
|
|
|
|
// oddsWeight 1: odds override the rating.
|
|
expect(
|
|
resolveRatings(inputs, ratingProfile, {
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
|
|
}).get("favorite")?.method
|
|
).toBe("sourceOdds");
|
|
|
|
// Between: blended.
|
|
expect(
|
|
resolveRatings(inputs, ratingProfile, {
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
|
|
}).get("favorite")?.method
|
|
).toBe("blend");
|
|
});
|
|
|
|
it("parses and clamps the base Elo priority and odds weight", () => {
|
|
const defaults = getSimulatorInputPolicy({});
|
|
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);
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
|
expect(
|
|
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
|
|
).toEqual(["projectedWins", "sourceElo"]);
|
|
// An empty/all-invalid priority falls back to the default.
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
|
|
DEFAULT_BASE_ELO_PRIORITY
|
|
);
|
|
});
|
|
|
|
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
|
const resolved = resolveRatings(
|
|
[
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
],
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
{
|
|
inputPolicy: {
|
|
missingRatingStrategy: "worstKnownMinus",
|
|
fallbackRatingDelta: 0.05,
|
|
ratingMin: 0.15,
|
|
ratingMax: 0.99,
|
|
},
|
|
}
|
|
);
|
|
|
|
expect(resolved.get("favorite")?.rating).toBeLessThanOrEqual(0.99);
|
|
expect(resolved.get("tail")).toMatchObject({ rating: 0.15, method: "worstKnownMinus" });
|
|
});
|
|
});
|