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:
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* binomialHits (hits-v1) — hits as the at-bat-bounded conversion it is.
|
||||
*
|
||||
* The property that matters is the one an unbounded count model cannot express:
|
||||
* hits are CAPPED by opportunity, and two hitters with the same mean hits can
|
||||
* have very different P(0) once you know how they got there. Everything else
|
||||
* here guards the honest-absent paths and the P(0) axis the 0.5 line rides on.
|
||||
*/
|
||||
|
||||
const b = require('../../src/services/projection/binomialHits');
|
||||
|
||||
const row = (ab, h) => ({ stat: { atBats: ab, hits: h } });
|
||||
const logOf = (n, ab, h) => Array.from({ length: n }, () => row(ab, h));
|
||||
|
||||
describe('the at-bat distribution is real, empirical and bounded', () => {
|
||||
it('sums to 1 and its mean matches the observed at-bat average', () => {
|
||||
const rows = [row(4, 1), row(3, 0), row(5, 2), row(4, 1), row(4, 0), row(3, 1)];
|
||||
const ab = b.abPmfFromLog(rows);
|
||||
expect(ab.pmf.reduce((x, y) => x + y, 0)).toBeCloseTo(1, 9);
|
||||
const pmfMean = ab.pmf.reduce((a, p, k) => a + p * k, 0);
|
||||
expect(pmfMean).toBeCloseTo(ab.mean, 9);
|
||||
expect(ab.games).toBe(6);
|
||||
});
|
||||
|
||||
it('keeps a 0-at-bat APPEARANCE — it is a real game that settles as 0 hits', () => {
|
||||
// A statsapi game log lists only games the player appeared in, so a 0-AB row
|
||||
// is a walk/pinch-run appearance, not an absence. Dropping it would delete
|
||||
// genuine P(0 hits) mass; a count model has to infer that mass instead.
|
||||
const rows = [row(0, 0), row(4, 1), row(4, 2), row(3, 0), row(4, 1)];
|
||||
const ab = b.abPmfFromLog(rows);
|
||||
expect(ab.pmf[0]).toBeGreaterThan(0);
|
||||
expect(ab.games).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('THE POINT — P(hits >= k) respects the at-bat ceiling', () => {
|
||||
it('P(>=2 hits) is exactly 0 for a hitter who only ever gets one at-bat', () => {
|
||||
// No count family with unbounded support can state this. Two hits in one
|
||||
// at-bat is not improbable, it is impossible.
|
||||
const rows = logOf(8, 1, 0);
|
||||
const out = b.projectHits({ rows, line: 1.5 });
|
||||
expect(out.p_over_line).toBe(0);
|
||||
});
|
||||
|
||||
it('P(>=1 hit) is 1 - E[(1-q)^N] — the P(0) axis stated directly', () => {
|
||||
// Fixed 4 at-bats every game makes the expectation collapse to one term, so
|
||||
// the model's answer is checkable in closed form. Read the UNROUNDED
|
||||
// internals — projectHits rounds its output to 3dp for storage, and
|
||||
// comparing two separately-rounded numbers is a test of the rounding.
|
||||
const rows = logOf(10, 4, 1);
|
||||
const ab = b.abPmfFromLog(rows);
|
||||
const rate = b.hitRateFromLog(rows);
|
||||
expect(b.pAtLeastHits(ab.pmf, rate.q, 1)).toBeCloseTo(1 - (1 - rate.q) ** 4, 9);
|
||||
});
|
||||
|
||||
it('MORE at-bats at the same rate means a higher P(>=1) — opportunity is modelled', () => {
|
||||
// Identical conversion rate (.250), different opportunity. An unbounded count
|
||||
// model sees only the resulting mean and cannot separate these.
|
||||
const few = b.projectHits({ rows: logOf(12, 2, 0.5), line: 0.5 });
|
||||
const many = b.projectHits({ rows: logOf(12, 6, 1.5), line: 0.5 });
|
||||
expect(many.hit_rate).toBeCloseTo(few.hit_rate, 2);
|
||||
expect(many.p_over_line).toBeGreaterThan(few.p_over_line);
|
||||
});
|
||||
|
||||
it('P(>=k) is monotonically non-increasing in k', () => {
|
||||
const rows = [row(4, 2), row(4, 1), row(3, 0), row(5, 3), row(4, 1), row(4, 0)];
|
||||
const ab = b.abPmfFromLog(rows);
|
||||
const rate = b.hitRateFromLog(rows);
|
||||
let prev = 1;
|
||||
for (let k = 1; k <= 6; k += 1) {
|
||||
const p = b.pAtLeastHits(ab.pmf, rate.q, k);
|
||||
expect(p).toBeLessThanOrEqual(prev + 1e-12);
|
||||
prev = p;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the binomial tail is exact', () => {
|
||||
it('matches closed form for the cases with one', () => {
|
||||
expect(b.binomAtLeast(4, 0.25, 1)).toBeCloseTo(1 - 0.75 ** 4, 9);
|
||||
expect(b.binomAtLeast(3, 0.5, 3)).toBeCloseTo(0.125, 9);
|
||||
expect(b.binomAtLeast(5, 0.2, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('cannot get more hits than at-bats', () => {
|
||||
expect(b.binomAtLeast(2, 0.9, 3)).toBe(0);
|
||||
expect(b.binomAtLeast(0, 0.9, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNKNOWN IS NOT ZERO — the eighth instance does not ship', () => {
|
||||
it('a row with no atBats field is DROPPED, not read as a 0-at-bat game', () => {
|
||||
const rows = [row(4, 1), row(4, 2), { stat: { hits: 1 } }, row(3, 0), row(4, 1), row(4, 1)];
|
||||
const ab = b.abPmfFromLog(rows);
|
||||
expect(ab.games).toBe(5); // the null-AB row contributed nothing
|
||||
expect(ab.mean).toBeCloseTo(3.8, 1); // and did NOT drag the mean toward 0
|
||||
});
|
||||
|
||||
it('a null at-bat count contributes no denominator and no game', () => {
|
||||
// `games` is the tell. Reading the null as 0 at-bats would count a sixth
|
||||
// game asserting "he came up and got nothing" — an opportunity claim made
|
||||
// out of an absence of data. It contributes nothing at all instead.
|
||||
const withNull = b.hitRateFromLog([
|
||||
row(4, 1), row(4, 1), row(4, 1), row(4, 1), row(4, 1), { stat: { atBats: null, hits: 0 } },
|
||||
]);
|
||||
expect(withNull.games).toBe(5);
|
||||
expect(withNull.at_bats).toBe(20);
|
||||
expect(withNull.hits).toBe(5);
|
||||
});
|
||||
|
||||
it('a REAL 0-at-bat game does survive — a measured zero is a fact', () => {
|
||||
const ab = b.abPmfFromLog([row(0, 0), row(4, 1), row(4, 1), row(4, 1), row(4, 1)]);
|
||||
expect(ab.pmf[0]).toBeCloseTo(1 / 5, 9); // uniform weights: 5 games, none recent-only
|
||||
});
|
||||
});
|
||||
|
||||
describe('honest-absent — never fabricate a read', () => {
|
||||
it('returns null below the minimum games rather than guessing a shape', () => {
|
||||
expect(b.abPmfFromLog([row(4, 1), row(4, 1)])).toBeNull();
|
||||
expect(b.projectHits({ rows: [row(4, 1), row(4, 1)], line: 0.5 })).toBeNull();
|
||||
});
|
||||
|
||||
it('a log with games but no known at-bats yields no rate, not a .000 hitter', () => {
|
||||
const rows = Array.from({ length: 8 }, () => ({ stat: { hits: 0 } }));
|
||||
expect(b.hitRateFromLog(rows)).toBeNull();
|
||||
expect(b.projectHits({ rows, line: 0.5 })).toBeNull();
|
||||
});
|
||||
|
||||
it('SKIPS an impossible row (hits > at-bats) rather than clamping it', () => {
|
||||
const rate = b.hitRateFromLog([row(1, 3), row(4, 1), row(4, 1), row(4, 1), row(4, 1), row(4, 1)]);
|
||||
expect(rate.games).toBe(5);
|
||||
expect(rate.hits).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the prior regularises a thin sample without pulling a settled one', () => {
|
||||
it('a 4-for-9 callup does not project as a .444 hitter', () => {
|
||||
const rows = [row(2, 1), row(2, 1), row(1, 1), row(2, 1), row(2, 0)];
|
||||
const rate = b.hitRateFromLog(rows);
|
||||
expect(rate.q).toBeLessThan(0.40);
|
||||
expect(rate.q).toBeGreaterThan(b.LEAGUE_HIT_RATE);
|
||||
});
|
||||
|
||||
it('barely moves a hitter with a full season of at-bats', () => {
|
||||
const rows = logOf(120, 4, 1); // 480 AB, exactly .250
|
||||
const rate = b.hitRateFromLog(rows);
|
||||
expect(Math.abs(rate.q - 0.25)).toBeLessThan(0.005);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectHits — the full read', () => {
|
||||
const rows = [row(4, 1), row(3, 0), row(5, 2), row(4, 1), row(4, 0), row(3, 1), row(4, 2)];
|
||||
|
||||
it('the multiplier moves the CONVERSION RATE, not the at-bat count', () => {
|
||||
const base = b.projectHits({ rows, line: 0.5, multiplier: 1 });
|
||||
const up = b.projectHits({ rows, line: 0.5, multiplier: 1.2 });
|
||||
expect(up.hit_rate).toBeGreaterThan(base.hit_rate);
|
||||
expect(up.p_over_line).toBeGreaterThan(base.p_over_line);
|
||||
// Park, weather, platoon and matchup do not change how often a hitter bats.
|
||||
expect(up.ab_per_game).toBeCloseTo(base.ab_per_game, 9);
|
||||
});
|
||||
|
||||
it('q stays a probability however the multipliers stack', () => {
|
||||
const out = b.projectHits({ rows: logOf(10, 4, 3), line: 0.5, multiplier: 5 });
|
||||
expect(out.hit_rate).toBeLessThanOrEqual(1);
|
||||
expect(out.p_over_line).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('labels its family and carries the stated caveat', () => {
|
||||
const out = b.projectHits({ rows, line: 0.5 });
|
||||
expect(out.family).toBe('binomial_over_empirical_at_bats');
|
||||
expect(out.ab_independence_caveat).toBe(true);
|
||||
expect(out.games_used).toBe(7);
|
||||
expect(out.mean).toBeCloseTo(out.ab_per_game * out.hit_rate, 2);
|
||||
});
|
||||
});
|
||||
@@ -161,3 +161,70 @@ describe('attachProjection — champion byte-identical', () => {
|
||||
expect(out[0].proj_point).not.toBeNull(); // projected even with no game log
|
||||
});
|
||||
});
|
||||
|
||||
describe('hits-v1 — the challenger rides alongside, and reads the RIGHT takeable axis', () => {
|
||||
// A real log: at-bat counts AND hits, which the negative-binomial path never
|
||||
// asks for. Most recent LAST (statsapi order).
|
||||
const abLog = (pairs) => pairs.map(([ab, h]) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { atBats: ab, hits: h } }));
|
||||
const LOG = abLog([[4, 1], [3, 0], [5, 2], [4, 1], [4, 0], [3, 1], [4, 2], [4, 1], [3, 1], [4, 0]]);
|
||||
|
||||
it('emits proj_hits_p_over WITHOUT disturbing the current ladder or the champion', () => {
|
||||
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.55, team: 'NYY', book: 'draftkings', over_odds: -150, under_odds: 130 };
|
||||
const withHits = pc.projectProp({ grade, gameLog: LOG });
|
||||
// The ladder value is computed from the SAME log by the SAME code path as
|
||||
// before this challenger existed — hits-v1 is additive, never substitutive.
|
||||
expect(withHits.proj_hits_p_over).toBeGreaterThan(0);
|
||||
expect(withHits.proj_hits_p_over).toBeLessThanOrEqual(1);
|
||||
expect(withHits.proj_p_over_line).not.toBeNull();
|
||||
expect(withHits.proj_distribution.family).toBe('negative_binomial');
|
||||
expect(withHits.proj_hits_meta.version).toBe('hits-v1');
|
||||
expect(withHits.proj_hits_meta.family).toBe('binomial_over_empirical_at_bats');
|
||||
});
|
||||
|
||||
it('MODELS a -300 one-sided hits market — takeable by identity, outside the promotion band', () => {
|
||||
// The whole reason the three questions were disambiguated. A -300 hits-over
|
||||
// from a real book is a bet you can place: steep juice is a PRICE, not a
|
||||
// disqualification, and one-sidedness is normal baseball market structure.
|
||||
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'betrivers', over_odds: -300, under_odds: null };
|
||||
const p = pc.projectProp({ grade, gameLog: LOG });
|
||||
expect(p.proj_hits_p_over).not.toBeNull(); // MODELLED, not thrown away
|
||||
const m = p.proj_hits_meta.market;
|
||||
expect(m.market_takeable).toBe(true); // identity: yes
|
||||
expect(m.within_promotion_band).toBe(false); // price band: no
|
||||
expect(m.one_sided).toBe(true); // normal, not an error
|
||||
expect(m.price_filtered).toBe(false); // no price-shape gate ran
|
||||
});
|
||||
|
||||
it('a longshot +1300 hits market is still modelled — recording is not crowning', () => {
|
||||
const grade = { stat_type: 'hits', line: 2.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'fanduel', over_odds: 1300, under_odds: -2000 };
|
||||
const p = pc.projectProp({ grade, gameLog: LOG });
|
||||
expect(p.proj_hits_p_over).not.toBeNull();
|
||||
expect(p.proj_hits_meta.market.market_takeable).toBe(true);
|
||||
expect(p.proj_hits_meta.market.within_promotion_band).toBe(false);
|
||||
});
|
||||
|
||||
it('never reads the deprecated `takeable` mirror', () => {
|
||||
const src = require('fs').readFileSync(require.resolve('../../src/services/projection/binomialHits'), 'utf8')
|
||||
+ require('fs').readFileSync(require.resolve('../../src/services/projectionChallenger'), 'utf8');
|
||||
// The disambiguated names only. `takeable:` as a bare field read would be
|
||||
// the ambiguous mirror the S-prior commit deprecated.
|
||||
expect(/\btakeable\s*:/.test(src.replace(/market_takeable\s*:/g, ''))).toBe(false);
|
||||
});
|
||||
|
||||
it('is NULL on a non-hits prop — never a fabricated 0', () => {
|
||||
const grade = { stat_type: 'total_bases', line: 1.5, direction: 'over', season_avg: 1.4, team: 'NYY', book: 'draftkings', over_odds: -110 };
|
||||
const p = pc.projectProp({ grade, gameLog: LOG });
|
||||
expect(p.proj_hits_p_over).toBeNull();
|
||||
expect(p.proj_hits_meta).toBeNull();
|
||||
});
|
||||
|
||||
it('abstains honestly when the at-bat inputs are underivable, keeping the ladder', () => {
|
||||
// A log with hits but no at-bat counts — exactly what the NB path runs on.
|
||||
const noAb = [1, 0, 2, 1, 1, 0, 1].map((h) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { hits: h } }));
|
||||
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'draftkings', over_odds: -140 };
|
||||
const p = pc.projectProp({ grade, gameLog: noAb });
|
||||
expect(p.proj_hits_p_over).toBeNull(); // no read, not a zero
|
||||
expect(p.proj_hits_meta.reason).toBe('inputs_underivable');
|
||||
expect(p.proj_p_over_line).not.toBeNull(); // the ladder still stands
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user