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
This commit is contained in:
Kev
2026-08-14 16:53:37 -04:00
parent 0657b71d18
commit 6c34af3414
22 changed files with 4958 additions and 32 deletions
+294
View File
@@ -0,0 +1,294 @@
# WNBA source survey — possession / play-by-play options
**Status:** READ-ONLY SURVEY. Nothing built, nothing decided, nothing ingested.
**Date:** 2026-08-13
**Question:** what is the best source to build the basketball chainFn's possession
feed — including the feedback layer — on?
---
## 0. Correction to the premise, and the real gap
The v1 report did **not** conclude "box scores only, no feedback loop". It
recorded that ESPN play-by-play *is* present ("378 plays on the sampled game")
and that it was not needed for the box identities, and it listed shot location
and on/off as MISSING **from the box endpoint**.
The real gap the order names is correct though: **one source was surveyed.** And
that produced a false ceiling — this survey found event-level data, shot
coordinates, and pre-parsed per-player possessions across four other sources.
**The single most important finding is an environment artifact, not a data fact:**
> `stats.wnba.com` fails over IPv6 and **works over IPv4**. Default curl (and
> Node's default resolver order) picks the AAAA record, connects, and hangs. The
> same IPv6 pathology that made `db.<ref>.supabase.co` unreachable in WSL2.
> Anything in this environment that reports a stats.nba/stats.wnba endpoint as
> "unreachable" should be re-tested with `-4` before it is believed.
---
## 1. `stats.wnba.com` / `playbyplayv3` — what `nba_api` wraps
**Reachable: YES, over IPv4 only. Sustained ingest: NO.**
```
curl -4 ... "https://stats.wnba.com/stats/playbyplayv3?GameID=1022600001&StartPeriod=1&EndPeriod=4"
→ HTTP 200, 207,769 bytes, 3.78s
game.actions: 469 events
fields: actionId, actionNumber, actionType, clock, description, isFieldGoal,
location, period, personId, playerName, playerNameI, pointsTotal,
scoreAway, scoreHome, shotDistance, shotResult, shotValue, subType,
teamId, teamTricode, xLegacy, yLegacy
1 PT10M00.00S Jump Ball J. Jones Jump Ball Jones vs. Griner
1 PT09M44.00S Missed Shot A. Morrow MISS Morrow 27' 3PT Jump Shot
1 PT09M39.00S Rebound M. Johannes Johannes REBOUND (Off:0 Def:1)
```
**Resolution:** full event stream with **shot coordinates** (`xLegacy`/`yLegacy`),
`shotDistance`, `shotValue`, `shotResult`, `personId`, running score. Richer raw
detail than anything else surveyed.
**Rate limits — the disqualifier.** One call succeeded. Every subsequent call
returned **HTTP 000 (connection accepted, then stalled)**: immediately after,
after 5s, and again after a ~3 minute cool-down at a 50s timeout. Two sibling
endpoints (`boxscoreadvancedv3`, `boxscoreplayertrackv3`) returned 000 on first
attempt. This is the well-known stats.nba.com throttle posture.
**`nba_api` is not installed locally** (`ModuleNotFoundError`); it is pinned in
`src/services/python/requirements.txt:7` — the offline Python service.
**Point-in-time:** per-game, immutable ⇒ native, same as every source here.
**Verdict:** richest raw feed, **hostile to sustained ingest**. Not a dependency.
---
## 2. `wehoop` / sportsdataverse — the bulk archive
**Reachable: YES. Best cost profile of any option.**
```
GET github.com/sportsdataverse/wehoop-wnba-data/raw/main/wnba/pbp/parquet/play_by_play_2026.parquet
→ 3,090,207 bytes (the WHOLE 2026 season, one file)
archive: play_by_play_2021 (2.63 MB) … 2024 (3.39) … 2025 (4.06) … 2026 (3.09 MB)
columns include: athlete_id_1/2/3, athlete_name_1/2/3, coordinate_x, coordinate_y,
coordinate_x_raw, coordinate_y_raw, clock_display_value, clock_minutes,
clock_seconds, period_number, home_score, away_score, score_value,
shooting_play, team_id, type_id, type_text, wallclock, home_team_spread, …
```
**What it wraps:** ESPN's WNBA feeds, pre-collected and normalised.
**Coverage:** 2021 → live 2026, updated through the season.
**License:** repo reports `NOASSERTION` (sportsdataverse projects are generally
MIT/CC-BY; **the license needs confirming before redistribution** — it does not
block internal analytical use, but it is not a clean SPDX tag).
**Access:** one HTTP GET per season. No rate limit, GitHub CDN.
**Footprint:** ~3 MB/season compressed — the cheapest full-PBP option by an order
of magnitude, and materially relevant with the DB at 406/500 MB.
**Verdict:** the **backfill and cross-check** source. Cannot serve tonight's game
(it lags the live feed), so it is not the freshness layer.
---
## 3. PBPStats (`api.pbpstats.com`) — possessions already parsed
**Reachable: YES. Richest *derived* layer. This is the standout.**
```
GET api.pbpstats.com/get-games/wnba?Season=2026&SeasonType=Regular Season
→ 250 games, 2026-05-08 .. 2026-08-12, HomePossessions/AwayPossessions on 250/250
{"GameId":"1022600001","Date":"2026-05-08","HomeTeamAbbreviation":"NYL",
"HomePoints":106,"AwayPoints":75,"HomePossessions":87,"AwayPossessions":88}
GET api.pbpstats.com/get-game-stats?Type=Player&GameId=1022600001&League=wnba
→ 47 fields PER PLAYER PER GAME:
Usage, OffPoss, DefPoss, Minutes, Points, TsPct, EfgPct, ShotQualityAvg,
SecondChanceOffPoss, PenaltyOffPoss, PenaltyOffPossPct, PenaltyDefPoss,
Arc3FGA, Arc3Frequency, AtRimFG3AFrequency, Avg2ptShotDistance,
Avg3ptShotDistance, LongMidRangeAccuracy/FGA/FGM/Frequency,
ShortMidRangeFGA/Frequency, Blocked2s, FoulsDrawn, ShootingFouls, …
```
**This delivers the entire chainFn input set pre-computed**, and `OffPoss` is
**better than the derived team possessions in v1**: it is the possessions the
player was actually on the floor for — the true usage denominator, not a team
estimate apportioned by minutes.
**WOWY (with-or-without-you) — the feedback layer, and it works:**
```
GET api.pbpstats.com/get-wowy-combination-stats/wnba?Season=2026&SeasonType=Regular Season
&TeamId=1611661313&PlayerIds=1629568
→ HTTP 200
{"OffRtg":113.41,"DefRtg":109.77,"NetRtg":3.65,"Minutes":1370.0,
"On":"","Off":"Kennedy Burke", …}
```
On/Off splits by player combination — the direct measurement of what changes when
a player is off the floor.
**Rate posture:** 6 rapid sequential calls → **200, 200, 200, 200, 200, 200.** No
throttling observed. (Endpoints are undocumented and unversioned; `get-possessions`
returned 500 with valid params and `get-lineup-stats` 404 — the surface is
uneven, and a 422 helpfully names missing params.)
**Point-in-time:** per-game rows ⇒ native.
**Footprint:** ~5,000 player-game rows/season × 47 fields ≈ single-digit MB.
**Verdict:** **the primary source.**
---
## 4. Basketball-Reference `/wnba/` — scrapeable, and permitted
**Reachable: YES. `/wnba/` is NOT disallowed.**
```
robots.txt User-agent: * Crawl-delay: 3
Disallow: /basketball/ (…team paths…) ← /wnba/ is absent
GET /wnba/boxscores/202605080NYL.html → 200 426,971 b
GET /wnba/boxscores/pbp/202605080NYL.html → 200 245,516 b
GET /wnba/boxscores/shot-chart/202605080NYL.html → 200 174,986 b
GET /wnba/boxscores/plus-minus/202605080NYL.html → linked from the boxscore
3 sequential pbp fetches at the stated 3s crawl-delay → 200, 200, 200
```
**Note:** the earlier 404s in this survey were a wrong URL guess of mine
(`…0PHO`), not a BBR limitation. The real ids come off
`/wnba/years/2026_games.html`.
**Coverage:** play-by-play, shot charts AND plus-minus per game.
**Cost:** HTML scrape + parse, 3s crawl-delay ⇒ ~13 min for a 250-game season.
**ToS:** `Disallow` does not cover `/wnba/`; `Crawl-delay: 3` must be honoured.
`GPTBot` is banned outright, so identify honestly and stay slow.
**Verdict:** best **independent audit cross-check** (a second opinion on
possessions from a different parser). Too slow and too brittle for primary.
---
## 5. ESPN WNBA (already wired) — the freshness layer
**Reachable: YES, already in production use.**
```
summary?event=401857134 → plays: 378
fields: awayScore, clock, coordinate, homeScore, id, participants, period,
pointsAttempted, scoreValue, scoringPlay, sequenceNumber, shootingPlay,
shortDescription, team, text, type, wallclock
1 9:45 Pullup Jump Shot athlete 4433403 coord {x:16,y:25} 3-0
1 9:26 Fade Away Jump Shot athlete 2998928 coord {x:31,y:1} 3-2
1 9:17 Driving Floating Jump Shot athlete 4433403 coord {x:30,y:3} 3-2
```
Event-level with coordinates and athlete ids — so **shot location was never
actually missing**; it was missing from the *box* endpoint I read in v1.
**Stability:** undocumented but long-lived, no auth, no observed throttle, and
already the host for schedules/box/live-tracking in this codebase.
**Verdict:** the **live/tonight** layer.
---
## 6. News / context layer — game state, actives, rest
**No scraper needed. ESPN already serves it.**
```
GET .../basketball/wnba/injuries → HTTP 200, 14 teams, 46 entries
Atlanta Dream | Brionna Jones | Out | Leg
Chicago Sky | Maddy Westbeld | Out | Coach's Decision
summary?event=… also carries per-game injuries for both sides:
Aliyah Boston Day-To-Day
Caitlin Clark Day-To-Day
Damiris Dantas Out
```
**The one real limitation: this is a LATEST-ONLY snapshot.** There is no as-of
query for injury state, so point-in-time actives require **capturing it daily** —
which is exactly what the existing changedetection/cron stack is for. That is a
small dated table, not a scraper build.
Miniflux/SearxNG/RSS would add *narrative* (beat-reporter rest news ahead of the
official designation). Useful later; **not required** for the feedback layer,
because ESPN injuries + `starter` + `Minutes` already answer "who played, who
sat, who started".
---
## 7. Scorecard
| | possession-level for feedback? | as-of? | cost / footprint | reliability & ToS | live freshness |
|---|---|---|---|---|---|
| **PBPStats** | **YES — per-player `OffPoss`/`DefPoss`/`Usage` + WOWY on/off** | native (per-game) | ~single-digit MB/season | 6/6 rapid 200s; undocumented, uneven surface | good (through 08-12) |
| **wehoop** | YES — full event stream | native | **~3 MB/season** (best) | GitHub CDN; license `NOASSERTION` — confirm | lags live |
| **ESPN** | YES — 378 events w/ coords | native | small | already in prod, no throttle seen | **best (live)** |
| **BBR** | YES — pbp + shot chart + plus-minus | native | scrape, 3s delay ⇒ ~13 min/season | `/wnba/` allowed, crawl-delay 3 | good |
| **stats.wnba.com** | YES — richest raw (shot coords, 469 events) | native | small | **HOSTILE — 1 call then HTTP 000, still blocked after 3 min; IPv4-only** | good if you could call it |
---
## 8. Ranked recommendation
**A combination, not one source. Three roles:**
1. **PBPStats — PRIMARY.** It has already done the possession parsing, and
per-player `OffPoss` is the correct usage denominator rather than my v1
team-level estimate apportioned by minutes. `Usage`, `TsPct`, `ShotQualityAvg`
and the shot-zone splits arrive free. **WOWY gives the feedback layer
directly.** Tradeoff: undocumented, unversioned, single maintainer, uneven
endpoint surface (a 500 and a 404 in this survey) — so it needs a fallback,
and its numbers should be cross-checked once against a second parser.
2. **ESPN — LIVE + CONTEXT.** Tonight's game before PBPStats has it, plus
injuries/actives. Already wired, already trusted in this codebase.
3. **wehoop — BACKFILL + CROSS-CHECK.** One 3 MB GET replaces 250 API calls for
a historical season, and being an independent collection of the same ESPN
feed it is a genuine second opinion. Confirm the license before anything
leaves the building.
**Not recommended as a dependency:** `stats.wnba.com`. Richest raw data,
unusable throttle. Worth keeping as a *manual* one-off tool now that the IPv4
workaround is known.
**BBR:** hold as an audit path. Its independent possession parse is the best
available check on PBPStats, at 3s/request.
### Can the FEEDBACK LAYER be built now?
**Yes — with PBPStats, and without deferring.** Two mechanisms are already
available:
- **Usage redistribution:** per-player `Usage` + `OffPoss` per game, joined to
who was out that night (ESPN injuries, captured daily). "How does this
player's usage move in games where the primary creator sat" is then a direct
measurement, not a model.
- **Blowout → minutes:** `final_margin` (already in v1's feed) against
`Minutes` and `OffPoss` per game.
**Ingest cost:** 1 call per game (~250/season) + 1 injuries call/day. Storage
~single-digit MB — against 94 MB of current headroom.
**What is genuinely still missing:** *within-game* possession-by-possession
lineup state (who was on the floor at each moment). `get-lineup-stats` 404s.
Deriving it needs substitution events reconstructed from the raw PBP — available
in wehoop/ESPN/BBR, but a real build. Not required for the two mechanisms above.
---
## 9. Nothing built
No ingest, no schema, no chainFn. The v1 feed (`wnba_player_game`, migration 039)
remains written and unapplied; this survey is evidence that **its source choice
should be revisited before it is applied** — PBPStats supersedes the derived-usage
approach with a measured one.