#!/usr/bin/env node 'use strict'; /** * prove-park-weather — DOES PARK GEOMETRY AND AIR READ TOTAL BASES? * * Run through the two-part gate like every other factor, with one addition that * changes the answer: the rows are CLUSTERED BY GAME. Park and weather assign a * single value to every hitter in a ballpark on a night, so eighteen prop rows * from one game are one reading of that game's conditions. Resampling rows would * treat them as eighteen and hand back an interval far tighter than the evidence * supports — which is how a gate passes a factor on sample it never had. * * SUPABASE_URL=... node scripts/prove-park-weather.js */ require('dotenv').config(); const { createClient } = require('@supabase/supabase-js'); const pw = require('../src/services/model/parkWeather'); const fg = require('../src/services/model/factorGate'); const tl = require('../src/services/model/testLedger'); 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 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; } /** Hit-type shares → expected total bases, so a reshape has a consequence. */ const tbFromShares = (s) => s.single + 2 * s.double + 3 * s.triple + 4 * s.home_run; async function main() { const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } }); const parks = await page(sb, 'park_dimensions', '*', (q) => q.eq('sport', 'mlb')); const byVenue = new Map(); for (const p of parks) if (!byVenue.has(p.venue_id)) byVenue.set(p.venue_id, p); const league = pw.leagueGeometry([...byVenue.values()]); const ctx = 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(ctx.map((c) => [c.game_id, c])); const led = await page(sb, 'ledger_entries', 'game_id, game_date, player_key, stat, line, side, outcome, quarantine_reason, p_win, proj_hits_p_over', (q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases').in('outcome', ['hit', 'miss'])); const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book')); // League-typical hit-type shares — the shape the atom reshapes. const BASE_SHARES = { single: 0.655, double: 0.195, triple: 0.017, home_run: 0.133 }; const baseTb = tbFromShares(BASE_SHARES); const loss = { no_context: 0, no_venue: 0, no_park: 0, no_baseline: 0, kept: 0 }; const rows = []; for (const r of clean) { const c = ctxBy.get(r.game_id); if (!c) { loss.no_context += 1; continue; } if (c.venue_id == null) { loss.no_venue += 1; continue; } const dims = byVenue.get(c.venue_id); if (!dims) { loss.no_park += 1; continue; } const baseline = knownNumber(r.p_win); if (baseline === null) { loss.no_baseline += 1; continue; } const read = pw.parkWeatherRead({ dims, wx: c, league }); if (!read) { loss.no_park += 1; continue; } // The atom reshapes hit type; the consequence for total bases is the ratio // of expected bases per hit under the reshaped shape. const shaped = pw.applyToShares(BASE_SHARES, read); const ratio = tbFromShares(shaped) / baseTb; const conditioned = Math.max(0.01, Math.min(0.99, baseline * ratio)); rows.push({ cluster: r.game_id, // ONE reading per game — the whole point baseline, conditioned, won: r.outcome === 'hit' ? 1 : 0, }); loss.kept += 1; } // Cumulative Bonferroni across the programme lifetime — this hypothesis is // one more test, and the bar rises for it like every other. const mc = await tl.recordAndCount(tl.supabaseStore(sb), [ { sport: 'mlb', stat: 'total_bases', archetype: null, interaction: 'factor:park_weather_hit_type', target: 'outcome' }, ]); const verdict = fg.adjudicate(rows, { factor: 'park_weather_hit_type', stat: 'total_bases', cumulativeTests: mc.cumulative_tests, }); const games = new Set(rows.map((r) => r.cluster)).size; const venues = new Set(clean.map((r) => ctxBy.get(r.game_id)?.venue_id).filter((v) => v != null)).size; console.log(JSON.stringify({ clean_settled_tb_rows: clean.length, rows_built: rows.length, row_loss: loss, distinct_games: games, distinct_venues: venues, rows_per_game: games ? Math.round((rows.length / games) * 10) / 10 : null, cumulative_tests: mc.cumulative_tests, verdict, honest_note: 'sample judged in GAMES, not prop rows — park and weather vary per game', }, null, 2)); process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); });