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