6386e737b9
A THIRD challenger (after arch-v1, contact-v1), MLB batting v1. Champion is market-relative P(stat>LINE); proj-v1 is ABSOLUTE — what the hitter will DO — emitted as a full distribution from which the WHOLE LADDER (P≥1,P≥2,P≥3) derives. Champion untouched; nothing claimed; the ledger decides per rung, per stat. - projection/distribution.js — Bayesian Gamma-Poisson → negative-binomial predictive. Admits over-dispersion; under-dispersion → Poisson approx (conservative, documented). Uncertainty scales with sample by construction (r=α): thin → WIDE (real mass on P≥1, honestly thin P≥3), thick → tight. NEVER abstains — width carries the honesty. - projection/matchupRead.js — the input the book doesn't use. HONEST FIDELITY: pitcher repertoire is rich (97% pitch-mix) but hitters have NO pitch-type performance, so TRUE repertoire-vs-profile is impossible today. This is the COARSE version (arsenal buckets fastball/sinker/breaking + whiff/hard-hit tendency × hitter whiff/chase/gb-fb/hard-hit) — beats generic L/R, derived + documented + TESTED two-sided. A hitter pitch-type feed unlocks the true form. - projectionChallenger.js — park RELATIVE to the player's own log exposure (isHome→own park, away→opp park; Phase B's raw-multiply bug solved), recency- weighted fit, per-factor breakdown (form/park/weather/platoon/matchup — show your work), full rung set + book-implied per rung. Combined non-form multiplier bounded. - Wired after contact-v1, own try, flag PROJ_V1_ENABLED, reusing arch-v1's already-computed park/weather/platoon (no duplicate env I/O). Own ledger columns (migration 032, applied to prod): distribution, ladder, point, line, our-P, book-implied, factor breakdown — measurable per rung/stat after settle. Phase 0 (prod-verified): venue join via isHome; NB family; uncertainty-as-width; coarse matchup honest fidelity; no lineup-slot (per-game rate, volume implicit). Sanity: thin-hot → wide (credible low rung, thin high rung); .300 hitter ≠ 3.0; matchup two-sided; champion byte-identical. proj-v1 suites 23/23; snapshot/ ledger/siblings 74 green. Forward-only, version-stamped, PROJ_V1_ENABLED kill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
64 lines
3.0 KiB
JavaScript
64 lines
3.0 KiB
JavaScript
/* 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
|
||
});
|
||
});
|