---
title: Classify NFL snaps by the playcaller's situation
type: task
status: completed
description: >-
  Classify every snap by the situation the playcaller called it from (mode by score and clock, field
  zone, down and distance), and each team-game by its situation mix and call volume, so profile
  branches declare the situations that decide opportunities.
base_uri: user:task/league/classify-nfl-playcaller-situations.md
created_at: '2026-10-20T05:41:03.710Z'
entity_id: f091f894-53af-49a3-921c-6d92d63ca707
finished_at: '2026-10-20T15:24:23.062Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - subtask_of [[█████████████-██████████████-████-████-███-█████-███-██████████████-███████████]]
started_at: '2026-10-20T14:55:47.836Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-10-20T15:40:50.453Z'
visibility_analyzed_at: '2026-11-05T23:48:30.795Z'
---

# Implementation Plan: Classify NFL snaps by the playcaller's situation

## Overview

- Classify every snap by the situation the playcaller called it from, on three separate axes named by facts: game state (score and clock), field zone, and down and distance. A team-game's situation mix and its call volume (possessions, red-zone trips, short-field possessions) are computed from those classes.
- Impact: a profile branch declares the situations each offense will play in, which decides who gets the opportunities, and is graded against the measured mix. The game-grain classes of [[user:task/league/classify-nfl-games-on-four-axes.md]] stay as descriptors of the result.
- Ordering: [[█████████████-███████████████-██████-███████-██-███████-███████████]] waits on this (operator, 2026-10-20).

## Background

- The kit's situation library (`cli/nfl/situation-library.yaml`, evaluator `cli/nfl/situation_evaluator.py`, readers `read-situational-usage` and `compose-opportunities` in `cli/nfl/pricing-kit.py`) holds quarters, opening script, first drive, two-minute, third down short/medium/long (2 / 3-6 / 7+), red zone, goal-to-go and win-probability extremes. It has no score-margin state, late-game window, backed-up zone, early down or fourth down.
- League code defines neutral three ways: within 6 (`libs-server/load-player-situational-usage.mjs`), within 7 (`libs-shared/default-data-views/data-view-segments.mjs`), within 8 (`libs-shared/constants/score-state-constants.mjs`, canonical). The late window is 240 seconds in the usage loader and 300 in `libs-shared/nfl-classification/classify-nfl-drive.mjs`. Garbage time is 22+ points in the plays presets and win probability outside 0.2-0.8 on drives.
- Volume is already counted: the game-sample team frame (`libs-server/game-sample/load-game-sample-frames.mjs`) counts drives and red-zone trips; `nfl_drives` gives short fields; `libs-server/game-sample/team-game-counted-plays-sql.mjs` is the one definition of a team-game's counted plays.
- Each week 6 offense carries a score-state read (`text/nfl/season/2026/<team>-offense-reads.md` § Score-state play calling) from [[█████████████-█████████████-████-██████-█████-█████-████████]]: where that caller starts chasing. The operator ruled (2026-10-13) that no margin rule answers that.

### Assumptions

- The pre-snap columns are populated on live passes and rushes, 2024 onward: down, distance, clock, score_difference, yard line (observed 2026-10-20: 35,415 of 35,415 in 2024, 35,036 of 35,036 in 2025, 11,620 of 11,620 in 2026).
- `score_difference` is null on a game's plays until enrichment lands after the game (observed on WAS @ SF 2026-10-19, 0 of 144); such snaps take no game state until it does.
- Goal-to-go is derived as `yard_line_100 <= yards_to_go`, the kit's own predicate, not `is_goal_to_go`, which was wrong before 2026-09-25 per the █████████ query-trap sheet.
- The overtime clock is unusable: `seconds_remaining_half` runs negative in overtime (observed 2026-10-20: down to -894 in 2025 REG), so the late-game window takes every overtime snap.
- The 222 games parked by the `nfl-play-score-progression` data check carry unreliable score states; the population check reports them apart.

## Design

- **Three play-grain registry classes, one axis each, named by facts** (operator, 2026-10-20). The first value that holds applies; the offense's side, before the snap.
  - `game_state`: `end_of_half` (second quarter, 120 seconds or fewer left); `late_game_leading` (fourth quarter with 300 seconds or fewer left, or any overtime snap, ahead); `late_game_trailing_or_tied` (same window, behind or level); `trailing_two_scores` (behind by 9+); `leading_two_scores` (ahead by 9+); `within_one_score`.
  - `field_zone`: `backed_up` (90+ yards to the end zone); `open_field` (21-89); `red_zone` (11-20); `inside_ten` (10 or fewer).
  - `down_distance`: `goal_to_go`; `third_or_fourth_short` (1-2 to go); `third_or_fourth_medium` (3-6); `third_or_fourth_long` (7+); `early_down`.
- **Reconciled cut points** (operator, 2026-10-20): neutral is within one score, 8 points; the late window is 300 seconds, matching the drive classes; garbage time is not a game state; the 6- and 7-point sites stay as user filters.
- **No crossed grid** (operator ruling 2026-10-11 holds): each axis stands alone; a read crosses axes in a query.
- **The class curates, the read interprets**: whether `trailing_two_scores` is catch-up for a caller comes from his score-state read, stated in registry terms.
- **One definition, three consumers.** `libs-shared/nfl-classification/classify-nfl-play-situation.mjs` classifies a snap in JS and builds each value's SQL predicate from `libs-shared/constants/play-situation-constants.mjs`. `scripts/export-nfl-classification-registry.mjs` also writes `static-data/nfl-play-situation-predicates.json`, which the kit's evaluator reads. The kit gains the registry values as library entries resolved from that export; its operator seed entries stay as they are, except `goal_to_go`, which matches the registry value exactly and becomes its reference (operator, 2026-10-20).
- **Computed, not stored** (operator, 2026-10-20): `libs-server/load-team-game-situation-mix.mjs` returns each team-game's counted snaps per value, axis by axis, and its possessions, red-zone trips and short-field possessions from `nfl_drives`. No table, sweep or version churn.
- **Knowledge Base Impact**: taxonomy reference regenerated; the kit library's documentation; [[█████████████████████████████████-█-████-███-█-███████]] states score-state reads in registry terms; the █████████ query-trap sheet names the classes. No scratch directory.

## Notes

- The opportunity link (who a staff feeds in each situation) is the kit's `read-situational-usage`, read on-field and under the current caller and quarterback; this plan only gives it registry situations.
- A later need for a data-view column is the trigger to revisit storage.

## Plan

### Core Implementation

- [x] **Cut-point constants** in `libs-shared/constants/play-situation-constants.mjs`, reusing `ONE_SCORE_MAXIMUM_MARGIN`. (no verify — mechanical, covered by the classifier spec)
- [x] **Registry entries** `game_state`, `field_zone`, `down_distance` at play grain, measured, no stored column. Verify: `test/libs-shared.nfl-classification-registry.spec.mjs` asserts the three axes and their values.
- [x] **Classifier and predicates** in `libs-shared/nfl-classification/classify-nfl-play-situation.mjs`. Verify: a spec at every cut boundary, and a database spec that classifies a fixture table both ways (JS and SQL predicate) with identical results.
- [x] **Predicate export** `static-data/nfl-play-situation-predicates.json` from `scripts/export-nfl-classification-registry.mjs`, held fresh by `db/gates/check-classification-registry-vocabulary.mjs`. Verify: the gate passes, and its stale-export negative control reds on an edited predicate.
- [x] **Team-game mix and volume loader** `libs-server/load-team-game-situation-mix.mjs`. Verify: a spec over a two-team fixture game checks every axis sums to the counted plays and the volume counts.
- [x] **Game-sample engine** reads the loader so a sample can cut on a team-game's situation mix. Verify: `scripts/query-game-sample.mjs` with a `trailing_two_scores` share cut returns CHI @ ATL 2026 week 6 for ATL.
- [x] **Kit library gains the registry values** as entries resolved from the predicate export by `cli/nfl/situation_evaluator.py`; its seed entries stay, and `goal_to_go` becomes the registry reference. Verify: before the change, capture `read-situational-usage` for ATL at CHI @ ATL 2026 week 6 with its exact command recorded on this task; after, ATL's `goal_to_go` counts are identical, and the kit's counts for every registry value equal `load-team-game-situation-mix.mjs` for the same team-game.

### Testing & Validation

- [x] **Population check, 2024 onward**: every counted snap with a score state takes exactly one value per axis; each value is non-empty per season; snaps in the 222 parked games and in not-yet-enriched games are reported apart. Verify: the per-season query and its counts recorded on this task.
- [x] **Week 6 2026 spot check**: each team-game's mix and volume beside the review's account (ATL trailing by two scores on most snaps at CHI @ ATL). Verify: the table on this task.
- [x] **Full league suite** before each commit. Verify: `yarn test:isolated` with zero failures beyond known clock-dependent ones, named.

### Knowledge Base Updates

- [x] **Taxonomy reference regenerated.** Verify: `rg -n "game_state" █████████████████████████████████████████████████`.
- [x] **Kit library documentation** names the registry references. Verify: `rg -n "registry" cli/nfl/situation-library.yaml`.
- [x] **Team-profile workflow** states score-state reads in registry terms. Verify: `rg -n "trailing_two_scores" █████████████████████████████████████████████████`.
- [x] **█████████ query-trap sheet** names the situation classes and the goal-to-go derivation. Verify: `rg -n "field_zone" █████████████████████████████████████████████`.

## Population check

Measured 2026-10-20 over the counted snaps of every final REG and POST game, against production. Every snap takes exactly one value on each axis, and every value is non-empty per season; no snap was unclassified.

| Season | Snaps | Trailing two scores | Leading two scores | Within one score | Late leading | Late behind or tied | End of half | Backed up | Inside ten | Goal to go | Third or fourth short |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2024 | 34,903 | 5,261 | 3,700 | 19,466 | 1,120 | 2,805 | 2,551 | 937 | 2,603 | 2,196 | 1,752 |
| 2025 | 34,504 | 5,322 | 3,441 | 19,232 | 1,104 | 2,890 | 2,515 | 924 | 2,565 | 2,085 | 1,843 |
| 2026 | 11,441 | 1,606 | 1,039 | 6,566 | 327 | 1,047 | 856 | 319 | 872 | 739 | 559 |

A few of the 222 parked score-progression games fall in 2025; their snaps classify on the stored score.

## Week 6 2026 spot check

Each offense's counted snaps by game state, with its call volume, from `load-team-game-situation-mix.mjs`.

| Game | Offense | Snaps | Trailing 2+ | Leading 2+ | Within 1 | Late lead | Late behind | End of half | Inside 10 | Third/fourth long | Possessions | RZ trips | Short fields |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| SEA @ DEN | SEA | 80 | 40 | 0 | 19 | 0 | 16 | 5 | 1 | 10 | 12 | 1 | 1 |
| SEA @ DEN | DEN | 56 | 0 | 16 | 29 | 5 | 3 | 3 | 3 | 8 | 13 | 2 | 1 |
| HOU @ JAX | HOU | 59 | 0 | 5 | 43 | 2 | 6 | 3 | 7 | 6 | 10 | 5 | 0 |
| HOU @ JAX | JAX | 67 | 18 | 0 | 37 | 0 | 6 | 6 | 9 | 5 | 10 | 4 | 1 |
| TEN @ IND | TEN | 44 | 14 | 0 | 21 | 3 | 5 | 1 | 1 | 5 | 10 | 2 | 4 |
| TEN @ IND | IND | 77 | 0 | 12 | 50 | 0 | 10 | 5 | 5 | 10 | 11 | 4 | 1 |
| CAR @ PHI | CAR | 72 | 0 | 0 | 60 | 7 | 0 | 5 | 3 | 8 | 12 | 4 | 2 |
| CAR @ PHI | PHI | 61 | 24 | 0 | 34 | 0 | 3 | 0 | 2 | 10 | 11 | 2 | 0 |
| CHI @ ATL | CHI | 72 | 0 | 32 | 25 | 8 | 0 | 7 | 9 | 8 | 14 | 5 | 5 |
| CHI @ ATL | ATL | 50 | 40 | 0 | 3 | 0 | 3 | 4 | 3 | 9 | 12 | 3 | 1 |
| NO @ NYG | NO | 67 | 1 | 17 | 35 | 4 | 0 | 10 | 7 | 8 | 16 | 3 | 4 |
| NO @ NYG | NYG | 67 | 24 | 0 | 34 | 0 | 9 | 0 | 2 | 6 | 16 | 4 | 0 |
| PIT @ TB | PIT | 73 | 26 | 0 | 22 | 0 | 19 | 6 | 6 | 8 | 15 | 4 | 3 |
| PIT @ TB | TB | 73 | 0 | 29 | 25 | 0 | 16 | 3 | 1 | 15 | 14 | 2 | 1 |
| NYJ @ NE | NYJ | 59 | 15 | 0 | 36 | 0 | 5 | 3 | 1 | 4 | 11 | 3 | 1 |
| NYJ @ NE | NE | 67 | 0 | 10 | 40 | 7 | 0 | 10 | 7 | 2 | 11 | 4 | 2 |
| BAL @ CLE | BAL | 57 | 0 | 15 | 31 | 0 | 3 | 8 | 4 | 11 | 11 | 2 | 0 |
| BAL @ CLE | CLE | 60 | 25 | 0 | 20 | 0 | 12 | 3 | 3 | 7 | 11 | 1 | 1 |
| ARI @ LA | ARI | 70 | 0 | 0 | 58 | 3 | 4 | 5 | 8 | 4 | 10 | 4 | 0 |
| ARI @ LA | LA | 56 | 0 | 0 | 43 | 0 | 11 | 2 | 1 | 5 | 10 | 4 | 1 |
| LAC @ KC | LAC | 63 | 34 | 0 | 15 | 0 | 11 | 3 | 4 | 9 | 12 | 3 | 0 |
| LAC @ KC | KC | 55 | 0 | 21 | 27 | 3 | 0 | 4 | 10 | 3 | 11 | 4 | 1 |
| BUF @ LV | BUF | 61 | 0 | 0 | 50 | 7 | 2 | 2 | 6 | 5 | 10 | 5 | 2 |
| BUF @ LV | LV | 61 | 14 | 0 | 35 | 0 | 5 | 7 | 5 | 7 | 10 | 5 | 0 |
| DAL @ GB | DAL | 70 | 40 | 0 | 15 | 0 | 13 | 2 | 4 | 6 | 10 | 4 | 2 |
| DAL @ GB | GB | 68 | 0 | 24 | 30 | 5 | 0 | 9 | 5 | 5 | 10 | 4 | 1 |
| WAS @ SF | WAS | 58 | 37 | 0 | 9 | 0 | 12 | 0 | 3 | 6 | 10 | 4 | 0 |
| WAS @ SF | SF | 60 | 0 | 30 | 20 | 3 | 0 | 7 | 7 | 5 | 9 | 4 | 0 |

- CHI @ ATL: Atlanta ran 40 of 50 snaps trailing by two scores, the review's "44 of its 50 plays down 14 or more" read on the canonical one-score cut. Chicago's 5 red-zone trips and 5 short-field possessions are the volume behind the review's 18 red-zone plays.
- SEA @ DEN (14-17), WAS @ SF (17-23): one-score finals in which the losing offense ran most of its snaps trailing by two scores, which the game's margin band cannot show.

Worktree: /Users/trashman/worktrees/league/feat/classify-nfl-playcaller-situations
