#!/usr/bin/env node 'use strict'; /** * hits-input-coverage — STEP 0 of hits-v1. CONFIRM THE INPUTS EXIST. * * hits-v1 models P(hits >= line) as a binomial over AT-BATS. That needs two * inputs per player that the current negative-binomial ladder never asked for: * * 1. at-bats per game (the OPPORTUNITY count — the binomial's n) * 2. per-AB hit rate (the CONVERSION rate — the binomial's q) * * A model is not "wired" until its inputs are present on real rows at real * coverage. This probe pulls the actual players carrying hits props in the * ledger, fetches their REAL statsapi game logs, and reports what fraction * yields usable AB + hit-rate inputs. * * UNKNOWN IS NOT ZERO. Every read goes through `knownRate`. A game-log row with * no atBats field is COUNTED AS MISSING, never as a 0-AB game — reading it as * zero would say "this player had no opportunity", the strongest possible * statement, from an absence of data. That is the defect this codebase has * shipped seven times. * * Usage: node scripts/hits-input-coverage.js [limit] */ const { knownRate } = require('../src/utils/known'); const mlb = require('../src/services/adapters/mlbStatsAdapter'); // The real players carrying hits props in the public ledger (2026-08-02 pull, // ordered by row count). Hard-coded rather than re-queried so the probe runs // without Supabase credentials — these are REAL names off REAL rows. const PLAYERS = [ 'Esmerlyn Valdez', 'Trea Turner', 'Ryan Jeffers', 'Steven Kwan', 'Jake Mangum', 'Jazz Chisholm Jr', 'JT Realmuto', 'Bo Bichette', 'Ben Rice', 'Brandon Lowe', 'Nick Gonzales', 'Alan Roden', 'Junior Caminero', 'Bryce Harper', 'Travis Bazzana', 'Jasson Dominguez', 'Trent Grisham', 'Chase DeLauter', 'Jorge Polanco', 'Wyatt Langford', 'Petey Halpin', 'Alec Bohm', 'Munetaka Murakami', 'Kyle Schwarber', 'AJ Ewing', 'Royce Lewis', 'Javier Sanoja', 'Ben Williamson', 'Bryson Stott', 'Francisco Lindor', ]; const MIN_GAMES = Number(process.env.HITS_MIN_GAMES || 5); async function main() { const limit = Number(process.argv[2] || PLAYERS.length); const names = PLAYERS.slice(0, limit); const report = []; for (const name of names) { const row = { player: name, resolved: false, games: 0, ab_games: 0, hit_games: 0, ab_per_game: null, hit_rate: null, usable: false }; try { const found = await mlb.searchPlayer(name); if (!found || !found.id) { report.push(row); continue; } row.resolved = true; const log = await mlb.getPlayerGameLog(found.id); row.games = (log || []).length; let abSum = 0; let hSum = 0; let abGames = 0; let hitGames = 0; for (const g of log || []) { const s = (g && g.stat) || {}; const ab = knownRate(s.atBats); // absent -> null, NOT 0 const h = knownRate(s.hits); if (ab !== null) { abSum += ab; abGames += 1; } if (h !== null) { hSum += h; hitGames += 1; } } row.ab_games = abGames; row.hit_games = hitGames; if (abGames >= MIN_GAMES && abSum > 0) { row.ab_per_game = Math.round((abSum / abGames) * 1000) / 1000; row.hit_rate = Math.round((hSum / abSum) * 1000) / 1000; row.usable = true; } } catch (e) { row.error = e.message; } report.push(row); } const resolved = report.filter((r) => r.resolved).length; const usable = report.filter((r) => r.usable).length; const rates = report.filter((r) => r.usable).map((r) => r.hit_rate); const abs = report.filter((r) => r.usable).map((r) => r.ab_per_game); const avg = (a) => (a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : null); console.log(JSON.stringify({ probed: report.length, resolved, usable_combined_inputs: usable, coverage_pct: Math.round((usable / report.length) * 1000) / 10, min_games_required: MIN_GAMES, mean_ab_per_game: avg(abs), mean_hit_rate_per_ab: avg(rates), hit_rate_range: rates.length ? [Math.min(...rates), Math.max(...rates)] : null, rows: report, }, null, 2)); // Redis runs degraded locally; a reconnect timer would hold the process open // and piped output would be lost to SIGTERM. Same rule as verify-grade-range. process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); });