claude/clever-archimedes-dvye42 #110
3 changed files with 65 additions and 77 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue