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
9.0 KiB
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 (NIGERvsIND)2026-07-25— the All-Star game (SPOvsCOOP)
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 asplatoonSeverity'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 × efficiencyis 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.jsandanalyzeViaEngine1.jscontain no reference to the WNBA feed. CALIBRATION_DEPLOYEDstill[]; 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
ingestRangeis 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.