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..380e45a 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -359,18 +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, use it as-is - // (prepareSimulatorInputsForRun and the projection importer set the - // correct flags). 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 - 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/__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`
{m.inputName} - {m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'} + {m.name} →{' '} + {m.projection !== null + ? `${m.projection} ${projectionUnit} (Elo ${m.elo})` + : m.elo !== null ? `Elo ${m.elo}` : 'No Elo'} {usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
@@ -509,7 +546,9 @@ Mark Selby, 2432`
{u.inputName} - {u.elo !== null ? `Elo ${u.elo}` : 'No Elo'} + {u.projection !== null + ? `${u.projection} ${projectionUnit} (Elo ${u.elo})` + : u.elo !== null ? `Elo ${u.elo}` : 'No Elo'} {usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
@@ -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..e886de7 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -32,10 +32,17 @@ import { } from "~/services/simulations/manifest"; import { getSimulatorInputPolicy, + resolveRatings, + resolveSourceElos, type MissingEloStrategy, type MissingRatingStrategy, } 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` }]; @@ -75,13 +82,64 @@ 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), })); - return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns }; + // 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, + config, + inputRows, + readiness, + inputPolicy, + inputColumns, + resolvedEloRows, + resolvedRatingRows, + projectionEloOption, + }; } interface ActionData { @@ -124,6 +182,7 @@ const HONORED_ENGINE_KNOBS = new Set([ "baseDrawRate", "drawDecay", "ratingScaleFactor", + "projectedWinsWeight", ]); function parseOptionalNumber(value: string | undefined): number | null { @@ -232,17 +291,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 +382,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise - 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 +501,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 +663,26 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone this Elo — they are not blended again per game.

+ {projectionEloOption && ( +
+ + +

+ 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 {projectionEloOption.label} first when projections are + the source of truth for this season and a previously entered Elo should not override them. +

+
+ )} {config.profile.requiredInputs.includes("sourceElo") && ( <>
@@ -771,7 +872,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
) : ( pageRows.map(({ participant, input }) => { - const incomplete = isRowIncomplete(input); + const incomplete = isRowIncomplete(participant.id, input); return (
{inputColumns.length > 0 ? ( inputColumns.map((column) => { + if (column.key === "sourceElo") { + const resolved = resolvedEloRows[participant.id]; + const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null; + return ( +
+ {resolved ? resolved.sourceElo : "—"} + {methodLabel && ( + + {methodLabel} + + )} +
+ ); + } + if (column.key === "rating") { + const resolved = resolvedRatingRows[participant.id]; + const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null; + return ( +
+ {resolved ? resolved.rating : "—"} + {methodLabel && ( + + {methodLabel} + + )} +
+ ); + } 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..f0adbd0 100644 --- a/app/services/simulations/__tests__/mlb-simulator.test.ts +++ b/app/services/simulations/__tests__/mlb-simulator.test.ts @@ -7,6 +7,8 @@ import { rawWinRateFromElo, rdifWinProbability, eloToRDif, + projectionForSeeding, + seedingWinRateFor, sampleBinomial, simBo3, simBo5, @@ -281,8 +283,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 +348,159 @@ 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("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("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", () => { + 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 + ); + }); +}); + +// ─── 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/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 | null | undefined +): number | null { + return metadata?.sourceEloMethod === "projectedWins" ? projectedWins : null; +} + +/** + * Per-game win rate to use for a team's remaining regular-season games. + * + * A user-entered `projectedWins` is a projected *final* season win total, so the + * rate that reproduces it is spread over the games still to play: + * + * target = (projectedWins − currentWins) / remainingGames + * + * Pre-season this is a no-op — with currentWins 0 and remainingGames 162 the + * target equals projectedWins / 162, which is exactly the rate the Elo derived + * from that projection already encodes. Mid-season it is what makes the + * simulation actually land on the projection: a team at 60-50 projected for 95 + * needs .673 over its last 52 games, not the .586 its season-long Elo implies. + * + * 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. + */ +export function seedingWinRateFor( + eloRate: number, + projectedWins: number | null, + currentWins: number, + remainingGames: number, + weight: number = DEFAULT_PROJECTED_WINS_WEIGHT +): number { + if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate; + const target = (projectedWins - currentWins) / remainingGames; + 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; } /** @@ -270,6 +366,8 @@ interface TeamEntry { originalSeed?: number; currentWins: number; // from regularSeasonStandings (0 pre-season) remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed + /** User-entered projected *final* season win total, or null when not set. */ + projectedWins: number | null; } /** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */ @@ -443,6 +541,10 @@ function simLeagueBracket( export class MLBSimulator implements Simulator { async simulate(sportsSeasonId: string, config: 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. + // 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(); // 1. Load all participants for this sports season. @@ -465,6 +567,18 @@ 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, 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, + projectionForSeeding(input.projectedWins, input.metadata), + ]) + ); + const teams: TeamEntry[] = participantRows.map((r) => { const standing = standingsByParticipantId.get(r.id); const gamesPlayed = standing?.gamesPlayed ?? 0; @@ -474,6 +588,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 +661,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..5af2515 100644 --- a/docs/agents/simulators.md +++ b/docs/agents/simulators.md @@ -91,13 +91,62 @@ 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). + +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