brackt/app/services/simulations/input-policy.ts
Claude a77485d5a5
Some checks failed
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m20s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 51s
Cleanup: dedupe blend logic, single-home oddsWeight
Address code-review cleanup notes:
- Extract the base/odds blend ladder (duplicated between resolveSourceElos and
  resolveRatings) into one generic `blendBaseAndOdds` helper.
- Move the futures blend weight to a single home: oddsWeight now lives only under
  inputPolicy. Manifest per-profile defaults moved from top-level config into
  inputPolicy.oddsWeight, and getSimulatorInputPolicy no longer reads a legacy
  top-level config.oddsWeight. Drops the now-dead mlb rDiffWeight config knob.
- Clarify the college_hockey oddsWeight 0 rationale (it disables the central
  blend so its internal odds blend isn't double-counted, while still letting odds
  resolve an Elo when they're the only source — a separate centralBlend flag
  would wrongly drop that sole-source path and break readiness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-26 05:10:35 +00:00

401 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { convertFuturesToElo } from "~/services/probability-engine";
import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest";
import { logger } from "~/lib/logger";
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
/**
* "Base" strength sources — the ways to produce a participant's underlying Elo
* before any futures-odds blend. These are *substitutes* (a season rarely has
* more than one), so they are tried in order and the first available one wins.
* Futures odds are not in this list: they blend on top of the base Elo via
* `oddsWeight`, rather than being one more interchangeable base.
*/
export type BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints";
export const BASE_ELO_KEYS: readonly BaseEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
];
/** Default order for choosing the base Elo: raw Elo, then projections. */
export const DEFAULT_BASE_ELO_PRIORITY: BaseEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
];
export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy;
/** Order for choosing the base strength Elo from its substitutable sources. */
baseEloPriority: BaseEloKey[];
/**
* Weight (01) of the futures-odds-derived Elo when blending with the base Elo
* into the single Elo that feeds every simulator. `0` = base only (Elo /
* projections), `1` = futures fully override the base, in between = blend.
*/
oddsWeight: number;
fallbackElo: number;
fallbackEloDelta: number;
eloMin: number;
eloMax: number;
fallbackRating: number;
fallbackRatingDelta: number;
ratingMin: number;
ratingMax: number;
}
export interface ParticipantInputForPolicy {
participantId: string;
sourceOdds: number | null;
sourceElo: number | null;
rating: number | null;
projectedWins: number | null;
projectedTablePoints: number | null;
}
export interface ResolvedSourceElo {
participantId: string;
sourceElo: number;
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy;
}
export interface ResolvedRating {
participantId: string;
rating: number;
method: "direct" | "sourceOdds" | "blend" | MissingRatingStrategy;
}
const DEFAULT_ODDS_WEIGHT = 0.3;
const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block",
missingRatingStrategy: "block",
baseEloPriority: DEFAULT_BASE_ELO_PRIORITY,
oddsWeight: DEFAULT_ODDS_WEIGHT,
fallbackElo: 1400,
fallbackEloDelta: 25,
eloMin: 1100,
eloMax: 1900,
fallbackRating: 0,
fallbackRatingDelta: 5,
ratingMin: -20,
ratingMax: 40,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function toWinRate(projection: number, maxProjection: number): number {
if (maxProjection <= 0) return 0.5;
return clamp(projection / maxProjection, 0.01, 0.99);
}
function projectionToElo(
projection: number,
maxProjection: number,
parityFactor: number,
policy: SimulatorInputPolicy
): number {
const rate = toWinRate(projection, maxProjection);
const elo = 1500 + parityFactor * Math.log10(rate / (1 - rate));
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
}
function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] {
if (!Array.isArray(value)) return fallback;
const seen = new Set<BaseEloKey>();
const parsed: BaseEloKey[] = [];
for (const entry of value) {
if (typeof entry === "string" && (BASE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as BaseEloKey)) {
seen.add(entry as BaseEloKey);
parsed.push(entry as BaseEloKey);
}
}
return parsed.length > 0 ? parsed : fallback;
}
export function getSimulatorInputPolicy(config: Record<string, unknown>): SimulatorInputPolicy {
const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
const eloStrategy = rawPolicy.missingEloStrategy;
const ratingStrategy = rawPolicy.missingRatingStrategy;
return {
missingEloStrategy:
eloStrategy === "fallbackElo" || eloStrategy === "averageKnown" || eloStrategy === "worstKnownMinus"
? eloStrategy
: "block",
missingRatingStrategy:
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
? ratingStrategy
: "block",
baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
// 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),
eloMax: optionalNumber(rawPolicy.eloMax, DEFAULT_POLICY.eloMax),
fallbackRating: optionalNumber(rawPolicy.fallbackRating, DEFAULT_POLICY.fallbackRating),
fallbackRatingDelta: optionalNumber(rawPolicy.fallbackRatingDelta, DEFAULT_POLICY.fallbackRatingDelta),
ratingMin: optionalNumber(rawPolicy.ratingMin, DEFAULT_POLICY.ratingMin),
ratingMax: optionalNumber(rawPolicy.ratingMax, DEFAULT_POLICY.ratingMax),
};
}
export function sourceEloRequirementLabel(profile: SimulatorManifestProfile): string {
const alternatives = profile.derivableInputs?.sourceElo ?? [];
// Omit "configured fallback" from the label: if a participant appears in
// missingInputs it means no fallback resolved it, so mentioning fallback
// as an option would be misleading.
return ["Elo rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
}
export function ratingRequirementLabel(profile: SimulatorManifestProfile): string {
const alternatives = profile.derivableInputs?.rating ?? [];
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">,
config: Record<string, unknown>
): Map<string, ResolvedSourceElo> {
const policy = getSimulatorInputPolicy(config);
const resolved = new Map<string, ResolvedSourceElo>();
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
const parityFactor = optionalNumber(config.parityFactor, 400);
// 1. Base Elo: the first available *substitutable* source per baseEloPriority.
// `sourceElo` (raw) is always permitted; projections must be declared in
// the profile's derivableInputs.sourceElo.
const baseElo = new Map<string, { elo: number; method: ResolvedSourceElo["method"] }>();
for (const source of policy.baseEloPriority) {
if (source !== "sourceElo" && !alternatives.has(source)) continue;
if (source === "sourceElo") {
for (const input of inputs) {
if (baseElo.has(input.participantId)) continue;
if (input.sourceElo !== null && input.sourceElo !== undefined) {
baseElo.set(input.participantId, { elo: input.sourceElo, method: "direct" });
}
}
} else if (source === "projectedWins") {
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
for (const input of inputs) {
if (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
baseElo.set(input.participantId, {
elo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
method: "projectedWins",
});
}
} else if (source === "projectedTablePoints") {
const seasonGames = optionalNumber(config.seasonGames, 38);
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
for (const input of inputs) {
if (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
baseElo.set(input.participantId, {
elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
method: "projectedTablePoints",
});
}
}
}
// 2. Odds-derived Elo (field-relative): only when futures odds are a permitted
// source and at least two participants have odds (a spread is required).
const oddsElo = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) {
logger.warn(
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — ignoring the lone odds value.`
);
}
if (oddsInputs.length >= 2) {
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
oddsElo.set(participantId, elo);
}
}
}
// 3. Blend base + odds into the single Elo that feeds the simulator.
// 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 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: blended.value,
method: blended.method,
});
}
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
const averageKnown = knownElos.length > 0
? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
: policy.fallbackElo;
const worstKnown = knownElos.length > 0 ? Math.min(...knownElos) : policy.fallbackElo;
for (const input of inputs) {
if (resolved.has(input.participantId) || policy.missingEloStrategy === "block") continue;
let sourceElo: number;
if (policy.missingEloStrategy === "averageKnown") {
sourceElo = averageKnown;
} else if (policy.missingEloStrategy === "worstKnownMinus") {
sourceElo = worstKnown - policy.fallbackEloDelta;
} else {
sourceElo = policy.fallbackElo;
}
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: clamp(Math.round(sourceElo), policy.eloMin, policy.eloMax),
method: policy.missingEloStrategy,
});
}
return resolved;
}
export function resolveRatings(
inputs: ParticipantInputForPolicy[],
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
config: Record<string, unknown>
): Map<string, ResolvedRating> {
const policy = getSimulatorInputPolicy(config);
const resolved = new Map<string, ResolvedRating>();
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
// Ratings follow the same single-value blend as Elo: the base is the direct
// `rating`; futures odds (when permitted, scaled onto the rating range) blend
// on top by oddsWeight. `0` = rating only, `1` = futures override.
const baseRating = new Map<string, number>();
for (const input of inputs) {
if (input.rating !== null && input.rating !== undefined) {
baseRating.set(input.participantId, input.rating);
}
}
const oddsRating = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) {
logger.warn(
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful rating — ignoring the lone odds value.`
);
}
if (oddsInputs.length >= 2) {
const converted = convertFuturesToElo(oddsInputs, "american", {
exponent: 0.33,
eloMin: policy.ratingMin,
eloMax: policy.ratingMax,
});
for (const [participantId, rating] of converted) {
oddsRating.set(participantId, rating);
}
}
}
// 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 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: blended.value,
method: blended.method,
});
}
const knownRatings = [...resolved.values()].map((value) => value.rating);
const averageKnown = knownRatings.length > 0
? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length
: policy.fallbackRating;
const worstKnown = knownRatings.length > 0 ? Math.min(...knownRatings) : policy.fallbackRating;
for (const input of inputs) {
if (resolved.has(input.participantId) || policy.missingRatingStrategy === "block") continue;
let rating: number | null;
if (policy.missingRatingStrategy === "averageKnown") {
rating = averageKnown;
} else if (policy.missingRatingStrategy === "worstKnownMinus") {
rating = knownRatings.length > 0 ? worstKnown - policy.fallbackRatingDelta : null;
} else {
rating = policy.fallbackRating;
}
if (rating === null) continue;
resolved.set(input.participantId, {
participantId: input.participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
method: policy.missingRatingStrategy,
});
}
return resolved;
}