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

209 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.