'use strict'; /** * The causally-correct platoon atom. * * The flat version applies the same boost to a hitter with a 63-point split and * one with none. These tests lock the thing that fixes that — and the refusal * that stops a thin split becoming a fabricated severity. */ const ps = require('../../src/services/model/platoonSeverity'); /** vl/vr with a given average over a given number of PA. */ const side = (avg, pa) => ({ pa, atBats: Math.round(pa * 0.9), hits: Math.round(pa * 0.9 * avg) }); const bigSplit = { vl: side(0.284, 183), vr: side(0.221, 291) }; // real hitter const noSplit = { vl: side(0.260, 200), vr: side(0.258, 300) }; describe('the severity is the hitter\'s own, not the league\'s', () => { it('a hitter with a real split gets a real move; one without gets almost none', () => { const strong = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); const flat = ps.platoonRead({ splits: noSplit, bats: 'R', throws: 'L' }); expect(strong.readable).toBe(true); expect(flat.readable).toBe(true); // The whole point: the flat factor would have moved these identically. expect(strong.multiplier).toBeGreaterThan(flat.multiplier); expect(strong.observed_split).toBeGreaterThan(0.05); expect(Math.abs(flat.observed_split)).toBeLessThan(0.01); }); it('direction follows tonight\'s matchup, not a stored label', () => { const edge = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); const wrongSide = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'R' }); expect(edge.facing_opposite_hand).toBe(true); expect(wrongSide.facing_opposite_hand).toBe(false); expect(edge.multiplier).toBeGreaterThan(1); expect(wrongSide.multiplier).toBeLessThan(1); }); }); describe('sample discipline — shrink, then refuse', () => { it('shrinks a thin split toward league and keeps an established one', () => { const thin = ps.platoonRead({ splits: { vl: side(0.400, 70), vr: side(0.200, 300) }, bats: 'R', throws: 'L' }); const deep = ps.platoonRead({ splits: { vl: side(0.400, 900), vr: side(0.200, 900) }, bats: 'R', throws: 'L' }); // Same raw 200-point split; the thin one must not be believed as much. expect(thin.shrunk_severity).toBeLessThan(deep.shrunk_severity); expect(thin.shrink_weight).toBeLessThan(deep.shrink_weight); }); it('REFUSES below the floor rather than shrinking to a league-average guess', () => { // A heavily-shrunk severity is indistinguishable from a MEASURED // league-average one, and those are different claims. const r = ps.platoonRead({ splits: { vl: side(0.350, 25), vr: side(0.250, 400) }, bats: 'R', throws: 'L' }); expect(r.readable).toBe(false); expect(r.reason).toBe('insufficient_split_sample'); expect(r.multiplier).toBeNull(); expect(r.smaller_side_pa).toBe(25); }); it('the SMALLER side governs — 500 against 40 is a 40-PA read', () => { const r = ps.platoonRead({ splits: { vl: side(0.300, 40), vr: side(0.250, 500) }, bats: 'R', throws: 'L' }); expect(r.readable).toBe(false); expect(r.smaller_side_pa).toBe(40); }); }); describe('switch hitters are unreadable, not automatically credited', () => { it('does not hand a switch hitter a free edge', () => { // He always bats opposite, so the DIRECTION is never in doubt — but the // per-side value of his swing is a question this sample cannot answer. const r = ps.platoonRead({ splits: bigSplit, bats: 'S', throws: 'L' }); expect(r.readable).toBe(false); expect(r.reason).toBe('switch_hitter_side_value_unknown'); expect(r.multiplier).toBeNull(); }); }); describe('honesty', () => { it('missing a split side is unreadable, never assumed symmetric', () => { const r = ps.platoonRead({ splits: { vl: side(0.300, 200) }, bats: 'R', throws: 'L' }); expect(r.readable).toBe(false); expect(r.reason).toBe('missing_split'); }); it('no pitcher hand → no read at all', () => { expect(ps.platoonRead({ splits: bigSplit, bats: 'R', throws: null })).toBeNull(); expect(ps.platoonRead({ splits: null, bats: 'R', throws: 'L' })).toBeNull(); }); it('the effect is bounded however extreme the split', () => { const absurd = ps.platoonRead({ splits: { vl: side(0.900, 500), vr: side(0.050, 500) }, bats: 'R', throws: 'L' }); expect(absurd.multiplier).toBeLessThanOrEqual(1 + ps.MAX_EFFECT + 1e-9); }); it('reasoning is emitted only for a real read', () => { const good = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); expect(ps.explain(good, 'R', 'L')).toMatch(/measured split is \d+ points/); expect(ps.explain({ readable: false }, 'R', 'L')).toBeNull(); }); });