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