eabf3b5bcf
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
102 lines
4.2 KiB
JavaScript
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);
|
|
});
|
|
});
|