'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); }); });