#!/usr/bin/env node 'use strict'; /** * backfill-context — WERE WE WAITING, OR UNDER-QUERYING? * * The platoon test ran on 452 rows against 1,266 clean settled hits rows in the * ledger, so "48 short of the gate" was never a statement about how much data * exists. It was a statement about how much the JOIN survived — and the join was * losing rows to inputs we simply had not fetched for every player. * * This backfills the inputs (pure sample, zero waiting) and reports exactly * where each row is lost, so the next "we need more data" claim is a measured * one rather than an inherited one. * * ── THE ONE HONEST CAVEAT, STATED UP FRONT ─────────────────────────────── * Platoon splits from statsapi are SEASON-TO-DATE as of the moment they are * fetched. Applying today's split to a 2026-07-15 game means the split contains * that game. For a ~400-PA season line one game is roughly a quarter of one * percent, so the contamination is small — but it is real, it runs in the * flattering direction, and it is why this is labelled a reconstruction rather * than a clean point-in-time backtest. * * SUPABASE_URL=... node scripts/backfill-context.js */ require('dotenv').config(); const { createClient } = require('@supabase/supabase-js'); const ctx = require('../src/services/lineupContextService'); const mlb = require('../src/services/adapters/mlbStatsAdapter'); const { knownNumber } = require('../src/utils/known'); const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; const SEASON = Number(process.env.BF_SEASON || 2026); const PAGE = 1000; async function page(sb, table, select, apply) { const out = []; for (let from = 0; ; from += PAGE) { const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1); if (error) throw error; if (!data || data.length === 0) break; out.push(...data); if (data.length < PAGE) break; } return out; } async function main() { if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required'); const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } }); // Every hitter who appears on a CLEAN settled row — the true denominator. const led = await page(sb, 'ledger_entries', 'player_key, player_name, stat, outcome, quarantine_reason', (q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases']) .in('outcome', ['hit', 'miss'])); const need = new Map(); for (const r of led) { if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue; if (!need.has(r.player_key)) need.set(r.player_key, r.player_name); } const have = new Set((await page(sb, 'platoon_splits', 'player_key', (q) => q.eq('sport', 'mlb'))) .map((r) => r.player_key)); const missing = [...need.entries()].filter(([k]) => !have.has(k)); console.error(`[backfill] hitters on clean settled rows: ${need.size}; splits already held: ${have.size}; to fetch: ${missing.length}`); const asOf = ctx.dateET(); const rows = []; let unresolved = 0; for (const [key, name] of missing) { let found = null; try { found = await mlb.searchPlayer(name); } catch { found = null; } if (!found || !found.id) { unresolved += 1; continue; } const sp = await ctx.fetchPlatoonSplits(found.id, SEASON, {}); if (!sp) continue; // absent, never a symmetric guess rows.push({ player_key: key, player_name: name, source_id: found.id, ...sp }); } let written = 0; for (let i = 0; i < rows.length; i += 200) { const batch = rows.slice(i, i + 200).map((r) => ({ ...r, sport: 'mlb', season: SEASON, as_of_date: asOf })); const { error } = await sb.from('platoon_splits') .upsert(batch, { onConflict: 'as_of_date,sport,season,player_key' }); if (!error) written += batch.length; else console.error('[backfill] write failed:', error.message); } console.log(JSON.stringify({ hitters_on_clean_settled_rows: need.size, splits_held_before: have.size, attempted: missing.length, unresolved_by_name: unresolved, no_splits_available: missing.length - unresolved - rows.length, written, caveat: 'season-to-date splits applied to past games contain those games — small (~0.25% of a 400-PA line) but real and flattering', }, null, 2)); process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); });