brackt/docs/agents/simulators.md
Claude 101e102e23
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
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

10 KiB

Simulator Guide

Brackt EV simulators project real-world sports outcomes into the standard top-eight probability shape used by fantasy expected values.

Architecture

  • app/services/simulations/registry.ts maps each simulatorType to the algorithm implementation.
  • app/services/simulations/manifest.ts describes each simulator for admin/UI use: display name, default config, required inputs, optional inputs, and setup sections.
  • app/services/simulations/input-policy.ts resolves acceptable alternate inputs, such as projected wins or futures odds, into simulator-ready Elo values.
  • app/models/simulator.ts owns simulator profile/config/input queries. Routes should use this model layer and must not query Drizzle directly.
  • app/services/simulations/runner.ts is the only supported way for admin routes to run a sports-season simulation.
  • season_participant_expected_values is an output table. New mutable simulator inputs belong in season_participant_simulator_inputs.

Data Flow

  1. Admin configures the sport's default simulatorType on the sport.
  2. Each sports_seasons row gets its own simulator config through sports_season_simulator_configs.
  3. Admin/import flows save participant inputs to season_participant_simulator_inputs.
  4. The runner validates readiness, runs the registered simulator, normalizes output columns, persists EVs, snapshots probabilities, zeroes omitted participants, and recalculates linked fantasy standings.
  5. /admin/simulators inventories all sports-season simulators and can run ready/idle seasons.

Before running older Elo-based simulators, the runner materializes resolved Elo values back through season_participant_simulator_inputs and the legacy EV compatibility bridge. This lets older simulator algorithms keep reading sourceElo while admin setup can use direct Elo, projected wins/table points, futures odds, or an explicit fallback policy.

The runner also materializes derived rating values for rating-based simulators that declare that support, such as preseason NCAAM/NCAAW using futures odds before KenPom/Barttorvik-style ratings are available.

Preseason Draftability

Brackt sports are expected to be draftable before their real-world season starts. When adding or revising a team-sport simulator, design for preseason and in-season runs first:

  • Include all draftable teams/participants, not only the eventual playoff field.
  • Simulate regular-season qualification or standings when playoff participation is not known yet.
  • Let current standings override or blend with preseason projections as the season progresses.
  • Use projected wins, projected table points, futures odds, direct ratings, or sport-specific ratings as season-scoped inputs that can drive preseason EVs.
  • Switch to bracket-aware simulation only when a real bracket or finalized seeds exist.

A playoff-only simulator is not sufficient for team sports that Brackt drafts in preseason. If a sport ever has a true exception, that exception must be explicit in the sport-specific implementation plan and product requirements.

Season Scoping

Never store mutable simulator state on sports. A sport can have multiple active seasons at once, such as NHL playoffs for one season and preseason drafting for the next. Each sports season needs independent:

  • Participants
  • Simulator config overrides
  • Ratings, rankings, odds, seeds, and metadata
  • Standings/events/brackets
  • EV outputs and snapshots

When cloning a sports season, copy simulator structure/config by default. Do not copy volatile inputs such as odds, Elo ratings, rankings, or standings unless an admin explicitly opts in.

Adding A Simulator

  1. Add the simulator type to database/schema.ts and generate a Drizzle migration with npm run db:generate.
  2. Implement Simulator in app/services/simulations/.
  3. Register the simulator in app/services/simulations/registry.ts.
  4. Add a manifest profile in app/services/simulations/manifest.ts.
  5. Add readiness and setup expectations through required/optional inputs and setup sections.
  6. If a required input can be derived, add derivableInputs. For example, an Elo simulator with projected wins support should declare derivableInputs: { sourceElo: ["projectedWins"] }.
  7. Add unit tests for algorithm helpers and a readiness/manifest regression test.
  8. Confirm /admin/simulators shows the season and the per-season setup page reports actionable missing inputs.

Hardcoding Rules

Do not hardcode refreshable season data in simulator files:

  • No current-season Elo tables
  • No KenPom/Barttorvik/SRS maps as production inputs
  • No bookmaker odds
  • No team rankings/seeds that change per season
  • No participant-specific metadata that admins may need to change

It is acceptable to keep stable algorithmic rules in code:

  • Bracket advancement mechanics
  • Swiss-stage mechanics
  • Best-of series math
  • Plackett-Luce, Elo, Log5, and Harville formulas
  • Sport format rules that are intentionally not admin-editable yet

If a hardcoded fallback is temporarily retained for backwards compatibility, admin readiness should still require the corresponding DB-managed input before normal admin-triggered runs.

Admin Setup

Use /admin/sports-seasons/:id/simulator for per-season setup. The page should show readiness, missing inputs, config JSON, links to sport-specific setup pages, and a generic CSV importer for common inputs:

name,sourceElo,sourceOdds,worldRanking,rating,projectedWins,projectedTablePoints,seed,region
Boston Celtics,1699,+450,1,0.95,58,,1,East

Keep specialized pages when they provide real workflow value, such as Golf Skills, Surface Elo, CS2 setup, regular standings, and bracket setup. Those pages should still feed readiness through the simulator model layer.

Input Policies

Direct ratings are preferred by default. If a simulator declares derived inputs, readiness may also pass with those alternatives:

  • projectedWins can become Elo using seasonGames and parityFactor from season config.
  • projectedTablePoints can become Elo using seasonGames, maxTablePoints, and parityFactor.
  • sourceOdds can become Elo through the shared futures-to-Elo conversion.
  • sourceOdds can become a generic rating when the simulator declares derivableInputs: { rating: ["sourceOdds"] }.

Raw Elo vs. projections

Raw Elo and projections are substitutes, not a blend: inputPolicy.baseEloPriority lists them in order and the first source a participant has wins outright. The default is ["sourceElo", "projectedWins", "projectedTablePoints"], so a stored Elo beats a projection. Set the Base Elo Source control on the simulator page (or baseEloPriority directly) to ["projectedWins", "sourceElo"] when projections are the season's source of truth. Futures odds are separate — they blend on top of whichever base won, weighted by inputPolicy.oddsWeight.

Whenever you write a projection without an explicit Elo, stamp metadata.sourceEloMethod ("projectedWins" / "projectedTablePoints") on the row. getParticipantSimulatorInputs reads that flag and returns sourceElo: null so the Elo is re-derived from the projection on every run. Skip it and the non-destructive upsert leaves the previous Elo in place as a direct value, which then wins the priority race — the projection is stored and silently ignored. Both the Elo Ratings page's projections mode and the simulator page's CSV importer do this; any new importer must too.

Projections are stored and displayed exactly as entered. Never round-trip one through its derived Elo for display: the conversion rounds to an integer Elo, and a run re-resolves that Elo through the input policy (clamping, plus any futures blend), so the number the admin sees drifts away from the number they typed.

Mid-season projections

A projected win total is a projected final total. A simulator that seeds from projections mid-season must spread the difference over the games still to play — (projectedWins - currentWins) / remainingGames — rather than reusing the season-long rate the derived Elo encodes, or it will never reach the projection. See seedingWinRateFor in mlb-simulator.ts (config knob projectedWinsWeight, 1 = the projection is authoritative) and simulateRegularSeasonSeeds in nll-simulator.ts (which additionally decays a preseason prior as the season completes).

Two guards belong on any such rest-of-season rate:

  • A target outside (0, 1) means the projection is stale — the team has already met it, or can no longer reach it. Fall back to the Elo rate. Clamping to a floor or ceiling instead simulates a team to stop winning entirely, or to win out, and collapses its seeding variance.
  • Only apply a projection that actually produced the resolved Elo. Check metadata.sourceEloMethod === "projectedWins" (see projectionForSeeding in mlb-simulator.ts). Any other method means the Elo represents something else: a hand-entered Elo that won the baseEloPriority race, or a futures blend. Seeding off the raw projection in those cases makes the projection simultaneously ignored as the Elo source and authoritative for the standings, and runs seeding and playoff matchups on two different strength scales.

Missing tail participants must remain blocked unless the season config explicitly chooses an inputPolicy.missingEloStrategy:

{
  "inputPolicy": {
    "missingEloStrategy": "worstKnownMinus",
    "fallbackElo": 1400,
    "fallbackEloDelta": 25,
    "eloMin": 1100,
    "eloMax": 1900
  }
}

Supported strategies are block, fallbackElo, averageKnown, and worstKnownMinus. Use fallbacks only when the tail participants have genuinely tiny EV impact; the setup page will warn when derived or fallback Elo is being used.

For rating conversions, configure inputPolicy.ratingMin and inputPolicy.ratingMax to match the target model scale. NCAAM uses a KenPom-like net rating range; NCAAW uses a Barthag-like 0-1 range. Futures-derived ratings are a preseason approximation and should be replaced by real ratings when available.

Required Checks

After simulator changes, run:

npm run typecheck
npm run lint
npm run test:run

At minimum, add or update tests for:

  • Manifest/schema/registry drift
  • Readiness validation
  • Input persistence and legacy compatibility bridge
  • Shared runner success/failure behavior
  • Any simulator whose required inputs changed