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
+40
View File
@@ -0,0 +1,40 @@
-- Migration 038: model_snapshots.chain_shadow — the SHADOW CHAIN read.
--
-- The chain (src/services/model/chain.js) computes a probability by chaining
-- base-event atoms: p per plate appearance from the PA outcome tree, chained
-- over expected plate appearances. It is SERVED BY NOTHING. The counter
-- (probabilityEstimator) remains the only thing a user ever sees.
--
-- WHY A COLUMN AT ALL. Evidence you cannot adjudicate is not evidence. A chain
-- probability stored on its own could only ever be compared to itself. This
-- column stores the chain's number NEXT TO the number it must beat, on a row
-- that already carries the result:
--
-- chain_shadow.chain_p the chain's probability, SIDE-ALIGNED
-- chain_shadow.counter_p the served counter's p_win for that same side
-- model_snapshots.outcome written by the ordinary settle pass
--
-- Those three make the triple. Without all three on one row, the chain could be
-- described but never judged.
--
-- SIDE ALIGNMENT IS LOAD-BEARING. The chain computes P(over the line); p_win is
-- expressed for the graded side. An under row stores 1 - p_over. Storing the raw
-- over-probability against an under row would invert every later comparison,
-- silently.
--
-- IT IS LABELLED UN-SERVABLE IN THE DATA, not only in a comment: every block
-- carries status 'UN-SERVABLE' and servable=false, because it was produced with
-- requireCalibrated=false and the chain has never passed a calibration gate.
--
-- A SEPARATE COLUMN, not extra keys inside `features` — champion-ablation.js
-- iterates every key of `features` for its residual scan, so widening it would
-- silently enlarge that multiple-comparisons denominator. Same reasoning as 034.
--
-- NULLABLE and populated only on MLB `hits` rows the chain could read, so the
-- storage cost is bounded. Adding a nullable column is metadata-only in
-- Postgres — no table rewrite — which matters while this database sits near its
-- free-tier size cap.
ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS chain_shadow jsonb;
COMMENT ON COLUMN model_snapshots.chain_shadow IS
'Chain v1 SHADOW: side-aligned chain_p + the served counter_p, forming the (chain_p, counter_p, outcome) triple with model_snapshots.outcome. UN-SERVABLE (requireCalibrated=false); read by no serving path.';
@@ -0,0 +1,106 @@
-- Migration 039: wnba_player_game — the WNBA possession / usage / minutes feed.
--
-- The chain's basketball chainFn is `usage x possessions x efficiency`. None of
-- those three existed for WNBA anywhere in this codebase; this is the feed that
-- makes the slot fillable. It is scoped to exactly those inputs plus the
-- game-state the `redistribute` hook reads — NOT a general WNBA stats dump.
-- (closing_captures grew to 4.2M rows of something nothing read; the lesson is
-- to ingest for a named consumer or not at all.)
--
-- ── POINT-IN-TIME IS STRUCTURAL, NOT A LATER RETROFIT ──────────────────────
-- `statcast_aggregates` stores a SEASON AGGREGATE upserted in place, so every
-- prior version is destroyed and a point-in-time question is unanswerable from
-- it — which is why `statcast_history` had to be built afterwards, and why the
-- 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, not a
-- dated snapshot of a mutable aggregate. There is nothing to overwrite, so there
-- is nothing to retain a history OF.
--
-- STRICTLY `<`, never `<=`: a game played ON the as-of date may have tipped
-- after grade time, and counting it would leak the evening being predicted into
-- the prediction. Same rule as isPreGame in snapshotSettlementService.
--
-- ── THE KEY ────────────────────────────────────────────────────────────────
-- (game_id, source_id) is unique: one row per player per game. `game_date` and
-- `sport` lead the index because every read is date-bounded and single-sport,
-- and safePaginate orders on the full tuple. Registered in src/utils/tableKeys.js
-- — a stale entry there surfaces as a runtime duplicate-tuple throw.
--
-- ── DERIVED vs SERVED ──────────────────────────────────────────────────────
-- ESPN returns the COMPONENTS (minutes, FGA, FTA, TOV, OREB and team totals),
-- not the rates. `usage_rate`, `team_possessions`, `team_pace`, `ts_pct` and
-- `efg_pct` are computed here from the standard box-score identities and stored
-- so a chainFn read is one query. Every component is stored ALONGSIDE its
-- derived value, so each rate is re-derivable and checkable rather than an
-- unfalsifiable number (the factorFreeze rule: store inputs, not just outputs).
CREATE TABLE IF NOT EXISTS wnba_player_game (
sport text NOT NULL DEFAULT 'wnba',
season integer,
game_id text NOT NULL,
game_date date NOT NULL,
source_id text NOT NULL,
player_key text NOT NULL,
player_name text,
team text,
opponent text,
home_away text,
starter boolean,
-- raw box components (the usage/possession inputs)
minutes numeric,
points integer,
fgm integer,
fga integer,
fg3m integer,
fg3a integer,
ftm integer,
fta integer,
reb integer,
oreb integer,
dreb integer,
ast integer,
tov integer,
stl integer,
blk integer,
pf integer,
plus_minus integer,
-- derived, per the identities in espnWnbaAdapter
usage_rate numeric,
ts_pct numeric,
efg_pct numeric,
-- team possession context, denormalised so a chainFn read is ONE query
team_minutes numeric,
team_fga integer,
team_fta integer,
team_tov integer,
team_oreb integer,
team_possessions numeric,
team_pace numeric,
-- game state — what the archetype `redistribute` hook reads (live in
-- basketball: a blowout fades the star and feeds the bench)
team_score integer,
opp_score integer,
final_margin integer,
ingested_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, source_id)
);
-- Every read is "this player, before this date" or "this slate's date range".
CREATE INDEX IF NOT EXISTS wnba_player_game_asof_idx
ON wnba_player_game (sport, player_key, game_date DESC);
CREATE INDEX IF NOT EXISTS wnba_player_game_date_idx
ON wnba_player_game (sport, game_date);
COMMENT ON TABLE wnba_player_game IS
'WNBA per-player-per-game possession/usage/minutes feed for the chain basketball chainFn. PER-GAME grain makes point-in-time native: an as-of profile is WHERE game_date < asOf (strictly), over immutable completed box scores. Scoped to chainFn inputs + redistribute game-state.';
COMMENT ON COLUMN wnba_player_game.usage_rate IS
'DERIVED (not served by ESPN): 100*((FGA+0.44*FTA+TOV)*(TmMIN/5))/(MIN*(TmFGA+0.44*TmFTA+TmTOV)). Components stored alongside so it is re-derivable.';
COMMENT ON COLUMN wnba_player_game.team_possessions IS
'DERIVED: FGA - OREB + TOV + 0.44*FTA. The 0.44 free-throw-trip coefficient is the only estimated term.';