Files
vyndr/scripts/hits-input-coverage.js
T
builtbykev 07626de3de hits-v1: built on the right structure, measured honestly, REFUTED
Hits was diagnosed as a family mismatch: 84% of hits rows trade at 0.5, so
the stat rides on P(0), and a negative binomial has unbounded support and no
notion of opportunity at all. hits-v1 models it as the bounded conversion it
is -- N ~ the player's empirical at-bat distribution, hits|N ~ Binomial(N,q),
with the multiplier scaling q (conversion) and never N (opportunity).

STEP 0 confirmed the inputs before the model existed: 30/30 real ledger
players, 100% combined-input coverage. Every read goes through knownRate --
a row with no atBats is dropped, never counted as a 0-at-bat game.

It FIRES: 158/159 hits props (99.4%) on the live production snapshot, through
the real attachProjection path. Scoping by book IDENTITY rather than price
shape kept 94 out-of-promotion-band props on the board, 93 of them modelled --
59% that a price rule would have deleted.

And it LOST. Point-in-time replay (game log truncated strictly before each
row's game_date, real grade-time multiplier), hits-only, direction-aligned,
n=242: resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired
bootstrap on the same rows: hits-v1 - ladder = -0.022, CI95 excluding zero.
Not promoted.

The value is in what it eliminates. The family was wrong AND the mean was not
the constraint -- hits-v1 moved the line-0.5 mean 0.554 -> 0.581 toward a
0.598 base rate while resolution fell. What is left is per-prop
discrimination: the ladder's inputs, not its distribution.

The pre-registered fallback is recorded as WRONG rather than deleted. It said
hits might be genuinely low-resolution for anyone; the champion scores 0.276
on the identical 189 rows, so there is real signal and the ceiling claim was
the comfortable reading, not the honest one. Its own control refuted it, and
that control was already in hand when the branch was written.

hits-v1 stays wired as a challenger writing its own ledger columns so the
forward accrual can confirm the backtest. Champion, ladder, ranking,
calibration, reference ruler and the four accruing verdicts are byte-identical
-- the diff has zero deleted lines.

Tests 4,156 green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 19:04:08 -04:00

103 lines
4.2 KiB
JavaScript

#!/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); });