/* ============================================================ Session 76 — PLATOON SPLITS. Highest value, highest noise. The regression IS the feature: raw splits applied unregressed are harmful. ============================================================ */ const pl = require('../../src/services/platoonSplits'); const ch = require('../../src/services/challengerProjection'); /** A hitter with `pa` PA vs LHP at `avg`, 300 PA vs RHP at his overall rate. */ const mk = (pa, avg, overall = 0.250) => ({ vsL: { pa, avg, ops: avg * 2.6, slg: avg * 1.7 }, vsR: { pa: 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 }, overall: { pa: pa + 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 }, }); const est = (splits, ph = 'L', stat = 'hits', bh = 'R') => pl.platoonEstimate({ splits, batterHand: bh, pitcherHand: ph, statType: stat }); describe('THE MAKE-OR-BREAK TEST — thin splits must not move anything', () => { it('a .310 split on 30 PA is regressed almost entirely away', () => { const e = est(mk(30, 0.310)); expect(e.observed_weight).toBeLessThan(0.06); // ~4.8% evidence expect(Math.abs(e.multiplier - 1)).toBeLessThan(0.02); expect(e.material).toBe(false); }); it('the SAME split on 400 PA produces a real adjustment', () => { const e = est(mk(400, 0.310)); expect(e.observed_weight).toBeCloseTo(0.4, 1); expect(e.multiplier).toBeGreaterThan(1.05); expect(e.material).toBe(true); }); it('deep sample moves the projection MANY times more than thin', () => { const thin = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(30, 0.310)) }); const deep = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(400, 0.310)) }); // If this ratio ever collapses toward 1, the regression has broken and // platoon is applying noise. expect(Math.abs(deep.delta)).toBeGreaterThan(Math.abs(thin.delta) * 5); }); it('is symmetric — a deep NEGATIVE split suppresses by the same magnitude', () => { const up = est(mk(400, 0.310)); const down = est(mk(400, 0.190)); expect(Math.abs(up.multiplier - 1)).toBeCloseTo(Math.abs(down.multiplier - 1), 2); expect(down.multiplier).toBeLessThan(1); }); it('regresses toward the HITTER\'S OWN rate, not the league', () => { // The question is "is he different vs this hand than he normally is", so a // hitter whose split matches his overall gets NO adjustment however deep. const e = est(mk(2000, 0.250, 0.250)); expect(e.multiplier).toBe(1); }); }); describe('regressSplit — the mechanism', () => { it('weight rises with PA and never reaches 1', () => { const w = (pa) => pl.regressSplit({ observedRate: 0.3, observedPa: pa, priorRate: 0.25 }).weight; expect(w(30)).toBeLessThan(w(130)); expect(w(130)).toBeLessThan(w(400)); expect(w(400)).toBeLessThan(w(1000)); expect(w(100000)).toBeLessThan(1); }); it('no observation → the prior stands, untouched', () => { const r = pl.regressSplit({ observedRate: null, observedPa: null, priorRate: 0.25 }); expect(r.rate).toBe(0.25); expect(r.weight).toBe(0); }); it('no prior → no estimate at all (never invents one)', () => { expect(pl.regressSplit({ observedRate: 0.3, observedPa: 400, priorRate: null }).rate).toBeNull(); }); it('K is conservative — 600 PA of prior', () => { expect(pl.K_PA).toBe(600); expect(pl.regressSplit({ observedRate: 1, observedPa: 600, priorRate: 0 }).weight).toBe(0.5); }); }); describe('honest-absent — frequent and CORRECT here', () => { it.each([ ['no batter handedness', { bh: null, ph: 'L' }, 'batter_hand_absent'], ['no pitcher handedness', { bh: 'R', ph: null }, 'pitcher_hand_absent'], ])('%s → multiplier 1', (_n, { bh, ph }, state) => { const e = pl.platoonEstimate({ splits: mk(400, 0.310), batterHand: bh, pitcherHand: ph, statType: 'hits' }); expect(e.multiplier).toBe(1); expect(e.state).toBe(state); }); it('a stat platoon says nothing about → not_applicable', () => { expect(est(mk(400, 0.310), 'L', 'stolen_bases').state).toBe('not_applicable'); }); it('no splits at all → absent, never a guess', () => { expect(pl.platoonEstimate({ splits: null, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }).state) .toBe('splits_absent'); }); it('a missing SIDE falls back to the prior rather than zero', () => { const s = { vsL: null, vsR: { pa: 300, avg: 0.25 }, overall: { pa: 300, avg: 0.25 } }; const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }); expect(e.multiplier).toBe(1); expect(e.observed_weight).toBe(0); }); }); describe('conditioned on TONIGHT\'S pitcher hand', () => { it('uses the vs-LHP split against an LHP and the vs-RHP split against an RHP', () => { const s = { vsL: { pa: 400, avg: 0.310 }, vsR: { pa: 400, avg: 0.210 }, overall: { pa: 800, avg: 0.260 }, }; const vsL = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }); const vsR = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'R', statType: 'hits' }); expect(vsL.multiplier).toBeGreaterThan(1); expect(vsR.multiplier).toBeLessThan(1); }); it('inverts for strikeouts — a higher K rate means a LOWER hit prop, not higher', () => { const s = { vsL: { pa: 400, k_rate: 0.35 }, vsR: { pa: 400, k_rate: 0.20 }, overall: { pa: 800, k_rate: 0.25 }, }; const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'strikeouts' }); // He strikes out MORE vs LHP → the strikeout prop over is MORE likely. expect(e.rate_key).toBe('k_rate'); expect(e.multiplier).not.toBe(1); }); }); describe('INDEPENDENT of the environment coefficient', () => { it('platoon rides its own slot, labelled separately', () => { const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: { ...est(mk(400, 0.310)), label: 'PLATOON' }, environment: { multiplier: 1.10, label: 'PARK × WEATHER', venue: 'Coors Field' }, }); const axes = r.adjustments.map((a) => a.axis); expect(axes).toContain('matchup'); expect(axes).toContain('environment'); // Two separate entries → the instrument can attribute them independently. expect(r.adjustments.find((a) => a.axis === 'matchup').label).toBe('PLATOON'); }); it('mirrors on the under', () => { const m = { ...est(mk(400, 0.310)), label: 'PLATOON' }; const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: m }); const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'hits', matchup: m }); expect(under.delta).toBeCloseTo(-over.delta, 3); }); it('is capped — platoon leans, never re-forecasts', () => { const absurd = est(mk(5000, 0.600)); expect(Math.abs(absurd.multiplier - 1)).toBeLessThanOrEqual(pl.MAX_MULT + 1e-9); }); }); describe('parseSplits — statsapi shape', () => { const payload = { stats: [{ splits: [ { split: { code: 'vl' }, stat: { plateAppearances: 126, avg: '.259', ops: '.698', slg: '.400', strikeOuts: 30, baseOnBalls: 10 } }, { split: { code: 'vr' }, stat: { plateAppearances: 265, avg: '.248', ops: '.755', slg: '.430', strikeOuts: 55, baseOnBalls: 20 } }, ] }] }; it('parses both sides and derives rates', () => { const s = pl.parseSplits(payload); expect(s.vsL.pa).toBe(126); expect(s.vsL.avg).toBeCloseTo(0.259, 3); expect(s.vsR.k_rate).toBeCloseTo(55 / 265, 3); }); it('builds the overall prior as a PA-weighted blend when none is supplied', () => { const s = pl.parseSplits(payload); expect(s.overall.pa).toBe(391); expect(s.overall.avg).toBeGreaterThan(0.248); expect(s.overall.avg).toBeLessThan(0.259); }); it('an absent side stays null — never zero-filled', () => { const s = pl.parseSplits({ stats: [{ splits: [payload.stats[0].splits[1]] }] }); expect(s.vsL).toBeNull(); }); });