Files
vyndr/tests/unit/compoundTotalBases.test.js
builtbykev eabf3b5bcf tb-v1: model total_bases as a compound outcome (challenger)
Current ladder (proj_p_over_line) and champion p_win are BYTE-IDENTICAL.
tb-v1 writes alongside them, on total_bases props only.

STEP 0 -- components confirmed on real data, not assumed. statsapi has no
singles field, but hits - doubles - triples - homeRuns reproduces stored
totalBases EXACTLY on a real 10-game log. So the decomposition is exact,
not an approximation.

THE MODEL. Each component gets its own per-game Poisson rate; TB is their
weighted sum, and the PMF is built by exact convolution rather than
simulated (TB support is small). It inherits the SAME combined multiplier
proj-v1.1 computes, so the two models differ only in STRUCTURE.

Why this is the fix: with identical mean TB of 1.0, a pure-HR hitter and a
pure-singles hitter get P(TB>=4) of 0.221 vs 0.019 -- a 12x difference an NB
on TB alone cannot express, because it treats one home run as four events.
A test asserts that separation, and asserts P(TB>=4) for a pure-HR hitter
equals P(at least one HR) exactly.

INDEPENDENCE IS AN APPROXIMATION AND IS LABELLED AS ONE: a plate appearance
that becomes a double cannot also become a single, so the components are
weakly negatively correlated and independent Poissons slightly overstate
the tail. Closer to the truth than what it replaces; not a solved problem.

HONEST-ABSENT throughout: fewer than 3 usable games, or no derivable
component, returns null and the prop keeps the current ladder value. An
inconsistent row (hits < extra-base hits) is SKIPPED rather than clamped to
zero -- clamping would invent a plausible line out of a broken one.

I HIT THE Number(null)===0 TRAP IN MY OWN CODE and a test caught it: a null
rate passed a naive finite check and was treated as a measured zero, which
is the difference between "this player never triples" and "we do not know
his triple rate". Both tbPmf and tbMean now reject null/''/boolean strictly.

Holdout committed: TB ROWS ONLY (49 of 437 settled -- averaging into other
stats would hide the effect) and DIRECTION-ALIGNED, since the unaligned
comparison is the artifact that accounted for 41% of the ladder's apparent
loss. If tb-v1 does NOT improve, the family-mismatch hypothesis is wrong
and the mean/similarity branch reopens -- recorded in the query header.

Migration applied: proj_tb_p_over + proj_tb_meta, NULL-meaningful.

Gates: 4,104 tests / 329 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 03:29:23 -04:00

102 lines
4.2 KiB
JavaScript

'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);
});
});