claude/mlb-simulator-projected-wins-elo-siess8 #146

Merged
chrisp merged 2 commits from claude/mlb-simulator-projected-wins-elo-siess8 into main 2026-08-29 07:53:09 +00:00
11 changed files with 688 additions and 64 deletions
Showing only changes of commit 280a46eb5f - Show all commits

View file

@ -111,4 +111,50 @@ describe("simulator input model", () => {
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600); expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull(); expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
}); });
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
// This is what stops a stale Elo from winning the baseEloPriority race. A row
// carrying projectedWins and a projectedWins method flag must surface with a
// null sourceElo, so resolveSourceElos falls through to the projection rather
// than reusing an Elo that was itself derived from an older projection.
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "projected" },
{ id: "hand-entered" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "projected",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "projectedWins" },
},
{
participantId: "hand-entered",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: {},
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
// No flag means the admin entered that Elo themselves — it is trusted as direct.
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
});
}); });

View file

@ -359,14 +359,17 @@ export async function batchUpsertParticipantSimulatorInputs(
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`, region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that // Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted // tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. When a caller supplies explicit metadata, use it as-is // direct value. When a caller supplies explicit metadata, merge it over
// (prepareSimulatorInputsForRun and the projection importer set the // what is already stored (prepareSimulatorInputsForRun and the projection
// correct flags). Otherwise preserve existing metadata, but drop the // importers set the correct flags) — a merge rather than a replace so a
// caller that only needs to stamp a method flag does not wipe unrelated
// metadata keys. Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a // method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be // stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs). // filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata WHEN excluded.metadata IS NOT NULL
THEN COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) || excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END) - (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END) - (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "../admin.sports-seasons.$id.simulator.helpers";
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
describe("projectionMethodMetadata", () => {
it("flags a row that supplies projected wins and no Elo", () => {
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
sourceEloMethod: "projectedWins",
});
});
it("flags a row that supplies projected table points and no Elo", () => {
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
sourceEloMethod: "projectedTablePoints",
});
});
it("leaves metadata alone when the row supplies an explicit Elo", () => {
// An explicit Elo is a direct entry and must stay trusted, even alongside a
// projection — the upsert then clears any stale generated flag.
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
});
it("leaves metadata alone for a row with neither", () => {
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
});
it("prefers wins over table points when a row somehow carries both", () => {
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
sourceEloMethod: "projectedWins",
});
});
});
describe("parseBaseEloPriorityChoice", () => {
it("puts projections ahead of raw Elo", () => {
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
"projectedWins",
"projectedTablePoints",
"sourceElo",
]);
});
it("puts raw Elo first for eloFirst", () => {
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
DEFAULT_BASE_ELO_PRIORITY
);
});
it("keeps the stored ordering when the select was not on the form", () => {
// Simulators with no projection alternative never render the control; saving
// other config must not rewrite their ordering.
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
});
it("preserves the relative order of the projection keys", () => {
expect(
parseBaseEloPriorityChoice("projectionsFirst", [
"projectedTablePoints",
"sourceElo",
"projectedWins",
])
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
});
it("round-trips: flipping back restores Elo-first", () => {
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
});
});
describe("resolvedInputMethodLabel", () => {
it("badges nothing for a directly entered Elo or rating", () => {
expect(resolvedInputMethodLabel("direct")).toBeNull();
});
it("badges both projection methods the same way", () => {
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
});
it("distinguishes futures and blended Elo", () => {
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
expect(resolvedInputMethodLabel("blend")).toBe("blended");
});
it("badges every missing-input strategy as a fallback", () => {
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
});
});

View file

@ -31,7 +31,7 @@ import {
projectedWinsToElo, projectedWinsToElo,
} from '~/services/probability-engine'; } from '~/services/probability-engine';
import { runSportsSeasonSimulation } from '~/services/simulations/runner'; import { runSportsSeasonSimulation } from '~/services/simulations/runner';
import { getSportsSeasonSimulatorConfig } from '~/models/simulator'; import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
// Simulator types that use worldRanking in addition to sourceElo // Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']); const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
@ -80,14 +80,39 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId); const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {}; // The projection a participant was actually saved with. Read it back verbatim:
for (const ev of existingEVs) { // deriving the field from the stored Elo instead (as this page used to) shows the
existingData[ev.participantId] = { // admin a different number than they typed, because wins → Elo rounds to an
elo: ev.sourceElo ?? null, // integer Elo and a simulation run then re-resolves that Elo through the input
ranking: ev.worldRanking ?? null, // policy (clamping, and blending in futures odds when a season has them).
const projectionsByParticipant = new Map(
simulatorInputs.map((input) => [
input.participantId,
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
])
);
const existingData: Record<
string,
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
> = {};
for (const participant of participants) {
const projection = projectionsByParticipant.get(participant.id);
existingData[participant.id] = {
elo: null,
ranking: null,
projectedWins: projection?.projectedWins ?? null,
projectedTablePoints: projection?.projectedTablePoints ?? null,
}; };
} }
for (const ev of existingEVs) {
const existing = existingData[ev.participantId];
if (!existing) continue;
existing.elo = ev.sourceElo ?? null;
existing.ranking = ev.worldRanking ?? null;
}
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
@ -252,7 +277,16 @@ export default function AdminSportsSeasonEloRatings() {
if (simulatorConfig) { if (simulatorConfig) {
participants.forEach(p => { participants.forEach(p => {
const d = existingData[p.id]; const d = existingData[p.id];
if (d?.elo !== null && d?.elo !== undefined) { // A stored projection is shown exactly as it was entered. Only fall back to
// deriving it from the Elo when this season has no projection saved (a
// season that has only ever had Elos entered still gets a useful starting
// point) — that derived value is lossy and must never overwrite a real one.
const stored = simulatorConfig.projectionInput === 'tablePoints'
? d?.projectedTablePoints
: d?.projectedWins;
if (stored !== null && stored !== undefined) {
initial[p.id] = stored.toString();
} else if (d?.elo !== null && d?.elo !== undefined) {
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints' initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) ? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) : eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
@ -265,8 +299,8 @@ export default function AdminSportsSeasonEloRatings() {
const [bulkText, setBulkText] = useState(''); const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{ const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>; matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>; unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
} | null>(null); } | null>(null);
function findParticipantMatch(inputName: string) { function findParticipantMatch(inputName: string) {
@ -291,8 +325,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() { function parseBulkText() {
const lines = bulkText.split('\n'); const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = []; const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = []; const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const line of lines) { for (const line of lines) {
@ -315,9 +349,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName); const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) { if (participant && !seen.has(participant.id)) {
seen.add(participant.id); seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName }); matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
} else if (!participant) { } else if (!participant) {
unmatched.push({ inputName, elo, ranking: null }); unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
} }
} else { } else {
const match = usesRanking const match = usesRanking
@ -342,9 +376,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName); const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) { if (participant && !seen.has(participant.id)) {
seen.add(participant.id); seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName }); matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
} else if (!participant) { } else if (!participant) {
unmatched.push({ inputName, elo, ranking }); unmatched.push({ inputName, elo, ranking, projection: null });
} }
} }
} }
@ -360,11 +394,11 @@ export default function AdminSportsSeasonEloRatings() {
for (const m of parseResults.matched) { for (const m of parseResults.matched) {
if (m.elo !== null) newElos[m.participantId] = m.elo.toString(); if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString(); if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) { // The pasted number goes in as typed. Round-tripping it through the derived
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints' // Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) // came back as 95.1 before anything was even saved.
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) if (inputMode === 'projectedWins' && m.projection !== null) {
).toFixed(1); newWins[m.participantId] = m.projection.toString();
} }
} }
setEloValues(newElos); setEloValues(newElos);
@ -489,7 +523,10 @@ Mark Selby, 2432`
<div key={m.participantId} className="flex justify-between px-3 py-1.5"> <div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span> <span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium"> <span className="font-medium">
{m.name} &rarr; {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'} {m.name} &rarr;{' '}
{m.projection !== null
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''} {usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
</span> </span>
</div> </div>
@ -509,7 +546,9 @@ Mark Selby, 2432`
<div key={u.inputName} className="flex justify-between px-3 py-1.5"> <div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span> <span>{u.inputName}</span>
<span className="font-medium"> <span className="font-medium">
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'} {u.projection !== null
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''} {usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
</span> </span>
</div> </div>
@ -540,7 +579,7 @@ Mark Selby, 2432`
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{inputMode === 'projectedWins' {inputMode === 'projectedWins'
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.` ? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
: usesRanking : usesRanking
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.` ? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`} : `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}

View file

@ -0,0 +1,86 @@
/**
* Pure helpers for the Simulator Setup page, split out so they can be unit tested
* without pulling the route's server-only imports into the test.
*/
import type {
BaseEloKey,
ResolvedRating,
ResolvedSourceElo,
} from "~/services/simulations/input-policy";
/**
* Short badge text for how a participant's Elo or rating was produced, or null for a
* directly entered one the unremarkable case, which needs no badge.
*
* The preview table needs this because a generated value is deliberately hidden from
* `getParticipantSimulatorInputs`, so without the resolved value plus this label the
* row reads as "nothing saved" and a projection losing to a raw Elo is invisible.
*
* Every remaining method is a missing-input fallback (`fallbackElo`,
* `fallbackRating`, `averageKnown`, `worstKnownMinus`, `block`), which all read the
* same way to an admin: this participant had nothing usable of its own.
*/
export function resolvedInputMethodLabel(
method: ResolvedSourceElo["method"] | ResolvedRating["method"]
): string | null {
switch (method) {
case "direct":
return null;
case "projectedWins":
case "projectedTablePoints":
return "from projections";
case "sourceOdds":
return "from futures";
case "blend":
return "blended";
default:
return "fallback";
}
}
/**
* Method flag for a bulk-input row that carries a projection instead of an Elo, or
* undefined when the row says nothing about how its Elo was produced.
*
* A row supplying a projection but no explicit Elo means "derive the Elo from this
* projection". Stamping the flag marks whatever Elo is already stored as generated,
* so `getParticipantSimulatorInputs` hides it and `resolveSourceElos` re-derives
* from the projection without it, the non-destructive upsert leaves a stale
* hand-entered Elo in place, and that Elo wins the `baseEloPriority` race so the
* projection is written to the database and then ignored on every run.
*
* Returning undefined (rather than an empty object) matters: the upsert only
* preserves existing metadata, and clears a stale flag for a fresh direct Elo, when
* the incoming metadata is null.
*/
export function projectionMethodMetadata(
sourceElo: number | undefined,
projectedWins: number | undefined,
projectedTablePoints: number | undefined
): Record<string, unknown> | undefined {
if (sourceElo !== undefined) return undefined;
if (projectedWins !== undefined) return { sourceEloMethod: "projectedWins" };
if (projectedTablePoints !== undefined) return { sourceEloMethod: "projectedTablePoints" };
return undefined;
}
/**
* Translate the Base Elo Source select into a full `baseEloPriority` list. Only the
* head of the list is user-facing (raw Elo vs. projections); the remaining keys keep
* their existing relative order so a season that already has a custom ordering is
* not silently flattened.
*/
export function parseBaseEloPriorityChoice(
value: FormDataEntryValue | null,
current: BaseEloKey[]
): BaseEloKey[] {
// The select only renders for simulators that can derive Elo from a projection.
// When it was not on the form there is no choice to apply, so keep what is stored
// rather than silently rewriting the season's ordering.
if (value === null) return current;
const projections = current.filter((key) => key !== "sourceElo");
return value === "projectionsFirst"
? [...projections, "sourceElo"]
: ["sourceElo", ...projections];
}

View file

@ -32,10 +32,18 @@ import {
} from "~/services/simulations/manifest"; } from "~/services/simulations/manifest";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
resolveRatings,
resolveSourceElos,
type MissingEloStrategy, type MissingEloStrategy,
type MissingRatingStrategy, type MissingRatingStrategy,
type ResolvedSourceElo,
} from "~/services/simulations/input-policy"; } from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner"; import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "./admin.sports-seasons.$id.simulator.helpers";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -66,6 +74,31 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config); const inputPolicy = getSimulatorInputPolicy(config.config);
// The Elo each participant will actually run with, and which source produced it.
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
// saved" — and a raw Elo silently beating a projection is invisible.
const resolvedElos = config.profile.requiredInputs.includes("sourceElo")
? resolveSourceElos(inputs, config.profile, config.config)
: new Map<string, ResolvedSourceElo>();
const resolvedEloRows = Object.fromEntries(
[...resolvedElos.values()].map((resolved) => [
resolved.participantId,
{ sourceElo: resolved.sourceElo, method: resolved.method },
])
);
// Same for ratings, which are blanked by the same rule when generated. The
// preview's "missing a required input" marker reads both, so it agrees with
// readiness instead of flagging every participant a projection resolved.
const resolvedRatingRows = Object.fromEntries(
config.profile.requiredInputs.includes("rating")
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
resolved.participantId,
{ rating: resolved.rating, method: resolved.method },
])
: []
);
// Sport-aware preview columns: the intersection of the displayable numeric keys // Sport-aware preview columns: the intersection of the displayable numeric keys
// with this simulator's required + optional inputs, so each season shows exactly // with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...). // the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
@ -81,7 +114,17 @@ export async function loader({ params }: Route.LoaderArgs) {
required: config.profile.requiredInputs.includes(key), required: config.profile.requiredInputs.includes(key),
})); }));
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns }; return {
sportsSeason,
participants,
config,
inputRows,
readiness,
inputPolicy,
inputColumns,
resolvedEloRows,
resolvedRatingRows,
};
} }
interface ActionData { interface ActionData {
@ -124,6 +167,7 @@ const HONORED_ENGINE_KNOBS = new Set([
"baseDrawRate", "baseDrawRate",
"drawDecay", "drawDecay",
"ratingScaleFactor", "ratingScaleFactor",
"projectedWinsWeight",
]); ]);
function parseOptionalNumber(value: string | undefined): number | null { function parseOptionalNumber(value: string | undefined): number | null {
@ -232,17 +276,28 @@ function parseInputCsv(
continue; continue;
} }
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
inputs.push({ inputs.push({
participantId, participantId,
sportsSeasonId, sportsSeasonId,
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined, sourceElo,
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined, sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined, worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined, rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined, projectedWins,
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined, projectedTablePoints,
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined, seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
region: cols[indexes.get("region") ?? -1] || undefined, region: cols[indexes.get("region") ?? -1] || undefined,
// A row that supplies a projection but no explicit Elo means "derive the Elo
// from this projection". Stamping the method flag marks whatever Elo is
// already stored as generated, so getParticipantSimulatorInputs hides it and
// resolveSourceElos re-derives from the projection instead of letting a stale
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
// projections mode.
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
}); });
} }
@ -312,6 +367,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
...currentPolicy, ...currentPolicy,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo), fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
@ -384,6 +440,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === "submitting";
const setupSections = config.profile.setupSections; const setupSections = config.profile.setupSections;
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? []; const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
// The projection key this simulator can derive Elo from (wins or table points),
// or null when it has none — the base-priority control only makes sense with one.
const projectionEloKey =
sourceEloAlternatives.find((key) => key === "projectedWins" || key === "projectedTablePoints") ?? null;
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
const ratingAlternatives = config.profile.derivableInputs?.rating ?? []; const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
const showsInputPolicy = const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
@ -397,7 +458,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
// Preview columns are resolved server-side in the loader (see note there) and // Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest. // arrive as plain data, so this client component never imports the manifest.
const { inputColumns } = loaderData; const { inputColumns, resolvedEloRows, resolvedRatingRows } = loaderData;
const requiredInputs = config.profile.requiredInputs; const requiredInputs = config.profile.requiredInputs;
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`; const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// For this sport the inputs live on a dedicated page, not the shared bulk paste. // For this sport the inputs live on a dedicated page, not the shared bulk paste.
@ -409,8 +470,17 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
: null) : null)
: null; : null;
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) => // A required Elo/rating counts as present when the input policy resolves one,
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined); // not only when it is stored directly: getParticipantSimulatorInputs deliberately
// blanks a generated value so it is re-derived each run, so reading the raw input
// alone would mark every projection-configured participant as missing.
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => {
if (input?.[key] !== null && input?.[key] !== undefined) return false;
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
return true;
});
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false); const [onlyMissing, setOnlyMissing] = useState(false);
@ -420,11 +490,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const normalizedSearch = normalizeName(search); const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => { return inputRows.filter(({ participant, input }) => {
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false; if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
if (onlyMissing && !isRowIncomplete(input)) return false; if (onlyMissing && !isRowIncomplete(participant.id, input)) return false;
return true; return true;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs]); }, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1); const safePage = Math.min(page, totalPages - 1);
@ -582,6 +652,26 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
this Elo they are not blended again per game. this Elo they are not blended again per game.
</p> </p>
</div> </div>
{projectionEloKey && (
<div className="space-y-2 md:col-span-5">
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
<select
id="baseEloPriority"
name="baseEloPriority"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
>
<option value="eloFirst">Entered Elo first, then {simulatorInputLabel(projectionEloKey)}</option>
<option value="projectionsFirst">{simulatorInputLabel(projectionEloKey)} first, then entered Elo</option>
</select>
<p className="text-xs text-muted-foreground">
Raw Elo and projections are substitutes the first one a participant has wins, and
the other is ignored (futures odds are separate and blend on top via the weight above).
Pick <strong>{simulatorInputLabel(projectionEloKey)} first</strong> when projections are
the source of truth for this season and a previously entered Elo should not override them.
</p>
</div>
)}
{config.profile.requiredInputs.includes("sourceElo") && ( {config.profile.requiredInputs.includes("sourceElo") && (
<> <>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
@ -771,7 +861,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</div> </div>
) : ( ) : (
pageRows.map(({ participant, input }) => { pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(input); const incomplete = isRowIncomplete(participant.id, input);
return ( return (
<div <div
key={participant.id} key={participant.id}
@ -784,6 +874,34 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</div> </div>
{inputColumns.length > 0 ? ( {inputColumns.length > 0 ? (
inputColumns.map((column) => { inputColumns.map((column) => {
if (column.key === "sourceElo") {
const resolved = resolvedEloRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.sourceElo : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
if (column.key === "rating") {
const resolved = resolvedRatingRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.rating : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
const value = input?.[column.key]; const value = input?.[column.key];
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>; return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
}) })

View file

@ -26,6 +26,33 @@ describe("simulator input policy", () => {
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" }); expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
}); });
it("puts projections ahead of a stored Elo when baseEloPriority says so", () => {
// The season-level escape hatch for "projections are the source of truth here":
// without it a stale hand-entered Elo silently beats a fresh projection.
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("team-1")?.method).toBe("projectedWins");
expect(resolved.get("team-1")?.sourceElo).not.toBe(1600);
});
it("still falls back to the stored Elo for participants without a projection", () => {
const resolved = resolveSourceElos(
[
{ participantId: "projected", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null },
{ participantId: "elo-only", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("projected")?.method).toBe("projectedWins");
expect(resolved.get("elo-only")).toMatchObject({ sourceElo: 1600, method: "direct" });
});
it("derives Elo from projected wins when Elo is missing", () => { it("derives Elo from projected wins when Elo is missing", () => {
const resolved = resolveSourceElos( const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }], [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],

View file

@ -7,6 +7,7 @@ import {
rawWinRateFromElo, rawWinRateFromElo,
rdifWinProbability, rdifWinProbability,
eloToRDif, eloToRDif,
seedingWinRateFor,
sampleBinomial, sampleBinomial,
simBo3, simBo3,
simBo5, simBo5,
@ -281,8 +282,8 @@ describe("sampleBinomial", () => {
// ─── Series simulators ──────────────────────────────────────────────────────── // ─── Series simulators ────────────────────────────────────────────────────────
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 }; const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 }; const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const alwaysA = () => 1.0; // team A always wins each game const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5; const coinFlip = () => 0.5;
@ -346,9 +347,88 @@ describe("eloToRDif", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5); expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
}); });
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => { it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
const elo = 1620; // 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400)); // ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4); // ~+686 the old RDIF_DIVISOR scaling produced.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
expect(eloToRDif(elo)).toBeGreaterThan(120);
expect(eloToRDif(elo)).toBeLessThan(160);
});
it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => {
// The whole point of RDIF_DIVISOR: playoff series are near coin-flips between
// playoff-calibre teams. An Elo-rated team must not skip that compression.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
const playoffRate = winRateFromRDif(eloToRDif(elo));
expect(playoffRate).toBeCloseTo(0.517, 2);
// Strictly compressed relative to the team's raw season win rate.
expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo));
});
it("agrees with the hardcoded rdif path for a team of equivalent strength", () => {
// Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should
// produce a comparable playoff win rate rather than a wildly more dominant one.
const dodgers = getTeamData("Los Angeles Dodgers");
const eloEquivalent = 1500 + 400 * Math.log10(
rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0))
);
expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo(
winRateFromRDif(dodgers?.rdif ?? 0),
3
);
});
});
// ─── seedingWinRateFor ────────────────────────────────────────────────────────
describe("seedingWinRateFor", () => {
const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies
it("is a no-op pre-season: the target equals the Elo-implied rate", () => {
expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6);
});
it("spreads the shortfall over the remaining games mid-season", () => {
// 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the
// .586 the season-long Elo implies. Without this the sim finishes around 90.5.
expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6);
});
it("reaches the projection in expectation", () => {
const currentWins = 60;
const remaining = 52;
const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining);
expect(currentWins + rate * remaining).toBeCloseTo(95, 6);
});
it("clamps a team that has already passed its projection", () => {
expect(seedingWinRateFor(eloRate, 95, 96, 20)).toBe(0.01);
});
it("clamps a target that is unreachable", () => {
expect(seedingWinRateFor(eloRate, 95, 60, 10)).toBe(0.99);
});
it("falls back to the Elo rate with no projection", () => {
expect(seedingWinRateFor(eloRate, null, 60, 52)).toBe(eloRate);
});
it("falls back to the Elo rate when the season is over", () => {
expect(seedingWinRateFor(eloRate, 95, 95, 0)).toBe(eloRate);
});
it("falls back to the Elo rate at weight 0", () => {
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0)).toBe(eloRate);
});
it("blends target and Elo rate at an intermediate weight", () => {
const target = 35 / 52;
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0.5)).toBeCloseTo(
0.5 * target + 0.5 * eloRate,
6
);
}); });
}); });

View file

@ -171,7 +171,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "surfaceElo", "events"], setupSections: ["participants", "surfaceElo", "events"],
}, },
mlb_bracket: { mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } }, defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },

View file

@ -8,8 +8,9 @@
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings * 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
* 3. Load sourceElo ratings from seasonParticipantExpectedValues * 3. Load sourceElo ratings from seasonParticipantExpectedValues
* 4. Match participant names to hardcoded team data (RDif + league/division) * 4. Load raw projected win totals from seasonParticipantSimulatorInputs
* 5. For each simulation: * 5. Match participant names to hardcoded team data (RDif + league/division)
* 6. For each simulation:
* a. For each league (AL/NL), simulate remaining regular season games for * a. For each league (AL/NL), simulate remaining regular season games for
* every team using Binomial sampling, giving final projected wins. * every team using Binomial sampling, giving final projected wins.
* b. Division winner = best record in each division (3 per league). * b. Division winner = best record in each division (3 per league).
@ -21,8 +22,8 @@
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other * - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
* - League Championship Series (best-of-7) * - League Championship Series (best-of-7)
* e. World Series (best-of-7): AL champ vs NL champ * e. World Series (best-of-7): AL champ vs NL champ
* 6. Track placement counts per scoring tier * 7. Track placement counts per scoring tier
* 7. Convert counts to probability distributions * 8. Convert counts to probability distributions
* *
* Win probability (log5 formula): * Win probability (log5 formula):
* Step 1 convert projected RDif to win rate for playoff matchups: * Step 1 convert projected RDif to win rate for playoff matchups:
@ -32,17 +33,22 @@
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB) * P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
* *
* Regular season simulation (seeding): * Regular season simulation (seeding):
* Each team's raw per-game win rate is derived from sourceElo (if set) or * Each team's base per-game win rate is derived from sourceElo (if set) or
* from the hardcoded RDif using SEEDING_RDIF_SCALE 10 runs/win × 162 games. * from the hardcoded RDif using SEEDING_RDIF_SCALE 10 runs/win × 162 games.
* When the team also has a user-entered projected win total, that base rate is
* replaced by the rest-of-season rate that actually reaches the projection:
* target = (projectedWins currentWins) / remainingGames
* (see seedingWinRateFor; config `projectedWinsWeight` blends it back toward the
* base rate, and is a no-op pre-season where the two rates coincide).
* Remaining games = TOTAL_SEASON_GAMES gamesPlayed are drawn from a * Remaining games = TOTAL_SEASON_GAMES gamesPlayed are drawn from a
* Binomial distribution. This makes playoff seeding respond to both current * Binomial distribution. This makes playoff seeding respond to both current
* standings and user-entered projected wins. * standings and user-entered projected wins.
* *
* Futures blending: * Input resolution:
* If sourceOdds are stored in participantExpectedValues for this season, * sourceElo is the single Elo produced by the shared input policy already a
* the per-game win probability for playoff series is blended: * blend of any raw Elo / projections / futures odds, written by
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb * prepareSimulatorInputsForRun before the run. This simulator does not blend
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3. * futures odds itself.
* *
* Placement tiers SimulationProbabilities mapping: * Placement tiers SimulationProbabilities mapping:
* probFirst = World Series champion (1 per sim) * probFirst = World Series champion (1 per sim)
@ -74,9 +80,10 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access"; import { configNumber, positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -100,6 +107,13 @@ const RDIF_DIVISOR = 8000;
*/ */
const SEEDING_RDIF_SCALE = 1620; const SEEDING_RDIF_SCALE = 1620;
/**
* Default weight given to a user-entered projected win total when deriving the
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
*/
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ──────────────────── // ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
// //
// rdif: Projected run differential from FanGraphs Depth Charts. // rdif: Projected run differential from FanGraphs Depth Charts.
@ -206,13 +220,59 @@ export function rawWinRateFromElo(elo: number): number {
} }
/** /**
* Convert an Elo rating to an equivalent projected run differential. * Convert an Elo rating to an equivalent projected run differential, on the same
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500), * scale as the hardcoded TEAMS_DATA.rdif values.
* then inverts the winRateFromRDif formula: rdif = (winRate 0.5) × RDIF_DIVISOR. *
* Uses the standard Elo win probability formula (parity factor 400, average Elo
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate 0.5) × SEEDING_RDIF_SCALE.
*
* SEEDING_RDIF_SCALE not RDIF_DIVISOR is deliberate. Scaling by RDIF_DIVISOR
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
* gets: a 95-win projection (Elo 1561) mapped to RDif +686 and played playoff
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
* maps to +140 right alongside the Dodgers' hardcoded +137 and
* winRateFromRDif then compresses it to .5175 like any other team.
*
* Exported for unit testing. * Exported for unit testing.
*/ */
export function eloToRDif(elo: number): number { export function eloToRDif(elo: number): number {
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR; return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
}
/**
* Per-game win rate to use for a team's remaining regular-season games.
*
* A user-entered `projectedWins` is a projected *final* season win total, so the
* rate that reproduces it is spread over the games still to play:
*
* target = (projectedWins currentWins) / remainingGames
*
* Pre-season this is a no-op with currentWins 0 and remainingGames 162 the
* target equals projectedWins / 162, which is exactly the rate the Elo derived
* from that projection already encodes. Mid-season it is what makes the
* simulation actually land on the projection: a team at 60-50 projected for 95
* needs .673 over its last 52 games, not the .586 its season-long Elo implies.
*
* `weight` (config `projectedWinsWeight`, default 1) blends the target back
* toward the Elo-implied rate. At 1 the projection is treated as authoritative;
* lower values hedge it. Note that at weight 1 a team that has already passed its
* projection is clamped to a .01 rest-of-season rate lower the weight if that
* proves too rigid for in-season use.
*
* Exported for unit testing.
*/
export function seedingWinRateFor(
eloRate: number,
projectedWins: number | null,
currentWins: number,
remainingGames: number,
weight: number = DEFAULT_PROJECTED_WINS_WEIGHT
): number {
if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate;
const target = (projectedWins - currentWins) / remainingGames;
const rate = weight * target + (1 - weight) * eloRate;
return Math.min(0.99, Math.max(0.01, rate));
} }
/** /**
@ -270,6 +330,8 @@ interface TeamEntry {
originalSeed?: number; originalSeed?: number;
currentWins: number; // from regularSeasonStandings (0 pre-season) currentWins: number; // from regularSeasonStandings (0 pre-season)
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
/** User-entered projected *final* season win total, or null when not set. */
projectedWins: number | null;
} }
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */ /** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
@ -443,6 +505,9 @@ function simLeagueBracket(
export class MLBSimulator implements Simulator { export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// configNumber (not positiveConfigNumber) so an explicit 0 — ignore projections,
// use the Elo-implied rate — is honored rather than falling back to the default.
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -465,6 +530,14 @@ export class MLBSimulator implements Simulator {
const standings = await getRegularSeasonStandings(sportsSeasonId); const standings = await getRegularSeasonStandings(sportsSeasonId);
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s])); const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
// 3. Load the raw projected win totals. The resolved Elo already encodes the
// projection as a season-long rate, but the raw total is what lets seeding
// spread the *remaining* wins correctly once games have been played.
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map(
simInputs.map((input) => [input.participantId, input.projectedWins])
);
const teams: TeamEntry[] = participantRows.map((r) => { const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsByParticipantId.get(r.id); const standing = standingsByParticipantId.get(r.id);
const gamesPlayed = standing?.gamesPlayed ?? 0; const gamesPlayed = standing?.gamesPlayed ?? 0;
@ -474,6 +547,7 @@ export class MLBSimulator implements Simulator {
data: getTeamData(r.name), data: getTeamData(r.name),
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
projectedWins: projectedWinsMap.get(r.id) ?? null,
}; };
}); });
@ -546,11 +620,28 @@ export class MLBSimulator implements Simulator {
/** /**
* Raw per-game win rate for regular-season seeding simulation. * Raw per-game win rate for regular-season seeding simulation.
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif *
* with SEEDING_RDIF_SCALE (Pythagorean approximation). * The base rate comes from sourceElo when available, else from the hardcoded
* rdif via SEEDING_RDIF_SCALE (Pythagorean approximation). A user-entered
* projected win total then re-expresses that as a rest-of-season target so the
* projection is actually reached mid-season see seedingWinRateFor.
*
* The result depends only on fixed per-team inputs, so it is resolved once here
* rather than on every one of the ~1.5M calls the seeding loop makes.
*/ */
const seedingWinRate = (entry: TeamEntry): number => const seedingWinRateMap = new Map(
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry)); teams.map((team) => [
team.id,
seedingWinRateFor(
rawWinRateMap.get(team.id) ?? rawWinRateFromRDif(getEntryRDif(team)),
team.projectedWins,
team.currentWins,
team.remainingGames,
projectedWinsWeight
),
])
);
const seedingWinRate = (entry: TeamEntry): number => seedingWinRateMap.get(entry.id) ?? 0.5;
/** /**
* Per-game win probability for team A over team B in a playoff series, from * Per-game win probability for team A over team B in a playoff series, from

View file

@ -91,13 +91,48 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
## Input Policies ## Input Policies
Direct ratings are always preferred. If a simulator declares derived inputs, readiness may also pass with those alternatives: Direct ratings are preferred by default. If a simulator declares derived inputs, readiness may also pass with those alternatives:
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config. - `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`. - `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion. - `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`. - `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
### Raw Elo vs. projections
Raw Elo and projections are *substitutes*, not a blend: `inputPolicy.baseEloPriority`
lists them in order and the first source a participant has wins outright. The
default is `["sourceElo", "projectedWins", "projectedTablePoints"]`, so a stored Elo
beats a projection. Set the Base Elo Source control on the simulator page (or
`baseEloPriority` directly) to `["projectedWins", "sourceElo"]` when projections are
the season's source of truth. Futures odds are separate — they blend on top of
whichever base won, weighted by `inputPolicy.oddsWeight`.
Whenever you write a projection without an explicit Elo, stamp
`metadata.sourceEloMethod` (`"projectedWins"` / `"projectedTablePoints"`) on the
row. `getParticipantSimulatorInputs` reads that flag and returns `sourceElo: null`
so the Elo is re-derived from the projection on every run. Skip it and the
non-destructive upsert leaves the previous Elo in place as a *direct* value, which
then wins the priority race — the projection is stored and silently ignored. Both
the Elo Ratings page's projections mode and the simulator page's CSV importer do
this; any new importer must too.
Projections are stored and displayed exactly as entered. Never round-trip one
through its derived Elo for display: the conversion rounds to an integer Elo, and a
run re-resolves that Elo through the input policy (clamping, plus any futures
blend), so the number the admin sees drifts away from the number they typed.
### Mid-season projections
A projected win total is a projected *final* total. A simulator that seeds from
projections mid-season must spread the difference over the games still to play —
`(projectedWins - currentWins) / remainingGames` — rather than reusing the
season-long rate the derived Elo encodes, or it will never reach the projection.
See `seedingWinRateFor` in `mlb-simulator.ts` (config knob `projectedWinsWeight`,
1 = the projection is authoritative) and `simulateRegularSeasonSeeds` in
`nll-simulator.ts` (which additionally decays a preseason prior as the season
completes).
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`: Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
```json ```json