Merge pull request 'claude/mlb-simulator-projected-wins-elo-siess8' (#146) from claude/mlb-simulator-projected-wins-elo-siess8 into main
Reviewed-on: #146
This commit is contained in:
commit
f3e00fcacf
11 changed files with 839 additions and 70 deletions
|
|
@ -111,4 +111,50 @@ describe("simulator input model", () => {
|
||||||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -359,18 +359,28 @@ export async function batchUpsertParticipantSimulatorInputs(
|
||||||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
||||||
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
||||||
// direct value. When a caller supplies explicit metadata, use it as-is
|
// direct value. Two rules apply, and both always apply — they are not
|
||||||
// (prepareSimulatorInputsForRun and the projection importer set the
|
// alternatives:
|
||||||
// correct flags). Otherwise preserve existing metadata, but drop the
|
//
|
||||||
// method flag for any column receiving a fresh direct value — otherwise a
|
// 1. Drop the method flag for any column receiving a fresh direct
|
||||||
// stale "generated" flag would cause that newly-entered Elo/rating to be
|
// value, otherwise a stale "generated" flag would cause that
|
||||||
// filtered out as derived (see getParticipantSimulatorInputs).
|
// newly-entered Elo/rating to be filtered out as derived (see
|
||||||
metadata: sql`CASE
|
// getParticipantSimulatorInputs).
|
||||||
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
|
// 2. Merge any metadata the caller supplied over the result
|
||||||
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
// (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.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||||||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||||||
END`,
|
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
|
||||||
updatedAt: sql`excluded.updated_at`,
|
updatedAt: sql`excluded.updated_at`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
projectedWinsToElo,
|
projectedWinsToElo,
|
||||||
} from '~/services/probability-engine';
|
} from '~/services/probability-engine';
|
||||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
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
|
// Simulator types that use worldRanking in addition to sourceElo
|
||||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
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 participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||||
|
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||||
|
|
||||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
// The projection a participant was actually saved with. Read it back verbatim:
|
||||||
for (const ev of existingEVs) {
|
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||||||
existingData[ev.participantId] = {
|
// admin a different number than they typed, because wins → Elo rounds to an
|
||||||
elo: ev.sourceElo ?? null,
|
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||||||
ranking: ev.worldRanking ?? null,
|
// 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 ?? '');
|
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||||
|
|
||||||
|
|
@ -252,7 +277,16 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
if (simulatorConfig) {
|
if (simulatorConfig) {
|
||||||
participants.forEach(p => {
|
participants.forEach(p => {
|
||||||
const d = existingData[p.id];
|
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'
|
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
: eloToProjectedWins(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 [bulkText, setBulkText] = useState('');
|
||||||
const [parseResults, setParseResults] = useState<{
|
const [parseResults, setParseResults] = useState<{
|
||||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
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 }>;
|
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
function findParticipantMatch(inputName: string) {
|
function findParticipantMatch(inputName: string) {
|
||||||
|
|
@ -291,8 +325,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
|
|
||||||
function parseBulkText() {
|
function parseBulkText() {
|
||||||
const lines = bulkText.split('\n');
|
const lines = bulkText.split('\n');
|
||||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
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 }> = [];
|
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -315,9 +349,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(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) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking: null });
|
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const match = usesRanking
|
const match = usesRanking
|
||||||
|
|
@ -342,9 +376,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(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) {
|
} 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) {
|
for (const m of parseResults.matched) {
|
||||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
// The pasted number goes in as typed. Round-tripping it through the derived
|
||||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||||||
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
// came back as 95.1 before anything was even saved.
|
||||||
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||||||
).toFixed(1);
|
newWins[m.participantId] = m.projection.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEloValues(newElos);
|
setEloValues(newElos);
|
||||||
|
|
@ -489,7 +523,10 @@ Mark Selby, 2432`
|
||||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||||
<span className="text-muted-foreground">{m.inputName}</span>
|
<span className="text-muted-foreground">{m.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{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}` : ''}
|
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -509,7 +546,9 @@ Mark Selby, 2432`
|
||||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||||
<span>{u.inputName}</span>
|
<span>{u.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{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}` : ''}
|
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -540,7 +579,7 @@ Mark Selby, 2432`
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins'
|
{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
|
: 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 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.`}
|
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||||||
|
|
|
||||||
86
app/routes/admin.sports-seasons.$id.simulator.helpers.ts
Normal file
86
app/routes/admin.sports-seasons.$id.simulator.helpers.ts
Normal file
|
|
@ -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<string, unknown> | 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];
|
||||||
|
}
|
||||||
|
|
@ -32,10 +32,17 @@ import {
|
||||||
} from "~/services/simulations/manifest";
|
} from "~/services/simulations/manifest";
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
|
resolveRatings,
|
||||||
|
resolveSourceElos,
|
||||||
type MissingEloStrategy,
|
type MissingEloStrategy,
|
||||||
type MissingRatingStrategy,
|
type MissingRatingStrategy,
|
||||||
} from "~/services/simulations/input-policy";
|
} from "~/services/simulations/input-policy";
|
||||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
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.requiredInputs,
|
||||||
...config.profile.optionalInputs,
|
...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) => ({
|
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: simulatorInputLabel(key),
|
label: simulatorInputLabel(key),
|
||||||
required: config.profile.requiredInputs.includes(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 {
|
interface ActionData {
|
||||||
|
|
@ -124,6 +182,7 @@ const HONORED_ENGINE_KNOBS = new Set([
|
||||||
"baseDrawRate",
|
"baseDrawRate",
|
||||||
"drawDecay",
|
"drawDecay",
|
||||||
"ratingScaleFactor",
|
"ratingScaleFactor",
|
||||||
|
"projectedWinsWeight",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseOptionalNumber(value: string | undefined): number | null {
|
function parseOptionalNumber(value: string | undefined): number | null {
|
||||||
|
|
@ -232,17 +291,28 @@ function parseInputCsv(
|
||||||
continue;
|
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({
|
inputs.push({
|
||||||
participantId,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
|
sourceElo,
|
||||||
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
||||||
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
||||||
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
||||||
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
|
projectedWins,
|
||||||
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
|
projectedTablePoints,
|
||||||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||||||
region: cols[indexes.get("region") ?? -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<Act
|
||||||
...currentPolicy,
|
...currentPolicy,
|
||||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||||
|
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
|
||||||
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||||
|
|
@ -384,6 +455,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === "submitting";
|
||||||
const setupSections = config.profile.setupSections;
|
const setupSections = config.profile.setupSections;
|
||||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||||
|
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
|
||||||
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
||||||
const showsInputPolicy =
|
const showsInputPolicy =
|
||||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||||
|
|
@ -397,7 +469,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
|
|
||||||
// Preview columns are resolved server-side in the loader (see note there) and
|
// 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.
|
// arrive as plain data, so this client component never imports the manifest.
|
||||||
const { inputColumns } = loaderData;
|
const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
|
||||||
const requiredInputs = config.profile.requiredInputs;
|
const requiredInputs = config.profile.requiredInputs;
|
||||||
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
|
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.
|
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
|
||||||
|
|
@ -409,8 +481,17 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
|
// A required Elo/rating counts as present when the input policy resolves one,
|
||||||
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
|
// 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 [search, setSearch] = useState("");
|
||||||
const [onlyMissing, setOnlyMissing] = useState(false);
|
const [onlyMissing, setOnlyMissing] = useState(false);
|
||||||
|
|
@ -420,11 +501,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const normalizedSearch = normalizeName(search);
|
const normalizedSearch = normalizeName(search);
|
||||||
return inputRows.filter(({ participant, input }) => {
|
return inputRows.filter(({ participant, input }) => {
|
||||||
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
|
||||||
const safePage = Math.min(page, totalPages - 1);
|
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.
|
this Elo — they are not blended again per game.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{projectionEloOption && (
|
||||||
|
<div className="space-y-2 md:col-span-5">
|
||||||
|
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
|
||||||
|
<select
|
||||||
|
id="baseEloPriority"
|
||||||
|
name="baseEloPriority"
|
||||||
|
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 {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>{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>
|
||||||
|
)}
|
||||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
|
@ -771,7 +872,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pageRows.map(({ participant, input }) => {
|
pageRows.map(({ participant, input }) => {
|
||||||
const incomplete = isRowIncomplete(input);
|
const incomplete = isRowIncomplete(participant.id, input);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
|
|
@ -784,6 +885,34 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</div>
|
</div>
|
||||||
{inputColumns.length > 0 ? (
|
{inputColumns.length > 0 ? (
|
||||||
inputColumns.map((column) => {
|
inputColumns.map((column) => {
|
||||||
|
if (column.key === "sourceElo") {
|
||||||
|
const resolved = resolvedEloRows[participant.id];
|
||||||
|
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||||
|
return (
|
||||||
|
<div key={column.key} className="flex items-center gap-1.5">
|
||||||
|
{resolved ? resolved.sourceElo : "—"}
|
||||||
|
{methodLabel && (
|
||||||
|
<Badge variant="outline" className="text-[10px] font-normal">
|
||||||
|
{methodLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (column.key === "rating") {
|
||||||
|
const resolved = resolvedRatingRows[participant.id];
|
||||||
|
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||||
|
return (
|
||||||
|
<div key={column.key} className="flex items-center gap-1.5">
|
||||||
|
{resolved ? resolved.rating : "—"}
|
||||||
|
{methodLabel && (
|
||||||
|
<Badge variant="outline" className="text-[10px] font-normal">
|
||||||
|
{methodLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const value = input?.[column.key];
|
const value = input?.[column.key];
|
||||||
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,33 @@ describe("simulator input policy", () => {
|
||||||
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
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", () => {
|
it("derives Elo from projected wins when Elo is missing", () => {
|
||||||
const resolved = resolveSourceElos(
|
const resolved = resolveSourceElos(
|
||||||
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
rawWinRateFromElo,
|
rawWinRateFromElo,
|
||||||
rdifWinProbability,
|
rdifWinProbability,
|
||||||
eloToRDif,
|
eloToRDif,
|
||||||
|
projectionForSeeding,
|
||||||
|
seedingWinRateFor,
|
||||||
sampleBinomial,
|
sampleBinomial,
|
||||||
simBo3,
|
simBo3,
|
||||||
simBo5,
|
simBo5,
|
||||||
|
|
@ -281,8 +283,8 @@ describe("sampleBinomial", () => {
|
||||||
|
|
||||||
// ─── Series simulators ────────────────────────────────────────────────────────
|
// ─── Series simulators ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const teamA = { id: "a", name: "Team A", 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 };
|
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 alwaysA = () => 1.0; // team A always wins each game
|
||||||
const alwaysB = () => 0.0; // team B always wins each game
|
const alwaysB = () => 0.0; // team B always wins each game
|
||||||
const coinFlip = () => 0.5;
|
const coinFlip = () => 0.5;
|
||||||
|
|
@ -346,9 +348,159 @@ describe("eloToRDif", () => {
|
||||||
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
|
it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
|
||||||
const elo = 1620;
|
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
|
||||||
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
|
||||||
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
|
// ~+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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "surfaceElo", "events"],
|
setupSections: ["participants", "surfaceElo", "events"],
|
||||||
},
|
},
|
||||||
mlb_bracket: {
|
mlb_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
|
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
||||||
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
||||||
* 4. Match participant names to hardcoded team data (RDif + league/division)
|
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
|
||||||
* 5. For each simulation:
|
* 5. Match participant names to hardcoded team data (RDif + league/division)
|
||||||
|
* 6. For each simulation:
|
||||||
* a. For each league (AL/NL), simulate remaining regular season games for
|
* a. For each league (AL/NL), simulate remaining regular season games for
|
||||||
* every team using Binomial sampling, giving final projected wins.
|
* every team using Binomial sampling, giving final projected wins.
|
||||||
* b. Division winner = best record in each division (3 per league).
|
* b. Division winner = best record in each division (3 per league).
|
||||||
|
|
@ -21,8 +22,8 @@
|
||||||
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
||||||
* - League Championship Series (best-of-7)
|
* - League Championship Series (best-of-7)
|
||||||
* e. World Series (best-of-7): AL champ vs NL champ
|
* e. World Series (best-of-7): AL champ vs NL champ
|
||||||
* 6. Track placement counts per scoring tier
|
* 7. Track placement counts per scoring tier
|
||||||
* 7. Convert counts to probability distributions
|
* 8. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (log5 formula):
|
* Win probability (log5 formula):
|
||||||
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
||||||
|
|
@ -32,17 +33,24 @@
|
||||||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||||||
*
|
*
|
||||||
* Regular season simulation (seeding):
|
* Regular season simulation (seeding):
|
||||||
* Each team's raw per-game win rate is derived from sourceElo (if set) or
|
* 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.
|
* from the hardcoded RDif using SEEDING_RDIF_SCALE ≈ 10 runs/win × 162 games.
|
||||||
|
* 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). 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
|
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
|
||||||
* Binomial distribution. This makes playoff seeding respond to both current
|
* Binomial distribution. This makes playoff seeding respond to both current
|
||||||
* standings and user-entered projected wins.
|
* standings and user-entered projected wins.
|
||||||
*
|
*
|
||||||
* Futures blending:
|
* Input resolution:
|
||||||
* If sourceOdds are stored in participantExpectedValues for this season,
|
* sourceElo is the single Elo produced by the shared input policy — already a
|
||||||
* the per-game win probability for playoff series is blended:
|
* blend of any raw Elo / projections / futures odds, written by
|
||||||
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
|
* prepareSimulatorInputsForRun before the run. This simulator does not blend
|
||||||
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
|
* futures odds itself.
|
||||||
*
|
*
|
||||||
* Placement tiers → SimulationProbabilities mapping:
|
* Placement tiers → SimulationProbabilities mapping:
|
||||||
* probFirst = World Series champion (1 per sim)
|
* probFirst = World Series champion (1 per sim)
|
||||||
|
|
@ -74,9 +82,10 @@ import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { positiveConfigNumber } from "./config-access";
|
import { configNumber, positiveConfigNumber } from "./config-access";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -100,6 +109,13 @@ const RDIF_DIVISOR = 8000;
|
||||||
*/
|
*/
|
||||||
const SEEDING_RDIF_SCALE = 1620;
|
const SEEDING_RDIF_SCALE = 1620;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default weight given to a user-entered projected win total when deriving the
|
||||||
|
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
|
||||||
|
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
|
||||||
|
*/
|
||||||
|
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
|
||||||
|
|
||||||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||||||
//
|
//
|
||||||
// rdif: Projected run differential from FanGraphs Depth Charts.
|
// rdif: Projected run differential from FanGraphs Depth Charts.
|
||||||
|
|
@ -206,13 +222,93 @@ export function rawWinRateFromElo(elo: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an Elo rating to an equivalent projected run differential.
|
* Convert an Elo rating to an equivalent projected run differential, on the same
|
||||||
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
* scale as the hardcoded TEAMS_DATA.rdif values.
|
||||||
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
*
|
||||||
|
* Uses the standard Elo win probability formula (parity factor 400, average Elo
|
||||||
|
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate − 0.5) × SEEDING_RDIF_SCALE.
|
||||||
|
*
|
||||||
|
* SEEDING_RDIF_SCALE — not RDIF_DIVISOR — is deliberate. Scaling by RDIF_DIVISOR
|
||||||
|
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
|
||||||
|
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
|
||||||
|
* gets: a 95-win projection (Elo ≈ 1561) mapped to RDif +686 and played playoff
|
||||||
|
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
|
||||||
|
* maps to ≈ +140 — right alongside the Dodgers' hardcoded +137 — and
|
||||||
|
* winRateFromRDif then compresses it to ≈ .5175 like any other team.
|
||||||
|
*
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function eloToRDif(elo: number): number {
|
export function eloToRDif(elo: number): number {
|
||||||
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
|
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.
|
||||||
|
*
|
||||||
|
* 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;
|
originalSeed?: number;
|
||||||
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
||||||
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
|
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. */
|
/** 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 {
|
export class MLBSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
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();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -465,6 +567,18 @@ export class MLBSimulator implements Simulator {
|
||||||
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
||||||
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
|
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 teams: TeamEntry[] = participantRows.map((r) => {
|
||||||
const standing = standingsByParticipantId.get(r.id);
|
const standing = standingsByParticipantId.get(r.id);
|
||||||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||||||
|
|
@ -474,6 +588,7 @@ export class MLBSimulator implements Simulator {
|
||||||
data: getTeamData(r.name),
|
data: getTeamData(r.name),
|
||||||
currentWins: standing?.wins ?? 0,
|
currentWins: standing?.wins ?? 0,
|
||||||
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
|
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.
|
* 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 =>
|
const seedingWinRateMap = new Map(
|
||||||
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
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
|
* Per-game win probability for team A over team B in a playoff series, from
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,62 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
|
||||||
|
|
||||||
## Input Policies
|
## 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.
|
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
|
||||||
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
||||||
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
||||||
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
|
- `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`:
|
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue