brackt/app/services/simulations/input-policy.ts
Claude 24de966b3b
Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.

Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
  sources: raw Elo / projected wins / projected table points) and oddsWeight
  (0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
  odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
  override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.

Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
  convertFuturesToElo calls, and per-match probability blend; they read the
  resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
  optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
  from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
  central resolver can't), so its central oddsWeight is set to 0 to avoid
  double-counting; the simulator is unchanged.

Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).

UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.

Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.

Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).

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

407 lines
15 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),
// 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
),
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(" / ");
}
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.
const w = policy.oddsWeight;
for (const input of inputs) {
const base = baseElo.get(input.participantId);
const odds = oddsElo.get(input.participantId);
let elo: number;
let method: ResolvedSourceElo["method"];
if (base !== undefined && odds !== undefined) {
if (w <= 0) {
elo = base.elo;
method = base.method;
} else if (w >= 1) {
elo = odds;
method = "sourceOdds";
} else {
elo = (1 - w) * base.elo + w * odds;
method = "blend";
}
} else if (base !== undefined) {
elo = base.elo;
method = base.method;
} else if (odds !== undefined) {
elo = odds;
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
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);
}
}
}
const w = policy.oddsWeight;
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 = odds;
method = "sourceOdds";
} else {
rating = (1 - w) * base + w * odds;
method = "blend";
}
} else if (base !== undefined) {
rating = base;
method = "direct";
} else if (odds !== undefined) {
rating = odds;
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
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;
}