/* proj-v1 distribution — Gamma-Poisson → NB predictive, ladder, uncertainty. */ const d = require('../../src/services/projection/distribution'); describe('gammaln', () => { it('matches known integer factorials', () => { expect(Math.exp(d.gammaln(5))).toBeCloseTo(24, 4); // 4! expect(Math.exp(d.gammaln(1))).toBeCloseTo(1, 6); }); it('handles fractional argument (needed for non-integer r)', () => { expect(Math.exp(d.gammaln(0.5))).toBeCloseTo(Math.sqrt(Math.PI), 5); }); }); describe('NB predictive from Gamma-Poisson posterior', () => { it('pmf sums to ~1 over a wide support', () => { const nb = d.nbFromPosterior({ alpha: 3, beta: 2 }); let s = 0; for (let x = 0; x < 200; x++) s += d.nbPmf(nb.r, nb.p, x); expect(s).toBeCloseTo(1, 4); }); it('predictive mean equals the posterior mean α/β', () => { const post = { alpha: 3, beta: 2 }; const nb = d.nbFromPosterior(post); expect(d.nbMean(nb)).toBeCloseTo(post.alpha / post.beta, 6); }); it('a rate multiplier scales the mean, preserving dispersion shape (r=α)', () => { const post = { alpha: 4, beta: 5 }; const base = d.nbFromPosterior(post); const lifted = d.nbFromPosterior(d.applyRateMultiplier(post, 1.2)); expect(d.nbMean(lifted)).toBeCloseTo(d.nbMean(base) * 1.2, 6); expect(lifted.r).toBeCloseTo(base.r, 6); // width tied to sample, not the lean }); }); describe('the ladder', () => { it('is monotonically non-increasing (P≥1 ≥ P≥2 ≥ P≥3 …)', () => { const nb = d.nbFromPosterior({ alpha: 3, beta: 2 }); const L = d.ladder(nb, 4).map((r) => r.p_at_least); for (let i = 1; i < L.length; i++) expect(L[i]).toBeLessThanOrEqual(L[i - 1]); }); }); describe('uncertainty scales with sample (the never-abstain mechanism)', () => { // Same observed per-game rate (~1.0), thin vs thick sample. const thin = d.gammaPoissonPosterior({ priorMean: 1, priorGames: 4, weightedSum: 3, weightedGames: 3 }); const thick = d.gammaPoissonPosterior({ priorMean: 1, priorGames: 4, weightedSum: 60, weightedGames: 60 }); it('dispersion ratio (variance/mean = 1 + 1/β) is WIDER for the thin sample', () => { const rThin = d.nbVariance(d.nbFromPosterior(thin)) / d.nbMean(d.nbFromPosterior(thin)); const rThick = d.nbVariance(d.nbFromPosterior(thick)) / d.nbMean(d.nbFromPosterior(thick)); expect(rThin).toBeGreaterThan(rThick); expect(rThick).toBeLessThan(1.1); // ~Poisson at 64 games }); it('a thin HOT sample keeps a credible LOW rung but an honestly thin HIGH rung', () => { // 3 games of 2 hits, shrunk toward a 0.9 season prior. const post = d.gammaPoissonPosterior({ priorMean: 0.9, priorGames: 4, weightedSum: 6, weightedGames: 3 }); const nb = d.nbFromPosterior(post); const L = d.ladder(nb, 3); expect(L[0].p_at_least).toBeGreaterThan(0.5); // P(≥1) is a real read expect(L[2].p_at_least).toBeLessThan(0.35); // P(≥3) stays honestly thin expect(d.nbMean(nb)).toBeLessThan(2); // shrinkage: not fooled by the hot streak }); });