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
14 KiB
hits-v1 — HITS AS A BINOMIAL OVER AT-BATS
VERDICT: REFUTED. DO NOT PROMOTE. The hypothesis was tested honestly and lost. §7 has the numbers, and §8 records where the pre-registration in §6 was itself wrong. hits-v1 stays wired as a challenger only, writing its own ledger columns, so the forward accrual can confirm or overturn the backtest.
Status: built, firing (99.4% on the live board), holdout PRE-REGISTERED
(§1–§6 were written before the holdout was run) and RESOLVED (§7–§8).
Scope: stat = 'hits', MLB, challenger only. Champion, current ladder,
ranking, calibration, the reference ruler and the four accruing challenger
verdicts are untouched and byte-identical.
1. The diagnosis this challenger is built on
Measured 2026-08-02 on the public ledger, hits rows only, direction-aligned, contamination excluded:
n = 245 matched settled rows base rate 0.588
resolution champion 0.2044
current ladder (proj-v1.1) 0.0595
The current ladder is not merely worse than the champion on hits — it is close to uninformative. And the line distribution says why it matters so much:
line 0.5 842 of 1,006 hits rows (84%)
line 1.5 144
line 2.5 20
84% of hits props are a single question: does he get one? That makes the
whole stat ride on P(0 hits) — precisely where the choice of count family does
the most damage, because P(0) is the one point of a count distribution that is
most sensitive to its assumed shape.
This is the HYPOTHESIS, and it is on trial: hits is being modelled by the wrong family. It is not a low-rate event count. It is a bounded conversion.
2. The structure hits actually has
A hitter gets N official at-bats and converts each at some rate q:
N ~ the player's own EMPIRICAL at-bat distribution
hits | N ~ Binomial(N, q)
P(hits >= k) = Σ_n P(N=n) · P(Bin(n,q) >= k)
At the traded line of 0.5 this reduces to the axis that matters, stated directly rather than inferred:
P(>=1 hit) = 1 − E_N[(1 − q)^N]
A negative binomial has unbounded support and no notion of opportunity at all. It must infer from the count alone what are actually two separate things: how many chances a hitter got, and how good he is. Two hitters with the same mean hits and very different at-bat profiles are the same object to it.
This is the same doctrine as tb-v1, one level deeper: model the stat by its generative structure, not by a family that fits its name. Total bases is a weighted sum; hits is a bounded conversion. Different wrongness, different fix.
Two deliberate modelling choices
-
The at-bat distribution is EMPIRICAL, not a fitted family. At-bats per game are driven by lineup slot and how the game goes, and 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.
-
Zero-at-bat games are KEPT — a conditioning argument. A statsapi game log contains only games the player appeared in, so it is already conditioned on appearing, which matches the settled population exactly: a prop on a player who never appears produces no game-log row and 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 must infer that mass.
The multiplier moves q, not N. Park × weather × platoon × matchup are all
effects on whether a batted ball becomes a hit; none of them changes how often a
hitter comes to the plate. Scaling the count mean, as the negative-binomial path
does, silently mixes opportunity with conversion.
3. Inputs — confirmed present before the model was built (STEP 0)
scripts/hits-input-coverage.js, run against the 30 real players carrying the
most hits props in the public ledger, pulling real statsapi game logs:
probed 30 · resolved 30 · usable combined inputs 30 · coverage 100%
mean at-bats/game 3.518
mean per-AB hit rate 0.248 range 0.131 – 0.312
Both binomial inputs exist at full coverage on real rows. 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; the guard
is unit-tested here so it does not ship an eighth.
4. Which takeable axis this reads, and why
Three questions once shared one word (src/config/takeability.js). hits-v1 reads
two of them, for two different purposes, and never the deprecated takeable
mirror:
| axis | question | used for |
|---|---|---|
isTakeableMarket(book) |
can a bettor place this? book identity | what is a real market to MODEL |
isWithinPromotionBand(odds) |
is it worth crowning? price band | recorded only; never gates the model |
No price-shape rule excludes a hits 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. A −300 hits-over from
BetRivers is market_takeable: true, within_promotion_band: false — both true at
once, and it gets modelled. Each row carries market.price_filtered: false as a
stated invariant, and the unit suite asserts the −300 one-sided case and the
+1300 longshot case are both modelled.
5. Where it lives — additive, never substitutive
src/services/projection/binomialHits.js— the pure model.src/services/projectionChallenger.js— emitsproj_hits_p_over+proj_hits_metabesideproj_p_over_line, which is untouched.ledger_entries.proj_hits_p_over/proj_hits_meta— own columns, so it joins to the same outcome and the same close as the champion and the ladder.- NULL on non-hits props and on underivable inputs. Never a fabricated 0.
6. THE HOLDOUT — pre-registered
Two measurements, kept separate because they answer different questions:
(a) Point-in-time replay (available now, n≈245). For each settled hits row,
rebuild the player's game log strictly before that row's game_date and
re-run hits-v1 with the row's stored grade-time combined_multiplier. No
lookahead: the log is truncated at the game being predicted, and the multiplier
is the real one recorded at lock. Hits rows only, direction-aligned to the over
basis. Reported: resolution of hits-v1 vs the current ladder vs the champion, on
matched rows, with n.
Stated limits of the replay: game-log reconstruction uses statsapi as it reads
today (retroactive stat corrections would not be visible); LEAGUE_HIT_RATE
(0.248) and PRIOR_AB (20) are constants chosen today, though at 20 at-bats
against a regular's 200–400 the prior moves a settled hitter by thousandths. It
is a backtest, and it is labelled one.
(b) Forward ledger accrual. hits-v1 writes at grade time from the next snapshot onward and is judged by the same instrument as every other challenger. This is the verdict of record. Hits is high-volume (1,006 rows, 405 settled), so it accrues faster than tb-v1.
The branch, recorded before the result is known
- If hits resolution improves materially → the family-mismatch mechanism
holds for hits as it did for total bases, and the per-stat structural doctrine
has a second confirmation. Promote hits-v1 for
stat='hits'only. - If it does not improve → the P(0)/family hypothesis is wrong for hits, and the honest reading is that hits at a 0.5 line may be genuinely low-resolution for anyone: 84% of the rows ask one near-coin-flip question at a base rate of 0.588, and there may be little separable signal in it. That is an honest ceiling, to be measured against what is achievable rather than forced. It would NOT license rescaling, re-tuning to fit, or re-labelling. Record it and say so.
Promotion requires a material improvement in hits resolution, not a directional one. No edge or CLV term is in this gate.
7. THE RESULT (measured 2026-08-02, after §1–§6 were fixed)
Firing — scripts/verify-hits-v1.js, live production snapshot
Real board (api.vyndr.app/api/snapshot/mlb, 428 grades, 159 hits props), run
through attachProjection — the exact function the snapshot calls — with the
real statsapi adapter behind it:
fired 158 / 159 firing coverage 99.4%
abstained 1 reason: inputs_underivable (an abstention, not a zero)
hits-v1 mean p 0.545 sd 0.124 range 0.210 – 0.728
ladder mean p 0.523 sd 0.112
The takeable axis earned its keep. Of 159 real hits props, 94 sit OUTSIDE
the promotion band and 93 of those were modelled anyway; price_filtered
is false on every row. A rule that inferred un-takeability from price shape would
have thrown away 59% of the live hits board. Every row was takeable by book
identity, which is the axis that decides what is a real market to model.
Holdout — scripts/hits-v1-holdout.js, point-in-time replay
242 of 245 matched settled rows (3 dropped: thin prior log), 184/184 players resolved, hits rows only, direction-aligned, game logs truncated strictly before each row's game date, real grade-time multipliers.
RESOLUTION (corr with outcome)
overall n=242 line 0.5 n=189
champion 0.1951 0.2760
current ladder 0.0483 0.1439
hits-v1 0.0259 0.0899
base rate 0.5868 0.5979
Paired bootstrap on the differences (4,000 resamples, same rows, deterministic seed — the models are scored on identical rows, so treating their errors as independent would overstate the uncertainty):
hits-v1 − ladder overall −0.0224 CI95 [−0.0460, −0.0003] P(improves) 0.023
hits-v1 − ladder line 0.5 −0.0540 CI95 [−0.0933, −0.0197] P(improves) 0.001
champion − ladder overall +0.1468 CI95 [+0.0423, +0.2495] P(improves) 0.997
champion − hits-v1 overall +0.1692 CI95 [+0.0635, +0.2749] P(improves) 0.999
hits-v1 does not improve hits resolution. It is slightly and reliably worse — the confidence interval excludes zero at the traded line, where 84% of the rows live. The promotion gate required a material improvement. It got a small regression. Not promoted.
8. WHAT THIS ELIMINATES — including one thing I pre-registered wrongly
The family-mismatch hypothesis is REFUTED for hits. Modelling hits by its true generative structure — a bounded conversion over at-bats, with P(0) stated directly instead of inferred from a count family — moved resolution by nothing, in the wrong direction. The mechanism that worked for total bases does not transfer. The per-stat structural doctrine is not universal, and this is the case that shows its limit.
The mean was not the binding constraint either, and that is now measurable. The working diagnosis was "it's the MEAN not the shape." hits-v1 corrected the mean — at the 0.5 line the ladder averages 0.554 against a 0.598 base rate, and hits-v1 averages 0.581, materially closer — while resolution got worse. So both candidate explanations, the mean and the shape, are now eliminated. What is left is per-prop DISCRIMINATION: the projection ladder's inputs do not separate one hitter's night from another's, and no redistribution of the same inputs will fix that. Both models are near-uninformative because they are reading nearly the same thing.
The pre-registered fallback in §6 was WRONG, and I am recording it as wrong
§6 said: if hits-v1 does not improve, "hits may be genuinely low-resolution at 0.5 — an honest ceiling for anyone." The data refutes that fallback. On the identical 189 rows at the identical line, the champion resolves 0.276, and the paired bootstrap puts champion-over-ladder at +0.147 with a CI excluding zero. There is real, separable signal in hits, and something in this codebase already extracts it. Calling this an honest ceiling would have been the comfortable reading, not the honest one.
The error was mine and it was avoidable: the champion's number was already in hand when I wrote the fallback, and I wrote a ceiling hypothesis that its own control refuted. A pre-registration is only worth something if it is written against the evidence already available. Recorded here rather than quietly dropped, because a wrong branch that gets deleted teaches nothing.
The honest ceiling for hits is therefore not 0.06. It is at least 0.276 at the traded line — that is the achievable bar, measured, on a real instrument. The projection ladder is not near it.
9. WHAT STAYS, AND WHAT IS UNTOUCHED
- hits-v1 remains wired as a challenger, writing
proj_hits_p_over/proj_hits_metaon its own columns. The replay is a backtest with stated limits; the verdict of record is the forward accrual, and the cheapest way to confirm a negative is to let it accrue. It is never served and never promoted. - Byte-identical, verified: the diff against
projectionChallenger.jsandledgerService.jscontains zero deleted lines — it is purely additive. The champion (p_win), the current ladder (proj_p_over_line), the ranking, the calibration, the reference ruler and the four accruing challenger verdicts (arch-v1, contact-v1, proj-v1.1, tb-v1) are all untouched. - Gates: full suite 332 suites / 4,156 tests green;
webbuild exit 0.
10. OPEN — the next real question, NOT started here
If the deficit is discrimination rather than distribution, the next order is
about INPUTS, and it should begin by asking what the champion's p_win reads on
a hits prop that the projection ladder does not. That is a diagnosis to run, not
a model to build — and building another projection variant before running it
would repeat exactly the mistake this document just recorded.
Unrelated finding, logged not fixed: the local .env carries a transposed
Supabase project ref (zmdnczhtdxcddszxttub; the real ref is
zmdnczhtdxcddsxzttub), so any local script reading it fails DNS. Production is
unaffected — the deployed API and the MCP connection both use the correct ref.
The holdout was run with an explicit SUPABASE_URL override rather than editing
your credentials file.