8c764c22a4
Both guards are ADDITIVE. The full suite (4,111 -> 4,126 tests, 331 suites) passes unchanged through the migration, which is the evidence that no currently-correct output moved: served path, champion, reference ruler and the four accruing challengers are byte-identical. GUARD 1 -- src/utils/known.js. Number(null)===0 has produced at least SIX separate defects here, including one in a module written the same week its author documented the trap. Per-module vigilance has demonstrably failed, so the rule lives in one place and SEVEN sites now delegate: platoonSplits, projectionChallenger, challengerProjection, contactChallenger, statcastAggregateService, consensusRuler, gradeRanking -- plus compoundTotalBases moved onto knownRate. Two functions, deliberately: knownNumber (any finite number -- a REAL 0 is a fact and must survive) and knownRate (non-negative, rejects booleans -- for counts/rates where `true` or -1 is broken, not thin). Collapsing them is how the next variant gets in. firstKnown() exists because `a || b` discards a measured 0 and `a ?? b` does not. MY OWN GUARD HAD THE BUG IT EXISTS TO PREVENT, and its own test caught it: Number([]) === 0, so an empty array coerced to a measured ZERO. Same trap wearing a different type. Both helpers now reject objects outright. GUARD 2 -- src/config/takeability.js. Takeability is BOOK IDENTITY and never price shape. Baseball prop markets are genuinely thin, juiced and one-sided, and all three are NORMAL structure: betrivers and hardrockbet legitimately quote one side only (5 such rows surfaced in yesterday's re-stamp), and a hits-over at -300 is a real placeable bet. A rule that inferred un-takeability from price extremity or one-sidedness would throw those away while still admitting a DFS book at an ordinary -119 -- exactly backwards, because the -119 is the fake one. THE DISTINCTION THAT MUST NOT COLLAPSE, now enforced by test: isTakeableMarket(book) -- CAN it be bet? (identity) isWithinPriceBand(odds) -- SHOULD we promote? (policy band, floor -160) A -300 DraftKings prop is takeable AND out of band; a PrizePicks -119 is in band AND not takeable. Independent axes. FLAGGED, NOT SILENTLY CHANGED: the ledger's `takeable` column is the PRICE-BAND answer, and its name predates this distinction. Four challengers and the ranking gate read it, so renaming or redefining it is its own order -- doing it here would have changed correct current behaviour under cover of a hardening change. Fixtures are REAL prod rows from the 2026-08-02 re-stamp, not invented. Gates: 4,126 tests / 331 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
211 lines
8.9 KiB
JavaScript
211 lines
8.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* PLATOON SPLITS (Layer 3, Step 6) — the highest-value and highest-noise signal.
|
|
*
|
|
* The market often prices a hitter's BLENDED line while tonight he faces only
|
|
* one handedness. That gap is the value. The danger is that platoon samples are
|
|
* the thinnest in baseball — a hitter may see ~130 PA vs LHP in a season — so a
|
|
* raw split is mostly noise.
|
|
*
|
|
* ── THE SPINE: REGRESS, OR THIS IS ACTIVELY HARMFUL ──────────────────────
|
|
* Applying a raw split adjusts projections on noise, which is worse than not
|
|
* building the adjuster at all. Every estimate here is shrunk toward a prior:
|
|
*
|
|
* regressed = (PA · observed + K · prior) / (PA + K)
|
|
*
|
|
* The PRIOR is the hitter's OWN overall rate, not the league's. The question a
|
|
* platoon adjustment answers is "is he DIFFERENT against this hand than he
|
|
* normally is" — so the null hypothesis is his own blended line, and a hitter
|
|
* with no evidence of a split correctly gets no adjustment.
|
|
*
|
|
* K = 600 PA (env-tunable). Platoon skill is famously slow to stabilise; the
|
|
* sabermetric literature puts the half-signal point for right-handed batters
|
|
* near 1,000 PA vs a hand. 600 is deliberately conservative in the direction of
|
|
* doing nothing:
|
|
* 30 PA → 4.8% weight on the observed split (essentially the prior)
|
|
* 130 PA → 18%
|
|
* 400 PA → 40%
|
|
* 1,000 PA → 63%
|
|
* A 30-PA .310 hitter vs LHP therefore moves the projection almost not at all,
|
|
* which is the correct answer, not a limitation.
|
|
*
|
|
* ── IT IS A MATCHUP SIGNAL, NOT AN ENVIRONMENT ───────────────────────────
|
|
* Park and weather compose into one environment coefficient. Platoon does NOT
|
|
* join them: it is a property of this hitter against this pitcher's hand, and
|
|
* entangling it with the stadium would make both harder to attribute when the
|
|
* instrument scores them.
|
|
*/
|
|
|
|
/** Shrinkage constant, in PA vs the hand. Higher = more conservative. */
|
|
const K_PA = Number(process.env.PLATOON_K_PA) || 600;
|
|
/** Below this the adjustment is reported as immaterial (it will already be
|
|
* tiny from the regression; this is the honesty label, not the mechanism). */
|
|
const MATERIAL_THRESHOLD = Number(process.env.PLATOON_MATERIAL) || 0.02;
|
|
/** Hard cap. Platoon leans; it never re-forecasts. */
|
|
const MAX_MULT = Number(process.env.PLATOON_MAX_MULT) || 0.15;
|
|
|
|
/** Stats a platoon split speaks to. Absent elsewhere — we are not inventing a
|
|
* platoon effect on a strikeout-allowed prop. */
|
|
const PLATOON_STATS = Object.freeze(new Set([
|
|
'hits', 'home_runs', 'total_bases', 'rbi', 'runs', 'doubles', 'strikeouts', 'walks',
|
|
]));
|
|
|
|
/** Which observed rate speaks to which stat. */
|
|
const STAT_RATE = Object.freeze({
|
|
hits: 'avg', doubles: 'avg', runs: 'ops', rbi: 'ops',
|
|
home_runs: 'slg', total_bases: 'slg',
|
|
strikeouts: 'k_rate', walks: 'bb_rate',
|
|
});
|
|
/** Stats where a HIGHER rate means a LOWER prop outcome. */
|
|
const INVERTED = Object.freeze(new Set(['strikeouts']));
|
|
|
|
// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced
|
|
// at least SIX separate defects in this codebase, including one in a module
|
|
// written the same week its author documented the trap — per-module vigilance
|
|
// has demonstrably failed. Semantics are byte-identical to the local copy this
|
|
// replaces, so no output changes; the point is that there is now ONE rule.
|
|
const { knownNumber } = require('../utils/known');
|
|
const num = knownNumber;
|
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
|
|
/**
|
|
* regressSplit({ observedRate, observedPa, priorRate, k }) — PURE.
|
|
* The whole safety mechanism in six lines. Returns the shrunk rate plus the
|
|
* weight actually given to the observation, so callers can SEE how much of the
|
|
* estimate is evidence and how much is prior.
|
|
*/
|
|
function regressSplit({ observedRate, observedPa, priorRate, k = K_PA } = {}) {
|
|
const obs = num(observedRate);
|
|
const pa = num(observedPa);
|
|
const prior = num(priorRate);
|
|
if (prior == null) return { rate: null, weight: 0, reason: 'no prior' };
|
|
if (obs == null || pa == null || pa <= 0) {
|
|
return { rate: prior, weight: 0, reason: 'no observed split — prior stands' };
|
|
}
|
|
const weight = pa / (pa + k);
|
|
return {
|
|
rate: obs * weight + prior * (1 - weight),
|
|
weight: Math.round(weight * 1000) / 1000,
|
|
observed_pa: pa,
|
|
reason: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* platoonEstimate({ splits, batterHand, pitcherHand, statType }) — the signal.
|
|
*
|
|
* splits: { vsL: {pa, avg, ops, slg, k_rate, bb_rate}, vsR: {…}, overall: {…} }
|
|
* Returns { multiplier, state, material, ... }. 1.0 is a true no-op and is
|
|
* reached honestly and OFTEN — most hitters do not have enough evidence of a
|
|
* split to move a projection, and saying so is the correct output.
|
|
*/
|
|
function platoonEstimate({ splits, batterHand, pitcherHand, statType } = {}) {
|
|
const stat = String(statType || '').toLowerCase();
|
|
const neutral = (state, reason, extra = {}) => ({
|
|
multiplier: 1, state, reason, material: false,
|
|
regressed_rate: null, prior_rate: null, observed_weight: 0, ...extra,
|
|
});
|
|
|
|
if (!PLATOON_STATS.has(stat)) return neutral('not_applicable', 'platoon says nothing about this stat');
|
|
|
|
// GATE: without the hitter's handedness there is no platoon question to ask.
|
|
// (statcast_aggregates.bats is 100% populated since Session 69, but a prop
|
|
// for a player we never resolved still lands here.)
|
|
if (!batterHand) return neutral('batter_hand_absent', 'no batter handedness');
|
|
const ph = String(pitcherHand || '').toUpperCase();
|
|
if (ph !== 'L' && ph !== 'R') return neutral('pitcher_hand_absent', "tonight's pitcher handedness unknown");
|
|
|
|
if (!splits || !splits.overall) return neutral('splits_absent', 'no split data for this hitter');
|
|
|
|
const side = ph === 'L' ? splits.vsL : splits.vsR;
|
|
const rateKey = STAT_RATE[stat];
|
|
const prior = num(splits.overall[rateKey]);
|
|
if (prior == null || prior === 0) return neutral('no_prior', 'hitter has no overall rate for this stat');
|
|
|
|
const reg = regressSplit({
|
|
observedRate: side ? num(side[rateKey]) : null,
|
|
observedPa: side ? num(side.pa) : null,
|
|
priorRate: prior,
|
|
});
|
|
if (reg.rate == null) return neutral('no_prior', reg.reason);
|
|
|
|
// The adjustment is the REGRESSED split relative to his own blended line.
|
|
let ratio = reg.rate / prior;
|
|
if (INVERTED.has(stat)) ratio = ratio === 0 ? 1 : 1 / ratio;
|
|
const capped = clamp(ratio - 1, -MAX_MULT, MAX_MULT);
|
|
const multiplier = Math.round((1 + capped) * 1000) / 1000;
|
|
const material = Math.abs(multiplier - 1) >= MATERIAL_THRESHOLD;
|
|
|
|
return {
|
|
multiplier,
|
|
state: 'present',
|
|
reason: null,
|
|
material,
|
|
batter_hand: batterHand,
|
|
pitcher_hand: ph,
|
|
rate_key: rateKey,
|
|
prior_rate: Math.round(prior * 10000) / 10000,
|
|
observed_rate: side ? num(side[rateKey]) : null,
|
|
observed_pa: reg.observed_pa ?? 0,
|
|
observed_weight: reg.weight, // how much is evidence vs prior
|
|
regressed_rate: Math.round(reg.rate * 10000) / 10000,
|
|
capped: capped !== ratio - 1,
|
|
};
|
|
}
|
|
|
|
/** statsapi platoon splits for one hitter. `vl`/`vr` = vs left/right pitching. */
|
|
const SPLITS_URL = (playerId, season) =>
|
|
`https://statsapi.mlb.com/api/v1/people/${playerId}/stats`
|
|
+ `?stats=statSplits&sitCodes=vl,vr&group=hitting&season=${season}`;
|
|
|
|
/**
|
|
* parseSplits(payload, overall) — PURE. statsapi → the shape platoonEstimate
|
|
* wants. A side with no rows is ABSENT, never zero-filled.
|
|
*/
|
|
function parseSplits(payload, overall = null) {
|
|
const out = { vsL: null, vsR: null, overall: overall || null };
|
|
for (const st of (payload && payload.stats) || []) {
|
|
for (const sp of st.splits || []) {
|
|
const code = (sp.split && sp.split.code) || '';
|
|
const s = sp.stat || {};
|
|
const pa = num(s.plateAppearances);
|
|
const row = {
|
|
pa,
|
|
avg: num(s.avg), ops: num(s.ops), slg: num(s.slg), obp: num(s.obp),
|
|
k_rate: pa > 0 && num(s.strikeOuts) != null ? num(s.strikeOuts) / pa : null,
|
|
bb_rate: pa > 0 && num(s.baseOnBalls) != null ? num(s.baseOnBalls) / pa : null,
|
|
};
|
|
if (code === 'vl') out.vsL = row;
|
|
if (code === 'vr') out.vsR = row;
|
|
}
|
|
}
|
|
// Overall = the two sides combined, when a blended line was not supplied.
|
|
if (!out.overall && (out.vsL || out.vsR)) {
|
|
const parts = [out.vsL, out.vsR].filter((r) => r && r.pa);
|
|
const totalPa = parts.reduce((a, r) => a + r.pa, 0);
|
|
if (totalPa > 0) {
|
|
const w = (key) => {
|
|
const vals = parts.filter((r) => num(r[key]) != null);
|
|
if (!vals.length) return null;
|
|
const d = vals.reduce((a, r) => a + r.pa, 0);
|
|
return d > 0 ? vals.reduce((a, r) => a + r[key] * r.pa, 0) / d : null;
|
|
};
|
|
out.overall = { pa: totalPa, avg: w('avg'), ops: w('ops'), slg: w('slg'), obp: w('obp'), k_rate: w('k_rate'), bb_rate: w('bb_rate') };
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
platoonEstimate,
|
|
regressSplit,
|
|
parseSplits,
|
|
SPLITS_URL,
|
|
K_PA,
|
|
MATERIAL_THRESHOLD,
|
|
MAX_MULT,
|
|
PLATOON_STATS,
|
|
STAT_RATE,
|
|
};
|