Architecture · Snapcount data + modeling layer

Six stages.
Three independent gates.
One quarantined holdout.

This is the actual architecture, not a hand-wave. Each stage is re-runnable, idempotent, and refuses to start if the upstream stage failed. The validator hard-fails the pipeline on schema drift before bad data reaches modeling. The walk-forward harness invokes the leakage gate before the first model fit. The holdout 2025 season is read exactly once.

From the build log (spring 2026), kept current where it matters: the sealed 2025 season was subsequently opened exactly once — a frozen-weights replay published after the season on the 2025 proof page. The pipeline and gates described below still run on every commit.

§ 01The pipeline § 02Source taxonomy § 03Two-tier eval § 04Live vs training § 05Validator (3 layers) § 06Live snapshot contract § 07Walk-forward harness § 08Decision log
§ 01 · The pipeline

Ingest → Validate → Freeze → Features → Harness → Models

Six stages, all idempotent. scripts/ingest_raw.py calls the validator inline; if validation fails, ingest exits non-zero and the freeze step refuses to start.

01 / INGEST nflreadpy 27 sources 2016 → 2026 scripts/ingest_raw.py 02 / VALIDATE 3-layer schema + coverage + in-season currency scripts/validate_raw.py 03 / FREEZE two-tier slice val 2024 wks 9+ holdout 2025 scripts/freeze_holdout.py 04 / FEATURES Layer 0 + 1 id-keys + lagged M3 adds 2/3/4 calculators/ 05 / HARNESS walk-forward CV leakage gate 14 folds 2024 wk 9-22 eval/harness.py 06 / MODELS Ridge / XGB 8 (pos × target) target-aware loss models/ ↓ FAILS HERE if schema drift ↓ FAILS HERE if leakage gate → HOLDOUT 2025 data/holdout_2025/ · NEVER read in loop read once at ship-gate
FAIL-CLOSED

Three independent gates, all enforced

(1) validate_raw.py hard-fails ingest on schema drift. (2) walk_forward_splits invokes validate_feature_columns(live=True) at split-time, before any model fit. (3) holdout_seasons=(2025,) is honored by both walk-forward and season-rollover splits, AND by the runner's pre-filter.

IDEMPOTENT

Re-run anything, get the same artifact

Every stage writes a deterministic output. Re-running ingest produces the same parquets (modulo nflverse releases). Re-running freeze produces the same slices. Re-running the harness produces bit-identical fold predictions (XGBoost tree_method=hist + random_state=0; sklearn Ridge with random_state=0).

§ 02 · Source taxonomy

Four schema kinds. One filter rule each.

Every nflverse source falls into one of four schemas. Knowing which one a source belongs to determines how we filter it for slicing.

KindDefining traitFilter ruleExample sources
ASeason + Week Has season AND week columns; no nflverse_game_id season == X AND (week ≥ min_week OR week IS NULL) pbp, player_stats, team_stats, schedules, snap_counts, officials, ngs_*, rosters_weekly, injuries, pfr_advstats_*, depth_charts_legacy, ff_opportunity_weekly
BSeason only Has season but no week (annual snapshot) season == X rosters
CGame-keyed Has nflverse_game_id as authoritative join (with or without season/week) nflverse_game_id IN slice.game_ids participation, ftn_charting
DTimestamp-keyed Has dt ISO8601 column instead of season/week dt ∈ [Aug 1 of season X, Mar 1 of X+1) depth_charts_timestamped (post-2025 nflverse schema)
ZAll-time No season key (reference tables) Not slice-able; read whole players, teams, combine, draft_picks, contracts, ff_playerids
§ 03 · Two-tier eval

Validation drives the loop. Holdout opens the gate.

The freeze produces two physically-separate slice directories. Both are read-only post-freeze; the AutoResearch loop is contractually forbidden from writing to them.

VALIDATION · loop reads on every iteration

2024 weeks 9 onwards

Drives the AutoResearch loop's keep-or-revert decision. Every change to features, model class, hyperparameters, anything — gets evaluated here. Walk-forward CV across 14 folds (weeks 9-22), per fold the model trains on everything strictly earlier and predicts the target week.

season == 2024 AND week ≥ 9
  • games162
  • PBP rows28,198
  • walk-forward folds14
  • pathdata/validation_2024_w9plus/
HOLDOUT · ship-gate only · NEVER in loop

2025 entire season

The sacred ship-gate. The AutoResearch loop NEVER reads this. Belt + suspenders: run_baseline.py drops season == 2025 from the input frame BEFORE the harness; walk_forward_splits filters again; holdout_seasons is honored by both split flavors.

season == 2025 (all weeks 0..23)
  • games285
  • PBP rows48,771
  • depth-chart events517,851
  • pathdata/holdout_2025/

Per-source filtering uses one of four functions depending on the source's kind: _filt_season_week (predicate-based, captures wk 0/23/null), _filt_season_only, _filt_nflverse_game_id, _filt_dt_year. Two invariants enforced: (1) every source filters cleanly for at least one slice; (2) row counts match the raw season-predicate count exactly — no silent leak from PBP-only pair sets.

§ 04 · Live vs training surface

Some columns are training-only. The validator enforces this.

The model that ships to POST /api/snapscore/predict runs the LIVE pipeline. A research model can use the full training surface for upper-bound studies, but it doesn't serve traffic.

Live surface ALLOWED

Released within ~24h of game completion or known pre-kickoff. Safe to use as features in the production pipeline.

pbp pre-kickoff cols player_stats lagged schedules + Vegas snap_counts lagged avg_time_to_throw aggressiveness CPOE avg_separation RYOE avg_yac_above_expectation pfr_advstats_week_* ftn_charting lagged rosters_weekly depth_charts (live)

Block-list FORBIDDEN LIVE

Three surfaces: every column in participation.parquet (FTN end-of-regular-season backfill), PBP postgame mirrors, post-game telemetry.

was_pressure route defense_coverage_type defense_man_zone_type ngs_air_yards time_to_throw (per-play) n_pass_rushers n_offense / n_defense defenders_in_box offense_personnel defense_personnel offense_formation epa / wpa / qb_epa yards_gained / play_type complete_pass / fumble temp / wind head_official series_result fixed_drive_result

Per @realfrankbrank 2025-09-02 (FTN): "we will backfill each year at the end of the regular season." 2025 participation lands ~Feb 2026 post-Super-Bowl. The leakage validator runs at split-time via validate_feature_columns(feature_columns, live=True); any pipeline using a forbidden column raises LeakageError before training starts. 16 anti-leakage tests pin the contract; one is schema-driven against the actual participation.parquet file.

§ 05 · Validator (3 layers)

scripts/validate_raw.py — three layers, fail-closed.

Schema drift catches added/removed/dtype-changed columns. Coverage invariants catch missing seasons + weeks + key uniqueness violations. In-season currency catches a stale ingest at game-day. Each layer is independently tested with planted-regression smoke tests.

LAYER 1

Schema drift

HARD FAIL

Diff against data/raw/_expected_schemas.json (committed baseline). Any column that disappears, or whose dtype changes, fails the ingest.

  • Removed columns
  • Changed dtypes
  • Added columns (warn only)
LAYER 2

Coverage invariants

HARD FAIL

Per-source row count, per-season presence, per-week-set match. Plus PBP (game_id, play_id) uniqueness and depth-charts dual-schema invariant.

  • Row count regression > 10%
  • Missing season
  • Missing week per season
  • PBP key uniqueness
  • Dual-schema integrity
LAYER 3

In-season currency

HARD FAIL (in-season)

If today is in the NFL game window (Sept-Feb), 14 live-critical sources MUST have rows for the current season AND latest-week lag ≤ 2 weeks behind today. Otherwise predictions go stale.

  • Current season presence
  • Lag > 2 wks → fail
  • Lag = 1 wk → warn
§ 06 · Live snapshot contract

Per-source max-staleness, enforced at predict time.

Pre-kickoff inputs (current injuries, current depth charts, current Vegas lines, weather forecast, inactives list) are moving targets. Every prediction binds to a single (target_game_id, as_of_ts) tuple with per-source freshness guarantees. M_serve scope; spec'd in M0 architecture.

Live sourceProvidermax_stalenessRationale
injuriesAdam's ESPN/PFR scrape4hNFL official injury reports finalize Fri 4 PM ET; can shift Sat AM
depth_chartsnflverse depth_charts_timestamped24hDaily updates; rare same-day swings unless injury cascade
vegas_linesSportsbook API (TBD)1hLines move fast near kickoff; sharp money typically lands T-3h
weather_forecastNWS / Tomorrow.io6hForecast accuracy stabilizes inside 6h of kickoff
inactivesNFL inactives feed1.5hOfficially announced 90 min before kickoff
rosters_weeklynflverse7dUpdates Wed; covers the gameweek
schedules (Vegas opening)nflverse schedules24hOpening line + venue + opponent — slow-changing
pbp_lagged / ngs_laggednflverse48hPrior-week stats; landed by Tue AM

A snapshot is valid iff every source's actual_staleness_seconds ≤ max_staleness_seconds. Otherwise the loader raises StaleSnapshotError and the prediction is blocked. POST /api/snapscore/predict returns 503 instead of guessing.

§ 07 · Walk-forward harness

The contract: predict at (S, W) reads only data from before (S, W).

17 boundary tests pin this contract via synthetic frames. Every fit instantiates a fresh model — no parameters carry across folds. The imputer + scaler fit on each fold's train rows separately; no global statistics leak.

InvariantWhat's testedStatus
train never contains target weektrain.filter(season == S AND week == W).is_empty()✓ pinned
train never contains future seasonstrain.filter(season > S).is_empty()✓ pinned
train never contains future weeks same seasontrain.filter(season == S AND week > W).is_empty()✓ pinned
train DOES contain prior seasonspositive coverage check✓ pinned
train DOES contain prior weeks of same seasonpositive coverage check✓ pinned
train sizes monotonically grow fold-over-foldwalk-forward shape✓ pinned
min_train_weeks gate filters early foldsno fold fires with insufficient train✓ pinned
season_filter restricts predict seasonsarg-handling correctness✓ pinned
holdout_seasons excluded from train AND predictquarantine integrity✓ pinned
leakage gate fires at split-time on blocked columnraises before any fit✓ pinned
training mode permits everything (live=False)gate is gated on live flag✓ pinned
schema-driven leakage coverage from diskevery participation column blocked✓ pinned
§ 08 · Decision log

What we deliberately chose, with dates.

Every architectural decision is logged with a date and a reason. Future readers see the why, not just the what.

DateDecisionWhy
2026-05-08Migrate from nfl_data_py to nflreadpyUpstream parquet renames broke 2025 ingest under the archived nfl_data_py
2026-05-08Two-tier eval (validation 2024 wks 9+ + holdout 2025)Validation drives the loop; holdout is sacred ship-gate
2026-05-08Split depth_charts at ingestnflverse changed schema mid-2025; one parquet would mix two key formats
2026-05-09Predicate-based filtering (not pair-set) for Kind ACodex C1: PBP wks 1-22 missed NGS wk 0/23 + officials wk 23, leak risk
2026-05-09Add validate_raw.py 3-layer validatorCatch silent nflverse regressions at ingest, not at modeling
2026-05-09Holdout min_week=0 (full season including wk 0/23)Ensure NGS preseason aggregates can never leak to training
2026-05-09Commit _expected_schemas.json baselineAnyone cloning the repo can verify their fresh ingest matches our committed baseline
2026-05-09XGBoost target-aware objective (poisson for TDs)Poisson predicts non-negative rate; fixes Ridge "negative TD" sMAPE inflation
2026-05-09Add team_changed_since_last_game indicator (codex F4)Lets model down-weight prior-team signal on free-agency / trades
2026-05-09Block play_id + nflverse_game_id in leakage gate (codex F1)Even though these are normally join keys, the validator's contract is "no FEATURE column from participation"
2026-05-09Remove min_target_value auto-default (codex F2)Label-based filter introduced selection bias; default cohort is now unfiltered