'use strict'; /** * compoundTotalBases (tb-v1) — total bases as the compound outcome it is. * * The property that matters is the one an NB on TB cannot express: two hitters * with the SAME mean total bases but different STRUCTURE must get different * curves. Everything else here guards the honest-absent paths. */ const c = require('../../src/services/projection/compoundTotalBases'); const R = (s, d, t, hr) => ({ singles: s, doubles: d, triples: t, home_runs: hr }); const row = (h, d, t, hr) => ({ stat: { hits: h, doubles: d, triples: t, homeRuns: hr } }); describe('the PMF is a real distribution', () => { it('sums to 1 and its mean matches the analytic mean', () => { const rates = R(0.5, 0.15, 0.01, 0.12); const pmf = c.tbPmf(rates); expect(pmf.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); const pmfMean = pmf.reduce((a, p, i) => a + p * i, 0); expect(pmfMean).toBeCloseTo(c.tbMean(rates), 4); }); it('P(TB>=0) is 1 and P(>=k) is monotonically non-increasing', () => { const pmf = c.tbPmf(R(0.6, 0.2, 0.02, 0.1)); expect(c.pAtLeast(pmf, 0)).toBe(1); let prev = 1; for (let k = 1; k <= 8; k += 1) { const p = c.pAtLeast(pmf, k); expect(p).toBeLessThanOrEqual(prev + 1e-12); prev = p; } }); }); describe('THE POINT — structure separates hitters an NB would merge', () => { it('a slugger and a slap hitter with the SAME mean get different curves', () => { const slugger = c.tbPmf(R(0, 0, 0, 0.25)); // mean 1.0, all from home runs const slap = c.tbPmf(R(1.0, 0, 0, 0)); // mean 1.0, all from singles expect(c.tbMean(R(0, 0, 0, 0.25))).toBeCloseTo(c.tbMean(R(1.0, 0, 0, 0)), 6); // The 4-base tail is where an NB on TB alone is blind. expect(c.pAtLeast(slugger, 4)).toBeGreaterThan(10 * c.pAtLeast(slap, 4)); }); it('a home run is ONE event worth four bases, not four events', () => { // P(TB>=4) for a pure HR hitter equals P(at least one HR) exactly. const lambda = 0.25; const pmf = c.tbPmf(R(0, 0, 0, lambda)); expect(c.pAtLeast(pmf, 4)).toBeCloseTo(1 - Math.exp(-lambda), 4); }); }); describe('honest-absent — never fabricate a rate', () => { it('derives singles exactly, reproducing stored total bases', () => { const rates = c.ratesFromLog([row(2, 1, 0, 1), row(1, 0, 0, 0), row(0, 0, 0, 0)]); // game 1: singles = 2-1-0-1 = 0 expect(rates.singles).toBeCloseTo((0 + 1 + 0) / 3, 6); expect(rates.home_runs).toBeCloseTo(1 / 3, 6); }); it('SKIPS an inconsistent row rather than clamping it to zero', () => { // hits < extra-base hits is impossible; clamping would invent a plausible line. const rates = c.ratesFromLog([row(0, 2, 0, 0), row(1, 0, 0, 0), row(1, 0, 0, 0), row(1, 0, 0, 0)]); expect(rates.games).toBe(3); }); it('returns null below the minimum games — caller falls back to the ladder', () => { expect(c.ratesFromLog([row(1, 0, 0, 0), row(1, 0, 0, 0)])).toBeNull(); expect(c.projectTotalBases({ rows: [row(1, 0, 0, 0)], line: 1.5 })).toBeNull(); }); it('a missing component is absent (rate 0), not guessed', () => { const pmf = c.tbPmf({ singles: 0.8 }); // no doubles/triples/HR keys expect(pmf.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); expect(c.pAtLeast(pmf, 2)).toBeLessThan(c.pAtLeast(pmf, 1)); }); it('no usable component at all returns null, never a flat distribution', () => { expect(c.tbPmf({})).toBeNull(); expect(c.tbPmf({ singles: 'x', doubles: null })).toBeNull(); }); }); describe('projectTotalBases — the full read', () => { const rows = [row(2, 1, 0, 1), row(1, 0, 0, 0), row(0, 0, 0, 0), row(3, 1, 0, 1), row(1, 1, 0, 0)]; it('inherits the combined multiplier rather than ignoring adjustments', () => { const base = c.projectTotalBases({ rows, line: 1.5, multiplier: 1 }); const up = c.projectTotalBases({ rows, line: 1.5, multiplier: 1.2 }); expect(up.mean).toBeGreaterThan(base.mean); expect(up.p_over_line).toBeGreaterThan(base.p_over_line); }); it('labels its family and carries the independence caveat', () => { const out = c.projectTotalBases({ rows, line: 1.5 }); expect(out.family).toBe('compound_weighted_poisson'); expect(out.independence_caveat).toBe(true); expect(out.games_used).toBe(5); }); });