#!/usr/bin/env node 'use strict'; /** * verify-hits-v1 — STEP 2. WIRED IS NOT FIRING. * * A challenger that exists in the source and never produces a number on a real * row is not a challenger, it is a comment. This induces the REAL code path — * `projectionChallenger.attachProjection`, the exact function the snapshot * calls — over the REAL hits props on the live production snapshot, with the * REAL statsapi game-log adapter behind it. * * It reports FIRING COVERAGE: of the real hits props on the board, how many * yield a hits-v1 read, how many abstain, and why. An abstention is a valid * answer; a silent zero is not. * * The current ladder value is computed on the same rows in the same call, so the * two are compared on identical inputs. * * node scripts/verify-hits-v1.js [snapshotUrl] */ const projection = require('../src/services/projectionChallenger'); const mlb = require('../src/services/adapters/mlbStatsAdapter'); const SNAPSHOT_URL = process.argv[2] || 'https://api.vyndr.app/api/snapshot/mlb'; async function fetchSnapshot(url) { const res = await fetch(url, { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(`snapshot ${res.status}`); return res.json(); } async function main() { const snap = await fetchSnapshot(SNAPSHOT_URL); const grades = (snap.grades || []).filter( (g) => String(g.stat_type || g.stat || '').toLowerCase() === 'hits', ); const out = await projection.attachProjection(grades, { // The one dep that matters here. Everything else (park/weather/platoon/ // arsenal) is absent on the public payload and contributes a documented // 1.0 — which is the honest behaviour, not a fabricated push. gameLogFor: async (g) => { if (!g.playerId) return []; try { return (await mlb.getPlayerGameLog(g.playerId)) || []; } catch { return []; } }, }); const fired = out.filter((g) => g.proj_hits_p_over != null); const abstained = out.filter((g) => g.proj_hits_p_over == null && g.proj_hits_meta); const ladder = out.filter((g) => g.proj_p_over_line != null); // The market read — proving the model was scoped by IDENTITY, not by price. const withMarket = out.filter((g) => g.proj_hits_meta && g.proj_hits_meta.market); const takeableIdentity = withMarket.filter((g) => g.proj_hits_meta.market.market_takeable); const outsidePromotion = withMarket.filter((g) => g.proj_hits_meta.market.within_promotion_band === false); const oneSided = withMarket.filter((g) => g.proj_hits_meta.market.one_sided); // The rows the whole disambiguation exists for: real markets that a // price-shape rule would have thrown away, and which we modelled anyway. const juicedModelled = fired.filter((g) => { const m = g.proj_hits_meta.market; return m.market_takeable && m.within_promotion_band === false; }); const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null); const nums = fired.map((g) => g.proj_hits_p_over); const avg = (a) => (a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : null); const sd = (a) => { if (a.length < 2) return null; const m = a.reduce((x, y) => x + y, 0) / a.length; return Math.round(Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1)) * 1000) / 1000; }; const ladderNums = ladder.map((g) => g.proj_p_over_line); console.log(JSON.stringify({ snapshot: { url: SNAPSHOT_URL, updated_at: snap.updated_at, total_grades: (snap.grades || []).length }, hits_props: grades.length, hits_v1: { fired: fired.length, firing_coverage_pct: pct(fired.length, grades.length), abstained: abstained.length, abstain_reasons: abstained.reduce((acc, g) => { const r = g.proj_hits_meta.reason || 'unknown'; acc[r] = (acc[r] || 0) + 1; return acc; }, {}), mean_p: avg(nums), sd_p: sd(nums), p_range: nums.length ? [Math.min(...nums), Math.max(...nums)] : null, }, current_ladder: { fired: ladder.length, mean_p: avg(ladderNums), sd_p: sd(ladderNums), }, takeable_axis: { note: 'market scope = book IDENTITY; promotion band recorded, never gates the model', rows_with_market_read: withMarket.length, takeable_by_identity: takeableIdentity.length, outside_promotion_band: outsidePromotion.length, one_sided_quotes: oneSided.length, juiced_or_longshot_MODELLED_anyway: juicedModelled.length, any_price_filtered: withMarket.some((g) => g.proj_hits_meta.market.price_filtered), }, sample: fired.slice(0, 5).map((g) => ({ player: g.player, line: g.line, side: g.direction, book: g.book, hits_v1_p_over: g.proj_hits_p_over, ladder_p_over: g.proj_p_over_line, champion_p_win: g.p_win ?? null, hit_rate: g.proj_hits_meta.hit_rate, ab_per_game: g.proj_hits_meta.ab_per_game, games_used: g.proj_hits_meta.games_used, market: g.proj_hits_meta.market, })), }, null, 2)); // Redis is degraded locally; its 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); });