Four fixes from code review of the previous commit.
1. Preserve the futures board's dispersion (llws-simulator.ts).
convertFuturesToElo finishes by rescaling any field onto a fixed
1250-1750 Elo span, discarding how spread out the board actually is: a
board with a 22%-priced favorite and one with a 6%-priced favorite both
came out 500 Elo wide. On a tight board that inflated the favorite from
6% to 13% -- worse than the raw-futures model it replaced (RMSE 0.025 vs
0.005), so the previous commit was a regression in that regime.
buildLLWSElos now maps decompressed strengths by their log-ratio to the
field's geometric mean, so Elo span tracks real dispersion. Re-swept the
parity factor across three board shapes rather than one: 550 minimizes
total error. Simulated vs priced favorite, with RMSE:
wide 21.8% -> 20.7% (0.0055), elo span 1355-1682
top-heavy 28.2% -> 25.6% (0.0085), elo span 1354-1733
tight 6.0% -> 5.8% (0.0026), elo span 1484-1518
2. Rate an unpriced team at the field's median, not 1500.
DEFAULT_ELO is the midpoint of the Elo output range, not of the field;
on a typical board it ranked an unpriced team ~6th of 20, so blanking a
team's odds promoted it. buildLLWSElos now returns the priced field's
median alongside the ratings (ranks 11th of 20 on the same board).
3. Pick the bracket event deterministically.
scoringEvents.findFirst with no ordering returned an arbitrary row when a
season had more than one llws_20 playoff event; landing on a stale one
silently reverted to a randomized draw that ignored all recorded results.
Now takes the most recent, matching world-cup-simulator.ts.
4. Fail on a partially seeded bracket instead of discarding it.
readBracketSlots returned null on any single missing participant, throwing
away the draw and every completed result with no warning. Since
playoff_matches participant columns are ON DELETE SET NULL, removing and
re-adding one participant mid-tournament was enough to put eliminated
teams back in contention. It now distinguishes "generated but not seeded"
(0 slots filled -> randomized draw) from "partially seeded" (throws).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
The LLWS simulator was overestimating favorites and ignoring games that had
already been played. Two separate causes:
1. Championship futures were used directly as single-game strength
(p1 / (p1 + p2)). A future already compounds the ~6 wins needed to take
the title, so this made every individual game as lopsided as the whole
tournament and re-compounded that edge round after round. Against a
representative 20-team board the favorite priced at 21.8% simulated at
44.9%, and the longest shot fell to ~0%.
Futures are now decompressed to single-game Elo via convertFuturesToElo,
the same pipeline the other bracket simulators use, and games are played
with eloWinProbabilityWithParity. The parity factor was calibrated by
sweeping it until a randomized-draw simulation reproduces the board it
was fed: at 1000 the favorite simulates at 21.8% and field-wide RMSE
drops from 0.062 to 0.003. It is overridable per season via config.
2. The simulator never read playoff_matches, so it re-ran the tournament
from an empty bracket every time and shuffled the draw at random each
iteration. A recorded loss changed nothing.
It now loads the seeded llws_20 bracket, places teams in their real
slots, and replays completed games from their recorded result instead of
re-simulating them, so an eliminated team correctly drops to zero. When
no bracket exists (or it has no participants seeded) it falls back to the
previous randomized-draw behavior, and a seeded bracket is authoritative
about which side a team is on, so externalId is only required on the
pre-bracket path.
Guards: a recorded result is only honored when its two participants are the
ones the simulation routed into that game, so a corrupt or out-of-order row
cannot desynchronize the rest of the bracket; brackets seeding an unknown or
duplicated participant now fail loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
## Summary
- **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS.
- **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters.
- **Bug fixes** found during review and applied across all affected adapters:
- `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard
- Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA
- `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0)
- statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison)
- WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally
- MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`)
## Test plan
- [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`)
- [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406
- [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #62
* refactor(schema): rename per-window tables to season_* prefix
Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: rename participant.ts model file to season-participant.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(models): update model layer to use renamed schema exports
Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant
Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts
Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route layer to use renamed schema exports
- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file
Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts
Error count reduced from 499 to 453 (46 errors fixed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route files for schema rename
Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(services): update simulators and services for renamed schema
Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId
Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts
Typecheck errors reduced from 365 to 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: rename per-window tables to season_* prefix
* fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.
Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update remaining mock paths and keys after schema rename
* fix(tests): final two mock stragglers after schema rename
- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add post-phase1a baseline capture (temp, for diff verification)
* chore: capture pre-migration baselines
* chore: remove post-phase1a capture helper after verification
* schema: add canonical tournament & participant tables
Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(models): add canonical tournament, participant, result, surface-elo models
Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).
Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
createCanonicalParticipant, etc.) to avoid collision with existing
season-participant.ts exports
- All four models include comprehensive unit tests following the
audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints
Part of Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: create canonical tables, add nullable FKs
* scripts: add extractTournamentIdentity helper for backfill
Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.
* scripts: add backfill orchestrator for canonical layer
Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.
Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
(recorded in report.errors) rather than overwriting
* scripts: add backfill CLI with dry-run default
Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).
* fix(backfill-cli): wrap runBackfill in DatabaseContext.run
The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backfill-cli): exit 0 on success so pg pool doesn't block
The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Participants are created with externalId=null by default, causing the
LLWS simulator to crash at runtime. Fix in two parts:
1. Name-prefix inference: if externalId is null, participants whose
name starts with "US " or equals "US" are assigned to the US side;
all others are assigned to Intl. Pools are always randomized when
inferred (no pool suffix).
2. Admin UI: add an inline-editable "Group" column to the Manage
Participants page so admins can explicitly set externalId for any
simulator that uses it (LLWS, and future cases like NHL conferences).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add LLWS bracket Monte Carlo simulator
Simulates the 20-team Little League World Series: pool play round-robin
(5 teams/pool, top 2 advance) → 4-team double-elimination per side
(US and International) → consolation game → World Series final.
Uses championship futures odds as the sole win-probability signal.
Pool assignments are auto-detected from externalId: bare "US"/"Intl"
randomizes pools each simulation (pre-draw mode); "US:A"/"Intl:B" etc.
uses fixed assignments (post-draw mode). Sides can differ.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint violations in LLWS simulator
- Replace non-null assertions with null-safe access (! → ?? / optional chaining)
- Replace .sort() with .toSorted() per linting rules
- Promote bump() to simulate() scope and pass it into simulateSideBracket,
removing the need to pass counts into that function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>