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
This commit is contained in:
Kev
2026-07-23 03:59:36 -04:00
parent b6f12daa98
commit 6386e737b9
8 changed files with 815 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
/* proj-v1 — matchup read + orchestration. Champion untouched; never abstains. */
const pc = require('../../src/services/projectionChallenger');
const mr = require('../../src/services/projection/matchupRead');
// A sinker-heavy, high-whiff arsenal.
const SINKER_ARM = mr.classifyArsenal([
{ type: 'SI', usage_pct: 45, whiff_pct: 12, hard_hit_pct: 40 },
{ type: 'SL', usage_pct: 30, whiff_pct: 34, hard_hit_pct: 30 },
{ type: 'CH', usage_pct: 25, whiff_pct: 30, hard_hit_pct: 32 },
]);
// A fastball-heavy, low-whiff (pitch-to-contact) arsenal.
const FB_ARM = mr.classifyArsenal([
{ type: 'FF', usage_pct: 65, whiff_pct: 16, hard_hit_pct: 44 },
{ type: 'FC', usage_pct: 20, whiff_pct: 18, hard_hit_pct: 40 },
{ type: 'CU', usage_pct: 15, whiff_pct: 20, hard_hit_pct: 30 },
]);
// A fly-ball, hard-hit hitter; and a whiff-prone one.
const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, metrics: { gb_pct_bb: 30, fb_ld_pct: 55 } };
const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, metrics: { gb_pct_bb: 45, fb_ld_pct: 40 } };
describe('arsenal classification', () => {
it('buckets usage by pitch family + carries whiff/hard-hit tendency', () => {
expect(SINKER_ARM.sinker_pct).toBeCloseTo(0.45, 2);
expect(SINKER_ARM.breaking_pct).toBeCloseTo(0.30, 2);
expect(SINKER_ARM.whiff_tendency).toBeGreaterThan(FB_ARM.whiff_tendency);
});
it('absent mix → null (no matchup contribution, honest)', () => {
expect(mr.classifyArsenal(null)).toBeNull();
expect(mr.classifyArsenal([])).toBeNull();
});
});
describe('matchup direction (two-sided, coarse repertoire-vs-profile)', () => {
it('a groundy sinker arm SUPPRESSES a fly-ball hitter\'s power (HR mult < 1)', () => {
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(flyBat), statType: 'home_runs' });
expect(m.multiplier).toBeLessThan(1);
expect(m.components.some((c) => c.label === 'air_suppression')).toBe(true);
});
it('a high-whiff arsenal SUPPRESSES a whiff-prone hitter\'s hits (hits mult < 1)', () => {
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'hits' });
expect(m.multiplier).toBeLessThan(1);
});
it('the SAME whiff pressure moves a STRIKEOUT prop the other way (mult > 1)', () => {
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'strikeouts' });
expect(m.multiplier).toBeGreaterThan(1);
});
it('missing either side → neutral 1.0 (never fabricates a matchup)', () => {
expect(mr.matchupMultiplier({ arsenal: null, hitter: mr.hitterProfile(flyBat), statType: 'hits' }).multiplier).toBe(1);
expect(mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: null, statType: 'hits' }).multiplier).toBe(1);
});
it('the total matchup lean is bounded', () => {
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'total_bases' });
expect(Math.abs(m.multiplier - 1)).toBeLessThanOrEqual(mr.TOTAL_MAX + 1e-9);
});
});
describe('park RELATIVE to own exposure (Phase B fix)', () => {
it('baseline is the mean park factor over home(own)/away(opp) games', () => {
// NYY home park (parkBase) + away games; just assert it resolves a number
// from ≥3 venues and stays near 1.0 (park factors are ~1.0).
const log = [
{ isHome: true, opponent: 'Boston Red Sox', stat: {} },
{ isHome: false, opponent: 'Boston Red Sox', stat: {} },
{ isHome: false, opponent: 'Houston Astros', stat: {} },
{ isHome: true, opponent: 'Tampa Bay Rays', stat: {} },
];
const b = pc.parkBaselineFromLogs(log, 'NYY');
if (b != null) { expect(b).toBeGreaterThan(0.7); expect(b).toBeLessThan(1.3); }
});
it('too few resolvable venues → null (park contributes nothing, not a raw multiply)', () => {
expect(pc.parkBaselineFromLogs([{ isHome: true, stat: {} }], 'NYY')).toBeNull();
});
});
describe('projectProp — full object, never abstains, plausible', () => {
const hitLog = (vals) => vals.map((h) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { hits: h } }));
it('emits distribution + full ladder + point + factor breakdown', () => {
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.62, team: 'NYY' };
const p = pc.projectProp({ grade, gameLog: hitLog([1, 0, 2, 1, 1, 0, 1, 2, 1, 0]) });
expect(p.proj_version).toBe('proj-v1');
expect(p.proj_distribution.family).toBe('negative_binomial');
expect(p.proj_ladder.length).toBeGreaterThanOrEqual(3);
expect(p.proj_ladder[0].p_at_least).toBeGreaterThanOrEqual(p.proj_ladder[1].p_at_least);
expect(p.proj_factors.breakdown.map((f) => f.label)).toEqual(
expect.arrayContaining(['park_relative', 'weather', 'platoon', 'matchup']),
);
// traded rung (ceil 0.5 = 1) carries the book-implied for comparison
expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeCloseTo(0.62, 3);
expect(p.proj_p_over_line).toBeGreaterThan(0);
});
it('NEVER abstains — an empty game log still projects (wide, from the prior)', () => {
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY' };
const p = pc.projectProp({ grade, gameLog: [] });
expect(p.proj_point).not.toBeNull();
expect(p.proj_ladder[0].p_at_least).toBeGreaterThan(0);
});
it('PLAUSIBILITY: a .300-ish hitter does not project 3.0 hits', () => {
const grade = { stat_type: 'hits', line: 1.5, direction: 'over', season_avg: 0.95, team: 'NYY' };
const p = pc.projectProp({ grade, gameLog: hitLog([1, 1, 2, 0, 1, 1, 1, 0, 2, 1]) });
expect(p.proj_point).toBeLessThan(2.0);
expect(p.proj_point).toBeGreaterThan(0.5);
});
it('the combined non-form multiplier is bounded (no absurd Coors swing)', () => {
const grade = { stat_type: 'home_runs', line: 0.5, direction: 'over', season_avg: 0.2, team: 'COL' };
const p = pc.projectProp({
grade, gameLog: hitLog([0, 0, 1, 0, 0, 1, 0, 0, 0, 1]),
tonightParkFactor: 1.3, weatherMod: 1.1, platoonMult: 1.1, arsenal: FB_ARM,
batterRow: flyBat,
});
expect(Math.abs(p.proj_factors.combined_multiplier - 1)).toBeLessThanOrEqual(pc.COMBINED_MAX + 1e-9);
});
it('non-batting stat → not modeled (returns null projection object)', () => {
expect(pc.projectProp({ grade: { stat_type: 'pitcher_strikeouts', line: 5.5, direction: 'over' } })).toBeNull();
});
});
describe('attachProjection — champion byte-identical', () => {
it('adds proj-v1 fields, never mutates p_win / grade / other challengers', async () => {
const grades = [{
player: 'Slugger', playerId: null, stat_type: 'hits', line: 0.5, direction: 'over',
season_avg: 0.9, fair_prob: 0.6, team: 'NYY', p_win: 0.58, grade: 'B',
p_win_challenger: 0.6, p_win_contact: 0.61,
}];
const out = await pc.attachProjection(grades, {});
expect(out[0].p_win).toBe(0.58);
expect(out[0].grade).toBe('B');
expect(out[0].p_win_challenger).toBe(0.6);
expect(out[0].p_win_contact).toBe(0.61);
expect(out[0].proj_version).toBe('proj-v1');
expect(out[0].proj_point).not.toBeNull(); // projected even with no game log
});
});
+63
View File
@@ -0,0 +1,63 @@
/* 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
});
});