Files
vyndr/tests/unit/projectionDistribution.test.js
builtbykev 6386e737b9 proj-v1: absolute matchup projection challenger (distribution + full ladder)
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
2026-07-23 03:59:36 -04:00

64 lines
3.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
});
});