Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
import { describe, expect, it } from "vitest";
|
2026-06-26 05:16:54 +00:00
|
|
|
import {
|
|
|
|
|
getSimulatorInputPolicy,
|
|
|
|
|
resolveRatings,
|
|
|
|
|
resolveSourceElos,
|
|
|
|
|
DEFAULT_BASE_ELO_PRIORITY,
|
|
|
|
|
} from "../input-policy";
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
import type { SimulatorManifestProfile } from "../manifest";
|
|
|
|
|
|
|
|
|
|
const profile = {
|
|
|
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
|
|
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
const tablePointsProfile = {
|
|
|
|
|
derivableInputs: { sourceElo: ["projectedTablePoints"] },
|
|
|
|
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
|
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
describe("simulator input policy", () => {
|
|
|
|
|
it("keeps direct Elo ahead of derived values", () => {
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: 2000, projectedWins: 20, projectedTablePoints: null }],
|
|
|
|
|
profile,
|
|
|
|
|
{ seasonGames: 82 }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
|
|
|
|
});
|
|
|
|
|
|
Make MLB projected wins actually drive the simulation
Entering projected wins for an in-progress MLB season did not behave as
expected: the entered numbers came back changed, and the simulation appeared
to ignore them in favour of whatever Elo was already stored. Four separate
defects were involved.
Projections are now stored and shown verbatim. The Elo Ratings page never
kept the number typed into it — the field was a display derived from Elo, so
a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to
an integer Elo) and drifted again after each run, because a run re-resolves
that Elo through the input policy. The loader now reads back the stored
projection and the paste flow keeps the pasted value as-is; the derived
round-trip survives only as a prefill for seasons that have never had a
projection saved.
A stale Elo no longer silently outranks a projection. baseEloPriority takes
the first available base source, and the simulator page's bulk CSV wrote
projectedWins without stamping metadata.sourceEloMethod, so the
non-destructive upsert left the old Elo in place as a trusted direct value
and it won the race — the projection was stored and then ignored on every
run. The CSV path now stamps the flag like the Elo Ratings page does, the
metadata upsert merges rather than replaces so a flag-only write keeps
unrelated keys, and Base Elo Source is editable per season for the case where
a genuine hand-entered Elo should still lose to projections.
Projected wins now act as a projected final total. The value was baked into a
flat season-long rate (projectedWins / 162) applied to every remaining game,
so a team at 60-50 projected for 95 finished around 90.5 and the projection
was never reached mid-season. seedingWinRateFor spreads the difference over
the games still to play, which is a no-op pre-season where the two rates
coincide; projectedWinsWeight blends it back toward the Elo-implied rate.
Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled
by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so
any team with an Elo skipped the compression every hardcoded-rdif team gets:
a 95-win projection became RDif +686 and played playoff games at .586 instead
of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140
alongside the Dodgers' hardcoded +137.
Also fixes the preview table's "missing a required input" marker, which
flagged every projection-configured participant because a generated Elo or
rating is deliberately hidden from getParticipantSimulatorInputs. It now
consults the resolved values, so it agrees with readiness.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 05:31:15 +00:00
|
|
|
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" });
|
|
|
|
|
});
|
|
|
|
|
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
it("derives Elo from projected wins when Elo is missing", () => {
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
|
|
|
|
profile,
|
|
|
|
|
{ seasonGames: 82, parityFactor: 400 }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("team-1")?.method).toBe("projectedWins");
|
|
|
|
|
expect(resolved.get("team-1")?.sourceElo).toBeGreaterThan(1500);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
it("uses parityFactor as the projected table points to Elo spread", () => {
|
|
|
|
|
const input = {
|
|
|
|
|
participantId: "team-1",
|
|
|
|
|
sourceElo: null,
|
|
|
|
|
rating: null,
|
|
|
|
|
sourceOdds: null,
|
|
|
|
|
projectedWins: null,
|
|
|
|
|
projectedTablePoints: 76,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const compressed = resolveSourceElos([input], tablePointsProfile, {
|
|
|
|
|
seasonGames: 38,
|
|
|
|
|
parityFactor: 250,
|
|
|
|
|
});
|
|
|
|
|
const wider = resolveSourceElos([input], tablePointsProfile, {
|
|
|
|
|
seasonGames: 38,
|
|
|
|
|
parityFactor: 1000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(compressed.get("team-1")?.sourceElo).toBeLessThan(wider.get("team-1")?.sourceElo ?? 0);
|
|
|
|
|
});
|
|
|
|
|
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
it("blocks missing Elo unless a fallback strategy is configured", () => {
|
|
|
|
|
const inputs = [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }];
|
|
|
|
|
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, {}).has("team-1")).toBe(false);
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, {
|
|
|
|
|
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1375 },
|
|
|
|
|
}).get("team-1")).toMatchObject({ sourceElo: 1375, method: "fallbackElo" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("supports worst-known-minus tail fallback", () => {
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "known-1", sourceElo: 1500, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "known-2", sourceElo: 1430, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
profile,
|
|
|
|
|
{ inputPolicy: { missingEloStrategy: "worstKnownMinus", fallbackEloDelta: 30 } }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-25 17:43:31 +00:00
|
|
|
it("derives Elo from futures odds when no direct Elo is present", () => {
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
profile,
|
|
|
|
|
{}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(resolved.get("favorite")?.sourceElo).toBeGreaterThan(resolved.get("longshot")?.sourceElo ?? Infinity);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ignores a lone sourceOdds participant rather than assigning a flat Elo", () => {
|
|
|
|
|
const inputs = [
|
|
|
|
|
{ participantId: "only", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Needs at least 2 odds to derive a spread; with block strategy it stays unresolved.
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, {}).has("only")).toBe(false);
|
|
|
|
|
|
|
|
|
|
// With a fallback strategy it resolves via that method, not "sourceOdds".
|
|
|
|
|
expect(
|
|
|
|
|
resolveSourceElos(inputs, profile, {
|
|
|
|
|
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1400 },
|
|
|
|
|
}).get("only")
|
|
|
|
|
).toMatchObject({ sourceElo: 1400, method: "fallbackElo" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("derives Elo from odds once a prior direct Elo has been cleared", () => {
|
|
|
|
|
// Regression: after entering futures odds the simulator inputs row keeps
|
|
|
|
|
// sourceOdds but has its sourceElo nulled. The resolver must pick "sourceOdds",
|
|
|
|
|
// not resurrect a "direct" Elo.
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
profile,
|
|
|
|
|
{}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
|
|
|
});
|
|
|
|
|
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
it("derives ratings from futures odds when the profile allows it", () => {
|
|
|
|
|
const resolved = resolveRatings(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
|
|
|
{ inputPolicy: { ratingMin: -10, ratingMax: 35 } }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(resolved.get("favorite")?.rating).toBeGreaterThan(resolved.get("longshot")?.rating ?? 999);
|
|
|
|
|
});
|
2026-05-12 22:26:25 -07:00
|
|
|
|
|
|
|
|
it("blocks missing ratings unless a rating fallback strategy is configured", () => {
|
|
|
|
|
const inputs = [
|
|
|
|
|
{ participantId: "known", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
];
|
|
|
|
|
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
|
|
|
|
|
|
expect(resolveRatings(inputs, ratingProfile, {
|
|
|
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35 },
|
|
|
|
|
}).has("tail")).toBe(false);
|
|
|
|
|
|
|
|
|
|
expect(resolveRatings(inputs, ratingProfile, {
|
|
|
|
|
inputPolicy: {
|
|
|
|
|
missingRatingStrategy: "fallbackRating",
|
|
|
|
|
fallbackRating: -4,
|
|
|
|
|
ratingMin: -10,
|
|
|
|
|
ratingMax: 35,
|
|
|
|
|
},
|
|
|
|
|
}).get("tail")).toMatchObject({ rating: -4, method: "fallbackRating" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("supports worst-known-minus rating fallback with clamping", () => {
|
|
|
|
|
const resolved = resolveRatings(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "known-tail", sourceElo: null, rating: -8, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "missing-tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
|
|
|
{
|
|
|
|
|
inputPolicy: {
|
|
|
|
|
missingRatingStrategy: "worstKnownMinus",
|
|
|
|
|
fallbackRatingDelta: 5,
|
|
|
|
|
ratingMin: -10,
|
|
|
|
|
ratingMax: 35,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 05:16:54 +00:00
|
|
|
it("does not clamp a directly entered Elo above the policy ceiling", () => {
|
|
|
|
|
// Snooker/darts enter manual Elos well above the default 1900 ceiling and
|
|
|
|
|
// have no odds source — the direct value must pass through unclamped.
|
|
|
|
|
const eloOnlyProfile = {} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
|
const resolved = resolveSourceElos(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
eloOnlyProfile,
|
|
|
|
|
{}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" });
|
|
|
|
|
expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
|
|
|
|
|
const inputs = [
|
|
|
|
|
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// oddsWeight 0: the stored base Elo wins outright.
|
|
|
|
|
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
|
|
|
|
|
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
|
|
|
|
|
|
|
|
|
|
// oddsWeight 1: futures fully override the base Elo.
|
|
|
|
|
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
|
|
|
|
|
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
|
|
|
|
|
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
|
|
|
|
|
|
|
|
|
|
// Between: a blend that sits strictly between the base and the odds-derived Elo.
|
2026-06-26 07:26:55 +00:00
|
|
|
const oddsElo = oddsOnly.get("favorite")?.sourceElo ?? 0;
|
2026-06-26 05:16:54 +00:00
|
|
|
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
|
|
|
|
|
expect(blended.get("favorite")?.method).toBe("blend");
|
2026-06-26 07:26:55 +00:00
|
|
|
const blendedElo = blended.get("favorite")?.sourceElo ?? 0;
|
2026-06-26 05:16:54 +00:00
|
|
|
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
|
|
|
|
|
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
|
|
|
|
|
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blends projected wins with futures odds when both are present", () => {
|
|
|
|
|
const inputs = [
|
|
|
|
|
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// oddsWeight 0: projectedWins is the base.
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
|
|
|
|
|
.toBe("projectedWins");
|
|
|
|
|
|
|
|
|
|
// oddsWeight 1: futures override the projection.
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
|
|
|
|
|
.toBe("sourceOdds");
|
|
|
|
|
|
|
|
|
|
// Between: blended.
|
|
|
|
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
|
|
|
|
|
.toBe("blend");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blends a direct rating with futures odds by oddsWeight", () => {
|
|
|
|
|
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
|
|
|
|
const inputs = [
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// oddsWeight 0: direct rating wins.
|
|
|
|
|
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
|
|
|
|
|
.toMatchObject({ rating: 30, method: "direct" });
|
|
|
|
|
|
|
|
|
|
// oddsWeight 1: odds override the rating.
|
|
|
|
|
expect(
|
|
|
|
|
resolveRatings(inputs, ratingProfile, {
|
|
|
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
|
|
|
|
|
}).get("favorite")?.method
|
|
|
|
|
).toBe("sourceOdds");
|
|
|
|
|
|
|
|
|
|
// Between: blended.
|
|
|
|
|
expect(
|
|
|
|
|
resolveRatings(inputs, ratingProfile, {
|
|
|
|
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
|
|
|
|
|
}).get("favorite")?.method
|
|
|
|
|
).toBe("blend");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("parses and clamps the base Elo priority and odds weight", () => {
|
|
|
|
|
const defaults = getSimulatorInputPolicy({});
|
|
|
|
|
expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
|
|
|
|
expect(defaults.oddsWeight).toBe(0.3);
|
|
|
|
|
|
|
|
|
|
// oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level
|
|
|
|
|
// config.oddsWeight is ignored (single home); junk base-priority entries drop.
|
|
|
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6);
|
|
|
|
|
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3);
|
|
|
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
|
|
|
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
|
|
|
|
expect(
|
|
|
|
|
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
|
|
|
|
|
).toEqual(["projectedWins", "sourceElo"]);
|
|
|
|
|
// An empty/all-invalid priority falls back to the default.
|
|
|
|
|
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
|
|
|
|
|
DEFAULT_BASE_ELO_PRIORITY
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 22:26:25 -07:00
|
|
|
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
|
|
|
|
const resolved = resolveRatings(
|
|
|
|
|
[
|
|
|
|
|
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
|
|
|
|
],
|
|
|
|
|
{ derivableInputs: { rating: ["sourceOdds"] } },
|
|
|
|
|
{
|
|
|
|
|
inputPolicy: {
|
|
|
|
|
missingRatingStrategy: "worstKnownMinus",
|
|
|
|
|
fallbackRatingDelta: 0.05,
|
|
|
|
|
ratingMin: 0.15,
|
|
|
|
|
ratingMax: 0.99,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(resolved.get("favorite")?.rating).toBeLessThanOrEqual(0.99);
|
|
|
|
|
expect(resolved.get("tail")).toMatchObject({ rating: 0.15, method: "worstKnownMinus" });
|
|
|
|
|
});
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
});
|