---
title: Refuse a forecast for a league with no schedule, and clear league 119's contradictory odds
type: task
status: completed
description: >-
  Clone league 119 publishes playoff_odds and championship_odds summing to 0 while bye_odds sum to
  2, because it has no matchups rows for 2026 at all; the producer publishes zeros instead of
  refusing.
base_uri: user:task/league/refuse-a-forecast-for-a-league-with-no-schedule.md
created_at: '2026-09-25T21:22:35.935Z'
entity_id: 3b083d85-488e-4e25-ade6-d48a5af73ce3
finished_at: '2026-09-29T01:02:45.494Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
started_at: '2026-09-28T23:00:30.146Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-29T01:31:04.924Z'
visibility_analyzed_at: '2026-11-05T23:49:59.324Z'
---

## Problem

**Clone league 119 publishes a playoff forecast that contradicts itself, and it has been doing so every day since 2026-09-02.** Measured 2026-09-25 against the live `████████████████████` rows generated at 20:30 that day.

| lid | `sum(playoff_odds)` | `sum(bye_odds)` | `sum(championship_odds)` |
|---|---|---|---|
| 1 | 6.0000 | 2.0000 | 1.0000 |
| **119** | **0.0000** | **2.0000** | **0.0000** |
| 121 | 6.0000 | 2.0000 | 1.0000 |

So league 119 hands two teams a first-round bye out of a playoff field of **zero** teams, and tells every team it cannot win the championship. These columns render on the team dashboard, the standings page and the probability-leverage chart, so the contradiction is served rather than merely stored.

**It is not a configuration difference.** League 119's `seasons` row is identical to league 1's on every playoff field — `playoff_team_count` 6, `bye_count` 2, `bye_candidate_pool` `league`, `bye_selection_method` `all_play`, `division_berth_count` 0, `head_to_head_berth_count` 2, `playoff_format_kind` `scored_rounds`. The two leagues are asked the same question and only one answers.

## The cause, measured

**League 119 has ZERO `matchups` rows for 2026.** It has no schedule at all.

```sql
select lid, count(*) as matchup_rows, count(distinct week) as weeks
from matchups where season_year = 2026 and lid in (1, 119, 121) group by lid;
```

League 1 returns 70 rows across 14 weeks and league 121 returns 108 across 18. **League 119 returns no row whatsoever** — it is absent from the result set rather than present at zero, which is why nothing scanning for a low count would have found it.

**That explains the exact shape of the contradiction, which is the strongest part of the diagnosis.** `bye_odds` comes off the all-play pool and needs no head-to-head record, so it computes correctly. `playoff_odds` and `championship_odds` run through playoff seeding, which needs a head-to-head record that does not exist, so the field comes back empty and every team's odds are zero. A cause that did not distinguish the three columns would not produce 0 / 2 / 0.

## Two defects, and the second is the one that matters

**A league with no schedule cannot be forecast, and the producer does not know that.** It publishes zeros instead of refusing, which is the confident-wrong-number failure the external-league work exists to prevent — sharpened here, because the zeros are self-contradictory and a reader can see they are wrong without being able to tell what the right answer would be. This is the same class the platform already refuses elsewhere: `libs-server/league-starter-scoreability.mjs` raises a `DerivedDataRefusal` for a league whose rosters it cannot price, and the shape carries directly.

**Whether clone 119 should have a schedule at all is an OPERATOR question.** It is a clone, so the honest answer may be that it should never be forecast rather than that its schedule is missing. The refusal is correct either way and does not depend on the answer, which is why it goes first.

## Plan

- [x] **Refuse a forecast for a league-season with no schedule**, following `assert_league_starters_are_scoreable` — a `DerivedDataRefusal` naming the league, the season and the absent `matchups` rows, raised once per league-season before anything is computed, so the scheduled loops skip it without skipping a real fault.
  - Verify: the refusal fires for league 119 season 2026 and does NOT fire for leagues 1 or 121, asserted as a pair so a refusal that fired on everything could not pass. Confirm it goes red with the predicate inverted.
- [x] **Delete league 119's contradictory rows** rather than leaving them behind the new gate, which is what was done for mirrors 122 and 123. A refusal that stops new wrong rows while the old ones keep rendering has fixed nothing a reader can see.
  - Verify: `sum(playoff_odds)`, `sum(bye_odds)` and `sum(championship_odds)` return no row for lid 119 season 2026; leagues 1 and 121 unchanged, sums still 6 / 2 / 1.
- [x] **Ask the operator whether clone 119 should carry a schedule or stay unforecast.** A clone with no schedule may be exactly what it is meant to be. This is a gate, not a task step — do not guess it, and do not generate a schedule for a league nobody asked to have one.
- [x] **Check whether any other league-season is in this state**, enumerated from the leagues the forecast loop actually reaches rather than from the three that happened to be looked at. A league absent from a grouped count reads as clean, so the query has to be a left join from the league set.
  - Verify: state the population checked and its size, not just the count of offenders.

## Done when

- No league-season with no schedule carries a forecast row, and the producer refuses it by name rather than publishing zeros.
- Leagues 1 and 121 are unchanged, proved as a pair against the pre-change sums.
- The operator has ruled on whether league 119 should have a schedule.
