# 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.