From 280a46eb5fe6bf878e14ea2b7915fe3b3faf1afa Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 29 Aug 2026 05:31:15 +0000
Subject: [PATCH 1/2] Make MLB projected wins actually drive the simulation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Entering projected wins for an in-progress MLB season did not behave as
expected: the entered numbers came back changed, and the simulation appeared
to ignore them in favour of whatever Elo was already stored. Four separate
defects were involved.
Projections are now stored and shown verbatim. The Elo Ratings page never
kept the number typed into it — the field was a display derived from Elo, so
a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to
an integer Elo) and drifted again after each run, because a run re-resolves
that Elo through the input policy. The loader now reads back the stored
projection and the paste flow keeps the pasted value as-is; the derived
round-trip survives only as a prefill for seasons that have never had a
projection saved.
A stale Elo no longer silently outranks a projection. baseEloPriority takes
the first available base source, and the simulator page's bulk CSV wrote
projectedWins without stamping metadata.sourceEloMethod, so the
non-destructive upsert left the old Elo in place as a trusted direct value
and it won the race — the projection was stored and then ignored on every
run. The CSV path now stamps the flag like the Elo Ratings page does, the
metadata upsert merges rather than replaces so a flag-only write keeps
unrelated keys, and Base Elo Source is editable per season for the case where
a genuine hand-entered Elo should still lose to projections.
Projected wins now act as a projected final total. The value was baked into a
flat season-long rate (projectedWins / 162) applied to every remaining game,
so a team at 60-50 projected for 95 finished around 90.5 and the projection
was never reached mid-season. seedingWinRateFor spreads the difference over
the games still to play, which is a no-op pre-season where the two rates
coincide; projectedWinsWeight blends it back toward the Elo-implied rate.
Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled
by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so
any team with an Elo skipped the compression every hardcoded-rdif team gets:
a 95-win projection became RDif +686 and played playoff games at .586 instead
of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140
alongside the Dodgers' hardcoded +137.
Also fixes the preview table's "missing a required input" marker, which
flagged every projection-configured participant because a generated Elo or
rating is deliberately hidden from getParticipantSimulatorInputs. It now
consults the resolved values, so it agrees with readiness.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
---
app/models/__tests__/simulator-inputs.test.ts | 46 ++++++
app/models/simulator.ts | 11 +-
...orts-seasons.$id.simulator.helpers.test.ts | 99 +++++++++++++
.../admin.sports-seasons.$id.elo-ratings.tsx | 85 ++++++++---
...in.sports-seasons.$id.simulator.helpers.ts | 86 +++++++++++
.../admin.sports-seasons.$id.simulator.tsx | 138 ++++++++++++++++--
.../__tests__/input-policy.test.ts | 27 ++++
.../__tests__/mlb-simulator.test.ts | 92 +++++++++++-
app/services/simulations/manifest.ts | 2 +-
app/services/simulations/mlb-simulator.ts | 129 +++++++++++++---
docs/agents/simulators.md | 37 ++++-
11 files changed, 688 insertions(+), 64 deletions(-)
create mode 100644 app/routes/__tests__/admin.sports-seasons.$id.simulator.helpers.test.ts
create mode 100644 app/routes/admin.sports-seasons.$id.simulator.helpers.ts
diff --git a/app/models/__tests__/simulator-inputs.test.ts b/app/models/__tests__/simulator-inputs.test.ts
index d39afbe..5c04d5e 100644
--- a/app/models/__tests__/simulator-inputs.test.ts
+++ b/app/models/__tests__/simulator-inputs.test.ts
@@ -111,4 +111,50 @@ describe("simulator input model", () => {
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
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);
+ });
});
diff --git a/app/models/simulator.ts b/app/models/simulator.ts
index 7d03bb0..381d5ff 100644
--- a/app/models/simulator.ts
+++ b/app/models/simulator.ts
@@ -359,14 +359,17 @@ export async function batchUpsertParticipantSimulatorInputs(
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
- // direct value. When a caller supplies explicit metadata, use it as-is
- // (prepareSimulatorInputsForRun and the projection importer set the
- // correct flags). Otherwise preserve existing metadata, but drop the
+ // direct value. When a caller supplies explicit metadata, merge it over
+ // what is already stored (prepareSimulatorInputsForRun and the projection
+ // 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
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
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)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
diff --git a/app/routes/__tests__/admin.sports-seasons.$id.simulator.helpers.test.ts b/app/routes/__tests__/admin.sports-seasons.$id.simulator.helpers.test.ts
new file mode 100644
index 0000000..bbeb32d
--- /dev/null
+++ b/app/routes/__tests__/admin.sports-seasons.$id.simulator.helpers.test.ts
@@ -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");
+ });
+});
diff --git a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
index 0ef5ae9..fb0d529 100644
--- a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
+++ b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
@@ -31,7 +31,7 @@ import {
projectedWinsToElo,
} from '~/services/probability-engine';
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
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 existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
+ const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
- const existingData: Record = {};
- for (const ev of existingEVs) {
- existingData[ev.participantId] = {
- elo: ev.sourceElo ?? null,
- ranking: ev.worldRanking ?? null,
+ // The projection a participant was actually saved with. Read it back verbatim:
+ // deriving the field from the stored Elo instead (as this page used to) shows the
+ // admin a different number than they typed, because wins → Elo rounds to an
+ // integer Elo and a simulation run then re-resolves that Elo through the input
+ // 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 ?? '');
@@ -252,7 +277,16 @@ export default function AdminSportsSeasonEloRatings() {
if (simulatorConfig) {
participants.forEach(p => {
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'
? eloToProjectedTablePoints(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 [parseResults, setParseResults] = useState<{
- matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
- unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
+ 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; projection: number | null }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
@@ -291,8 +325,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() {
const lines = bulkText.split('\n');
- const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
- const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
+ 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; projection: number | null }> = [];
const seen = new Set();
for (const line of lines) {
@@ -315,9 +349,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(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) {
- unmatched.push({ inputName, elo, ranking: null });
+ unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
}
} else {
const match = usesRanking
@@ -342,9 +376,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(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) {
- 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) {
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
- if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
- newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
- ? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
- : eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
- ).toFixed(1);
+ // The pasted number goes in as typed. Round-tripping it through the derived
+ // Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
+ // came back as 95.1 before anything was even saved.
+ if (inputMode === 'projectedWins' && m.projection !== null) {
+ newWins[m.participantId] = m.projection.toString();
}
}
setEloValues(newElos);
@@ -489,7 +523,10 @@ Mark Selby, 2432`
@@ -540,7 +579,7 @@ Mark Selby, 2432`
{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
? `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.`}
diff --git a/app/routes/admin.sports-seasons.$id.simulator.helpers.ts b/app/routes/admin.sports-seasons.$id.simulator.helpers.ts
new file mode 100644
index 0000000..bc99646
--- /dev/null
+++ b/app/routes/admin.sports-seasons.$id.simulator.helpers.ts
@@ -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 | 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];
+}
diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx
index b4e1f7d..e18f138 100644
--- a/app/routes/admin.sports-seasons.$id.simulator.tsx
+++ b/app/routes/admin.sports-seasons.$id.simulator.tsx
@@ -32,10 +32,18 @@ import {
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
+ resolveRatings,
+ resolveSourceElos,
type MissingEloStrategy,
type MissingRatingStrategy,
+ type ResolvedSourceElo,
} from "~/services/simulations/input-policy";
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 {
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);
+ // 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();
+ 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
// with this simulator's required + optional inputs, so each season shows exactly
// 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),
}));
- return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
+ return {
+ sportsSeason,
+ participants,
+ config,
+ inputRows,
+ readiness,
+ inputPolicy,
+ inputColumns,
+ resolvedEloRows,
+ resolvedRatingRows,
+ };
}
interface ActionData {
@@ -124,6 +167,7 @@ const HONORED_ENGINE_KNOBS = new Set([
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
+ "projectedWinsWeight",
]);
function parseOptionalNumber(value: string | undefined): number | null {
@@ -232,17 +276,28 @@ function parseInputCsv(
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({
participantId,
sportsSeasonId,
- sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
+ sourceElo,
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
- projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
- projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
+ projectedWins,
+ projectedTablePoints,
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -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 key === "projectedWins" || key === "projectedTablePoints") ?? null;
+ const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
const showsInputPolicy =
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
// 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 gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// 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;
- const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
- requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
+ // A required Elo/rating counts as present when the input policy resolves one,
+ // 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 [onlyMissing, setOnlyMissing] = useState(false);
@@ -420,11 +490,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => {
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;
});
// 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 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.
+ {projectionEloKey && (
+
+
+
+
+ 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 {simulatorInputLabel(projectionEloKey)} first when projections are
+ the source of truth for this season and a previously entered Elo should not override them.
+
+ );
+ }
const value = input?.[column.key];
return
{typeof value === "number" || typeof value === "string" ? value : "—"}
;
})
diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts
index 532d8be..b4a9bd9 100644
--- a/app/services/simulations/__tests__/input-policy.test.ts
+++ b/app/services/simulations/__tests__/input-policy.test.ts
@@ -26,6 +26,33 @@ describe("simulator input policy", () => {
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", () => {
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts
index b994a9e..f2bf082 100644
--- a/app/services/simulations/__tests__/mlb-simulator.test.ts
+++ b/app/services/simulations/__tests__/mlb-simulator.test.ts
@@ -7,6 +7,7 @@ import {
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
+ seedingWinRateFor,
sampleBinomial,
simBo3,
simBo5,
@@ -281,8 +282,8 @@ describe("sampleBinomial", () => {
// ─── Series simulators ────────────────────────────────────────────────────────
-const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 };
-const teamB = { id: "b", name: "Team B", 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, projectedWins: null };
const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5;
@@ -346,9 +347,88 @@ describe("eloToRDif", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
- it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
- const elo = 1620;
- const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
- expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
+ it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
+ // 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
+ // ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
+ // ~+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
+ );
});
});
diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts
index b035804..f7bb342 100644
--- a/app/services/simulations/manifest.ts
+++ b/app/services/simulations/manifest.ts
@@ -171,7 +171,7 @@ const PROFILES: Record = {}): Promise {
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();
// 1. Load all participants for this sports season.
@@ -465,6 +530,14 @@ export class MLBSimulator implements Simulator {
const standings = await getRegularSeasonStandings(sportsSeasonId);
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 standing = standingsByParticipantId.get(r.id);
const gamesPlayed = standing?.gamesPlayed ?? 0;
@@ -474,6 +547,7 @@ export class MLBSimulator implements Simulator {
data: getTeamData(r.name),
currentWins: standing?.wins ?? 0,
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.
- * 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 =>
- rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
+ const seedingWinRateMap = new Map(
+ 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
diff --git a/docs/agents/simulators.md b/docs/agents/simulators.md
index 2da6d03..175a2ca 100644
--- a/docs/agents/simulators.md
+++ b/docs/agents/simulators.md
@@ -91,13 +91,48 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
## 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.
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
- `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`:
```json
From 101e102e23aa98681e09267a1c3f973da9f0ff33 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 29 Aug 2026 06:18:41 +0000
Subject: [PATCH 2/2] Fix review findings on the MLB projected-wins change
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
---
app/models/simulator.ts | 33 +++++---
.../admin.sports-seasons.$id.simulator.tsx | 81 +++++++++++--------
.../__tests__/mlb-simulator.test.ts | 80 +++++++++++++++++-
app/services/simulations/mlb-simulator.ts | 69 ++++++++++++----
docs/agents/simulators.md | 14 ++++
5 files changed, 211 insertions(+), 66 deletions(-)
diff --git a/app/models/simulator.ts b/app/models/simulator.ts
index 381d5ff..380e45a 100644
--- a/app/models/simulator.ts
+++ b/app/models/simulator.ts
@@ -359,21 +359,28 @@ export async function batchUpsertParticipantSimulatorInputs(
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
- // direct value. When a caller supplies explicit metadata, merge it over
- // what is already stored (prepareSimulatorInputsForRun and the projection
- // 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
- // stale "generated" flag would cause that newly-entered Elo/rating to be
- // filtered out as derived (see getParticipantSimulatorInputs).
- metadata: sql`CASE
- WHEN excluded.metadata IS NOT NULL
- THEN COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) || excluded.metadata
- ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
+ // direct value. Two rules apply, and both always apply — they are not
+ // alternatives:
+ //
+ // 1. Drop the method flag for any column receiving a fresh direct
+ // value, otherwise a stale "generated" flag would cause that
+ // newly-entered Elo/rating to be filtered out as derived (see
+ // getParticipantSimulatorInputs).
+ // 2. Merge any metadata the caller supplied over the result
+ // (prepareSimulatorInputsForRun and the projection importers set the
+ // correct flags) — a merge rather than a replace so a caller that
+ // only needs to stamp one method flag does not wipe unrelated keys.
+ //
+ // Ordering matters: strip first, then merge, so a caller stamping one flag
+ // still gets the other column's stale flag cleared. Running these as
+ // exclusive CASE branches instead would mean a bulk row carrying both a
+ // direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
+ // silently kept a stale ratingMethod, hiding the rating it just set.
+ metadata: sql`(
+ COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
- END`,
+ ) || COALESCE(excluded.metadata, '{}'::jsonb)`,
updatedAt: sql`excluded.updated_at`,
},
});
diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx
index e18f138..e886de7 100644
--- a/app/routes/admin.sports-seasons.$id.simulator.tsx
+++ b/app/routes/admin.sports-seasons.$id.simulator.tsx
@@ -36,7 +36,6 @@ import {
resolveSourceElos,
type MissingEloStrategy,
type MissingRatingStrategy,
- type ResolvedSourceElo,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import {
@@ -74,31 +73,6 @@ export async function loader({ params }: Route.LoaderArgs) {
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();
- 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
// with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
@@ -108,12 +82,52 @@ export async function loader({ params }: Route.LoaderArgs) {
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
+
+ // 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.
+ //
+ // Keyed off `relevantInputs`, not requiredInputs: the preview renders these
+ // columns solely from these maps, so gating on "required" would blank a stored
+ // value for every simulator that treats the input as optional (playoff_bracket
+ // and ncaam_bracket for Elo, golf_qualifying_points for rating).
+ const resolvedEloRows = Object.fromEntries(
+ relevantInputs.has("sourceElo")
+ ? [...resolveSourceElos(inputs, config.profile, config.config).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(
+ relevantInputs.has("rating")
+ ? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
+ resolved.participantId,
+ { rating: resolved.rating, method: resolved.method },
+ ])
+ : []
+ );
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
+ // The projection this simulator can derive Elo from, labelled here for the same
+ // reason as inputColumns: calling simulatorInputLabel from the rendered component
+ // would pull the manifest (and through it the registry and every simulator) into
+ // the client bundle.
+ const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
+ (key) => key === "projectedWins" || key === "projectedTablePoints"
+ );
+ const projectionEloOption = projectionEloKey
+ ? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
+ : null;
+
return {
sportsSeason,
participants,
@@ -124,6 +138,7 @@ export async function loader({ params }: Route.LoaderArgs) {
inputColumns,
resolvedEloRows,
resolvedRatingRows,
+ projectionEloOption,
};
}
@@ -440,10 +455,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const isSubmitting = navigation.state === "submitting";
const setupSections = config.profile.setupSections;
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 showsInputPolicy =
@@ -458,7 +469,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
// 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.
- const { inputColumns, resolvedEloRows, resolvedRatingRows } = loaderData;
+ const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
const requiredInputs = config.profile.requiredInputs;
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.
@@ -652,7 +663,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
this Elo — they are not blended again per game.
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 {simulatorInputLabel(projectionEloKey)} first when projections are
+ Pick {projectionEloOption.label} first when projections are
the source of truth for this season and a previously entered Elo should not override them.
diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts
index f2bf082..f0adbd0 100644
--- a/app/services/simulations/__tests__/mlb-simulator.test.ts
+++ b/app/services/simulations/__tests__/mlb-simulator.test.ts
@@ -7,6 +7,7 @@ import {
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
+ projectionForSeeding,
seedingWinRateFor,
sampleBinomial,
simBo3,
@@ -404,12 +405,47 @@ describe("seedingWinRateFor", () => {
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("falls back to the Elo rate once a team has passed its projection", () => {
+ // Clamping to a floor instead would simulate a 96-40 team to go 0-26 for the
+ // rest of the season and drop out of the field. The projection is stale, so it
+ // is dropped rather than obeyed.
+ expect(seedingWinRateFor(eloRate, 95, 96, 26)).toBe(eloRate);
});
- it("clamps a target that is unreachable", () => {
- expect(seedingWinRateFor(eloRate, 95, 60, 10)).toBe(0.99);
+ it("falls back to the Elo rate when a team has exactly met its projection", () => {
+ expect(seedingWinRateFor(eloRate, 95, 95, 26)).toBe(eloRate);
+ });
+
+ it("falls back to the Elo rate when the projection is unreachable", () => {
+ // 40-70 projected for 95 needs better than 1.000 — the mirror image of the
+ // case above, and dropped for the same reason.
+ expect(seedingWinRateFor(eloRate, 95, 40, 52)).toBe(eloRate);
+ });
+
+ it("falls back to the Elo rate when the target is exactly 1.000", () => {
+ expect(seedingWinRateFor(eloRate, 95, 43, 52)).toBe(eloRate);
+ });
+
+ it("keeps a target just inside the reachable range", () => {
+ expect(seedingWinRateFor(eloRate, 95, 94, 26)).toBeCloseTo(1 / 26, 6);
+ });
+
+ it("clamps a weight above 1 rather than extrapolating past the target", () => {
+ const target = 35 / 52;
+ expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBeCloseTo(target, 6);
+ expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBe(
+ seedingWinRateFor(eloRate, 95, 60, 52, 1)
+ );
+ });
+
+ it("never returns a rate outside (0, 1) for any weight", () => {
+ for (const weight of [0.25, 0.5, 0.75, 1, 5]) {
+ for (const [current, remaining] of [[0, 162], [60, 52], [94, 26], [10, 152]]) {
+ const rate = seedingWinRateFor(eloRate, 95, current, remaining, weight);
+ expect(rate).toBeGreaterThan(0);
+ expect(rate).toBeLessThan(1);
+ }
+ }
});
it("falls back to the Elo rate with no projection", () => {
@@ -432,3 +468,39 @@ describe("seedingWinRateFor", () => {
);
});
});
+
+// ─── projectionForSeeding ─────────────────────────────────────────────────────
+
+describe("projectionForSeeding", () => {
+ it("uses the projection when it alone produced the resolved Elo", () => {
+ expect(projectionForSeeding(95, { sourceEloMethod: "projectedWins" })).toBe(95);
+ });
+
+ it("ignores a projection that lost the baseEloPriority race", () => {
+ // The season resolved its Elo from a hand-entered value. Seeding off the
+ // projection anyway would ignore it as the Elo source while still letting it
+ // dictate the standings.
+ expect(projectionForSeeding(95, { sourceEloMethod: "direct" })).toBeNull();
+ });
+
+ it("ignores a projection that was blended with futures odds", () => {
+ // The blend lives in the Elo; seeding off the raw projection would discard it
+ // and run seeding and playoff matchups on different strength scales.
+ expect(projectionForSeeding(95, { sourceEloMethod: "blend" })).toBeNull();
+ expect(projectionForSeeding(95, { sourceEloMethod: "sourceOdds" })).toBeNull();
+ });
+
+ it("ignores a projection on a participant resolved by a fallback", () => {
+ expect(projectionForSeeding(95, { sourceEloMethod: "averageKnown" })).toBeNull();
+ });
+
+ it("ignores a projection with no method recorded", () => {
+ expect(projectionForSeeding(95, null)).toBeNull();
+ expect(projectionForSeeding(95, undefined)).toBeNull();
+ expect(projectionForSeeding(95, {})).toBeNull();
+ });
+
+ it("passes a null projection through", () => {
+ expect(projectionForSeeding(null, { sourceEloMethod: "projectedWins" })).toBeNull();
+ });
+});
diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts
index 7d49c32..961606d 100644
--- a/app/services/simulations/mlb-simulator.ts
+++ b/app/services/simulations/mlb-simulator.ts
@@ -35,11 +35,13 @@
* Regular season simulation (seeding):
* 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.
- * 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:
+ * When the resolved Elo came from a projected win total and nothing else, that
+ * base rate is replaced by the rest-of-season rate that 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).
+ * base rate). Pre-season the two rates coincide, so this is a no-op then. A
+ * projection that lost the baseEloPriority race, or that was blended with futures
+ * odds, is left to the resolved Elo — see projectionForSeeding.
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
* Binomial distribution. This makes playoff seeding respond to both current
* standings and user-entered projected wins.
@@ -240,6 +242,30 @@ export function eloToRDif(elo: number): number {
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
}
+/**
+ * The projected win total seeding should use, or null to leave seeding on the Elo.
+ *
+ * `prepareSimulatorInputsForRun` records which source won the base-Elo race in
+ * `metadata.sourceEloMethod`, and only `"projectedWins"` means the resolved Elo is
+ * the projection and nothing else. Every other method has to be left alone:
+ *
+ * - `"direct"` — the season's `baseEloPriority` put a hand-entered Elo ahead of
+ * the projection. Honouring the projection here anyway would ignore it as the
+ * Elo source while still letting it dictate seeding.
+ * - `"blend"` / `"sourceOdds"` — futures odds are folded into the Elo at
+ * `oddsWeight` (0.3 for MLB). Seeding off the raw projection would discard that
+ * blend and run seeding and playoff matchups on two different strength scales.
+ * - a fallback — the participant had no usable input of its own.
+ *
+ * Exported for unit testing.
+ */
+export function projectionForSeeding(
+ projectedWins: number | null,
+ metadata: Record | null | undefined
+): number | null {
+ return metadata?.sourceEloMethod === "projectedWins" ? projectedWins : null;
+}
+
/**
* Per-game win rate to use for a team's remaining regular-season games.
*
@@ -254,11 +280,18 @@ export function eloToRDif(elo: number): number {
* 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.
+ * A target outside (0, 1) is proof the projection has gone stale rather than a
+ * reason to bet everything on it: a 96-40 team projected for 95 would need a
+ * negative rate, and a 40-70 team projected for 95 would need better than 1.000.
+ * Both fall back to the Elo rate — clamping them instead would simulate a team to
+ * stop winning entirely, or to win out. NLL takes the same escape hatch
+ * (`nll-simulator.ts` clamps its prior at 0 and then uses the Elo rate outright).
+ *
+ * `weight` (config `projectedWinsWeight`, default 1) blends the target back toward
+ * the Elo-implied rate. At 1 the projection is authoritative wherever it is still
+ * reachable; lower values hedge it; 0 or less ignores it. Values above 1 are
+ * clamped — this is a blend weight, like inputPolicy.oddsWeight, and above 1 it
+ * would extrapolate past the target rather than blending toward it.
*
* Exported for unit testing.
*/
@@ -271,8 +304,11 @@ export function seedingWinRateFor(
): 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));
+ if (target <= 0 || target >= 1) return eloRate;
+ // Clamped here rather than at the call site so the blend cannot be turned into an
+ // extrapolation by a stray config value, whichever caller supplies it.
+ const blend = Math.min(1, weight);
+ return blend * target + (1 - blend) * eloRate;
}
/**
@@ -507,6 +543,7 @@ export class MLBSimulator implements Simulator {
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.
+ // seedingWinRateFor clamps the upper end; the knob is free-form on the Engine card.
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
const db = database();
@@ -530,12 +567,16 @@ export class MLBSimulator implements Simulator {
const standings = await getRegularSeasonStandings(sportsSeasonId);
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.
+ // 3. Load the raw projected win totals, keeping only those that actually
+ // produced the resolved Elo. The Elo encodes the projection as a season-long
+ // rate; the raw total is what lets seeding spread the *remaining* wins
+ // correctly once games have been played — see projectionForSeeding.
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map(
- simInputs.map((input) => [input.participantId, input.projectedWins])
+ simInputs.map((input) => [
+ input.participantId,
+ projectionForSeeding(input.projectedWins, input.metadata),
+ ])
);
const teams: TeamEntry[] = participantRows.map((r) => {
diff --git a/docs/agents/simulators.md b/docs/agents/simulators.md
index 175a2ca..5af2515 100644
--- a/docs/agents/simulators.md
+++ b/docs/agents/simulators.md
@@ -133,6 +133,20 @@ See `seedingWinRateFor` in `mlb-simulator.ts` (config knob `projectedWinsWeight`
`nll-simulator.ts` (which additionally decays a preseason prior as the season
completes).
+Two guards belong on any such rest-of-season rate:
+
+- **A target outside `(0, 1)` means the projection is stale** — the team has
+ already met it, or can no longer reach it. Fall back to the Elo rate. Clamping to
+ a floor or ceiling instead simulates a team to stop winning entirely, or to win
+ out, and collapses its seeding variance.
+- **Only apply a projection that actually produced the resolved Elo.** Check
+ `metadata.sourceEloMethod === "projectedWins"` (see `projectionForSeeding` in
+ `mlb-simulator.ts`). Any other method means the Elo represents something else: a
+ hand-entered Elo that won the `baseEloPriority` race, or a futures blend. Seeding
+ off the raw projection in those cases makes the projection simultaneously ignored
+ as the Elo source and authoritative for the standings, and runs seeding and
+ playoff matchups on two different strength scales.
+
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
```json