brackt/app/services/simulations/input-policy.ts
2026-05-12 22:26:25 -07:00

274 lines
10 KiB
TypeScript

import { convertFuturesToElo } from "~/services/probability-engine";
import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest";
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy;
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" | MissingEloStrategy;
}
export interface ResolvedRating {
participantId: string;
rating: number;
method: "direct" | "sourceOdds" | MissingRatingStrategy;
}
const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block",
missingRatingStrategy: "block",
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);
}
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",
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);
for (const input of inputs) {
if (input.sourceElo !== null && input.sourceElo !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: input.sourceElo,
method: "direct",
});
}
}
if (alternatives.has("projectedWins")) {
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
for (const input of inputs) {
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
method: "projectedWins",
});
}
}
if (alternatives.has("projectedTablePoints")) {
const seasonGames = optionalNumber(config.seasonGames, 38);
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
for (const input of inputs) {
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
method: "projectedTablePoints",
});
}
}
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
const converted = convertFuturesToElo(oddsInputs);
for (const [participantId, elo] of converted) {
resolved.set(participantId, {
participantId,
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
method: "sourceOdds",
});
}
}
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 ?? []);
for (const input of inputs) {
if (input.rating !== null && input.rating !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
rating: input.rating,
method: "direct",
});
}
}
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length >= 2) {
const converted = convertFuturesToElo(oddsInputs, "american", {
exponent: 0.33,
eloMin: policy.ratingMin,
eloMax: policy.ratingMax,
});
for (const [participantId, rating] of converted) {
resolved.set(participantId, {
participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
method: "sourceOdds",
});
}
}
}
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;
}