Skip to content

Render the seasons nflverse has published, rather than failing on one it has not - #11

Merged
stxkxs merged 1 commit into
mainfrom
season-rollover
Aug 25, 2026
Merged

stxkxs merged 1 commit into
mainfrom
season-rollover

Conversation

@stxkxs

@stxkxs stxkxs commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Why

The app breaks on 1 September 2026, about a week before the opener.

current_season() rolls to 2026 on 1 September, but nflverse publishes a season's assets only once
its games are played. Verified against the live API with the opener two weeks out:

2026 asset Status
schedules ✅ 272 rows
seasonal_rosters ✅ 2,930 rows
snap_counts / weekly_data / injuries ❌ 404
pbp ❌ NameError

And a mixed request fails wholesale — import_snap_counts([2025, 2026]) raises 404 rather than
returning the published 2025 season. So one unpublished season poisoned a request that a full prior
season would have satisfied, and five of six tools showed only an error toast for ~10 days.

This is pre-existing, not introduced by the recent refactor — the old hardcoded [2024, 2025] had
the same cliff, deferred until someone edited it by hand. The hermetic test suite couldn't catch it
because it never touches the network.

What changed

Tolerance, not prediction. A season list resolves one season at a time and drops what the source
hasn't published, so [2025, 2026] renders 2025 throughout the gap. Only when nothing resolves
does it raise, naming the dataset and season. Returning an empty frame instead would hand the
engines a schema-less frame — the ColumnNotFoundError class that start_sit and trade_value
carry declared output schemas to prevent.

Both failure shapes at one narrowed seam. A missing asset is HTTPError (an OSError).
import_pbp_data instead raises NameError, because its handler reads except Error as e: and
Error is bound nowhere in that module — the handler raises in place of the failure that reached
it. TimeoutError is re-raised ahead of both, since it also derives from OSError and a stalled
transfer is not a missing season.

Cache keys on one season, not a season list. The request is already split per season, so a
partial result becomes structurally unrepresentable rather than defended against. Seasons are shared
across windows, and a season published between loads is picked up because its key is simply absent.

current_season → season_for(day). The old name promised "the season we are in, which
therefore has data" — the reading that aimed six tools at unpublished assets. The new one names only
what it does. The September boundary is confirmed against every schedule since 1999.

A bug found while simulating September: start_sit and trade_value derived their season
filter from the schedule frame, which publishes months early — so both offered a season with no
statistics and defaulted to it, rendering empty against older data they were holding. Both now
derive options from the frame they render, matching the other four tools.

How tested

Live probe, real network, this repo's own loaders, today:

load_snap_counts([2025, 2026])  -> OK rows=26612 seasons=[2025]
load_snap_counts([2026])        -> RuntimeError: nflverse has not published snap counts for season 2026
load_weekly_stats([2025, 2026]) -> OK rows=5641  seasons=[2025]
load_pbp([2025, 2026])          -> OK rows=48771 seasons=[2025]
load_schedules([2025, 2026])    -> OK rows=557   seasons=[2025, 2026]

Plus all six tools driven headlessly with the date resolved to 2026-09-02: none shows an error
toast, every season filter offers only seasons with data, and every table renders.

Gate Result
ruff format --check / ruff check 0 / 0
mypy src (strict, no carve-out) 0
pytest --cov 0 — 466 tests, 87.14% vs floor 87
uv build 0

Reviewer notes

  • An unpublished asset costs one failed round trip per load — nothing negative is cached, which
    is what lets a season appear the moment nflverse publishes it. Measured at 14 failed requests
    across one pass through all six tools on 2026-09-02.
  • _team_bye_weeks was checked and cleared — schedules publish complete before any game, and it
    returned 32 teams with one bye each against the real 2026 schedule.
  • Start/Sit and Trade Value stay quiet early in a season by design: both need three weeks of
    history and say so in their empty state rather than rendering a projection they cannot support.
  • Injury Impact runs one season shallower during the gap, since its window is three seasons and
    only two have data.
  • Stale composite-key cache entries (snap_counts_2024_2025.parquet) are never read again and
    survive orphan reaping because their meta records exist. No correctness impact; cache.invalidate()
    clears them if you want the disk back.

… one it has not

nflverse publishes a season's assets only once its games are played, while the season
label turns over on 1 September. Every tool asked for the new label from that date, so
for the ten days before the opener five of the six requested data that could not exist.
A mixed request made it total: `import_snap_counts([2025, 2026])` raises HTTP 404 rather
than returning the published 2025 season, so one unpublished season poisoned a request a
full prior season would otherwise have satisfied.

Measured against live nflverse for 2026 with the opener two weeks out: schedules and
seasonal rosters resolve, published months ahead; snap counts, weekly data and injuries
return 404; play-by-play raises NameError.

─── Tolerance ───

loader — a season list resolves one season at a time and drops a season the source has
not published, logging which and why. The result carries whichever seasons resolved, so a
window spanning a published and an unpublished season renders the published one.

Nothing resolving raises, naming the dataset and the seasons: "nflverse has not published
snap counts for season 2026". Returning an empty frame instead would hand the engines a
frame with no columns, which raises ColumnNotFoundError at the first filter — the defect
class start_sit and trade_value carry declared output schemas to prevent.

Unavailability arrives in two shapes and both are caught at one narrowed seam. An absent
release asset surfaces as urllib.error.HTTPError, an OSError subclass. `import_pbp_data`
instead raises NameError: its download handler reads `except Error as e:` and `Error` is
bound nowhere in that module, so the handler raises in place of the failure that reached
it. TimeoutError is re-raised ahead of both, since it also derives from OSError and a
stalled transfer is not a missing season. The seam wraps a single call into nfl_data_py,
so a NameError raised by this repository stays a NameError.

cache — entries key on one season rather than a season list. The request is already split
per season, so the season is the unit of work; making it the unit of storage leaves a
partial result structurally unrepresentable rather than defended against. Seasons are
shared across windows, and a season published between two loads is picked up by the second
because its key is simply absent.

`load_weekly_stats` keeps its play-by-play fallback and gains the same tolerance one level
deeper: a season neither the weekly asset nor play-by-play covers is skipped rather than
fatal. The fallback issues one play-by-play request per derived season, which is what
makes an individual season skippable.

─── Season resolution ───

`current_season` promised "the most recent season to have played games" and returned the
new label from 1 September with none of its games played. The name carried the same
promise: a caller reading "current season" reads "the season we are in, which therefore
has data", and that reading is what aimed six tools at unpublished assets.

`season_for(day)` names only the operation it performs — which season a date falls in —
and takes its date, so the module is pure calendar arithmetic that six screens call with
no I/O. `recent_seasons` keeps its name and its call signature.

Resolving availability rather than the label was the alternative and buys nothing: the
screens derive their filter from the seasons present in loaded data, so a season
contributing no rows leaves the filter on its own. It would also make every caller load
schedules before knowing what to load, inverting the data layer's one-way layering.

The September boundary is confirmed against every published schedule from 1999: each
season opens in September of its label year and ends in January or February of the next,
the extremes being a 3 September opener and a 13 February final.

─── Season filters ───

start_sit, trade_value — season options came from the schedule frame rather than from the
frame being ranked. Schedules publish months early, so both tools offered a season with no
statistics and defaulted to it, rendering empty against data one season older that they
held. Both derive their options from the frame they render, matching the other four tools.

─── Tests ───

Loader coverage models nflverse's wholesale failure directly: a request naming an
unpublished season yields none of the seasons beside it. Both unavailability shapes, the
all-unavailable message, the weekly fallback skip, a non-availability failure still
propagating, and both halves of the per-season cache contract are pinned. Season tests
cover both sides of the boundary and the winter months from an injected date.

Co-authored-by: stxkxsbot <275011021+stxkxsbot@users.noreply.github.com>
@stxkxs
stxkxs merged commit 8b0a3d8 into main Aug 25, 2026
2 checks passed
@stxkxs
stxkxs deleted the season-rollover branch August 25, 2026 05:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant