Files
vyndr/specs/wnba-possession-feed.md
builtbykev 6c34af3414 checkpoint: chain shadow, WNBA possession feed, baseball chain
Backup commit of uncommitted working-tree state found during Legion
recon (Tony resurrection, STEP 0). This work existed only on the
laptop disk.

- chain shadow accrual + probe script (038_chain_shadow.sql)
- WNBA possession feed: ESPN adapter, usage service, verify script
  (039_wnba_player_game.sql)
- baseball chain
- retention/snapshot service updates, tableKeys, matchupKeys
- specs: chain-v1, wnba-possession-feed, wnba-source-survey
- unit tests for the above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnvJAkC3h5QGmb6dipoiWn
2026-08-14 16:53:37 -04:00

9.0 KiB
Raw Permalink Blame History

WNBA v1 — the possession / usage feed

Status: DATA INFRA ONLY. No chainFn, no archetype wiring, no shadow, nothing served. Date: 2026-08-13 Consumer it exists for: the chain's basketball chainFn (usage × possessions × efficiency), which cannot be written without it.


1. Phase 1 — the source, established before any plumbing

Candidates and the choice

candidate verdict
Python nba_api service (WNBA endpoints) Rejected as a dependency. The service is offline in production; a feed we cannot fetch is not a feed.
ESPN site API (site.api.espn.com/.../basketball/wnba) Chosen. Free, no auth, already the host for WNBA schedules, box scores and live tracking.

What it actually returns — verified live, 2026-08-13

summary?event={id} → boxscore.players[].statistics[0]:

keys: minutes, points, fieldGoalsMade-fieldGoalsAttempted,
      threePointFieldGoalsMade-threePointFieldGoalsAttempted,
      freeThrowsMade-freeThrowsAttempted, rebounds, assists, turnovers,
      steals, blocks, offensiveRebounds, defensiveRebounds, fouls, plusMinus

Breanna Stewart: ['30','19','8-21','1-5','2-2','7','5','0','1','0','2','5','2','-2']

plus starter / didNotPlay / ejected per athlete, and boxscore.teams[] with team totals (fieldGoalsMade-fieldGoalsAttempted, freeThrowsMade-freeThrowsAttempted, totalTurnovers, offensiveRebounds). Team minutes sum to 200 in regulation (5 × 40), so overtime enters through the same sum. Play-by-play is also present (378 plays on the sampled game) but is not needed — the box identities suffice.

chainFn inputs: delivered, derived, or missing

chainFn input status how
minutes SERVED minutes per player
usage rate DERIVED (exact) 100·((FGA+0.44·FTA+TOV)·(TmMIN/5)) / (MIN·(TmFGA+0.44·TmFTA+TmTOV))
possessions DERIVED (exact) FGA − OREB + TOV + 0.44·FTA
pace DERIVED (exact) possessions × 40 / (TmMIN/5)
efficiency DERIVED (exact) TS% PTS/(2(FGA+0.44·FTA)), eFG% (FGM+0.5·FG3M)/FGA
game state (redistribute hook) SERVED starter, team/opp score → final_margin
shot location / zone MISSING not in the box score; not a chainFn input today
on/off, lineup combinations MISSING would need play-by-play reconstruction
opponent defensive rating MISSING derivable later from the same rows (each game is also the opponent's)

"Derived" is not "proxied", and the distinction is load-bearing. ESPN does not serve a usage rate, a possession count or a pace figure — it serves their components, and these are the standard identities recomputed from counted events. The only estimated term anywhere is the 0.44 free-throw-trip coefficient, which is the field-standard value.

Point-in-time capability

Native. No statcast_history-style split is needed, and this is a design choice made at line one rather than a retrofit.

statcast_aggregates had to grow a history twin because it stores a season aggregate upserted in place, destroying every prior version — which is why the first skill backtest was honest only by accident. This stores per-game rows. A completed box score never changes, so an as-of profile is WHERE game_date < asOf: a filter over immutable facts. Nothing is overwritten, so there is nothing to retain a history of.

Strictly <, never <=. A game on the as-of date may have tipped after grade time; counting it leaks the evening being predicted into the prediction. Same rule as snapshotSettlementService.isPreGame.


2. Phase 2 — the feed

wnba_player_game (migration 039). Key (game_id, source_id), registered in src/utils/tableKeys.js, so safePaginate can walk it. Indexed (sport, player_key, game_date DESC) — every read is "this player, before this date".

Scoped to the chainFn's inputs plus the redistribute game-state. Not a general WNBA stats dump — closing_captures grew to 4.2M rows of something nothing read, and the lesson is to ingest for a named consumer or not at all.

Every derived rate is stored alongside its components, so it is re-derivable and checkable rather than an unfalsifiable number — the factorFreeze rule applied to a feed.

Coverage — full live season, measured

rows          5,085          games      256        players  241
teams            15          days        88        errors     0
date range    2026-05-01 .. 2026-08-12

FIELD COVERAGE
  minutes           5085/5085      starter          5085/5085
  usage_rate        5072/5085      final_margin     5085/5085
  team_possessions  5085/5085      ts_pct           4821/5085
  team_pace         5085/5085      efg_pct           4772/5085

The ts_pct / efg_pct gaps are correct refusals, not missing data: a player with zero field-goal and free-throw attempts has no true-shooting percentage, and returning 0 would assert he shot and missed.

Two contaminating games found and excluded

The season pull surfaced two games that are not league games:

  • 2026-05-02 — an exhibition against Nigeria (NIGER vs IND)
  • 2026-07-25 — the All-Star game (SPO vs COOP)

Their usage and pace context is meaningless for a forward projection (an All-Star game has no defence to speak of), and leaving them in would pollute every profile spanning those dates.

The filter reads ESPN's own /teams endpoint rather than a hardcoded fifteen — an expansion franchise is admitted the day the league adds it, and this league has expanded twice in three years. An empty teams response is treated as unknown membership and filters nothing, because a failed feed degrading to an empty index looks exactly like an honest absence (the fielding_oaa lesson).


3. Phase 3 — verification, on real data through the real functions

scripts/wnba-feed-verify.js drives the production parsers over the live season and then calls wnbaUsageService.profileAsOf against those rows. It exercises the shipped code, not a restatement of it.

The derivation, checkable by hand

Laura Juskaite (TOR) 2026-05-01
  MIN 28  FGA 10  FTA 2  TOV 3  PTS 6
  team: MIN 200  FGA 68  FTA 16  TOV 11  OREB 7

  possessions   stored 79.04    recomputed 79.040
  usage_rate    stored 23.046   recomputed 23.046

The as-of read

player: Natasha Howard — 36 games on record

  asOf 2026-05-12   REFUSED (2 games precede — under the 3-game floor)
  asOf 2026-06-24   games=18 (expected 18)  usage 24.826  min/g 28.11  pace 82.683  TS 0.5998
  asOf 2026-08-12   games=35 (expected 35)  usage 22.057  min/g 28.51  pace 82.275  TS 0.6010
  asOf 2026-12-31   games=36 (expected 36)  usage 22.027  min/g 28.50  pace 82.241  TS 0.6042

  STRICT CUTOFF — a game ON the as-of date is EXCLUDED (correct)
  THIN HISTORY  — asOf before his first game returns null (refused, correct)

The profile moves with the date (24.8 usage in June, 22.1 by August), which is the property that makes it a point-in-time read rather than a season number wearing a date.

Two refusals that are features

  • Thin history ⇒ null, never a league-average player. Usage feeds the chain's opportunity term directly; a stand-in would assert a usage rate about someone never observed. Same discipline as platoonSeverity's 60-PA floor.
  • Usage is MINUTES-WEIGHTED, not a flat mean across games. The lineup-K-rate lesson: an unweighted aggregate counts a 6-minute cameo like a 34-minute start, and unweighted actively hurt that model.

4. Not done, and not claimed

  • No chainFn. Basketball's usage × possessions × efficiency is the next order. This order stops at the feed.
  • No archetype wiring, no shadow, no serving. Nothing reads this table yet.
  • MLB untouched. A test asserts chainShadow.js, baseballChain.js and analyzeViaEngine1.js contain no reference to the WNBA feed.
  • CALIBRATION_DEPLOYED still []; A8 accrual, the chain's MLB shadow and the served grade are untouched.

5. ⛔ Blocked: the table is NOT created and NOT ingested

SUPABASE_DB_PASSWORD in the local .env fails authentication against the pooler (aws-1-us-east-1... → password authentication failed for user "postgres"), and the direct host resolves IPv6-only, which is unreachable from this WSL2 environment. The transposed project ref already documented in CLAUDE.md is the likely cause.

So:

  • migration 039 is written and unapplied
  • ingestRange is written and has never written a row
  • Everything in §2's coverage and §3's verification was measured by running the real parsers and the real as-of read over the live season in memory. The parse, the derivations and the as-of logic are verified on real data; the database round-trip is not.

That distinction is stated rather than glossed: a feed that has never been written is not a feed yet.

Also still outstanding: migration 038 (model_snapshots.chain_shadow) from the chain orders, for the same reason.