#!/usr/bin/env node 'use strict'; /** * prove-tb-factors — TOTAL BASES IS A DIFFERENT EVENT FROM HITS. * * A hit asks whether the ball found a hole. Total bases asks how hard and how * far it was struck. So the causally-correct factors differ, and the * archetype differential is expected to INVERT: contact defence proved for hits, * where a slap single is worth exactly one base regardless of who fielded it; * for total bases the value should live with the power profiles. * * ── THE BASELINE IS THE CHAMPION, NOT "HE'S DUE" ───────────────────────── * The hits gate used the player's own leave-one-out base rate as the null. That * cannot be reproduced here: total-bases lines VARY (1.5 on 559 rows, 0.5 on * 345, 2.5 on 45), and a player's rate of clearing 1.5 bases is a different * quantity from his rate of clearing 0.5. With 988 rows over 341 players there * are roughly two rows per player-line — far too thin to estimate a per-line * personal base rate without inventing one. * * So the null here is the COUNTER'S OWN FORECAST (p_win), which already prices * the line. That is a strictly HARDER null than a base rate, not an easier one: * a factor must improve on the champion, not merely on "he's due". Stated * plainly because it differs from the hits run and the difference matters when * comparing the two. * * SUPABASE_URL=... node scripts/prove-tb-factors.js */ require('dotenv').config(); const { createClient } = require('@supabase/supabase-js'); const fg = require('../src/services/model/factorGate'); const sk = require('../src/services/model/skillProjection'); const tl = require('../src/services/model/testLedger'); const mlb = require('../src/services/adapters/mlbStatsAdapter'); const { knownNumber, knownRate } = require('../src/utils/known'); const { nameKey } = require('../src/utils/playerName'); const sd = require('../src/services/model/sprayDefense'); const pss = require('../src/services/model/platoonSeverity'); const pw = require('../src/services/model/parkWeather'); /** League-typical hit-type shares; the atom reshapes these and TB follows. */ const BASE_SHARES = { single: 0.655, double: 0.195, triple: 0.017, home_run: 0.133 }; const tbFrom = (s) => s.single + 2 * s.double + 3 * s.triple + 4 * s.home_run; const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; const PAGE = 1000; const ARCHS = (process.env.TB_ARCHETYPES || 'ALL,BOMBER,GHOST,BRUSH,DRIVER').split(','); 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; } /** * THE FACTORS. Each returns a MULTIPLIER on the base rate, or null when the * input is absent — an absent factor must leave the baseline untouched rather * than nudge it toward some default. */ const FACTORS = [ { key: 'barrel_rate', needs: ['barrel_pct'], entity: (r) => r.player_key, mechanism: 'THE EXTRA-BASE SKILL ITSELF. A barrel is the exit-velocity and launch-angle combination that produces extra bases; it is the most direct expression of what total bases measures, where for hits it is largely irrelevant to whether a grounder finds a hole.', // UNITS: fromStatcastRow returns barrel_pct as a FRACTION (0.06), not the // 0-100 the raw table stores. Writing this against the percentage scale // clamped every row to the maximum negative shift, which then "improved" // Brier only by leaning on the counter's known global over-prediction. apply: (r) => 1 + Math.max(-0.20, Math.min(0.20, (r.barrel_pct - 0.078) * 1.8)), }, { key: 'exit_velo', needs: ['avg_exit_velo'], entity: (r) => r.player_key, mechanism: 'How hard the ball leaves the bat. Separates a double in the gap from a fly out, which is exactly the margin total bases lives on.', apply: (r) => 1 + Math.max(-0.15, Math.min(0.15, (r.avg_exit_velo - 88.9) * 0.020)), }, { key: 'hard_contact_allowed', needs: ['pitcher_hard_hit_allowed'], entity: (r) => r.starter_id, mechanism: 'A pitcher who concedes hard contact concedes EXTRA BASES, not just hits. For total bases this should read stronger than it did for hits.', apply: (r) => 1 + Math.max(-0.15, Math.min(0.15, (r.pitcher_hard_hit_allowed - 0.389) * 1.2)), }, { key: 'park_weather_hit_type', needs: ['park_weather_ratio'], entity: (r) => r.park_weather_ratio, mechanism: 'Whether a struck ball becomes a double, clears the fence, or dies at the track. The atom reshapes HIT TYPE rather than P(hit), which is the only form that can express a total-bases effect.', apply: (r) => r.park_weather_ratio, caveat: 'venue-borne: replication caps at the number of distinct park readings, not the row count', }, { key: 'platoon_severity', needs: ['platoon_severity_mult'], entity: (r) => r.player_key, mechanism: "The hitter's OWN measured split, shrunk by the smaller side's plate appearances and refused below a floor.", apply: (r) => r.platoon_severity_mult, }, ]; 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 } }); const statcast = await page(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb')); const batters = new Map(); const pitchersById = new Map(); for (const r of statcast) { const prof = sk.fromStatcastRow(r); if (r.role === 'pitcher' && r.source_id != null) pitchersById.set(Number(r.source_id), prof); if (r.role === 'batter' && r.player_key) batters.set(r.player_key, prof); } const sprayRows = await page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb')); const sprayByKey = new Map(); for (const r of sprayRows) { if (!r.player_key) continue; const prev = sprayByKey.get(r.player_key); if (!prev || String(r.as_of_date) > String(prev.as_of_date)) sprayByKey.set(r.player_key, r); } const platRows = await page(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb')); const platByKey = new Map(); for (const r of platRows) { if (!r.player_key) continue; const prev = platByKey.get(r.player_key); if (!prev || String(r.as_of_date) > String(prev.as_of_date)) platByKey.set(r.player_key, r); } const parkRows = await page(sb, 'park_dimensions', '*', (q) => q.eq('sport', 'mlb')); const parkByVenue = new Map(); for (const p of parkRows) if (!parkByVenue.has(p.venue_id)) parkByVenue.set(p.venue_id, p); const parkLeague = pw.leagueGeometry([...parkByVenue.values()]); const ctxRows = await page(sb, 'game_context', 'game_id, venue_id, wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg', (q) => q); const ctxBy = new Map(ctxRows.map((c) => [c.game_id, c])); const defRows = await page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb')); const defByTeam = new Map(); for (const d of defRows) defByTeam.set(d.team, d); const snaps = await page(sb, 'model_snapshots', 'player_key, game_date, archetype, stat', (q) => q.eq('sport', 'mlb').eq('stat', 'total_bases').not('archetype', 'is', null)); const archOf = new Map(); for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype); const led = await page(sb, 'ledger_entries', 'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base', (q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases') .in('outcome', ['hit', 'miss']).not('p_win', 'is', null)); const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book')); // Opponent faced, from each hitter's own game log. const names = new Map(); for (const r of clean) if (!names.has(r.player_key)) names.set(r.player_key, r.player_name); const oppBy = new Map(); const startersBy = new Map(); const dates = [...new Set(clean.map((r) => r.game_date))].sort(); for (const d of dates) { try { const games = await mlb.getScheduleWithPitchers(d); for (const g of games) { if (!g.home || !g.away) continue; if (g.home.probablePitcher) startersBy.set(`${d}|OPP:${g.home.team}`, g.home.probablePitcher.id); if (g.away.probablePitcher) startersBy.set(`${d}|OPP:${g.away.team}`, g.away.probablePitcher.id); } } catch { /* absent slate */ } } for (const [key, name] of names) { try { const found = await mlb.searchPlayer(name); if (!found || !found.id) continue; const log = await mlb.getPlayerGameLog(found.id); for (const g of log || []) if (g && g.date && g.opponent) oppBy.set(`${key}|${String(g.date).slice(0, 10)}`, g.opponent); } catch { /* no log */ } } // Per-player base rate — the honest null: "he's due", no reading of tonight. const byPlayer = new Map(); for (const r of clean) { const cur = byPlayer.get(r.player_key) || { n: 0, w: 0 }; cur.n += 1; cur.w += r.outcome === 'hit' ? 1 : 0; byPlayer.set(r.player_key, cur); } const loss = { no_batter_profile: 0, thin_base_rate: 0, no_opponent: 0, no_pitcher: 0, kept: 0 }; const rows = []; for (const r of clean) { const bat = batters.get(r.player_key); const bp = byPlayer.get(r.player_key); if (!bat) loss.no_batter_profile += 1; if (!bp || bp.n < 3) { loss.thin_base_rate += 1; continue; } // THE NULL IS THE CHAMPION. Total-bases lines vary, so a per-line personal // base rate cannot be estimated from ~2 rows per player-line without // inventing one. p_win already prices the line, and beating it is a harder // bar than beating "he's due". const baseline = knownNumber(r.p_win); if (baseline === null) { loss.thin_base_rate += 1; continue; } const faced = oppBy.get(`${r.player_key}|${r.game_date}`) || null; const nick = faced ? String(faced).split(' ').pop() : null; const def = faced ? (defByTeam.get(faced) || defByTeam.get(nick)) : null; if (!faced) loss.no_opponent += 1; const starterId = faced ? startersBy.get(`${r.game_date}|OPP:${faced}`) : null; const pit = starterId != null ? pitchersById.get(Number(starterId)) : null; if (faced && !pit) loss.no_pitcher += 1; loss.kept += 1; rows.push({ id: r.id, // Errors are correlated WITHIN a game — shared starter, park, weather and // the game's own randomness — so the interval must be clustered on it. // Three of these factors (pitcher profile, team defence, park) are also // CONSTANT across every hitter facing that starter, which makes row // resampling straightforwardly wrong for them. cluster: r.game_id, opp: faced, starter_id: starterId != null ? Number(starterId) : null, player_key: r.player_key, archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null, won: r.outcome === 'hit' ? 1 : 0, baseline, team_defense: def ? knownNumber(def.oaa_sum) : null, pitcher_hard_hit_allowed: pit ? knownRate(pit.hard_hit_pct) : null, line: knownNumber(r.line), barrel_pct: bat ? knownRate(bat.barrel_pct) : null, avg_exit_velo: bat ? knownNumber(bat.avg_exit_velo) : null, park_weather_ratio: (() => { const c = ctxBy.get(r.game_id); if (!c || c.venue_id == null) return null; const dims = parkByVenue.get(c.venue_id); if (!dims) return null; const read = pw.parkWeatherRead({ dims, wx: c, league: parkLeague }); if (!read) return null; const shaped = pw.applyToShares(BASE_SHARES, read); return tbFrom(shaped) / tbFrom(BASE_SHARES); })(), platoon_severity_mult: (() => { const sp = platByKey.get(r.player_key); if (!sp || !bat || !bat.bats || !pit || !pit.throws) return null; const out = pss.platoonRead({ splits: { 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 }, }, bats: bat.bats, throws: pit.throws, }); return out && out.readable ? out.multiplier : null; })(), spray_multiplier: (() => { const sp = sprayByKey.get(r.player_key); const posOaa = def && def.position_oaa ? def.position_oaa : null; if (!sp || !posOaa || !bat || !bat.bats) return null; const out = sd.sprayDefenseMultiplier({ spray: sp, bats: bat.bats, positionOaa: posOaa }); return out ? out.multiplier : null; })(), platoon_edge: (bat && pit && bat.bats && pit.throws) ? (String(bat.bats)[0] !== String(pit.throws)[0] ? 1 : -1) : null, }); } // Cumulative Bonferroni across the programme lifetime. const store = tl.supabaseStore(sb); const mc = await tl.recordAndCount(store, FACTORS.flatMap((f) => ARCHS.map((a) => ({ sport: 'mlb', stat: 'total_bases', archetype: a === 'ALL' ? null : a, interaction: `factor:${f.key}`, target: 'outcome' })))); // STEP 1 — FULL-HISTORY SAMPLE AUDIT PER SLOT, before any gating. const audit = []; for (const f of FACTORS) { for (const arch of ARCHS) { const slot = arch === 'ALL' ? rows : rows.filter((r) => String(r.archetype || '').toUpperCase() === arch); const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null)); audit.push({ factor: f.key, archetype: arch, rows: usable.length, games: new Set(usable.map((r) => r.cluster).filter(Boolean)).size, players: new Set(usable.map((r) => r.player_key)).size, }); } } const results = []; for (const arch of ARCHS) { const slot = arch === 'ALL' ? rows : rows.filter((r) => String(r.archetype || '').toUpperCase() === arch); for (const f of FACTORS) { const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null)); // A park effect is replicated across PARKS, not across games: 619 rows in // 45 games still only ever saw ~23 ballparks, and unmodelled park // heterogeneity is confounded with the very thing being estimated. So the // cluster is the COARSER of the game and the entity the treatment rides on. const ents = f.entity ? new Set(usable.map((r) => String(f.entity(r)))) : null; const games = new Set(usable.map((r) => String(r.cluster))); const useEntity = ents && ents.size < games.size; const paired = usable.map((r) => { const mult = f.apply(r); const cond = mult === null ? null : Math.min(0.99, Math.max(0.01, r.baseline * mult)); return { baseline: r.baseline, conditioned: cond, won: r.won, cluster: useEntity ? `e:${f.entity(r)}` : r.cluster, }; }); const v = fg.adjudicate(paired, { factor: f.key, archetype: arch, stat: 'total_bases', cumulativeTests: mc.cumulative_tests, // native cumulative correction }); results.push({ archetype: arch, factor: f.key, n: v.movement.n, clusters: v.improvement ? v.improvement.effective_n : null, cluster_unit: useEntity ? 'treatment_entity' : 'game', distinct_games: games.size, distinct_entities: ents ? ents.size : null, mean_abs_shift: v.movement.mean_abs_shift, brier_delta: v.improvement ? v.improvement.brier_delta : null, ci: v.improvement ? v.improvement.ci : null, ci_level: v.improvement ? v.improvement.ci_level : null, verdict: v.verdict, reason: v.reason, ...(f.caveat ? { input_caveat: f.caveat } : {}), }); } } console.log(JSON.stringify({ baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null", total_rows: rows.length, slot_audit: audit, clean_settled_rows_available: clean.length, row_loss: loss, cumulative_bonferroni: mc, gate: 'a factor must MOVE the prediction AND improve out-of-sample Brier; movement alone is THEATER', results, proven: results.filter((r) => r.verdict === 'PROVES'), theater: results.filter((r) => r.verdict === 'THEATER'), }, null, 2)); process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); });