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,392 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* chain-shadow-probe — WHAT WOULD THE CHAIN SAY, on the real board, right now?
|
||||
*
|
||||
* The shadow accrues on the cron. This runs the SAME code against the rows
|
||||
* already on record so the first divergence distribution is available before a
|
||||
* single new snapshot fires, and so the wiring is measured rather than assumed.
|
||||
*
|
||||
* IT IS A MEASUREMENT, NOT A VERDICT. The chain has never been through a
|
||||
* calibration gate and its head-to-head against the counter is not run here —
|
||||
* that needs settled outcomes, which is why the shadow stores the triple. What
|
||||
* this answers is narrower and comes first: does the chain fire at all, on how
|
||||
* much of the board, and how far does it sit from the number being served? A
|
||||
* challenger that agrees with the incumbent everywhere is carrying nothing; one
|
||||
* that disagrees everywhere is probably broken. Both are worth knowing before
|
||||
* anyone waits a fortnight for outcomes.
|
||||
*
|
||||
* POINT-IN-TIME CAVEAT, STATED UP FRONT: `statcast_aggregates` is upserted in
|
||||
* place and keeps ONE as-of date, so profiles read here are TODAY's. For rows
|
||||
* graded earlier that is contamination, and it is why this reports a
|
||||
* DIVERGENCE (a property of two forecasts) and never a resolution or a lift (a
|
||||
* property of a forecast against an outcome).
|
||||
*
|
||||
* SUPABASE_URL=... node scripts/chain-shadow-probe.js [--days 3]
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const { paginate } = require('../src/utils/safePaginate');
|
||||
const cs = require('../src/services/model/chainShadow');
|
||||
const reg = require('../src/services/model/featureRegistry');
|
||||
const mk = require('../src/services/model/matchupKeys');
|
||||
const { nameKey } = require('../src/utils/playerName');
|
||||
|
||||
const SB_URL = process.env.SUPABASE_URL;
|
||||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
const DAYS = Number((process.argv.find((a) => a.startsWith('--days=')) || '').split('=')[1]) || 3;
|
||||
|
||||
function daysAgo(n) {
|
||||
const d = new Date(Date.now() - n * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const pct = (v) => `${(v * 100).toFixed(1)}%`;
|
||||
|
||||
function quantiles(xs) {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const at = (q) => s[Math.min(s.length - 1, Math.max(0, Math.floor(q * (s.length - 1))))];
|
||||
return {
|
||||
min: s[0], p10: at(0.10), p25: at(0.25), median: at(0.50),
|
||||
p75: at(0.75), p90: at(0.90), max: s[s.length - 1],
|
||||
mean: s.reduce((a, b) => a + b, 0) / s.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!SB_URL || !SB_KEY) {
|
||||
console.error('SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY required.');
|
||||
process.exit(0);
|
||||
}
|
||||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||||
const since = daysAgo(DAYS);
|
||||
|
||||
// The graded rows, walked through the SAFE paginator (a `.range()` with no
|
||||
// stable order returns the right COUNT and the wrong ROWS — measured at up to
|
||||
// 33.6% duplication on this very table).
|
||||
const rows = await paginate(
|
||||
() => sb.from('model_snapshots')
|
||||
.select('id,player_key,player_name,stat,line,side,p_win,game_id,game_date,archetype,team,refused')
|
||||
.eq('sport', 'mlb').eq('stat', 'hits').gte('game_date', since),
|
||||
{ key: 'id', label: 'chain-shadow-probe:model_snapshots' },
|
||||
);
|
||||
const graded = rows.filter((r) => !r.refused && r.p_win != null);
|
||||
console.log(`\nBOARD — ${rows.length} hits rows since ${since}, ${graded.length} graded\n`);
|
||||
if (!graded.length) { process.exit(0); }
|
||||
|
||||
// Statcast profiles, keyed the way the shadow keys them.
|
||||
const sc = await paginate(
|
||||
() => sb.from('statcast_aggregates').select('*').eq('sport', 'mlb'),
|
||||
// The real unique key — statcast_aggregates is upserted in place and keeps
|
||||
// no as_of_date (which is exactly why this probe cannot be point-in-time).
|
||||
{ key: ['sport', 'season', 'source_id', 'role'], label: 'chain-shadow-probe:statcast' },
|
||||
);
|
||||
const byKey = new Map();
|
||||
for (const r of sc) {
|
||||
if (!r.player_key) continue;
|
||||
const prev = byKey.get(r.player_key);
|
||||
const size = Number(r.sample_pa || r.sample_ip || 0);
|
||||
if (!prev || size > Number(prev.sample_pa || prev.sample_ip || 0)) byKey.set(r.player_key, r);
|
||||
}
|
||||
console.log(`statcast profiles: ${byKey.size}`);
|
||||
|
||||
// ── THE MATCHUP INPUTS, resolved PER GAME DATE and AS-OF-CORRECT ────────
|
||||
// v1 passed `pitcherRowFor: () => null` and no hand split, so it measured a
|
||||
// batter-side SEASON read and said so. This resolves what the live shadow
|
||||
// resolves: the opposing starter (A6 keys) and the hitter's own platoon split
|
||||
// (A4-dated), each bounded at the row's own game date so nothing later than
|
||||
// the grade can leak in.
|
||||
const mlbAdapter = require('../src/services/adapters/mlbStatsAdapter');
|
||||
const dates = [...new Set(graded.map((r) => r.game_date).filter(Boolean))].sort();
|
||||
|
||||
const platoon = await paginate(
|
||||
() => sb.from('platoon_splits').select('*').eq('sport', 'mlb'),
|
||||
{ key: ['as_of_date', 'sport', 'season', 'player_key'], label: 'chain-shadow-probe:platoon' },
|
||||
);
|
||||
const lineups = await paginate(
|
||||
() => sb.from('lineup_context').select('*').eq('sport', 'mlb').in('game_date', dates),
|
||||
{ key: ['as_of_date', 'sport', 'game_pk', 'team', 'player_key'], label: 'chain-shadow-probe:lineups' },
|
||||
);
|
||||
console.log(`platoon splits: ${platoon.length} rows · lineup rows: ${lineups.length}`);
|
||||
|
||||
/** Latest row at or before `asOf` — refuse rather than reach forward (A4). */
|
||||
const latestAsOf = (rows, asOf) => {
|
||||
let best = null;
|
||||
for (const r of rows) {
|
||||
if (!r.as_of_date || r.as_of_date > asOf) continue;
|
||||
if (!best || r.as_of_date > best.as_of_date) best = r;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const platoonByPlayer = new Map();
|
||||
for (const r of platoon) {
|
||||
if (!r.player_key) continue;
|
||||
if (!platoonByPlayer.has(r.player_key)) platoonByPlayer.set(r.player_key, []);
|
||||
platoonByPlayer.get(r.player_key).push(r);
|
||||
}
|
||||
const slotByPlayerDate = new Map();
|
||||
for (const r of lineups) {
|
||||
if (!r.player_key || r.batting_order == null) continue;
|
||||
slotByPlayerDate.set(`${r.player_key}|${r.game_date}`, r.batting_order);
|
||||
}
|
||||
|
||||
// Pitcher statcast rows by NAME (the A6 key resolves a pitcher name).
|
||||
const pitcherByKey = new Map();
|
||||
for (const r of sc) if (r.role === 'pitcher' && r.player_key) pitcherByKey.set(r.player_key, r);
|
||||
|
||||
const keysByDate = new Map();
|
||||
for (const d of dates) {
|
||||
try {
|
||||
const resolve = await mk.build({
|
||||
sb, getSchedule: (dd) => mlbAdapter.getScheduleWithPitchers(dd), gameDate: d, asOf: d,
|
||||
});
|
||||
if (resolve) keysByDate.set(d, resolve);
|
||||
} catch (e) { console.warn(` matchupKeys ${d}: ${e.message}`); }
|
||||
}
|
||||
console.log(`matchup key indexes built for ${keysByDate.size}/${dates.length} dates`);
|
||||
|
||||
// One "grade" per row, in the shape runShadow reads. The LOCKED line is the
|
||||
// row's own line, which is what the counter was graded against.
|
||||
const grades = graded.map((r) => ({
|
||||
player: r.player_name || r.player_key,
|
||||
stat_type: r.stat,
|
||||
direction: r.side,
|
||||
p_win: r.p_win,
|
||||
game_id: r.game_id,
|
||||
game_date: r.game_date,
|
||||
team: r.team,
|
||||
archetype: r.archetype,
|
||||
gradedAt: { line: r.line },
|
||||
}));
|
||||
|
||||
const throwsFor = (g) => {
|
||||
const resolve = keysByDate.get(g.game_date);
|
||||
if (!resolve) return null;
|
||||
const k = resolve({ player: g.player });
|
||||
if (!k || !k.opposing_pitcher) return null;
|
||||
const row = pitcherByKey.get(nameKey(k.opposing_pitcher));
|
||||
return row || null;
|
||||
};
|
||||
|
||||
const shadowDeps = (over = {}) => ({
|
||||
statcastByKey: byKey,
|
||||
pitcherRowFor: throwsFor,
|
||||
lineupSlotFor: (g) => slotByPlayerDate.get(`${nameKey(g.player)}|${g.game_date}`) ?? null,
|
||||
handSplitFor: (g) => {
|
||||
const pk = nameKey(g.player);
|
||||
const rows = platoonByPlayer.get(pk) || [];
|
||||
// AS-OF-CORRECT: the split as it stood on the row's own game date.
|
||||
const sp = latestAsOf(rows, g.game_date);
|
||||
const pitcherRow = throwsFor(g);
|
||||
return {
|
||||
bats: sp && sp.bats ? String(sp.bats)[0] : ((byKey.get(pk) || {}).bats || null),
|
||||
throws: pitcherRow && pitcherRow.throws ? String(pitcherRow.throws)[0] : null,
|
||||
platoonSplits: sp ? {
|
||||
vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits },
|
||||
vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits },
|
||||
} : null,
|
||||
};
|
||||
},
|
||||
allowed: reg.candidateFeatures('mlb'),
|
||||
...over,
|
||||
});
|
||||
|
||||
const out = cs.runShadow(grades, shadowDeps());
|
||||
// THE A/B THE FIX IS JUDGED ON: identical rows, identical everything, except
|
||||
// the opportunity term is forced back to the constant `DEFAULT_PA` the v2
|
||||
// shadow actually ran on. Anything else would compare two different slates.
|
||||
const outFixedPa = cs.runShadow(grades, shadowDeps({ lineupSlotFor: () => null }));
|
||||
|
||||
const s = out.summary;
|
||||
console.log(`\nCHAIN FIRE — ${s.readable}/${s.atoms} atoms read (${s.refused} refused) `
|
||||
+ `across ${s.games_read}/${s.games} games`);
|
||||
if (s.refused) console.log(` refusal reasons: ${JSON.stringify(s.refusal_reasons)}`);
|
||||
console.log(`\nHAND SPLIT — fired on ${s.platoon_applied}/${s.props} unique props `
|
||||
+ `(${pct(s.platoon_applied / Math.max(1, s.props))})`);
|
||||
console.log(` season-rate reasons: ${JSON.stringify(s.platoon_reasons)}`);
|
||||
console.log(`\nOPPORTUNITY — posted lineup slot on ${s.opportunity_posted}/${s.props} props `
|
||||
+ `(${pct(s.opportunity_posted / Math.max(1, s.props))})`);
|
||||
console.log(` fell to a default regular: ${JSON.stringify(s.opportunity_reasons)}`);
|
||||
|
||||
// Side-align every row against its OWN served p_win — the triple's first two
|
||||
// thirds, exactly as the shadow stores them.
|
||||
const divergences = [];
|
||||
const chainPs = [];
|
||||
const counterPs = [];
|
||||
const firedDiv = []; // hand split APPLIED — a genuine matchup read
|
||||
const seasonDiv = []; // hand split refused — a season read
|
||||
let matched = 0;
|
||||
for (const r of graded) {
|
||||
const block = out.byKey.get(cs.shadowKey(r.player_key, r.stat, r.line));
|
||||
if (!block) continue;
|
||||
const aligned = cs.alignToSide(block, r.side, r.p_win);
|
||||
if (!aligned || aligned.divergence == null) continue;
|
||||
matched += 1;
|
||||
divergences.push(aligned.divergence);
|
||||
chainPs.push(aligned.chain_p);
|
||||
counterPs.push(aligned.counter_p);
|
||||
(block.platoon_applied ? firedDiv : seasonDiv).push(aligned.divergence);
|
||||
}
|
||||
|
||||
if (!matched) { console.log('\nNo comparable rows — nothing to report.'); process.exit(0); }
|
||||
|
||||
const abs = divergences.map(Math.abs);
|
||||
const q = quantiles(divergences);
|
||||
const qa = quantiles(abs);
|
||||
const qc = quantiles(chainPs);
|
||||
const qk = quantiles(counterPs);
|
||||
|
||||
console.log(`\nCHAIN vs COUNTER — n=${matched} (${pct(matched / graded.length)} of graded)\n`);
|
||||
const row = (label, x) => console.log(
|
||||
` ${label.padEnd(22)} min ${x.min.toFixed(3)} p25 ${x.p25.toFixed(3)} median ${x.median.toFixed(3)}`
|
||||
+ ` p75 ${x.p75.toFixed(3)} max ${x.max.toFixed(3)} mean ${x.mean.toFixed(3)}`);
|
||||
row('chain_p', qc);
|
||||
row('counter_p', qk);
|
||||
row('divergence (signed)', q);
|
||||
row('divergence (absolute)', qa);
|
||||
|
||||
const band = (lo, hi) => abs.filter((d) => d >= lo && d < hi).length;
|
||||
console.log(`\n |divergence| < 0.02 ${band(0, 0.02)} (${pct(band(0, 0.02) / matched)}) — agrees with the counter`);
|
||||
console.log(` 0.02 - 0.05 ${band(0.02, 0.05)} (${pct(band(0.02, 0.05) / matched)})`);
|
||||
console.log(` 0.05 - 0.10 ${band(0.05, 0.10)} (${pct(band(0.05, 0.10) / matched)})`);
|
||||
console.log(` 0.10 - 0.20 ${band(0.10, 0.20)} (${pct(band(0.10, 0.20) / matched)})`);
|
||||
console.log(` >= 0.20 ${band(0.20, 99)} (${pct(band(0.20, 99) / matched)}) — a different read entirely`);
|
||||
|
||||
const higher = divergences.filter((d) => d > 0).length;
|
||||
console.log(`\n chain HIGHER than counter on ${higher} (${pct(higher / matched)}), lower on ${matched - higher}`);
|
||||
|
||||
// BOTH SIDES OF EVERY PROP ARE IN THE SAMPLE, so each pair contributes +d and
|
||||
// -d and the signed distribution is forced to be symmetric about zero. That
|
||||
// symmetry is arithmetic, not a finding — reading it as "the chain is unbiased"
|
||||
// would be reading the sampling scheme. The OVER slice is where a directional
|
||||
// lean is visible at all.
|
||||
const overs = [];
|
||||
for (const r of graded) {
|
||||
if (String(r.side).toLowerCase() !== 'over') continue;
|
||||
const block = out.byKey.get(cs.shadowKey(r.player_key, r.stat, r.line));
|
||||
const aligned = block && cs.alignToSide(block, r.side, r.p_win);
|
||||
if (aligned && aligned.divergence != null) overs.push(aligned.divergence);
|
||||
}
|
||||
if (overs.length) {
|
||||
const qo = quantiles(overs);
|
||||
const hi = overs.filter((d) => d > 0).length;
|
||||
console.log(`\n OVER SIDE ONLY (n=${overs.length}) — the signed read that is not forced symmetric`);
|
||||
row(' divergence (signed)', qo);
|
||||
console.log(` chain HIGHER on ${hi} (${pct(hi / overs.length)}) — mean ${qo.mean.toFixed(4)}`);
|
||||
}
|
||||
|
||||
// ── THE SPLIT THAT ACTUALLY MATTERS ─────────────────────────────────────
|
||||
// A row where the hand split refused is a SEASON read; a row where it fired is
|
||||
// a MATCHUP read. Pooling them reports an average of two different models, and
|
||||
// any later adjudication would be measuring the mixture rather than the chain.
|
||||
const slice = (label, arr) => {
|
||||
if (!arr.length) { console.log(`\n ${label}: none`); return; }
|
||||
const q2 = quantiles(arr.map(Math.abs));
|
||||
console.log(`\n ${label} (n=${arr.length})`);
|
||||
row(' |divergence|', q2);
|
||||
const far = arr.filter((d) => Math.abs(d) >= 0.10).length;
|
||||
const near = arr.filter((d) => Math.abs(d) < 0.02).length;
|
||||
console.log(` disagree >= 0.10 on ${far} (${pct(far / arr.length)}) · `
|
||||
+ `agree within 0.02 on ${near} (${pct(near / arr.length)})`);
|
||||
};
|
||||
console.log('\n──────── MATCHUP READ vs SEASON READ ────────');
|
||||
slice('HAND SPLIT FIRED — a genuine matchup read', firedDiv);
|
||||
slice('HAND SPLIT REFUSED — a season read', seasonDiv);
|
||||
|
||||
// ── PHASE 1 CHECK: DID THE UNIFORM BIAS COLLAPSE? ───────────────────────
|
||||
// Signed bias on the OVER side only (the two-sided set is forced symmetric).
|
||||
// Same rows, both arms, so the difference is the opportunity term and nothing
|
||||
// else. `%below` is the shape that matters: a MECHANICAL bias pushes nearly
|
||||
// every row the same way, and a real conditioner does not.
|
||||
const armStats = (shadow) => {
|
||||
const d = [];
|
||||
for (const r of graded) {
|
||||
if (String(r.side).toLowerCase() !== 'over') continue;
|
||||
const b = shadow.byKey.get(cs.shadowKey(r.player_key, r.stat, r.line));
|
||||
const a = b && cs.alignToSide(b, r.side, r.p_win);
|
||||
if (a && a.divergence != null) d.push(a.divergence);
|
||||
}
|
||||
if (!d.length) return null;
|
||||
const q = quantiles(d);
|
||||
return { n: d.length, mean: q.mean, median: q.median, below: d.filter((x) => x < 0).length };
|
||||
};
|
||||
console.log('\n──────── PHASE 1 — E[PA]: REAL LINEUP SLOT vs CONSTANT 4.1 ────────');
|
||||
for (const [label, shadow] of [['REAL E[PA] (posted slot)', out], ['CONSTANT 4.1 (the v2 shadow)', outFixedPa]]) {
|
||||
const a = armStats(shadow);
|
||||
if (!a) { console.log(` ${label}: none`); continue; }
|
||||
console.log(` ${label.padEnd(30)} n=${a.n} mean ${a.mean >= 0 ? '+' : ''}${a.mean.toFixed(4)} `
|
||||
+ `median ${a.median >= 0 ? '+' : ''}${a.median.toFixed(4)} chain BELOW counter on ${pct(a.below / a.n)}`);
|
||||
}
|
||||
|
||||
// ── PHASE 2 — THE DIAGNOSTIC: ARTIFACT or SIGNAL? ───────────────────────
|
||||
// A MECHANICAL bias is flat across matchup difficulty: a broken conversion
|
||||
// does not know who is pitching. A CONDITIONER is not flat — it moves with the
|
||||
// matchup the counter is blind to.
|
||||
//
|
||||
// READ THE CAVEAT WITH THE RESULT: the chain reads the opposing pitcher's K
|
||||
// rate directly and the counter does not, so a monotone relationship here is
|
||||
// close to mechanical proof that the chain CONDITIONS on the matchup. It is
|
||||
// NOT evidence the conditioning is CORRECT. Only settled outcomes can say that.
|
||||
const rowsWithDifficulty = [];
|
||||
for (const r of graded) {
|
||||
if (String(r.side).toLowerCase() !== 'over') continue;
|
||||
const b = out.byKey.get(cs.shadowKey(r.player_key, r.stat, r.line));
|
||||
const a = b && cs.alignToSide(b, r.side, r.p_win);
|
||||
if (!a || a.divergence == null) continue;
|
||||
const g = grades.find((x) => nameKey(x.player) === r.player_key && x.game_date === r.game_date);
|
||||
const pit = g ? throwsFor(g) : null;
|
||||
const k = pit && pit.k_pct != null ? Number(pit.k_pct) : null;
|
||||
rowsWithDifficulty.push({ div: a.divergence, oppK: k, platoon: b.platoon_applied ? b.platoon_multiplier : null });
|
||||
}
|
||||
const withK = rowsWithDifficulty.filter((x) => Number.isFinite(x.oppK));
|
||||
console.log('\n──────── PHASE 2 — DIVERGENCE BY MATCHUP DIFFICULTY ────────');
|
||||
if (withK.length < 50) {
|
||||
console.log(` only ${withK.length} rows carry an opposing-pitcher profile — too few to bucket.`);
|
||||
} else {
|
||||
withK.sort((a, b) => a.oppK - b.oppK);
|
||||
const B = 5;
|
||||
const size = Math.floor(withK.length / B);
|
||||
console.log(` n=${withK.length}, quintiles of OPPOSING PITCHER K% (low = soft matchup)`);
|
||||
const means = [];
|
||||
for (let i = 0; i < B; i += 1) {
|
||||
const chunk = withK.slice(i * size, i === B - 1 ? withK.length : (i + 1) * size);
|
||||
const m = chunk.reduce((acc, x) => acc + x.div, 0) / chunk.length;
|
||||
means.push(m);
|
||||
const kLo = chunk[0].oppK.toFixed(1);
|
||||
const kHi = chunk[chunk.length - 1].oppK.toFixed(1);
|
||||
const below = chunk.filter((x) => x.div < 0).length;
|
||||
console.log(` Q${i + 1} oppK ${kLo}-${kHi}%`.padEnd(28)
|
||||
+ `n=${String(chunk.length).padEnd(6)}mean div ${m >= 0 ? '+' : ''}${m.toFixed(4)} below ${pct(below / chunk.length)}`);
|
||||
}
|
||||
const spread = means[0] - means[means.length - 1];
|
||||
// Pearson r between opposing-pitcher K% and divergence.
|
||||
const xs = withK.map((x) => x.oppK); const ys = withK.map((x) => x.div);
|
||||
const mx = xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||
const my = ys.reduce((a, b) => a + b, 0) / ys.length;
|
||||
let sxy = 0; let sxx = 0; let syy = 0;
|
||||
for (let i = 0; i < xs.length; i += 1) {
|
||||
sxy += (xs[i] - mx) * (ys[i] - my); sxx += (xs[i] - mx) ** 2; syy += (ys[i] - my) ** 2;
|
||||
}
|
||||
const r = sxy / Math.sqrt(sxx * syy);
|
||||
console.log(`\n Q1 - Q5 spread: ${spread >= 0 ? '+' : ''}${spread.toFixed(4)} `
|
||||
+ `Pearson r(oppK, divergence) = ${r.toFixed(4)}`);
|
||||
console.log(` VERDICT: ${Math.abs(spread) < 0.02 && Math.abs(r) < 0.05
|
||||
? 'FLAT across difficulty — the divergence is MECHANICAL, the chain is still miscomputing.'
|
||||
: 'MOVES with difficulty — the chain is CONDITIONING on the matchup the counter cannot see.'}`);
|
||||
console.log(' (Conditioning is not correctness. Only settled outcomes can say which read is right.)');
|
||||
}
|
||||
console.log('\n UN-SERVABLE. No calibration gate has been passed; this is a divergence, not a verdict.\n');
|
||||
|
||||
// Redis runs degraded locally and a reconnect timer holds the process open,
|
||||
// which loses piped output to SIGTERM.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
}
|
||||
|
||||
module.exports = { quantiles };
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/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); });
|
||||
Reference in New Issue
Block a user