Files
vyndr/scripts/verify-hits-v1.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

121 lines
5.1 KiB
JavaScript

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