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
This commit is contained in:
Kev
2026-08-02 03:29:23 -04:00
parent 48706210fe
commit eabf3b5bcf
5 changed files with 376 additions and 0 deletions
+5
View File
@@ -293,6 +293,11 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
proj_distribution: g.proj_distribution || null,
proj_ladder: g.proj_ladder || null,
proj_factors: g.proj_factors || null,
// tb-v1 CHALLENGER — total_bases as a compound outcome. Written alongside
// proj_p_over_line, never in place of it. NULL on non-TB props and when
// the components are underivable; never a fabricated 0.
proj_tb_p_over: numOrNull(g.proj_tb_p_over),
proj_tb_meta: g.proj_tb_meta || null,
// Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
// not the actual: this is what we knew when we projected, and it is what
// the instrument measures. The actual lands in game_context and is never
@@ -0,0 +1,200 @@
'use strict';
/**
* compoundTotalBases — TOTAL BASES modelled as the compound outcome it is.
*
* WHY THIS EXISTS. proj-v1.1 models every stat as a single negative binomial
* count. That is right for genuine low-rate event counts (walks, runs, doubles —
* measured resolution 0.519 / 0.345 / 0.207) and WRONG for total bases, which is
* not a count of events at all but a WEIGHTED SUM of them:
*
* TB = 1·singles + 2·doubles + 3·triples + 4·home_runs
*
* An NB fitted to TB treats one home run as "four events", which mis-states the
* variance badly — a 4-base outcome from ONE plate appearance is not the same
* random object as four separate 1-base outcomes. Measured: total_bases had the
* worst result in the ladder, resolution 0.009 (mean 0.019) against the
* champion's 0.273. See `specs/proj-v11-diagnosis.md`.
*
* THE MODEL. Each component is its own per-game Poisson rate; TB is their
* weighted sum. The exact PMF is built by convolution rather than simulated, so
* the result is deterministic and cheap (TB support is small — a cap of 20 bases
* covers every realistic game).
*
* INDEPENDENCE IS AN APPROXIMATION, and a stated one: a plate appearance that
* becomes a double cannot also become a single, so the components are weakly
* negatively correlated. Modelling them as independent Poissons slightly
* OVERSTATES the tail. That is still far closer to the truth than treating one
* home run as four independent events, which is what it replaces — but it is a
* known limitation, not a solved problem.
*
* DOCTRINE: this is the per-stat rule one level deeper — model the stat by its
* actual generative structure, not by a family that happens to fit its name.
*/
const TB_CAP = 20; // bases per game; beyond this is not a real outcome
const N_CAP = 8; // per-component events per game
/** Poisson pmf, computed iteratively so no factorial overflows. */
function poissonPmf(lambda, nMax) {
const l = Number(lambda);
if (!Number.isFinite(l) || l < 0) return null;
const out = new Array(nMax + 1).fill(0);
let term = Math.exp(-l); // n = 0
out[0] = term;
for (let n = 1; n <= nMax; n += 1) {
term = (term * l) / n;
out[n] = term;
}
return out;
}
/**
* The exact PMF of TB = Σ weightᵢ · Nᵢ, with Nᵢ ~ Poisson(rateᵢ) independent.
*
* @param {{singles:number,doubles:number,triples:number,home_runs:number}} rates
* per-GAME expected counts. Any missing/negative component is treated as
* ABSENT (rate 0) rather than guessed — a player with no recorded triples
* genuinely has ~0 triple rate, and inventing one would add tail mass.
* @returns {number[]|null} pmf indexed by total bases, or null if no component
* has a usable rate (caller must fall back, never fabricate).
*/
function tbPmf(rates = {}) {
const components = [
{ w: 1, rate: rates.singles },
{ w: 2, rate: rates.doubles },
{ w: 3, rate: rates.triples },
{ w: 4, rate: rates.home_runs },
];
// STRICT: `Number(null) === 0`, so a null rate would pass a naive finite check
// and be treated as a real, measured zero — the difference between "this
// player never triples" and "we do not know his triple rate". Absent stays
// absent; only a genuine number counts.
const isRate = (v) => v != null && v !== '' && typeof v !== 'boolean'
&& Number.isFinite(Number(v)) && Number(v) >= 0;
const usable = components.filter((c) => isRate(c.rate));
if (usable.length === 0) return null;
let pmf = new Array(TB_CAP + 1).fill(0);
pmf[0] = 1;
for (const c of usable) {
const rate = Number(c.rate);
if (rate === 0) continue; // contributes nothing, skip cleanly
const comp = poissonPmf(rate, N_CAP);
if (!comp) continue;
const next = new Array(TB_CAP + 1).fill(0);
for (let t = 0; t <= TB_CAP; t += 1) {
const pt = pmf[t];
if (pt === 0) continue;
for (let n = 0; n <= N_CAP; n += 1) {
const bases = t + c.w * n;
if (bases > TB_CAP) break; // truncated tail, accounted below
next[bases] += pt * comp[n];
}
}
pmf = next;
}
// Truncation leaves a small mass deficit (the >TB_CAP tail). Push it onto the
// top bucket rather than renormalising: renormalising would silently inflate
// every low bucket, and the deficit genuinely belongs at the top.
const total = pmf.reduce((a, b) => a + b, 0);
if (total > 0 && total < 1) pmf[TB_CAP] += 1 - total;
return pmf;
}
/** P(TB >= k) from a pmf. */
function pAtLeast(pmf, k) {
if (!Array.isArray(pmf)) return null;
const kk = Math.max(0, Math.ceil(Number(k)));
if (!Number.isFinite(kk)) return null;
if (kk === 0) return 1;
let s = 0;
for (let i = kk; i < pmf.length; i += 1) s += pmf[i];
return Math.min(1, Math.max(0, s));
}
/** Expected total bases implied by the component rates. */
function tbMean(rates = {}) {
// Same strictness as tbPmf: an absent component contributes nothing, and is
// not silently read as a measured zero.
const n = (v) => ((v != null && v !== '' && typeof v !== 'boolean'
&& Number.isFinite(Number(v)) && Number(v) >= 0) ? Number(v) : 0);
return n(rates.singles) + 2 * n(rates.doubles) + 3 * n(rates.triples) + 4 * n(rates.home_runs);
}
/**
* Derive component rates from per-game log rows.
*
* SINGLES ARE DERIVED, not read: statsapi has no `singles` field, and
* hits doubles triples homeRuns reproduces stored totalBases EXACTLY on
* real logs (verified). A negative result means the row is inconsistent, so that
* ROW is skipped rather than clamped to 0 — clamping would quietly invent a
* plausible line out of a broken one.
*
* @param {Array<{stat:object}>|Array<object>} rows game-log rows
* @param {number} [minGames=3]
*/
function ratesFromLog(rows, minGames = 3) {
const games = [];
for (const r of rows || []) {
const s = (r && r.stat) || r || {};
const h = Number(s.hits);
const d = Number(s.doubles);
const t = Number(s.triples);
const hr = Number(s.homeRuns);
if (![h, d, t, hr].every((v) => Number.isFinite(v))) continue;
const singles = h - d - t - hr;
if (singles < 0) continue; // inconsistent row — skip, never clamp
games.push({ singles, doubles: d, triples: t, home_runs: hr });
}
if (games.length < minGames) return null; // honest-absent: caller falls back
const mean = (k) => games.reduce((a, g) => a + g[k], 0) / games.length;
return {
singles: mean('singles'),
doubles: mean('doubles'),
triples: mean('triples'),
home_runs: mean('home_runs'),
games: games.length,
};
}
/**
* The full read: P(TB >= line) plus the mean, or null when the components are
* not derivable. `multiplier` scales every component rate together (the same
* park × weather × platoon × matchup product proj-v1.1 already computes), so the
* compound model inherits the adjustments rather than ignoring them.
*/
function projectTotalBases({ rows, line, multiplier = 1, minGames = 3 } = {}) {
const base = ratesFromLog(rows, minGames);
if (!base) return null;
const m = Number.isFinite(Number(multiplier)) && Number(multiplier) > 0 ? Number(multiplier) : 1;
const rates = {
singles: base.singles * m,
doubles: base.doubles * m,
triples: base.triples * m,
home_runs: base.home_runs * m,
};
const pmf = tbPmf(rates);
if (!pmf) return null;
const target = Math.max(1, Math.ceil(Number(line)));
if (!Number.isFinite(target)) return null;
return {
p_over_line: Math.round(pAtLeast(pmf, target) * 1000) / 1000,
mean: Math.round(tbMean(rates) * 1000) / 1000,
rates: {
singles: Math.round(rates.singles * 1000) / 1000,
doubles: Math.round(rates.doubles * 1000) / 1000,
triples: Math.round(rates.triples * 1000) / 1000,
home_runs: Math.round(rates.home_runs * 1000) / 1000,
},
games_used: base.games,
family: 'compound_weighted_poisson',
independence_caveat: true,
};
}
module.exports = {
tbPmf, pAtLeast, tbMean, ratesFromLog, projectTotalBases, poissonPmf, TB_CAP, N_CAP,
};
+28
View File
@@ -18,6 +18,7 @@
*/
const dist = require('./projection/distribution');
const compoundTb = require('./projection/compoundTotalBases');
const matchup = require('./projection/matchupRead');
const parkBase = require('./parkBase');
const { NAME_TO_ABBR } = require('./environmentContext');
@@ -204,8 +205,35 @@ function projectProp({
}));
const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null;
// ── TOTAL BASES, modelled as the COMPOUND OUTCOME it is (tb-v1) ───────────
// TB is a weighted sum (1B..HR = 1..4), not a count of events, so the single
// negative binomial above treats one home run as four events. Measured, that
// gave total_bases the worst result in the ladder (resolution 0.009 vs the
// champion's 0.273). This computes the exact PMF from per-component Poisson
// rates instead, and inherits the SAME combined multiplier so the two models
// differ only in structure.
//
// CHALLENGER ONLY: it is written alongside, never substituted for
// `proj_p_over_line`. The current ladder and the champion are byte-identical.
// Components underivable (thin/inconsistent log) → null, and the prop keeps
// the current ladder value. Never fabricated.
let tbCompound = null;
if (stat === 'total_bases' && tradedRung != null) {
try {
tbCompound = compoundTb.projectTotalBases({
rows: gameLog, line, multiplier: M,
});
} catch { tbCompound = null; }
}
return {
proj_version: PROJ_VERSION,
proj_tb_p_over: tbCompound ? tbCompound.p_over_line : null,
proj_tb_meta: tbCompound ? {
version: 'tb-v1', mean: tbCompound.mean, rates: tbCompound.rates,
games_used: tbCompound.games_used, family: tbCompound.family,
independence_caveat: tbCompound.independence_caveat,
} : null,
proj_point: point,
proj_line: line,
proj_p_over_line: pOverLine,