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
This commit is contained in:
Kev
2026-08-02 19:04:08 -04:00
parent d103ecf4c3
commit 07626de3de
11 changed files with 1409 additions and 2 deletions
+5
View File
@@ -335,6 +335,11 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
// the components are underivable; never a fabricated 0.
proj_tb_p_over: numOrNull(g.proj_tb_p_over),
proj_tb_meta: g.proj_tb_meta || null,
// hits-v1 CHALLENGER — hits as a binomial over at-bats. Written alongside
// proj_p_over_line, never in place of it. NULL on non-hits props and when
// the at-bat inputs are underivable; never a fabricated 0.
proj_hits_p_over: numOrNull(g.proj_hits_p_over),
proj_hits_meta: g.proj_hits_meta || null,
// Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
// not the actual: this is what we knew when we projected, and it is what
// the instrument measures. The actual lands in game_context and is never
+260
View File
@@ -0,0 +1,260 @@
'use strict';
/**
* binomialHits (hits-v1) — HITS MODELLED AS AN AT-BAT-BOUNDED BERNOULLI PROCESS.
*
* WHY THIS EXISTS. proj-v1.1 models every stat as a single negative binomial
* COUNT. That is right for genuine low-rate event counts (walks, runs, doubles —
* measured resolution 0.519 / 0.345 / 0.207) and wrong for hits, for a different
* reason than it was wrong for total bases.
*
* A hit is not a low-rate event drawn from an unbounded count process. It is a
* BOUNDED conversion: a hitter gets N official at-bats and converts each at some
* rate q. Hits can never exceed N. A negative binomial has unbounded support and
* no notion of opportunity at all, so it must infer from the count alone what is
* actually two separate things — how many chances he got, and how good he is.
*
* MEASURED, and this is the whole reason for the file. On 245 matched settled
* ledger rows (2026-08-02, direction-aligned, hits only):
*
* resolution — champion 0.2044 current ladder 0.0595
*
* and 84% of all hits rows are traded at a line of 0.5. So hits is very nearly a
* single question — does he get ONE — and the answer rides almost entirely on
* P(0 hits), which is exactly where the choice of count family does the most
* damage. This is a HYPOTHESIS the ledger will judge, not a claim.
*
* THE MODEL.
*
* N ~ the player's own EMPIRICAL at-bat distribution (per appearance)
* hits | N ~ Binomial(N, q)
* P(hits >= k) = Σ_n P(N=n) · P(Bin(n, q) >= k)
*
* At the 0.5 line this reduces to the axis that matters:
*
* P(>=1 hit) = 1 E_N[(1 q)^N]
*
* TWO DELIBERATE CHOICES, both of which are the honest reading rather than the
* convenient one:
*
* 1. THE AT-BAT DISTRIBUTION IS EMPIRICAL, not a fitted family. A player's
* at-bats per game are driven by lineup slot and how the game goes, and they
* are strongly UNDER-dispersed (3, 4 or 5, almost never 0 or 9). Poisson would
* overstate that spread badly. His own log is the distribution; there is no
* reason to fit a shape to something we can simply count.
*
* 2. ZERO-AT-BAT GAMES ARE KEPT, and this is a conditioning argument. A statsapi
* game log contains only games the player APPEARED in, so the distribution is
* already conditioned on appearing — which matches the settled population
* exactly, since a prop on a player who never appears produces no game-log row
* and therefore never settles. An appearance with 0 official at-bats (walked
* twice, pinch-ran) is a REAL outcome that settles as 0 hits, and carrying it
* is a structural advantage over a count model, which has to infer that mass.
*
* WHAT THE MULTIPLIER MOVES. The park × weather × platoon × matchup product
* scales q, the CONVERSION rate — not N. Those four are all effects on whether a
* batted ball becomes a hit; none of them changes how many times a hitter comes
* to the plate (that is lineup slot and team offense, which we do not model
* here). Scaling the count mean, as the negative binomial path does, silently
* mixes the two.
*
* UNKNOWN IS NOT ZERO. Every read goes through `knownRate`. A game-log row with
* no `atBats` field is DROPPED, never counted as a 0-at-bat game — reading it as
* a measured zero would assert "no opportunity", the strongest statement
* available, out of an absence of data. That defect has shipped seven times in
* this codebase; it does not ship an eighth here.
*
* DOCTRINE: model the stat by its actual generative structure, not by a family
* that happens to fit its name. Same rule as tb-v1, different structure —
* total bases is a weighted SUM, hits is a BOUNDED CONVERSION.
*/
const { knownRate } = require('../../utils/known');
const AB_CAP = 8; // official at-bats in one game; beyond is not real
const HITS_MIN_GAMES = 5; // below this the empirical AB shape is not a shape
const RECENT_WINDOW = 5; // mirrors the ladder's form window
const RECENT_WEIGHT = 2; // mirrors the ladder's 2x recency weight
/**
* League per-at-bat hit rate, used ONLY as a shrinkage anchor. Measured
* 2026-08-02 across the 30 real players carrying hits props in the public
* ledger (scripts/hits-input-coverage.js): mean 0.248, range 0.1310.312.
*/
const LEAGUE_HIT_RATE = 0.248;
/**
* Prior strength in at-bats. Deliberately weak: a regular carries 200400 at-bats
* by midseason, so this moves a settled hitter by a few thousandths and only
* meaningfully regularises a genuinely thin sample. It exists to stop a 4-for-9
* callup projecting as a .444 hitter, not to pull anyone toward the mean.
*/
const PRIOR_AB = 20;
/**
* The player's own empirical at-bat distribution, recency-weighted.
*
* @param {Array} rows game-log rows ({stat:{atBats}}), MOST-RECENT-LAST
* (statsapi order — the same order `recencyWeighted` assumes).
* @returns {{pmf:number[], games:number, mean:number}|null} null when too few
* games carry a known at-bat count (honest-absent; caller falls back).
*/
function abPmfFromLog(rows, minGames = HITS_MIN_GAMES) {
const list = (rows || []).filter((r) => r && (r.stat || typeof r === 'object'));
const n = list.length;
const counts = new Array(AB_CAP + 1).fill(0);
let wTotal = 0;
let games = 0;
list.forEach((r, i) => {
const s = (r && r.stat) || r || {};
const ab = knownRate(s.atBats); // absent -> null, NOT a measured 0
if (ab === null) return; // drop the row, never invent zero
const bucket = Math.min(AB_CAP, Math.round(ab));
const w = i >= n - RECENT_WINDOW ? RECENT_WEIGHT : 1;
counts[bucket] += w;
wTotal += w;
games += 1;
});
if (games < minGames || wTotal === 0) return null;
const pmf = counts.map((c) => c / wTotal);
const mean = pmf.reduce((a, p, k) => a + p * k, 0);
return { pmf, games, mean };
}
/**
* Per-at-bat hit rate from the log, recency-weighted and shrunk toward the
* league anchor.
*
* Only rows carrying BOTH a known at-bat count and a known hit count contribute:
* a hit total without its at-bat denominator is not a rate, and pairing it with
* someone else's denominator would be a fabricated one.
*
* @returns {{q:number, at_bats:number, hits:number, games:number}|null}
*/
function hitRateFromLog(rows, opts = {}) {
const priorAb = opts.priorAb != null ? Number(opts.priorAb) : PRIOR_AB;
const priorRate = opts.priorRate != null ? Number(opts.priorRate) : LEAGUE_HIT_RATE;
const list = (rows || []);
const n = list.length;
let wAb = 0; let wHits = 0; let games = 0; let rawAb = 0; let rawHits = 0;
list.forEach((r, i) => {
const s = (r && r.stat) || r || {};
const ab = knownRate(s.atBats);
const h = knownRate(s.hits);
if (ab === null || h === null) return; // need the PAIR to form a rate
if (h > ab) return; // inconsistent row — skip, never clamp
const w = i >= n - RECENT_WINDOW ? RECENT_WEIGHT : 1;
wAb += w * ab; wHits += w * h;
rawAb += ab; rawHits += h;
games += 1;
});
if (games < (opts.minGames != null ? opts.minGames : HITS_MIN_GAMES)) return null;
// A player with games but zero weighted at-bats has no rate to measure. That
// is genuinely unknown, not a 0.000 hitter.
if (wAb <= 0) return null;
const q = (wHits + priorAb * priorRate) / (wAb + priorAb);
return {
q,
at_bats: rawAb,
hits: rawHits,
games,
prior_ab: priorAb,
prior_rate: priorRate,
};
}
/** P(Bin(n, q) >= k), computed exactly. n is tiny (<= AB_CAP). */
function binomAtLeast(n, q, k) {
const nn = Math.max(0, Math.round(Number(n)));
const kk = Math.ceil(Number(k));
const p = Number(q);
if (!Number.isFinite(nn) || !Number.isFinite(kk) || !Number.isFinite(p)) return null;
if (kk <= 0) return 1;
if (kk > nn) return 0; // cannot get k hits in fewer at-bats
const pp = Math.min(1, Math.max(0, p));
// pmf iteratively: P(X=0) = (1-p)^n, then the standard ratio step.
let term = (1 - pp) ** nn;
let cum = term; // P(X <= 0)
for (let x = 1; x < kk; x += 1) {
if (pp === 1) { term = 0; } else {
term = (term * (nn - x + 1) * pp) / (x * (1 - pp));
}
cum += term;
}
return Math.min(1, Math.max(0, 1 - cum));
}
/**
* P(hits >= k) = Σ_n P(N=n) · P(Bin(n, q) >= k).
*
* At k = 1 (the 0.5 line, 84% of real hits rows) this is 1 E_N[(1q)^N] — the
* P(0) axis stated directly rather than inferred from a count family.
*/
function pAtLeastHits(abPmf, q, k) {
if (!Array.isArray(abPmf)) return null;
const kk = Math.max(0, Math.ceil(Number(k)));
if (!Number.isFinite(kk)) return null;
if (kk === 0) return 1;
let s = 0;
for (let n = 0; n < abPmf.length; n += 1) {
const pn = abPmf[n];
if (!pn) continue;
const tail = binomAtLeast(n, q, kk);
if (tail === null) continue;
s += pn * tail;
}
return Math.min(1, Math.max(0, s));
}
/**
* The full read: P(hits >= line) plus the mean, or null when the inputs are not
* derivable. `multiplier` is the SAME park × weather × platoon × matchup product
* proj-v1.1 already computes, so hits-v1 and the current ladder differ only in
* STRUCTURE — it is applied to the conversion rate q, never to the at-bat count
* (see the header).
*/
function projectHits({ rows, line, multiplier = 1, minGames = HITS_MIN_GAMES } = {}) {
const ab = abPmfFromLog(rows, minGames);
if (!ab) return null;
const rate = hitRateFromLog(rows, { minGames });
if (!rate) return null;
const mRaw = Number(multiplier);
const m = Number.isFinite(mRaw) && mRaw > 0 ? mRaw : 1;
// q is a probability: it cannot exceed 1 however the multipliers stack. The
// ceiling is a bound on the arithmetic, not a modelling opinion.
const q = Math.min(0.999, Math.max(0, rate.q * m));
const target = Math.max(1, Math.ceil(Number(line)));
if (!Number.isFinite(target)) return null;
const p = pAtLeastHits(ab.pmf, q, target);
if (p === null) return null;
return {
p_over_line: Math.round(p * 1000) / 1000,
mean: Math.round(ab.mean * q * 1000) / 1000,
hit_rate: Math.round(q * 1000) / 1000,
hit_rate_base: Math.round(rate.q * 1000) / 1000,
ab_per_game: Math.round(ab.mean * 1000) / 1000,
ab_distribution: ab.pmf.map((v) => Math.round(v * 1000) / 1000),
games_used: Math.min(ab.games, rate.games),
at_bats_observed: rate.at_bats,
hits_observed: rate.hits,
family: 'binomial_over_empirical_at_bats',
// The at-bat count is treated as independent of the conversion rate. A game
// that goes to extra innings gives a hitter both more at-bats AND, weakly,
// a different context; and a hitter who reaches keeps his own lineup turning
// over. Stated, not solved — the same class of caveat as tb-v1's
// independence approximation, and far smaller than the error it replaces.
ab_independence_caveat: true,
};
}
module.exports = {
abPmfFromLog, hitRateFromLog, binomAtLeast, pAtLeastHits, projectHits,
AB_CAP, HITS_MIN_GAMES, LEAGUE_HIT_RATE, PRIOR_AB, RECENT_WINDOW, RECENT_WEIGHT,
};
+73 -1
View File
@@ -19,7 +19,17 @@
const dist = require('./projection/distribution');
const compoundTb = require('./projection/compoundTotalBases');
const binomialHits = require('./projection/binomialHits');
const matchup = require('./projection/matchupRead');
// THE RIGHT TAKEABLE AXIS. Three questions once shared one word; they no longer
// do (src/config/takeability.js). This module reads TWO of them, for two
// different purposes, and never the deprecated `takeable` field:
// isTakeableMarket — book IDENTITY. Decides what is a real market to
// MODEL. Thin, one-sided and juiced markets are all
// real; a -300 hits-over is a bet you can place.
// isWithinPromotionBand — a PRICE policy band. Decides what is worth
// SURFACING. Recorded here, never read by the model.
const { isTakeableMarket, isWithinPromotionBand } = require('../config/takeability');
const parkBase = require('./parkBase');
const { NAME_TO_ABBR } = require('./environmentContext');
@@ -27,6 +37,10 @@ const { NAME_TO_ABBR } = require('./environmentContext');
// projection model is byte-identical; the version bump marks the basis so pre-fix
// (raw-book) and post-fix (fair) rows never silently mix in the handicapper test.
const PROJ_VERSION = 'proj-v1.1';
// hits-v1 — the per-stat structural challenger for HITS. Versioned separately
// from PROJ_VERSION so a later change to the ladder never silently re-labels
// rows that were written by this model.
const HITS_VERSION = 'hits-v1';
const PRIOR_GAMES = Number(process.env.PROJ_PRIOR_GAMES || 4);
const LADDER_MAX = Number(process.env.PROJ_LADDER_MAX || 4);
// Combined non-form multiplier bound (Phase B proved <0.12 stacked). A Coors +
@@ -228,8 +242,66 @@ function projectProp({
} catch { tbCompound = null; }
}
// ── HITS, modelled as the AT-BAT-BOUNDED CONVERSION it is (hits-v1) ───────
// A hit is not a low-rate count. A hitter gets N official at-bats and converts
// each at rate q, so hits are BOUNDED by opportunity — something a negative
// binomial cannot express, since it has unbounded support and no notion of
// opportunity at all. Measured on 245 matched settled rows (direction-aligned,
// hits only): the current ladder resolves 0.0595 against the champion's
// 0.2044, and 84% of hits rows trade at 0.5 — so almost the whole stat is the
// single question P(0 hits), which is exactly where the count family hurts
// most. HYPOTHESIS, not a claim: the ledger decides.
//
// CHALLENGER ONLY: written alongside `proj_p_over_line`, never substituted for
// it. The current ladder and the champion are byte-identical. Inputs
// underivable (thin log, no at-bat counts) → null, and the prop keeps the
// current ladder value. Never fabricated.
//
// MARKET SCOPE READS IDENTITY, NOT PRICE. `market_takeable` comes from the
// book, and NOTHING about the price shape excludes a prop from being modelled:
// baseball hits markets are genuinely thin, genuinely juiced and genuinely
// one-sided, and all three are normal structure rather than a bad quote. The
// promotion band is recorded beside it and deliberately never consulted here —
// a -300 hits-over is takeable AND outside the band, and both are true at once.
let hitsBinom = null;
let hitsMarket = null;
if (stat === 'hits' && tradedRung != null) {
// The graded side's price. `book_odds` is the graded-side price and is the
// only one present on a one-sided quote, so it is a genuine fallback rather
// than a substitute for the other side. Absent → null, and the promotion
// band answers null (an unknown price is not an out-of-band price).
const sideOdds = num(direction === 'under'
? (grade && grade.under_odds)
: (grade && grade.over_odds)) ?? num(grade && grade.book_odds);
hitsMarket = {
book: (grade && grade.book) || null,
market_takeable: isTakeableMarket(grade && grade.book),
within_promotion_band: isWithinPromotionBand(sideOdds),
one_sided: (grade && grade.over_odds != null) !== (grade && grade.under_odds != null),
price_filtered: false, // stated invariant: no price-shape rule gates the model
};
try {
hitsBinom = binomialHits.projectHits({ rows: gameLog, line, multiplier: M });
} catch { hitsBinom = null; }
}
return {
proj_version: PROJ_VERSION,
proj_hits_p_over: hitsBinom ? hitsBinom.p_over_line : null,
proj_hits_meta: hitsBinom ? {
version: HITS_VERSION,
mean: hitsBinom.mean,
hit_rate: hitsBinom.hit_rate,
hit_rate_base: hitsBinom.hit_rate_base,
ab_per_game: hitsBinom.ab_per_game,
ab_distribution: hitsBinom.ab_distribution,
games_used: hitsBinom.games_used,
at_bats_observed: hitsBinom.at_bats_observed,
hits_observed: hitsBinom.hits_observed,
family: hitsBinom.family,
ab_independence_caveat: hitsBinom.ab_independence_caveat,
market: hitsMarket,
} : (hitsMarket ? { version: HITS_VERSION, market: hitsMarket, reason: 'inputs_underivable' } : null),
proj_tb_p_over: tbCompound ? tbCompound.p_over_line : null,
proj_tb_meta: tbCompound ? {
version: 'tb-v1', mean: tbCompound.mean, rates: tbCompound.rates,
@@ -297,5 +369,5 @@ async function attachProjection(grades, deps = {}) {
module.exports = {
projectProp, attachProjection, parkBaselineFromLogs, recencyWeighted, parkFactorFor,
PROJ_VERSION, STAT_FIELD, COMBINED_MAX,
PROJ_VERSION, HITS_VERSION, STAT_FIELD, COMBINED_MAX,
};