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:
@@ -0,0 +1,208 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user