claude/mlb-simulator-projected-wins-elo-siess8 #146
5 changed files with 211 additions and 66 deletions
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, ResolvedSourceElo>();
|
||||
const resolvedEloRows = Object.fromEntries(
|
||||
[...resolvedElos.values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ sourceElo: resolved.sourceElo, method: resolved.method },
|
||||
])
|
||||
);
|
||||
// Same for ratings, which are blanked by the same rule when generated. The
|
||||
// preview's "missing a required input" marker reads both, so it agrees with
|
||||
// readiness instead of flagging every participant a projection resolved.
|
||||
const resolvedRatingRows = Object.fromEntries(
|
||||
config.profile.requiredInputs.includes("rating")
|
||||
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ rating: resolved.rating, method: resolved.method },
|
||||
])
|
||||
: []
|
||||
);
|
||||
|
||||
// Sport-aware preview columns: the intersection of the displayable numeric keys
|
||||
// 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.
|
||||
</p>
|
||||
</div>
|
||||
{projectionEloKey && (
|
||||
{projectionEloOption && (
|
||||
<div className="space-y-2 md:col-span-5">
|
||||
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
|
||||
<select
|
||||
|
|
@ -661,13 +672,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
|
||||
>
|
||||
<option value="eloFirst">Entered Elo first, then {simulatorInputLabel(projectionEloKey)}</option>
|
||||
<option value="projectionsFirst">{simulatorInputLabel(projectionEloKey)} first, then entered Elo</option>
|
||||
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
|
||||
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Raw Elo and projections are substitutes — the first one a participant has wins, and
|
||||
the other is ignored (futures odds are separate and blend on top via the weight above).
|
||||
Pick <strong>{simulatorInputLabel(projectionEloKey)} first</strong> when projections are
|
||||
Pick <strong>{projectionEloOption.label} first</strong> when projections are
|
||||
the source of truth for this season and a previously entered Elo should not override them.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue