#!/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 { paginate } = require('../src/utils/safePaginate'); const { uniqueKeyFor } = require('../src/utils/tableKeys'); const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; const PAGE = 1000; // ── THE SAFE WALK (Fix A2/A2b) ──────────────────────────────────────────── // This script used to walk pages with `.range()` and NO ORDER BY. Measured on // production, that returned the correct row COUNT and the wrong ROWS: up to // 33.6% of a read came back twice while an equal share never came back at all, // so `rows.length` looked perfect while a fifth of the sample was missing. // // `pageSafe` routes every read through `src/utils/safePaginate`: a stable ORDER // BY on the table's real UNIQUE key — single OR composite, looked up from // `src/utils/tableKeys` rather than assumed — a runtime tuple-uniqueness check, // and a THROWN error instead of a silent stop. The old unordered helper is gone // rather than left beside it, because a dead broken helper is an invitation. async function pageSafe(sb, table, select, apply, key = uniqueKeyFor(table)) { return paginate(() => apply(sb.from(table).select(select)), { key, pageSize: PAGE, label: `${table}` }); } /** THE MEASURED READS — main() and the harness call the same functions. */ const READS = { ledger: (sb) => pageSafe(sb, 'ledger_entries', 'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason', (q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases') .in('outcome', ['hit', 'miss'])), }; /** 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 pageSafe(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 pageSafe(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 pageSafe(sb, 'ledger_entries', 'id, 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); } if (require.main === module) { main().catch((e) => { console.error(e); process.exit(1); }); } // Exported so the read-integrity harness measures THE REAL FUNCTION. module.exports = { READS };