---
title: Correct the season forecast model
type: task
status: completed
description: >-
  The season forecast freezes All Play and Points For at actuals while the 2026 format makes them
  the first sort key for four of six berths, ranks the playoff bracket on Math.random(), and aliases
  division odds to bye odds; make every measure the format reads move with the simulation.
aliases:
  - ████████████████████████-██████-████████-█████-███-██████-████████████████
base_uri: user:task/league/correct-the-season-forecast-model.md
created_at: '2026-08-30T15:37:15.532Z'
entity_id: 923d24e6-5714-4e36-a03f-ed260f5b722a
finished_at: '2026-08-30T19:12:38.819Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - follows [[user:task/league/build-player-correlation-simulation-system.md]]
  - follows [[user:task/league/extend-simulation-to-playoff-rounds.md]]
  - >-
    relates
    [[██████████████-███████-█████████████-████████████████████████████████████████████-██-████████-█████████]]
  - relates [[████████████████████████-████████-██████-████████████]]
  - follows [[███████████████████████████████-█████████████]]
  - relates [[user:task/league/move-regular-season-boundary-onto-season-row.md]]
  - relates [[user:task/league/conform-division-finish-to-the-seeding-ladder.md]]
  - relates [[user:task/league/restore-the-forecast-leverage-columns.md]]
  - relates [[user:text/league/season-forecast-modeling-contract.md]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-03T03:55:29.399Z'
visibility_analyzed_at: '2026-11-05T23:48:40.268Z'
---

## Overview

Make the season forecast compute the quantities the league format actually defines, so every measure the playoff ladder reads moves with the simulation instead of being copied once from actuals.

**Impact.** `bye_odds`, `playoff_odds`, `division_odds` and `championship_odds` are rendered today. The first three appear on the team dashboard, the standings page and the probability-leverage chart; `division_odds` appears on the dashboard only. Right now, for the live 2026 league, all of them are degenerate — see the observed state below. After this work they are real forecasts.

**Ordering.** Follows the completed player-correlation simulation and playoff-rounds tasks, which built the correlated engine this work consumes. Split on 2026-08-30 into four: this task, `[[user:task/league/move-regular-season-boundary-onto-season-row.md]]` and `[[user:task/league/conform-division-finish-to-the-seeding-ladder.md]]` (both independent of it), and `[[user:task/league/restore-the-forecast-leverage-columns.md]]` (blocked by it). This task leaves `simulate-season-forecast.mjs:49` reading the global constant exactly as today; the boundary task changes that line afterward.

## Background

### The observed defect

Command, run 2026-08-30:

```sql
select tid, playoff_odds, bye_odds, division_odds, championship_odds
from ████████████████████
where lid=1 and season_year=2026
  and day=(select max(day) from ████████████████████ where lid=1 and season_year=2026)
order by tid;
```

Result: `playoff_odds` is exactly `1.0000` for tids 1, 2, 4, 5, 6, 7 and `0.0000` for 9, 10, 11, 12. `bye_odds` is `1.0000` for tids 1 and 2 and `0.0000` for the rest. `division_odds` equals `bye_odds` in every row. `championship_odds` sits near 0.25 for each of the two byes and near 0.12 for each of the other four — the signature of a uniform bracket over a fixed six-team field.

The 0/1 pattern is stable because it is deterministic; the `championship_odds` decimals are NOT, since they are a fresh random draw each hourly run and drift between executions. Re-run the query rather than comparing against the decimals quoted here.

The admitted six are precisely the six lowest team ids. That is the tell: `simulate-season-forecast.mjs:279` passes `Object.values(standings)`, whose integer-like keys V8 orders ascending, and `db('teams')` at `:62` has no `ORDER BY`. When every comparator returns 0, the stable sort is a no-op and seeding is team-id order.

2026 is in its zero state — all seasonlogs zero, every matchup `home_points 0.00`. So this is not a defect that arrives later in the season; it is the defect at its most visible.

### The governing rules

Amendment XL, consolidated into `constitution.md` (Article V §13 at `:726-730`, Article XVI §3-5 at `:1052-1066`, Article XVII §3-5 at `:1082-1091`), defines the post-season field. Article XVII §3 verbatim:

> The two (2) Teams admitted directly shall be those with the highest All Play win percentage over the Qualifying Season, drawn from the following Teams: 1. Where the LEAGUE has no Divisions, from all Teams in the LEAGUE. 2. Where the LEAGUE is organized into Divisions, from the Division winners only, as defined in Article V, Section 13, Subsection (e).

The Qualifying Season is fourteen weeks (13(f)). Ten teams means no divisions, twelve means four divisions of three (13(a), 13(b)). The constitution says "admitted directly" rather than "bye"; the schema calls it `bye_count` and this plan follows the schema.

The `seasons` row for `lid=1`, 2026 — verified live 2026-08-30 — is `playoff_team_count=6`, `bye_count=2`, `bye_selection_method='all_play'`, `at_large_selection_method='points_for'`, `has_division_winner_berths=false`, `head_to_head_berth_count=2`, `bye_candidate_pool='league'`. All ten 2026 `teams` rows have `division` null.

That configuration is constitutional **at ten teams**. It is not at twelve: Article XVII §3(b) requires `bye_candidate_pool='division_winners'`, XVI §3(a) requires `has_division_winner_berths=true`, and XVI §3(b) applies only where there are no divisions, so `head_to_head_berth_count` becomes 0. All three are data changes at expansion — the plumbing already exists end to end — and none is in this task's scope. Recorded here so the claim does not silently expire.

### Defect 1 — four of six berths are pinned by frozen data

`simulate-season-forecast.mjs:236-243` copies `points_for` and `all_play_*` from actuals once per iteration and never updates them; the week loop at `:248-274` mutates only `regular_season_wins`, `regular_season_losses` and `regular_season_ties`. Grep confirms `all_play` appears on the left of an assignment only at `:83-86` and `:241-243` in the whole file.

Under the 2026 format that pins four of the six berths:

- Both byes ride `compare_all_play_seed`, which reads all-play percentage then points for — both frozen. The candidate pool is the whole league, so the input set is iteration-invariant too. The bye pair is identical in every iteration.
- Both at-large berths ride `compare_at_large_berth`, which leads on frozen points for. The ranking is a fixed total order; only the pool varies, which still pins the two highest-points-for non-bye teams into the field every iteration.

Only the two head-to-head record berths move. Measured over the real `get_playoff_seeding` with fully random simulated records: exactly 4 of 10 teams at `playoff_odds 1.000`, in both correlated and anti-correlated fixtures.

It is total from week 2, not gradual: ten teams playing one week produce ten distinct all-play records, so `compare_all_play_seed` is already a strict total order at the first forecast after week 1.

### Defect 2 — playoff seeding ranks on `Math.random()`

`simulate_playoffs` (`:372-405`) assigns every playoff team a uniform random score, so `championship_odds` carries no projection, roster or record signal. `build_post_season_forecast` separately returns a fabricated `championship_odds: 0` at `:439`, which the caller persists at `scripts/process-projections.mjs` in the insert block beginning `:124`.

### Defect 3 — `division_odds` is a bye alias, and it is a regression

Set from the bye flag at `simulate-season-forecast.mjs:291` and `:437`, and at `simulate-playoff-forecast.mjs:195,426`. Note `:437` — inside `build_post_season_forecast`, one line above the fabricated championship odds — which earlier drafts of this plan missed.

This is not a vestigial column. Across `lid=1`, `division_odds is distinct from bye_odds` yields **zero** differing rows for 2023, 2024, 2025 and 2026 — and 2023 through 2025 had two real divisions. 2021 and 2022 do differ. So the code once computed real division odds and regressed at `2ce9f7225`. Computing it is restoration, not new feature work.

### The correlated score vectors already exist and are discarded

`simulate_league_week` builds `team_raw_scores` at `simulate-league-week.mjs:271-286` — per-team, per-simulation weekly totals — derives win probabilities from them, and simply omits them from the return object at `:328-347`.

### Assumptions

- **`team_raw_scores` is index-aligned across teams within a week.** Checked: each per-pid array is built sim-major and team totals sum at the same index at `:276-283`, so index `i` is a coherent joint league realization.
- **Correlation is within an NFL game only; across games it is exactly zero.** Checked: `:231-264` loops over games, one `simulate_nfl_game_with_raw_scores` call each with its own correlation matrix and its own RNG stream (`game_seed` incremented at `:261-263`). The phrase "one correlated draw across the whole league" is wrong and is not used in this plan. Alignment — the property the design actually needs — holds regardless.
- **Sequential mulberry32 seeds are independent enough to stack by index.** Checked: max absolute correlation between seed `s` and `s+1` over 10 pairs × 200k draws is 0.0056 against a 1/sqrt(N) floor of 0.0022; minimum state-collision offset between adjacent seeds is ~6.0e8 draws.
- **The outer loop is 10000 and the week vectors are built at 1000.** Checked: `SIMULATIONS = 10000` at `:17`, `n_simulations: 1000` at `:183`. These are not independent knobs once an index is drawn per week; see Design.
- **`force_win_tid` / `force_loss_tid` have zero callers.** Checked: `grep -rn "force_win_tid\|force_loss_tid"` returns only the parameter declarations and internal uses inside `simulate-season-forecast.mjs`.
- **The `*_with_win` / `*_with_loss` columns are read but no longer written.** Checked: read at `api/routes/leagues/teams.mjs:353-360,378-385`; the insert block in `scripts/process-projections.mjs` writes only the four base odds. Measured: 1,940 of 8,550 `████████████████████` rows carry them — 490 in 2024, 1,450 in 2025, and **zero** in 2026, 2023, 2022 and 2021. A writer existed and stopped.
- **`league_team_forecast.division_odds` is `NOT NULL`.** Checked: `db/schema.postgres.sql:4883`, with the division `_with_win` / `_with_loss` at `:4888,:4892` nullable already, and `api/swagger/config.mjs:2578` already `nullable: true`.
- **`scripts/process-projections.mjs` is the only caller of `simulate_season_forecast`.** Checked: repo-wide grep returns the definition, the `index.mjs` re-export, and that one call site.
- **The wildcard round resolves as top-N scorers, not bracketed pairings.** Checked: `:381-391` and `simulate-playoff-forecast.mjs:71-80`.
- **A starter with no projection contributes zero silently.** Checked: `simulate-nfl-game.mjs:180-183` drops players with no projection, and `simulate-league-week.mjs:277-282` guards `if (player_scores)` and skips. See the Design note — this becomes a seeding error under the rewrite.
- **Playoff-week rosters simulated fourteen weeks out are speculative.** Known: `load_teams_starters_by_week` computes future weeks as the optimal lineup from the current pool. Same assumption every other forecast week makes.
- **Cost is PARTIALLY measured and the figures understate it.** Retained memory for the week vectors is 1.1 MB at 1000 and 11.2 MB at 10000 — arithmetically exact at 14 weeks × 10 teams × 10000 doubles × 8 bytes, linear, and not a concern. The timing figures (uniform generation 0.4s to 3.4s; playoff pre-pass 0.46s; inner loop 7ms to 65ms) measure COMPONENTS, not the whole forecast: `simulate-nfl-game.mjs:266-283` calls `sample_from_distribution` once per player per simulation, roughly 12.6M inverse-CDF evaluations across fourteen weeks at 10000, on top of the Cholesky transform — and the inner-loop figure excludes 10000 calls to `get_playoff_seeding`, which sorts three or four times per call. The 10x scaling shape is right; the absolute delta is larger than the component figures suggest and the total is UNCHECKED. Take an end-to-end timing under `DEBUG=simulation:season-forecast` before and after, against a caller that runs ~9 minutes hourly (`docs/guides/ship.md:140`). The database cost is also UNCHECKED, though the query count does not change.
- **`n_simulations` is unbounded.** Checked: no clamp in `simulate-season-forecast.mjs`, `simulate-league-week.mjs` or `simulate-nfl-game.mjs`. Memory is linear, so exposing `--n_simulations` on the CLI is a footgun rather than a defect; consider a sanity ceiling.

## Design

One principle: every measure the format reads must move with the simulation. Nothing branches on team count.

**The correlated score path reaches the standings.** `simulate_league_week` exposes its score vectors. The Monte Carlo loop draws one index per remaining week and reads every team's score at that index, so all teams in a week share a draw and the cross-team joint distribution survives. Head-to-head outcome, `points_for` and all-play then come from one consistent model. The probability pre-pass and `decide_matchup_outcome` are deleted — the probabilities were the empirical win frequency over these same vectors.

**The two simulation counts are reconciled, and the second knob is deleted.** Drawing an index per week samples with replacement from however many draws the week vectors hold, so 1000 inner draws cap the effective sample size at 1000 regardless of the outer count. Measured at a true week win probability of 0.5: reported-odds RMSE 1.65pp, of which 1.57pp is frozen by the inner 1000 and only 0.51pp comes from the outer loop. This is not a regression for head-to-head — today's code inherits the same floor at `:183` — but it becomes the noise floor on `bye_odds`, which is the number this task exists to fix. Week vectors are therefore built at the outer `n_simulations`, and `week_n_simulations` is **not** introduced: a knob whose only effect is to silently degrade the forecast is the same class of defect being fixed here. The playoff pre-pass uses the outer `n` for the same reason, so its index mirrors `simulate-playoff-forecast.mjs:478-487` directly rather than needing a second draw.

**Force conditioning, with the persistence restored.** `force_win_tid` / `force_loss_tid` become conditional sampling over the draw indices where the forced team wins or loses. This is what the `*_with_win` / `*_with_loss` columns mean, and it is more correct than flipping an outcome while the scores contradict it. Two things the earlier draft missed: nothing calls these parameters today and nothing writes those columns since 2025, so this item must also restore the persistence or it hardens a path no one invokes — the new CLI becomes the first real caller. And the refusal rule is a minimum subset size, not emptiness: measured over 1000 draws, a team at p=0.9 conditioned on a loss has ~100 usable indices (5.0pp standard error), at p=0.95 ~41 (7.8pp), at p=0.99 ~6 (20.4pp), none of which the outer loop reduces. Refuse below 100. Emptiness alone is the wrong trigger and fires routinely: once every NFL game in the first remaining week is final and `use_actual_results` is true, every score vector is a constant, the win probability is exactly 0 or 1, and one subset is empty by construction — every week, between the last game and the rollover.

**Real playoff simulation.** `simulate_playoffs` is deleted; the forecast calls `simulate_playoff_weeks_correlated` over all teams — the playoff field varies per iteration — mirroring `simulate-playoff-forecast.mjs:258-284`, and reuses `select_wildcard_winners` and `find_highest_scoring_team`. Round definitions come from `seasons.wildcard_round` and `seasons.championship_round`. Running over all teams rather than the playoff field is not where the cost is; the cost is that two calls duplicate the entire shared-load block at `simulate-playoff-weeks.mjs:64-133`, so prefer one call over the union of weeks if the round split can be reconstructed afterward.

**Real division odds, with the codebase's own disposition on partial data.** `get_playoff_seeding` already derives division winners at `:134-141` and discards them; it returns them instead. `division_odds` is null when the league has no divisions, which requires dropping `NOT NULL`. The detection rule is **not** "null only when every division is null" — that silently emits a number on a partially configured league. `generate-fantasy-league-schedule.mjs:106-126` already rules on this case and **throws** when some teams carry a division and some do not. This plan follows that disposition. The reason matters: `get_playoff_seeding` treats `null` as a single Set key, so every undivided team collapses into one pseudo-division whose "winner" is merely the best undivided team, and with `has_division_winner_berths` set that phantom winner would be guaranteed a berth.

**A missing projection becomes a seeding error.** Today a starter with no projection perturbs a win probability. Under the rewrite it feeds `points_for` and all-play, the 2026 primary sort keys, so it silently moves seeding. This contradicts the no-fallback stance the module already takes at `:173-176`, and is handled as its own item rather than left implicit.

### Naming decisions

Run through `[[user:guideline/write/write-software.md]]`:

- **`raw_team_scores`** for the new public field on `simulate_league_week`, matching the field `simulate_playoff_weeks_correlated` already returns and the name used in `run-simulation.mjs`, `simulate-playoff-weeks.mjs` and `simulate-nfl-game.mjs`. The internal `team_raw_scores` at `simulate-league-week.mjs:272` is the lone outlier and is renamed to match.
- **`division_winner_tids`** on `get_playoff_seeding`, matching the existing `seeded_tids` / `playoff_tids` / `bye_tids` / `wildcard_tids`.
- **`calculate_week_all_play_records`**, **`accumulate_simulated_week_standings`** — full words, kebab-case files, meaningful without their directory.
- **`week_n_simulations` is deliberately NOT coined.** See Design.
- CLI flags: `lid`, `year`, `week`, `n_simulations`, `seed`, `json`, `save`, plus the force flags. `dry` is not coined — the absence of `--save` is dry.

### Knowledge Base Impact

- The forecast's modelling contract has no home in `text/league/` today. A new entity there records which measure decides which berth and why a simulation must move every measure the format reads. It lives in the user base, not the public league repo.
- Repo docs to check: `docs/simulation-system.md` (the canonical subsystem doc, which documents both changed entry points), `docs/guides/scripts.md` (new CLI), `docs/glossary.md` (All Play, division winner).
- Relations to the two completed simulation tasks, to Amendment XL, and to the two tasks split out of this one.

### Working tier

No new scratch slug. Research artifacts live in `scratch/codebase-review-league/`, owned by the codebase-review effort rather than by this task.

## Notes

- There IS existing coverage that exercises this module: `test/scripts.process-projections-forecast-propagation.spec.mjs` drives `run_season_forecast(1)` against a real fixture pinned to week 5 and pins that it REJECTS rather than resolving. An earlier draft of this plan claimed no file under `test/` references `simulate_season_forecast`; that was wrong. That spec induces its failure by DELETING the `seasons` row, which throws in `load_simulation_context` at `:56` — NOT in the `week_probabilities` pre-pass. So deleting the pre-pass does not break it, and an earlier draft of this plan gave that as the reason to keep a throw. The throw should still be kept, for the reason given in the rewrite item: without it a missing team yields an unnamed TypeError instead of a named failure.
- `test/simulation.league-wide.spec.mjs` in places constructs an object locally and asserts on it, so it cannot fail for any simulation change.
- `[[███████████████████████████████-█████████████]]` now surfaces on league's flat `test/*.spec.mjs` convention; its injection-boundary and mutation-scoring rules are load-bearing for the Testing group and should be read before writing the specs.
- The module-level test seam is carried here for `simulate-season-forecast.mjs` only. The earlier draft also seamed `simulate-playoff-forecast.mjs`, but this task changes three lines in that 495-line module, so retrofitting injection there is a separate undertaking. It stays `[owed]` on `[[████████████████████████-████████-██████-████████████]]`.
- DDL, the schema export, and dependent code ship in ONE commit. This task carries one DDL change.
- Use `yarn test:isolated`, never `yarn test:db:up && yarn test`.
- Build the CLI FIRST. Most of the verification below runs through it.

## Plan

### Core Implementation

- [x] **Add the forecast CLI and thread the simulation knobs**
  - Files: `scripts/simulate-season-forecast.mjs` (new), `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: the forecast can only be run by the hourly cron today, which is why these defects were found by review rather than by use. A `seed` is what makes output assertable at all. This item is FIRST because most later verifications invoke it.
  - Changes: yargs CLI modelled on `scripts/simulate-league-matchups.mjs` with `lid`, `week`, `year`, `n_simulations`, `seed`, `json`, `save`, and the force flags; parse argv inside a function, not at module scope, so the CLI stays importable by its own tests; thread `seed` to `simulate_league_week` using `libs-shared/seeded-random.mjs`. Note `n_simulations` is ALREADY a parameter defaulting to `SIMULATIONS` at `simulate-season-forecast.mjs:43` — the CLI only needs to pass it, not introduce it.
  - Verify: importing the module under mocha does not throw on argv; a run against `lid=1` produces output. Determinism is NOT verifiable here — see the note below — so do not assert it yet.
  - Ordering note: this item is first because later verifications invoke the CLI, not because it is complete. Seeded determinism is unreachable until the Monte Carlo rewrite, since `Math.random()` is called at `simulate-season-forecast.mjs:355` (`decide_matchup_outcome`), `:387` and `:400` (`simulate_playoffs`), and all three sites are deleted by later items. Threading `seed` into `simulate_league_week` makes the win probabilities reproducible but not the per-iteration outcome draw.

- [x] **Seed the playoff path**
  - Files: `libs-server/simulation/simulate-playoff-weeks.mjs`, `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: `simulate-playoff-weeks.mjs` contains zero occurrences of `seed`, so determinism is unreachable without this. The per-iteration index draw needs seeding too.
  - Changes: accept an optional `seed` and thread it through `simulate_playoff_weeks_correlated`; seed the index draw from the same source.
  - Verify: deferred to the determinism item in Testing & Validation, which runs after the three `Math.random()` sites are gone. Nothing here is assertable in isolation.

- [x] **Extract the shared all-play rule**
  - File: `libs-shared/calculate-week-all-play-records.mjs` (new)
  - Purpose: one implementation of the weekly all-play record, so the standings and the forecast cannot drift onto two implementations of one rule. Two concrete consumers: the standings block at `calculate-standings.mjs:173-186` and the new accumulator below. (The nested-filter block is O(n^2), but n is 10 or 12 — that is not a performance defect and is not the justification.)
  - Changes: export `calculate_week_all_play_records({ scores_by_team_id })` returning `Map<team_id, { all_play_wins, all_play_losses, all_play_ties }>`; sort once rather than filtering per team.
  - Verify: new unit spec asserts a three-way tie splits as ties for all three, and that a strict ordering yields the expected W/L per team; `yarn test:isolated --grep "all play"`.

- [x] **Call the shared rule from the standings**
  - File: `libs-shared/calculate-standings.mjs`
  - Purpose: retire the nested-filter block at `:173-186` in favour of the shared module.
  - Verify: `yarn test:isolated test/calculate-standings.spec.mjs` passes unchanged, confirming identical output on the existing fixtures.

- [x] **Expose the correlated score vectors**
  - File: `libs-server/simulation/simulate-league-week.mjs`
  - Purpose: the per-team, per-simulation score vectors already exist at `:271-286` and are omitted from the return object at `:328-347`.
  - Changes: rename the internal `team_raw_scores` to `raw_team_scores`; add it to the return object.
  - Verify: spec asserting the returned map has one entry per roster and each array has length `n_simulations`.

- [x] **Stop scoring a missing projection as zero without saying so**
  - File: `libs-server/simulation/simulate-league-week.mjs`
  - Purpose: `simulate-nfl-game.mjs:180-183` drops players with no projection and `simulate-league-week.mjs:277-282` then skips them silently, contributing zero. Today that perturbs a win probability; after the rewrite it feeds `points_for` and all-play, so it would silently move seeding.
  - Measured: across `lid=1` for 2024 and 2025, weeks 1-14, starting slots, **zero** starters lack a projection row for the league's own `scoring_format_id` in any of the 28 week-seasons. So this is a latent hazard, not a live one — the plan does not claim otherwise.
  - Changes: count the skipped players per team-week and raise a named error only when the count is non-zero, so the silent-zero path cannot persist. Place the guard in `simulate-league-week.mjs`, NOT in `simulate-nfl-game.mjs:180-183` — that module is shared by `simulate-playoff-weeks.mjs` and `scripts/simulate-league-matchups.mjs`, and a throw there changes behaviour for callers this task is not touching.
  - Verify: spec with a roster containing one player absent from `projections` asserts the named error and that its message carries the count and the team; confirm the pre-change code returns a score instead. Then run the CLI against `lid=1` for a real mid-season 2025 week and confirm it does NOT raise, matching the measurement above.

- [x] **Accumulate a drawn week into the standings**
  - File: `libs-server/simulation/accumulate-simulated-week-standings.mjs` (new)
  - Purpose: apply one drawn week consistently — head-to-head outcome, `points_for`, and all-play all derived from the same scores.
  - Changes: export `accumulate_simulated_week_standings({ standings, week_matchups, scores_by_team_id })`, delegating all-play to the shared module.
  - Verify: unit spec feeds a hand-built score map and asserts mutual consistency — the matchup winner is the higher of the pair, the all-play leader is the highest score, and `points_for` equals the sum.

- [x] **Rewrite the Monte Carlo loop onto shared per-week draws**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: make every measure the format reads move per iteration.
  - Changes: build each week's `raw_team_scores` at the outer `n_simulations` rather than 1000; store them; draw one seeded index per week per iteration and pass that week's scores to the accumulator; delete `decide_matchup_outcome` and the `week_probabilities` pre-pass.
  - Changes: preserve a named throw for a week that cannot be simulated. The deleted pre-pass carried the only coverage guard — `simulate-league-week.mjs:295-300` skips a matchup with missing team scores with a bare `log()` and `continue`, and `load-team-rosters.mjs:193-199` filters out teams with zero starters, so `raw_team_scores` can genuinely lack a team. Without a replacement the new design yields an unnamed TypeError from `raw_team_scores.get(tid)[i]`.
  - Verify: CLI run against `lid=1` 2026 asserts no team has `bye_odds` or `playoff_odds` exactly 0 or 1; the same command against the pre-change code returns the pinned 1/0 pattern recorded in Background. Do NOT use 2025 as the red control — its season row is all-head-to-head with `head_to_head_berth_count=0` and its persisted odds are already fractional (27-62 distinct `bye_odds` per week), so it cannot go red.
  - Verify: `yarn test:isolated test/scripts.process-projections-forecast-propagation.spec.mjs` still green, confirming the failure-propagation throw survived.

- [x] **Condition the force paths on the drawn outcome**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: `force_win_tid` / `force_loss_tid` must select from draws where that outcome occurred, rather than flipping a result the scores contradict. Conditioning is what the correlated draw makes possible, so it belongs with the rewrite.
  - Changes: build the winning and losing index subsets lazily for the forced team in the first remaining week; sample within the subset.
  - Scope boundary: this item makes the parameters CORRECT. It does not restore the `*_with_win` / `*_with_loss` persistence, and it must not — the historical writer called the forecast 2N+1 times (base, plus a forced win and a forced loss per team), so restoring the columns is a cost redesign against a caller already at ~9 minutes hourly. That work is `[[user:task/league/restore-the-forecast-leverage-columns.md]]`, blocked by this task. Nothing calls these parameters today except the new CLI.
  - Degenerate window: when every NFL game in the first remaining week is final and `use_actual_results` is true, every score vector is constant, the win probability is exactly 0 or 1, and one subset is empty by construction — every week, between the last game and the rollover. Return the unconditional result for the outcome that occurred and the degenerate 0/1 for the one that cannot, rather than throwing. A determined outcome is an answer.
  - Verify: CLI run asserting `playoff_odds_with_win >= playoff_odds >= playoff_odds_with_loss` for the forced team; a CLI run against a week whose games are all final returns for both forced directions rather than throwing.

- [x] **Replace the random playoff bracket with the correlated simulation**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: `championship_odds` must reflect rosters and projections.
  - Changes: delete `simulate_playoffs`; call `simulate_playoff_weeks_correlated` over all teams at the outer `n_simulations`, mirroring `simulate-playoff-forecast.mjs:258-284`; prefer one call over the union of wildcard and championship weeks rather than two, since each call repeats the whole shared-load block at `simulate-playoff-weeks.mjs:64-133`; index per iteration reusing `select_wildcard_winners` and `find_highest_scoring_team`; take round definitions from `seasons.wildcard_round` and `seasons.championship_round`.
  - Verify: CLI run asserting the team with the highest `playoff_odds` does not have the lowest `championship_odds`, and that `championship_odds` across teams is not consistent with a uniform draw over the field; record the elapsed time from the `simulation:season-forecast` debug namespace and state it in the commit.

- [x] **Stop fabricating post-season championship odds**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: `build_post_season_forecast` returns `championship_odds: 0` at `:439` and the caller persists it.
  - Changes: compute real odds over the actual playoff field by the same path.
  - Verify: CLI run for a completed season returns `championship_odds` summing to approximately 1.0 across teams.

- [x] **Return division winners from the seeding function**
  - File: `libs-shared/get-playoff-seeding.mjs`
  - Purpose: division winners are already derived at `:134-141` and discarded.
  - Changes: add `division_winner_tids` to the return object.
  - Verify: unit spec asserts one winner per division, that the winner is its division's best on `compare_playoff_seed`, and that an all-null-division league yields exactly one winner.

- [x] **Compute real division odds**
  - Files: `libs-server/simulation/simulate-season-forecast.mjs`, `libs-server/simulation/simulate-playoff-forecast.mjs`
  - Purpose: retire the bye-flag alias at `simulate-season-forecast.mjs:291`, `simulate-season-forecast.mjs:437`, and `simulate-playoff-forecast.mjs:195,426`. Note `:437` is a fourth site the earlier draft missed.
  - Changes: count `division_winner_tids` per iteration; emit `null` when no team carries a division; THROW when some teams carry a division and some do not, following `libs-server/generate-fantasy-league-schedule.mjs:106-126`, whose `group_by_division` already throws on partial division data at `:114-119`.
  - Placement: the partial-division check is a PRE-LOOP guard. Division membership is a `teams`-table property, invariant across all iterations, while `get_playoff_seeding` is called inside the Monte Carlo loop at `:278` — checking it there would re-evaluate a constant 10000 times and throw mid-loop. Check once before the loop.
  - Blast radius: `process_league` is per-league try/caught in `scripts/process-projections.mjs`, so a throw kills one league's forecast rather than the hourly job. Note the case is unreachable against production today — `lid=1` has never had partial division data (2020-2022 fully divided, 2023-2025 fully divided, 2026 all null, 2027 fully divided) — so this guard is for correctness, not for a live defect.
  - Verify: CLI run against 2026 (`lid=1`, all divisions null) returns `division_odds: null`; a run against 2025 (divisions 1 and 2) returns a value that differs from `bye_odds` for at least one team — confirmed reachable, since 2025 has ten teams across two divisions; a fixture with one team's division nulled raises the named throw.

- [x] **Cut the module-level test seam on the season forecast**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: the module has caller-level specs only and nothing at module level, because every entry point opens with `db('teams')` / `db('matchups')` and reaches `current_season` through a module global. Per `[[███████████████████████████████-█████████████]]` § Hidden Global Reads Are an Injection Boundary, the answer is injection, not a skip.
  - Changes: give the entry point optional parameters defaulting to the real loaders — the per-week score source (`raw_team_scores` by week), the starting standings, and the playoff score vectors. Inject the narrowest derived value each function actually consumes, not a database handle or a raw row set. Production callers pass nothing and behave identically, since a default parameter evaluates only when the argument is omitted.
  - Verify: a spec drives the full Monte Carlo from synthetic score vectors with no database available; confirm it runs with the test database stopped.

### Configuration & Setup

- [x] **Drop the division-odds NOT NULL**
  - Files: `db/adhoc/2026-08-30-allow-null-division-odds.sql` (new), `db/schema.postgres.sql`
  - Purpose: let `division_odds` be absent rather than fabricated.
  - Changes: drop `NOT NULL` from `league_team_forecast.division_odds`; `yarn export:schema`.
  - Note: consumers already tolerate null — `api/swagger/config.mjs:2578` is `nullable: true` and `app/core/teams/team.js:21` defaults to null. The API serializer at `api/routes/leagues/teams.mjs:351,375` passes it through unchanged.
  - Verify: `yarn db:exec` succeeds; inserting a row with a null `division_odds` is accepted. DDL, schema export and the code writing null ship in ONE commit.

- [x] **Hide the division-odds panel when there are no divisions**
  - Files: `app/views/components/dashboard-team-summary/dashboard-team-summary.js`, `app/views/components/dashboard-team-summary-division-odds/index.js`
  - Purpose: a null `division_odds` must render nothing rather than a duplicate of the bye panel. Today the Division block at `:31-33` and the Bye block at `:34` sit adjacently under the same gate, so the 2026 league renders the identical percentage twice.
  - Changes: render `DashboardTeamSummaryDivisionOdds` only on a non-null value; leave the connector's `b.division_odds - a.division_odds` sort at `index.js:13` tolerant of null, since the connector still runs if the gate is only in the parent.
  - Verify: `yarn dev:live` against the 2026 league shows no Division Odds panel; the panel returns when the value is non-null.

- [x] **Clear the stale 2027 division data**
  - Purpose: `teams` for `lid=1` 2027 already carries divisions 1 and 2 with five teams each — carryover from a clear that covered 2026 only — and there is no 2027 `seasons` row. At ten teams Article V §13(a) says there are no divisions, so a 2027 forecast would derive two division winners and publish a non-null `division_odds` for a season that has none.
  - AUTHORIZED 2026-08-30 (operator): the 2027 rows are stale and 2027 is unsettled, so clearing them is approved. No further confirmation needed.
  - Changes: null the 2027 `teams.division` values.
  - Verify: `select season_year, count(*) filter (where division is not null) from teams where lid=1 group by season_year` shows 2027 at zero, matching 2026.

### Knowledge Base Updates

- [x] **Record the forecast modelling contract**
  - Entity: a new `text/league/` document (plain path, not a wiki link, until it exists) describing which measure decides which berth, the tie to Amendment XL, and the rule that a simulation must move every measure the format reads.
  - Purpose: this contract exists only in the amendment and in code today.
  - Verify: `base entity get` returns the created entity; this task carries a `relates` edge to it.

- [x] **Observe the reusable lesson on this task**
  - Purpose: a Monte Carlo simulation that copies a stat from actuals degrades to a deterministic function of that stat the moment a format change promotes it to a primary sort key. The failure is total rather than gradual, and it is most visible in the season's zero state rather than least.
  - Verify: `base entity get` shows the observation on this task.

- [x] **Reconcile repo documentation**
  - Files: `docs/simulation-system.md`, `docs/guides/scripts.md`, `docs/glossary.md`
  - Purpose: `docs/simulation-system.md` is the canonical subsystem doc and describes both changed entry points; the CLI is new; the All Play and division-winner vocabulary may be missing from the glossary.
  - Verify: read each and edit only what the change actually makes stale; state in the commit which were checked and needed nothing.

### Testing & Validation

- [x] **Module-level spec on the forecast entry point**
  - File: `test/simulation.season-forecast.spec.mjs` (new)
  - Purpose: the coverage the injection seam exists to make possible.
  - Changes: drive `simulate_season_forecast` end to end from synthetic injected score vectors across a range of formats — byes on all-play, at-large on points for, divisions present and absent — asserting the odds each format implies. No mocking: the injected values are real inputs to real code paths, per `[[███████████████████████████████-█████████████]]` § Test Real Behavior.
  - Verify: `yarn test:isolated test/simulation.season-forecast.spec.mjs` green with the test database stopped, proving the seam is real rather than a database test in disguise. No `this.skip()` anywhere in the file.

- [x] **Negative control distinguishing the joint model from independent draws**
  - File: `test/simulation.season-forecast.spec.mjs`
  - Purpose: this is the test that stops someone "simplifying" the shared-index draw back into independent per-team draws.
  - Changes: build two teams whose score vectors are perfectly anti-correlated across indices and assert their all-play records are exact complements. The two teams must be constructed as sharing an NFL game — correlation in this engine exists within a game and is exactly zero across games, so a control built on two teams in different games asserts a joint structure the engine does not provide and would pass against independent draws.
  - Verify: score the control on the SPECIFIC assertion it was built to break, not on the suite going red — per `[[███████████████████████████████-█████████████]]`, a mutant that dies at load kills nothing while reading as a clean kill. Substitute an independent-draw stand-in, confirm this case fails on the complement assertion, and confirm the rest of the file still loads.

- [x] **Non-degeneracy ratchet on the persisted odds**
  - File: `test/simulation.season-forecast.spec.mjs`
  - Purpose: this is the regression that actually shipped.
  - Changes: assert that NO team has `bye_odds` or `playoff_odds` exactly 0 or 1, over a fixture where teams differ. Assert the shape, not the distinct-value count: today's broken output already yields two distinct `bye_odds` values and seven distinct `championship_odds`, so a `count(distinct) > 1` assertion passes against the defect.
  - Verify: confirm the assertion goes RED against the pre-change frozen-stats code. A green it has not been shown can go red is not evidence.

- [x] **Population-level check on the persisted forecast**
  - Purpose: an asset-level spot check cannot distinguish a working simulation from a pass-through. The failure this catches is a forecast that varies for one team while the rest are pinned.
  - Changes: after a save-mode CLI run, query `████████████████████` for the written week and assert that at least eight of ten teams carry strictly fractional `playoff_odds`, that no team is pinned at exactly 0 or 1 on `bye_odds`, and that `championship_odds` is non-uniform across the field.
  - Verify: run the query; record the date, the row count and the command per the baseline rule.

- [x] **Pre-register the production baseline before deploy**
  - Purpose: the comparison is run by a different session at a different time, so the numbers alone do not say how to regenerate them.
  - Changes: capture current `████████████████████` values for the latest `(week, day)` with the reproducing SQL stored beside them in `scratch/codebase-review-league/`. The Background query in this plan is that SQL.
  - Verify: the stored artifact contains both the values and the executable query.

- [x] **Seeded determinism, once the random draws are gone**
  - Purpose: this is the verify deferred from the CLI and playoff-seeding items. It cannot run until all three `Math.random()` sites — `simulate-season-forecast.mjs:355`, `:387`, `:400` — are deleted by the Monte Carlo rewrite and the playoff-bracket replacement.
  - Changes: none; this is a verification item.
  - Verify: two CLI runs at the same `seed` produce byte-identical `--json` output including `championship_odds`, and two runs at different seeds do not. Confirm by grep that no `Math.random()` remains in `libs-server/simulation/simulate-season-forecast.mjs`.

- [x] **Full suite and gates**
  - Verify: `yarn test:isolated` green; `yarn lint`; `yarn check:types`; `npx prettier --check` on the touched files only, since `yarn prettier` rewrites the whole tree.
