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.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
||||||
expect(defaults.oddsWeight).toBe(0.3);
|
expect(defaults.oddsWeight).toBe(0.3);
|
||||||
|
|
||||||
// oddsWeight can be sourced from the top-level config or the input policy,
|
// oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level
|
||||||
// and is clamped to [0, 1]; junk base-priority entries are dropped.
|
// config.oddsWeight is ignored (single home); junk base-priority entries drop.
|
||||||
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6);
|
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: 5 } }).oddsWeight).toBe(1);
|
||||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,8 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
|
||||||
? ratingStrategy
|
? ratingStrategy
|
||||||
: "block",
|
: "block",
|
||||||
baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
|
baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
|
||||||
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
|
// The futures blend weight lives only under inputPolicy (single home).
|
||||||
// top level of config (legacy per-profile defaultConfig), preferring the policy.
|
oddsWeight: clamp(optionalNumber(rawPolicy.oddsWeight, DEFAULT_POLICY.oddsWeight), 0, 1),
|
||||||
oddsWeight: clamp(
|
|
||||||
optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)),
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
),
|
|
||||||
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
|
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
|
||||||
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
||||||
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
||||||
|
|
@ -173,6 +168,32 @@ export function ratingRequirementLabel(profile: SimulatorManifestProfile): strin
|
||||||
return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
|
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(
|
export function resolveSourceElos(
|
||||||
inputs: ParticipantInputForPolicy[],
|
inputs: ParticipantInputForPolicy[],
|
||||||
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
|
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
|
||||||
|
|
@ -240,42 +261,23 @@ export function resolveSourceElos(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Blend base + odds into the single Elo that feeds the simulator.
|
// 3. Blend base + odds into the single Elo that feeds the simulator.
|
||||||
// Only odds-derived / blended / fallback values are clamped+rounded onto the
|
// Directly entered Elos (some sports use ratings above the default ceiling,
|
||||||
// [eloMin, eloMax] band (they are mapped onto it by design). A base Elo is
|
// e.g. snooker ~2450) pass through unclamped; only derived values are
|
||||||
// passed through untouched: a directly entered Elo is trusted as-is (some
|
// clamped onto [eloMin, eloMax].
|
||||||
// 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);
|
const clampDerived = (elo: number): number => clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const base = baseElo.get(input.participantId);
|
const base = baseElo.get(input.participantId);
|
||||||
const odds = oddsElo.get(input.participantId);
|
const blended = blendBaseAndOdds(
|
||||||
let sourceElo: number;
|
base ? { value: base.elo, method: base.method } : undefined,
|
||||||
let method: ResolvedSourceElo["method"];
|
oddsElo.get(input.participantId),
|
||||||
if (base !== undefined && odds !== undefined) {
|
policy.oddsWeight,
|
||||||
if (w <= 0) {
|
clampDerived
|
||||||
sourceElo = base.elo;
|
);
|
||||||
method = base.method;
|
if (!blended) continue;
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
resolved.set(input.participantId, {
|
resolved.set(input.participantId, {
|
||||||
participantId: input.participantId,
|
participantId: input.participantId,
|
||||||
sourceElo,
|
sourceElo: blended.value,
|
||||||
method,
|
method: blended.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,39 +351,22 @@ export function resolveRatings(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// As with Elo: only odds-derived / blended values are clamped onto the rating
|
// As with Elo: a directly entered rating passes through untouched; only
|
||||||
// band; a directly entered rating is trusted as-is (matching prior behavior).
|
// derived values are clamped onto the rating band.
|
||||||
const w = policy.oddsWeight;
|
|
||||||
const clampDerived = (rating: number): number => clamp(rating, policy.ratingMin, policy.ratingMax);
|
const clampDerived = (rating: number): number => clamp(rating, policy.ratingMin, policy.ratingMax);
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const base = baseRating.get(input.participantId);
|
const base = baseRating.get(input.participantId);
|
||||||
const odds = oddsRating.get(input.participantId);
|
const blended = blendBaseAndOdds(
|
||||||
let rating: number;
|
base !== undefined ? { value: base, method: "direct" as const } : undefined,
|
||||||
let method: ResolvedRating["method"];
|
oddsRating.get(input.participantId),
|
||||||
if (base !== undefined && odds !== undefined) {
|
policy.oddsWeight,
|
||||||
if (w <= 0) {
|
clampDerived
|
||||||
rating = base;
|
);
|
||||||
method = "direct";
|
if (!blended) continue;
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
resolved.set(input.participantId, {
|
resolved.set(input.participantId, {
|
||||||
participantId: input.participantId,
|
participantId: input.participantId,
|
||||||
rating,
|
rating: blended.value,
|
||||||
method,
|
method: blended.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ucl_bracket: {
|
ucl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, oddsWeight: 0.3 },
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds"],
|
optionalInputs: ["sourceOdds"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -143,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "surfaceElo", "events"],
|
setupSections: ["participants", "surfaceElo", "events"],
|
||||||
},
|
},
|
||||||
mlb_bracket: {
|
mlb_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
|
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
|
|
@ -157,7 +157,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
world_cup: {
|
world_cup: {
|
||||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, oddsWeight: 0.3 },
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -176,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, oddsWeight: 0.4 },
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -189,10 +189,12 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds"],
|
setupSections: ["participants", "futuresOdds"],
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
// College hockey already blends odds into Elo internally (and also uses NPI
|
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||||
// rank, which the central resolver can't), so the central blend is disabled
|
// which the central resolver can't represent). oddsWeight 0 makes the central
|
||||||
// (oddsWeight 0) to avoid double-counting the odds.
|
// resolver leave a present base Elo untouched (no double-count), while still
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, oddsWeight: 0 },
|
// 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"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue