Files
vyndr/src/services/platoonSplits.js
T
builtbykev 8c764c22a4 Structural hardening: unknown-is-not-zero + takeability-is-book-identity
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
2026-08-02 17:25:40 -04:00

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,
};