Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
normalizeTeamName,
|
|
|
|
|
|
getTeamData,
|
2026-03-25 19:05:12 -07:00
|
|
|
|
winRateFromRDif,
|
2026-06-01 20:50:03 +00:00
|
|
|
|
rawWinRateFromRDif,
|
|
|
|
|
|
rawWinRateFromElo,
|
2026-03-25 19:05:12 -07:00
|
|
|
|
rdifWinProbability,
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
on resolveElo() (overrides SRS, overrides futures, overrides fallback,
null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
covering fallback to season-projection, structure validation errors, probability
distributions, fully-decided champion, and source tag
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
eloToRDif,
|
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
|
|
|
|
projectionForSeeding,
|
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
|
|
|
|
seedingWinRateFor,
|
2026-06-01 20:50:03 +00:00
|
|
|
|
sampleBinomial,
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
simBo3,
|
|
|
|
|
|
simBo5,
|
|
|
|
|
|
simBo7,
|
|
|
|
|
|
} from "../mlb-simulator";
|
|
|
|
|
|
|
|
|
|
|
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("normalizeTeamName", () => {
|
|
|
|
|
|
it("lowercases and trims", () => {
|
|
|
|
|
|
expect(normalizeTeamName(" Los Angeles Dodgers ")).toBe("los angeles dodgers");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("collapses internal whitespace", () => {
|
|
|
|
|
|
expect(normalizeTeamName("New York Yankees")).toBe("new york yankees");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is identity for already-normalized strings", () => {
|
|
|
|
|
|
expect(normalizeTeamName("houston astros")).toBe("houston astros");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── getTeamData ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("getTeamData", () => {
|
|
|
|
|
|
it("returns data for an exact match", () => {
|
|
|
|
|
|
const d = getTeamData("Los Angeles Dodgers");
|
|
|
|
|
|
expect(d).toBeDefined();
|
|
|
|
|
|
expect(d?.league).toBe("NL");
|
|
|
|
|
|
expect(d?.division).toBe("NL West");
|
2026-03-25 19:05:12 -07:00
|
|
|
|
expect(d?.rdif).toBeGreaterThan(0);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is case-insensitive", () => {
|
|
|
|
|
|
expect(getTeamData("los angeles dodgers")).toEqual(getTeamData("Los Angeles Dodgers"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns undefined for an unknown team", () => {
|
|
|
|
|
|
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("all 30 MLB teams are present", () => {
|
|
|
|
|
|
const allTeams = [
|
|
|
|
|
|
// AL East
|
|
|
|
|
|
"New York Yankees", "Baltimore Orioles", "Boston Red Sox",
|
|
|
|
|
|
"Tampa Bay Rays", "Toronto Blue Jays",
|
|
|
|
|
|
// AL Central
|
|
|
|
|
|
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins",
|
|
|
|
|
|
"Detroit Tigers", "Chicago White Sox",
|
|
|
|
|
|
// AL West
|
|
|
|
|
|
"Houston Astros", "Seattle Mariners", "Texas Rangers",
|
|
|
|
|
|
"Los Angeles Angels", "Athletics",
|
|
|
|
|
|
// NL East
|
|
|
|
|
|
"Philadelphia Phillies", "Atlanta Braves", "New York Mets",
|
|
|
|
|
|
"Washington Nationals", "Miami Marlins",
|
|
|
|
|
|
// NL Central
|
|
|
|
|
|
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals",
|
|
|
|
|
|
"Cincinnati Reds", "Pittsburgh Pirates",
|
|
|
|
|
|
// NL West
|
|
|
|
|
|
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks",
|
|
|
|
|
|
"San Francisco Giants", "Colorado Rockies",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const name of allTeams) {
|
|
|
|
|
|
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("all teams have the correct league", () => {
|
|
|
|
|
|
const alTeams = [
|
|
|
|
|
|
"New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays",
|
|
|
|
|
|
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox",
|
|
|
|
|
|
"Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics",
|
|
|
|
|
|
];
|
|
|
|
|
|
const nlTeams = [
|
|
|
|
|
|
"Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins",
|
|
|
|
|
|
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates",
|
|
|
|
|
|
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const name of alTeams) {
|
|
|
|
|
|
expect(getTeamData(name)?.league, `${name} should be AL`).toBe("AL");
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const name of nlTeams) {
|
|
|
|
|
|
expect(getTeamData(name)?.league, `${name} should be NL`).toBe("NL");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
it("Dodgers have the highest RDif in NL West", () => {
|
|
|
|
|
|
const dodgers = getTeamData("Los Angeles Dodgers")?.rdif ?? -999;
|
|
|
|
|
|
const padres = getTeamData("San Diego Padres")?.rdif ?? -999;
|
|
|
|
|
|
const dbacks = getTeamData("Arizona Diamondbacks")?.rdif ?? -999;
|
|
|
|
|
|
expect(dodgers).toBeGreaterThan(padres);
|
|
|
|
|
|
expect(dodgers).toBeGreaterThan(dbacks);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
it("rebuilding teams have negative RDif", () => {
|
|
|
|
|
|
expect(getTeamData("Chicago White Sox")?.rdif ?? 0).toBeLessThan(0);
|
|
|
|
|
|
expect(getTeamData("Colorado Rockies")?.rdif ?? 0).toBeLessThan(0);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
// ─── winRateFromRDif ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("winRateFromRDif", () => {
|
|
|
|
|
|
it("returns 0.5 for RDif = 0", () => {
|
|
|
|
|
|
expect(winRateFromRDif(0)).toBeCloseTo(0.5, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a value above 0.5 for positive RDif", () => {
|
|
|
|
|
|
expect(winRateFromRDif(100)).toBeGreaterThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a value below 0.5 for negative RDif", () => {
|
|
|
|
|
|
expect(winRateFromRDif(-100)).toBeLessThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("clamps to [0.01, 0.99]", () => {
|
|
|
|
|
|
expect(winRateFromRDif(10000)).toBe(0.99);
|
|
|
|
|
|
expect(winRateFromRDif(-10000)).toBe(0.01);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("Dodgers (RDif +137) project to a win rate above .500", () => {
|
|
|
|
|
|
expect(winRateFromRDif(137)).toBeGreaterThan(0.5);
|
|
|
|
|
|
expect(winRateFromRDif(137)).toBeLessThan(0.65);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-01 20:50:03 +00:00
|
|
|
|
// ─── rawWinRateFromRDif ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("rawWinRateFromRDif", () => {
|
|
|
|
|
|
it("returns 0.5 for RDif = 0", () => {
|
|
|
|
|
|
expect(rawWinRateFromRDif(0)).toBeCloseTo(0.5, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is higher than winRateFromRDif for positive RDif (less compression)", () => {
|
|
|
|
|
|
const rdif = 137;
|
|
|
|
|
|
expect(rawWinRateFromRDif(rdif)).toBeGreaterThan(winRateFromRDif(rdif));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("Dodgers (RDif +137) project to ~.585 season win rate", () => {
|
|
|
|
|
|
// 137 runs / 1620 ≈ 0.585
|
|
|
|
|
|
expect(rawWinRateFromRDif(137)).toBeCloseTo(0.585, 2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("clamps to [0.01, 0.99]", () => {
|
|
|
|
|
|
expect(rawWinRateFromRDif(10000)).toBe(0.99);
|
|
|
|
|
|
expect(rawWinRateFromRDif(-10000)).toBe(0.01);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── rawWinRateFromElo ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("rawWinRateFromElo", () => {
|
|
|
|
|
|
it("returns 0.5 for Elo 1500 (average)", () => {
|
|
|
|
|
|
expect(rawWinRateFromElo(1500)).toBeCloseTo(0.5, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns above 0.5 for Elo above 1500", () => {
|
|
|
|
|
|
expect(rawWinRateFromElo(1600)).toBeGreaterThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns below 0.5 for Elo below 1500", () => {
|
|
|
|
|
|
expect(rawWinRateFromElo(1400)).toBeLessThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("round-trips from projected wins: projectedWins → Elo → rawWinRate ≈ winRate", () => {
|
|
|
|
|
|
// 95 wins out of 162 → winRate ≈ 0.5864
|
|
|
|
|
|
const projectedWins = 95;
|
|
|
|
|
|
const totalGames = 162;
|
|
|
|
|
|
const winRate = projectedWins / totalGames;
|
|
|
|
|
|
// Build Elo from winRate the same way projectedWinsToElo does
|
|
|
|
|
|
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
|
|
|
|
|
|
expect(rawWinRateFromElo(elo)).toBeCloseTo(winRate, 4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is symmetric around 1500", () => {
|
|
|
|
|
|
expect(rawWinRateFromElo(1600)).toBeCloseTo(1 - rawWinRateFromElo(1400), 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
// ─── rdifWinProbability ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("rdifWinProbability (log5)", () => {
|
|
|
|
|
|
it("returns 0.5 for equal RDif", () => {
|
|
|
|
|
|
expect(rdifWinProbability(50, 50)).toBeCloseTo(0.5, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("favors the team with better RDif", () => {
|
|
|
|
|
|
expect(rdifWinProbability(100, 0)).toBeGreaterThan(0.5);
|
|
|
|
|
|
expect(rdifWinProbability(0, 100)).toBeLessThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
|
|
|
|
|
|
const p = rdifWinProbability(80, -20);
|
|
|
|
|
|
expect(p + rdifWinProbability(-20, 80)).toBeCloseTo(1.0, 10);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a value strictly between 0 and 1", () => {
|
|
|
|
|
|
expect(rdifWinProbability(200, -200)).toBeGreaterThan(0);
|
|
|
|
|
|
expect(rdifWinProbability(200, -200)).toBeLessThan(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("Dodgers vs Rockies favors the Dodgers", () => {
|
|
|
|
|
|
// Even the largest RDif gap in the league should still favour the better team,
|
|
|
|
|
|
// but compression toward .500 via RDIF_DIVISOR keeps it well below 1.0.
|
|
|
|
|
|
const p = rdifWinProbability(137, -173);
|
|
|
|
|
|
expect(p).toBeGreaterThan(0.5);
|
|
|
|
|
|
expect(p).toBeLessThan(1.0);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-01 20:50:03 +00:00
|
|
|
|
// ─── sampleBinomial ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("sampleBinomial", () => {
|
|
|
|
|
|
it("returns 0 when n = 0", () => {
|
|
|
|
|
|
expect(sampleBinomial(0, 0.5)).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns 0 when p = 0 (exact path, n < 30)", () => {
|
|
|
|
|
|
expect(sampleBinomial(10, 0)).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns n when p = 1 (exact path, n < 30)", () => {
|
|
|
|
|
|
expect(sampleBinomial(10, 1)).toBe(10);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns 0 when p = 0 (normal-approx path, n >= 30)", () => {
|
|
|
|
|
|
// p <= 0 guard fires before normal approx
|
|
|
|
|
|
expect(sampleBinomial(162, 0)).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns n when p = 1 (normal-approx path, n >= 30)", () => {
|
|
|
|
|
|
// p >= 1 guard fires before normal approx
|
|
|
|
|
|
expect(sampleBinomial(162, 1)).toBe(162);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("result is always in [0, n] — exact path (n < 30)", () => {
|
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
|
const result = sampleBinomial(10, 0.5);
|
|
|
|
|
|
expect(result).toBeGreaterThanOrEqual(0);
|
|
|
|
|
|
expect(result).toBeLessThanOrEqual(10);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("result is always in [0, n] — normal-approx path (n >= 30)", () => {
|
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
|
const result = sampleBinomial(162, 0.5);
|
|
|
|
|
|
expect(result).toBeGreaterThanOrEqual(0);
|
|
|
|
|
|
expect(result).toBeLessThanOrEqual(162);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("mean is close to n*p over many samples — normal-approx path", () => {
|
|
|
|
|
|
const n = 162;
|
|
|
|
|
|
const p = 0.586;
|
|
|
|
|
|
const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p));
|
|
|
|
|
|
const mean = samples.reduce((s, x) => s + x, 0) / samples.length;
|
|
|
|
|
|
// Expected mean ≈ 94.9; allow ±3 (well within 3σ for 2000 samples)
|
|
|
|
|
|
expect(mean).toBeGreaterThan(n * p - 3);
|
|
|
|
|
|
expect(mean).toBeLessThan(n * p + 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("mean is close to n*p over many samples — exact path", () => {
|
|
|
|
|
|
const n = 10;
|
|
|
|
|
|
const p = 0.6;
|
|
|
|
|
|
const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p));
|
|
|
|
|
|
const mean = samples.reduce((s, x) => s + x, 0) / samples.length;
|
|
|
|
|
|
// Expected mean = 6; allow ±0.5
|
|
|
|
|
|
expect(mean).toBeGreaterThan(n * p - 0.5);
|
|
|
|
|
|
expect(mean).toBeLessThan(n * p + 0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
// ─── Series simulators ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
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
|
|
|
|
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
|
|
|
|
|
|
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
const alwaysA = () => 1.0; // team A always wins each game
|
|
|
|
|
|
const alwaysB = () => 0.0; // team B always wins each game
|
|
|
|
|
|
const coinFlip = () => 0.5;
|
|
|
|
|
|
|
|
|
|
|
|
describe("simBo3 (Wildcard Round — first to 2 wins)", () => {
|
|
|
|
|
|
it("correct team wins when one side is dominant", () => {
|
|
|
|
|
|
expect(simBo3(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
|
|
|
|
expect(simBo3(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a winner and loser", () => {
|
|
|
|
|
|
const result = simBo3(teamA, teamB, coinFlip);
|
|
|
|
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
|
|
|
|
expect([teamA, teamB]).toContain(result.loser);
|
|
|
|
|
|
expect(result.winner).not.toBe(result.loser);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("simBo5 (Division Series — first to 3 wins)", () => {
|
|
|
|
|
|
it("correct team wins when one side is dominant", () => {
|
|
|
|
|
|
expect(simBo5(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
|
|
|
|
expect(simBo5(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a winner and loser", () => {
|
|
|
|
|
|
const result = simBo5(teamA, teamB, coinFlip);
|
|
|
|
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
|
|
|
|
expect(result.winner).not.toBe(result.loser);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
|
|
|
|
|
|
it("correct team wins when one side is dominant", () => {
|
|
|
|
|
|
expect(simBo7(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
|
|
|
|
expect(simBo7(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a winner and loser", () => {
|
|
|
|
|
|
const result = simBo7(teamA, teamB, coinFlip);
|
|
|
|
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
|
|
|
|
expect(result.winner).not.toBe(result.loser);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
on resolveElo() (overrides SRS, overrides futures, overrides fallback,
null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
covering fallback to season-projection, structure validation errors, probability
distributions, fully-decided champion, and source tag
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── eloToRDif ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("eloToRDif", () => {
|
|
|
|
|
|
it("maps Elo 1500 (average) to RDif 0", () => {
|
|
|
|
|
|
expect(eloToRDif(1500)).toBeCloseTo(0, 5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("maps Elo above 1500 to a positive RDif", () => {
|
|
|
|
|
|
expect(eloToRDif(1600)).toBeGreaterThan(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("maps Elo below 1500 to a negative RDif", () => {
|
|
|
|
|
|
expect(eloToRDif(1400)).toBeLessThan(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => {
|
|
|
|
|
|
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
|
|
|
|
|
|
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
|
|
|
|
|
|
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
|
|
|
|
|
|
// ~+686 the old RDIF_DIVISOR scaling produced.
|
|
|
|
|
|
const winRate = 95 / 162;
|
|
|
|
|
|
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
|
|
|
|
|
|
expect(eloToRDif(elo)).toBeGreaterThan(120);
|
|
|
|
|
|
expect(eloToRDif(elo)).toBeLessThan(160);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => {
|
|
|
|
|
|
// The whole point of RDIF_DIVISOR: playoff series are near coin-flips between
|
|
|
|
|
|
// playoff-calibre teams. An Elo-rated team must not skip that compression.
|
|
|
|
|
|
const winRate = 95 / 162;
|
|
|
|
|
|
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
|
|
|
|
|
|
const playoffRate = winRateFromRDif(eloToRDif(elo));
|
|
|
|
|
|
expect(playoffRate).toBeCloseTo(0.517, 2);
|
|
|
|
|
|
// Strictly compressed relative to the team's raw season win rate.
|
|
|
|
|
|
expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("agrees with the hardcoded rdif path for a team of equivalent strength", () => {
|
|
|
|
|
|
// Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should
|
|
|
|
|
|
// produce a comparable playoff win rate rather than a wildly more dominant one.
|
|
|
|
|
|
const dodgers = getTeamData("Los Angeles Dodgers");
|
|
|
|
|
|
const eloEquivalent = 1500 + 400 * Math.log10(
|
|
|
|
|
|
rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0))
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo(
|
|
|
|
|
|
winRateFromRDif(dodgers?.rdif ?? 0),
|
|
|
|
|
|
3
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── seedingWinRateFor ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("seedingWinRateFor", () => {
|
|
|
|
|
|
const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies
|
|
|
|
|
|
|
|
|
|
|
|
it("is a no-op pre-season: the target equals the Elo-implied rate", () => {
|
|
|
|
|
|
expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("spreads the shortfall over the remaining games mid-season", () => {
|
|
|
|
|
|
// 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the
|
|
|
|
|
|
// .586 the season-long Elo implies. Without this the sim finishes around 90.5.
|
|
|
|
|
|
expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("reaches the projection in expectation", () => {
|
|
|
|
|
|
const currentWins = 60;
|
|
|
|
|
|
const remaining = 52;
|
|
|
|
|
|
const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining);
|
|
|
|
|
|
expect(currentWins + rate * remaining).toBeCloseTo(95, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
|
|
|
|
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);
|
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
|
|
|
|
});
|
|
|
|
|
|
|
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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("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
|
|
|
|
|
|
);
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
on resolveElo() (overrides SRS, overrides futures, overrides fallback,
null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
covering fallback to season-projection, structure validation errors, probability
distributions, fully-decided champion, and source tag
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|