#!/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 };