---
title: Conform the matchup projection column to one estimator
type: task
status: completed
description: >-
  Bring matchups.home_projection/away_projection to a single reproducible estimator across all
  seasons, and retire the current-season binds that blocked historical rebuilds. Complete: all seven
  seasons run through one estimator over starter baselines that now exist for every year, with the
  zero phantom closed and measured.
base_uri: user:task/league/conform-matchup-projection-column.md
created_at: '2026-09-10T16:44:21.458Z'
entity_id: 78474d52-bbf2-47a2-af11-c1409e81cf78
finished_at: '2026-09-10T19:46:18.692Z'
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-10T19:46:18.692Z'
visibility_analyzed_at: '2026-11-05T23:48:36.777Z'
---

## Objective

Bring `matchups.home_projection` / `away_projection` to a single, consistent, reproducible estimator across every season, and retire the current-season binds that made a historical rebuild impossible.

## Final state

All 526 matchups in league 1 carry a projection, all seven seasons from ONE estimator: `project_lineups_for_past_season` (`scripts/project-lineups.mjs`) then `calculate-matchup-projection.mjs`, over `starter` baselines that now exist for every season from the same `calculateBaselines` definition the live board uses.

The three win-probability columns (`home_win_probability`, `away_win_probability`, `simulation_timestamp`) were dropped — dead in both directions, non-null on 0 of 526 rows.

Source projections (`scoring_format_player_projection_points`) sit clean in the 25-31 band (max 31.18, p99 20.05), so no season-total contamination. Post-rebuild projections span 85-165.

Per-season measurements are in the two tables below.

## Plan

- [x] Confirm the queued league commits reached origin (origin/master at `19efa8b9a`, guard `pass`; gates-guide commit rode behind live siblings)
- [x] Get an operator ruling on re-running 2023-2025 through the reconstruction path — ruled yes 2026-09-10
- [x] Re-run `project-lineups --year <y>` then `calculate-matchup-projection --year <y>` for 2023, 2024, 2025 and re-read bias/corr per season
- [x] Drop the stale swagger docs in `api/routes/leagues/matchups.mjs` (matchup-panel lane closed; taken over, committed `fe529a951`)
- [x] Decide whether `get-players.mjs` should honour its own `season_year` for projection reads — removed the parameter, commit `800af0e71`
- [x] Derive the `process_average_projections` POST playoff week from the requested season — commit `9b800f0f4`
- [x] Drop the stale `home_projection` swagger note — commit `4f21669ed`
- [x] Operator ruling: should past seasons get `starter` baselines rather than a zero phantom? — ruled CONSISTENCY 2026-09-10
- [x] Widen the `league_baselines` unique key to include `season_year`, with the consumer sweep — applied, commit `2ba3aed97`
- [x] Reconstruct past-season starter baselines from the shared definition — commit `bc4d57a97`, dry-run clean on all six seasons
- [x] Deploy `5f277b68d` to both hosts, gated on no live auction period — verified on both, build manifest and served bundle checked
- [x] Drop the season-blind index once the host carried the new conflict target — commit `23244fa5a`
- [x] Backfill starter baselines for 2020-2025, re-run `project-lineups` and `calculate-matchup-projection`, re-measure

## The current-season binds are closed

Both landed 2026-09-10. `get-players.mjs` no longer accepts a `season_year` — the parameter reached two of its joins while the rosters, all nine projection reads and the lineup contributions stayed pinned to the live year, and no caller ever passed it. A past-season player view already has its own module in `libs-server/context-docs/get-players.mjs`. `process_average_projections` now derives the POST playoff weeks from the requested season's own published source data, on the same rule `final_week` already used for REG, and the past-season refusal is gone.

## The zero phantom is closed

Measured against `███████████████████` for lid 1, before and after the backfill. The phantom fires when `baseline_total` exceeds `optimal_total` — that is, when an unfillable starting slot scores at replacement level rather than 0.

| Season | fired before | fired after | avg gap after | max gap after |
| --- | --- | --- | --- | --- |
| 2020 | 0 of 192 | 25 | 0.370 | 9.00 |
| 2021 | 0 of 204 | 30 | 1.064 | 17.00 |
| 2022 | 0 of 204 | 25 | 0.623 | 21.00 |
| 2023 | 0 of 204 | 17 | 0.216 | 6.00 |
| 2024 | 0 of 170 | 20 | 0.465 | 10.00 |
| 2025 | 0 of 170 | 15 | 0.353 | 11.00 |
| 2026 | 23 of 170 | 23 | 0.665 | 14.00 |

0 of 1144 became 132 across 2020-2025, at rates and magnitudes matching the live season — and 2026 is byte-identical before and after, which is what shows the live path was not disturbed. `project-lineups` also stopped emitting its `WARNING: no starter baselines` line, the oracle that had been firing on every past-season run.

## What it cost in calibration, measured rather than predicted

Home-side bias and correlation, the same basis the table above this one uses:

| Season | bias before | bias after | corr before | corr after |
| --- | --- | --- | --- | --- |
| 2020 | +0.5 | +0.85 | 0.332 | 0.334 |
| 2021 | +6.5 | +7.16 | 0.563 | 0.553 |
| 2022 | +7.6 | +8.08 | 0.640 | 0.623 |
| 2023 | +2.8 | +2.93 | 0.329 | 0.330 |
| 2024 | -0.4 | -0.03 | 0.487 | 0.489 |
| 2025 | +5.0 | +5.29 | 0.486 | 0.486 |

Exactly the predicted shape and size: bias moves up by 0.2-0.7 because scoring an unfillable slot at replacement level can only raise a projection, and correlation is unchanged to within 0.017. Mean absolute error moved by less than 0.7 in every season. All 526 matchups still carry a projection, spanning 85-165.

## A standing asymmetry this measurement surfaced, not caused

Away-side bias runs far above home-side in five of six seasons — 2021 is +7.16 home against +17.34 away, 2022 +8.08 against +11.58, 2025 +5.29 against +8.60. The gap predates this lane and is untouched by it; the recorded table in earlier revisions of this task was home-side only, which is why it read lower than a pooled figure. Worth its own investigation: a systematic 10-point away-side over-projection is not something the estimator should produce.

## How the migration was sequenced

`league_baselines` now keys on `(lid, season_year, week, player_position, type)` with `season_year` `NOT NULL`. It could not simply be swapped: `ON CONFLICT` resolves against a unique index at EXECUTION, so dropping the old season-blind key while the deployed hourly `process_league` still named it would have failed every baseline upsert for the length of a human-gated deploy. So it went in two applies with the deploy between them — add the widened key and retain the old one, ship the writer, confirm the host carries it with `git merge-base --is-ancestor`, then drop.

The overlap is not free in the other direction, and that set the backfill's timing: while the old index survived it still held one lid to a single row per `(week, position, type)` across all seasons, so no past-season row could be written until the drop landed.

Two consumers needed sweeping for the widened grain, and the second was a defect that predated this lane. The baselines API route read the table with no season predicate — harmless while only one year existed, and once history arrived it would have handed the SPA several years for one key with the heap picking the winner. And `app/core/players/reducer.js` destructured `pos`, a leftover from the 2026-08-04 rename to `player_position` that no consumer gate covers (`app/` is in none of their corpora), so every row from that route had been landing under the key `undefined`.

## Two properties of the reconstruction that look wrong and are not

Both are inherited from the shared definition rather than introduced, and both are documented at `process_league_baselines_for_past_season`.

A position with NO starting slot still gets a row, carrying the best AVAILABLE player rather than a worst starter — `calculateBaselines` ends on `worstStarter || groupedRemainingPlayers[0]`. So kicker rows for 2023 onward, whose formats set `starter_slots_kicker: 0`, read near 14 against a real replacement level. Nothing consumes them, since `optimizeLineup` can never seat a phantom at a position with no slot, and 2026's own live row has the same shape. Filtering it would have been the second estimator this rebuild exists to avoid.

2020's kicker and defense baselines sit far below every other season (1.1 and 1.2 against 6-8). That is the SOURCE data carried faithfully: 2020 kicker projections average 1.20 and top out at 4.10, where every other season averages 6.0-6.9.

One fallback exists in the backfill: the max-roster-per-position caps are lifted for a week ONLY after that week has already failed with the real limits, so it cannot change a result it was not needed for. It fired in 8 of 101 week-runs — 2025 week 7 holds a team with four defenses against a limit of three. `calculateBaselines` re-seats a roster by benching every starter and refilling, and a BENCH move routes through the eligibility variant written for admitting a NEW signing; moving a player already on the roster to its own bench cannot exceed a cap he was not already inside.

## Notable context

- `docs/guides/gates.md` § "Run a gate against the target it will run against in `verify`" — written from this lane's red-master incident.
- `db/adhoc/2026-09-10-remove-2020-season-totals-misfiled-as-weekly.sql` and `-remove-three-residual-2020-season-totals.sql` — the 2020 source repair and the discriminator that scoped it.
- `docs/guides/schema.md` governs any further DDL here.
