Files
vyndr/scripts/wnba-feed-verify.js
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

145 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* wnba-feed-verify — prove the tap, the ingest and the as-of read on REAL data.
*
* Runs the production parsers over the live WNBA season and holds the rows in
* memory, then drives `wnbaUsageService.profileAsOf` against them through a stub
* client. It exercises the SAME functions the pipeline will call — not a
* restatement of them — so a passing run is evidence about the code that ships.
*
* node scripts/wnba-feed-verify.js [--from=2026-05-01] [--to=2026-08-12]
*/
const adapter = require('../src/services/adapters/espnWnbaAdapter');
const usage = require('../src/services/wnbaUsageService');
const { nameKey } = require('../src/utils/playerName');
const arg = (k, d) => {
const hit = process.argv.find((a) => a.startsWith(`--${k}=`));
return hit ? hit.split('=')[1] : d;
};
/** A stub client that serves in-memory rows through the real read path. */
function memClient(rows) {
return {
from() {
const state = { rows: rows.slice(), order: [], range: null };
const q = {
select: () => q,
eq: (col, val) => { state.rows = state.rows.filter((r) => r[col] === val); return q; },
lt: (col, val) => { state.rows = state.rows.filter((r) => String(r[col]) < String(val)); return q; },
order: (col, o) => { state.order.push([col, o && o.ascending !== false]); return q; },
range: async (from, to) => {
const sorted = state.rows.slice().sort((a, b) => {
for (const [c, asc] of state.order) {
const cmp = String(a[c]).localeCompare(String(b[c]));
if (cmp) return asc ? cmp : -cmp;
}
return 0;
});
return { data: sorted.slice(from, to + 1), error: null };
},
};
return q;
},
};
}
async function main() {
const to = arg('to', usage.dateET());
const from = arg('from', '2026-05-01');
console.log(`\nWNBA FEED — pulling completed games ${from} .. ${to}\n`);
const rows = [];
const games = new Set();
const dateStats = [];
let errors = 0;
for (const date of usage.eachDate(from, to)) {
let ids = [];
try { ids = await adapter.getFinalGameIds(date); } catch (e) { errors += 1; continue; }
if (!ids.length) continue;
let dayRows = 0;
for (const id of ids) {
try {
const parsed = await adapter.getGameRows(id);
if (parsed.skipped || !parsed.rows.length) continue;
games.add(id);
for (const r of parsed.rows) {
if (!r.source_id || !r.player_name) continue;
rows.push({ ...r, player_key: nameKey(r.player_name) });
dayRows += 1;
}
} catch { errors += 1; }
}
dateStats.push({ date, games: ids.length, rows: dayRows });
process.stdout.write(` ${date}: ${ids.length} games, ${dayRows} rows\r`);
}
const dates = [...new Set(rows.map((r) => r.game_date))].sort();
console.log('\n\n──────── COVERAGE ────────');
console.log(` rows ${rows.length}`);
console.log(` games ${games.size}`);
console.log(` players ${new Set(rows.map((r) => r.player_key)).size}`);
console.log(` teams ${new Set(rows.map((r) => r.team)).size}`);
console.log(` date range ${dates[0]} .. ${dates[dates.length - 1]} (${dates.length} game days)`);
console.log(` fetch errors ${errors}`);
const cov = (f) => `${rows.filter((r) => r[f] != null).length}/${rows.length}`;
console.log('\n FIELD COVERAGE (the chainFn inputs)');
for (const f of ['minutes', 'usage_rate', 'team_possessions', 'team_pace', 'ts_pct', 'efg_pct', 'starter', 'final_margin']) {
console.log(` ${f.padEnd(20)} ${cov(f)}`);
}
// A worked example, so the derivation is checkable by hand.
const sample = rows.find((r) => r.minutes > 25 && r.usage_rate != null);
if (sample) {
console.log('\n WORKED EXAMPLE (derivation is exact arithmetic, not a proxy)');
console.log(` ${sample.player_name} (${sample.team}) ${sample.game_date}`);
console.log(` MIN ${sample.minutes} FGA ${sample.fga} FTA ${sample.fta} TOV ${sample.tov} PTS ${sample.points}`);
console.log(` team: MIN ${sample.team_minutes} FGA ${sample.team_fga} FTA ${sample.team_fta} TOV ${sample.team_tov} OREB ${sample.team_oreb}`);
const poss = sample.team_fga - sample.team_oreb + sample.team_tov + 0.44 * sample.team_fta;
const usg = 100 * ((sample.fga + 0.44 * sample.fta + sample.tov) * (sample.team_minutes / 5))
/ (sample.minutes * (sample.team_fga + 0.44 * sample.team_fta + sample.team_tov));
console.log(` possessions stored ${sample.team_possessions} recomputed ${poss.toFixed(3)}`);
console.log(` usage_rate stored ${sample.usage_rate} recomputed ${usg.toFixed(3)}`);
console.log(` pace ${sample.team_pace} TS% ${sample.ts_pct} starter ${sample.starter} margin ${sample.final_margin}`);
}
// ── AS-OF VERIFICATION, through the REAL read path ──────────────────────
console.log('\n──────── AS-OF READ (through wnbaUsageService.profileAsOf) ────────');
const sb = memClient(rows);
const counts = new Map();
for (const r of rows) counts.set(r.player_key, (counts.get(r.player_key) || 0) + 1);
const busiest = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
const pk = busiest[0];
const theirs = rows.filter((r) => r.player_key === pk).sort((a, b) => a.game_date.localeCompare(b.game_date));
const name = theirs[0].player_name;
console.log(` player: ${name} (${pk}) — ${theirs.length} games on record\n`);
const mid = theirs[Math.floor(theirs.length / 2)].game_date;
for (const asOf of [theirs[2].game_date, mid, theirs[theirs.length - 1].game_date, '2026-12-31']) {
const p = await usage.profileAsOf(sb, { playerKey: pk, asOf });
const expected = theirs.filter((r) => r.game_date < asOf).length;
const ok = p ? p.games === expected : expected < usage.MIN_GAMES;
console.log(` asOf ${asOf} games=${p ? p.games : 'REFUSED'} (expected ${expected}) ${ok ? 'OK' : 'MISMATCH'}`
+ (p ? ` usage ${p.usage_rate} min/g ${p.minutes_per_game} pace ${p.team_pace} TS ${p.ts_pct}` : ''));
}
// THE LEAK TEST: a game ON the as-of date must never be counted.
const onDate = theirs[theirs.length - 1].game_date;
const p = await usage.profileAsOf(sb, { playerKey: pk, asOf: onDate });
const includesSameDay = p && p.last_game_date === onDate;
console.log(`\n STRICT CUTOFF — a game ON the as-of date is ${includesSameDay ? 'INCLUDED (LEAK)' : 'EXCLUDED (correct)'}`);
console.log(` last game counted: ${p ? p.last_game_date : 'n/a'} vs as-of ${onDate}`);
// And the refusal.
const thin = await usage.profileAsOf(sb, { playerKey: pk, asOf: theirs[0].game_date });
console.log(` THIN HISTORY — asOf before his first game returns ${thin === null ? 'null (refused, correct)' : 'A PROFILE (WRONG)'}`);
process.exit(0);
}
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });