---
title: Restore the forecast leverage columns
type: task
status: planned
description: >-
  The playoff, bye and championship odds_with_win/_with_loss columns are read by the API and the
  probability-leverage chart but nothing has written them since 2025; the historical writer ran 2N+1
  full simulations, so restoring them is a cost redesign rather than a code change.
base_uri: user:task/league/restore-the-forecast-leverage-columns.md
created_at: '2026-08-30T16:51:38.010Z'
entity_id: e1fe2ef5-1d0b-4e13-93a1-97ec564de9d1
observations:
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Low
public_read: true
relations:
  - follows [[user:task/league/correct-the-season-forecast-model.md]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-08-30T20:11:49.427Z'
visibility_analyzed_at: '2026-11-05T23:50:13.660Z'
---

## Overview

Restore the `*_with_win` / `*_with_loss` forecast columns, which the API and the probability-leverage chart still read but which nothing has written since the 2025 season.

**Impact.** `probability-leverage-chart.js` is mounted at `standings.js:226` and reads `playoff_odds_with_win`, `bye_odds_with_win` and `championship_odds_with_win` (plus the `_with_loss` counterparts) by interpolating its key from `chart_data` at `:13-26`. With nothing written since 2025 the chart has no data to render for the current season. Restoring the write makes it work again.

**Ordering.** Split out of `[[user:task/league/correct-the-season-forecast-model.md]]` on 2026-08-30, after an adversarial audit established that this is a cost redesign rather than a code change and does not belong inside that task. **Blocked by it**: conditional sampling over draw indices is only meaningful once the forecast draws correlated per-week score vectors at all. Do not start this before that task lands.

## Background

### The regression

Measured 2026-08-30: `select season_year, count(*), count(playoff_odds_with_win) from ████████████████████ group by season_year` returns 490 of 1030 written in 2024, 1450 of 1580 in 2025, and **zero** in 2021, 2022, 2023 and 2026. The columns are read at `api/routes/leagues/teams.mjs:353-360` and assigned at `:378-385`, and typed as nullable at `api/swagger/config.mjs` and `app/core/teams/team.js:24-31`.

### Why restoring it is not a small change

The historical writer was `scripts/simulate-season.mjs`, added in `b2a8f3863` ("feat: calculate playoff, bye, and championship odd leverage", 2024-10-22) and since deleted. Its shape, read from `git show b2a8f3863:scripts/simulate-season.mjs`: it called `simulate()` once for a base result at `:57`, then inside a loop over teams called it **again with `force_win_tid`** at `:68-72` and **again with `force_loss_tid`** at `:75-79`, reading the resulting odds off each.

That is **2N+1 full simulations** — 21 for a ten-team league.

`simulate_season_forecast` inherits the same singular-parameter shape: `force_win_tid` and `force_loss_tid` are scalars (`libs-server/simulation/simulate-season-forecast.mjs:44-45`), and the function returns four odds per team. `run_season_forecast` calls it exactly once (`scripts/process-projections.mjs:95-99`). So adding the eight columns to the insert block writes eight `undefined` values — the data does not exist to write.

### The cost problem this creates

`process-projections` is the only caller, runs hourly from cron, and takes roughly nine minutes (`docs/guides/ship.md:140`). Multiplying the whole forecast by 21 is not viable at that cadence, and the host has a documented history of cron-collision load incidents.

Any restoration therefore has to answer the cost question first, which is why this is its own task rather than a checklist item. Three shapes are worth costing before choosing:

- **Reuse the draws across all forced teams.** The per-week `raw_team_scores` vectors and the playoff pre-pass are the expensive part, and they do not depend on which team is forced. If the forecast builds them once and the conditioning is only a filter over draw indices, 2N+1 conditioned passes over cached vectors may cost far less than 2N+1 forecasts. This is the shape the correlated rewrite makes possible and is the reason this task is blocked by it.
- **Compute leverage for a subset.** The chart renders one team at a time. Writing the columns only for teams whose matchup is undecided, or only on demand, may serve the surface without 2N+1 anything.
- **Retire the columns and the chart.** If the cost cannot be brought down, the honest end state is removing the chart panel and dropping the columns rather than leaving a read path with no writer. This is a real option, not a fallback.

### The refusal-threshold interaction

Conditional sampling refuses when too few draw indices carry the forced outcome. That refusal has a routine trigger: once every NFL game in the first remaining week is final and `use_actual_results` is true, every score vector is constant, each win probability is exactly 0 or 1, and one of the two subsets is empty by construction — every week, between the last game and the week rollover.

If the forecast forces both a win and a loss for every team every hour, that window makes the refusal fire for every team. `run_season_forecast` has no catch, so `process_league` would abort before the `leagues.processed_at` update and trip the staleness oracle. **Any design here must make the degenerate window return a defined answer rather than throw** — when a team's outcome is already determined, its conditional odds are simply its unconditional odds, which is a correct answer and not a failure.

### Assumptions

- **The historical writer made 2N+1 calls.** Checked: `git show b2a8f3863:scripts/simulate-season.mjs`, base call at `:57`, forced calls at `:68` and `:75` inside a per-team loop.
- **`force_win_tid` / `force_loss_tid` are singular and currently have zero callers.** Checked: declared at `simulate-season-forecast.mjs:44-45`; repo-wide grep returns only the declarations and internal uses.
- **The insert block is shared by all three forecast types.** Checked: `scripts/process-projections.mjs` uses one insert block for `simulate_season_forecast`, `simulate_wildcard_forecast` and `simulate_championship_forecast`. The latter two produce no `*_with_*` values, so any added columns must be conditional or explicitly null on those branches.
- **`division_odds_with_win` / `_with_loss` have no reader.** Checked: `probability-leverage-chart.js:13-26` defines exactly three chart keys and division is not among them. Write them null or not at all; do not build a display.
- **Wall-clock cost of every proposed shape is UNCHECKED.** This is the task's central open question and its first work item.

## Design

Deliberately not fixed. The cost measurement decides the design, and taking the measurement is the first item. What IS settled:

- The degenerate-window case returns unconditional odds rather than throwing.
- Whatever shape is chosen must not multiply the hourly job's runtime materially.
- If no shape achieves that, retiring the chart and the columns is the chosen end state rather than leaving the current read-with-no-writer limbo.

### Knowledge Base Impact

- No new entity. The finding belongs as an observation here and on the parent task.

## Notes

- Do not start before `[[user:task/league/correct-the-season-forecast-model.md]]` lands; conditional sampling over draw indices presupposes the draws exist.
- Use `yarn test:isolated`, never `yarn test:db:up && yarn test`.

## Plan

### Core Implementation

- [ ] **Measure the cost of each candidate shape before choosing one**
  - Purpose: the design question is entirely a cost question, and no figure exists today.
  - Changes: with the correlated rewrite in place, time (a) one base forecast, (b) one conditioned pass reusing cached per-week vectors and the playoff pre-pass, and (c) a full second forecast. Establish whether a conditioned pass is a small fraction of a forecast or comparable to one.
  - Verify: record all three timings with the command and date in `scratch/codebase-review-league/`; state the ratio explicitly. Run each three times and discard the first, so the figures are warm.

- [ ] **Choose and record the shape**
  - Purpose: reuse-the-draws, subset, or retire. The measurement decides.
  - Changes: write the decision and its basis into this plan's Design section, then expand the remaining items against it.
  - Verify: the Design section names one shape and cites the timing that chose it. If the shape is "retire", replace the remaining items with the removal of the chart panel, the API columns and the DDL.

- [ ] **Make the degenerate window return unconditional odds**
  - File: `libs-server/simulation/simulate-season-forecast.mjs`
  - Purpose: when a team's first remaining week is already decided, one conditioned subset is empty by construction. That is a determined outcome, not a failure, and its conditional odds equal its unconditional odds.
  - Changes: return the unconditional result for a forced outcome that already occurred, and the degenerate 0/1 for one that cannot; do not throw. Refuse only when the subset is non-empty but too small to estimate — measured standard error over 1000 draws is 5.0pp at ~100 indices, 7.8pp at ~41, 20.4pp at ~6.
  - Verify: a spec pinning a week whose games are all final asserts the forecast returns rather than throwing, for both a forced win and a forced loss; confirm the same input throws under a naive empty-subset guard.

- [ ] **Write the columns on the season-forecast branch only**
  - File: `scripts/process-projections.mjs`
  - Purpose: the insert block is shared with the wildcard and championship forecasts, which produce no conditional values.
  - Changes: add the `*_with_win` / `*_with_loss` values only where the season-forecast branch produced them; leave them absent or explicitly null on the other two branches rather than emitting undefined bindings.
  - Verify: after a run on each of the three branches, `select count(playoff_odds_with_win) from ████████████████████ where ...` is non-zero for the season branch and null-valued rather than erroring for the other two.

### Testing & Validation

- [ ] **Monotonicity check on the conditional odds**
  - Purpose: the columns are only meaningful if forcing a win cannot lower a team's odds.
  - Changes: assert `playoff_odds_with_win >= playoff_odds >= playoff_odds_with_loss` for every team over a fixture.
  - Verify: confirm the assertion goes RED against an implementation that flips the outcome rather than conditioning the draws.

- [ ] **Runtime ratchet on the hourly caller**
  - Purpose: this task's whole risk is runtime.
  - Changes: assert the full `run_season_forecast` for a ten-team league completes within a stated multiple of the base forecast.
  - Verify: state the multiple and the measured baseline; confirm the check fails if the implementation regresses to 2N+1 full forecasts.

### Knowledge Base Updates

- [ ] **Record the read-with-no-writer lesson**
  - Purpose: a column read by a live UI surface but written by nothing is invisible until someone queries write counts by season. The chart renders empty rather than erroring.
  - Verify: `base entity get` shows the observation.
