'use strict'; /** * The causally-correct defence atom. * * The bug these guard against is subtle and total: getting handedness backwards * sends half the league's grounders to the wrong infielders, and the atom still * LOOKS like it is reading defence. Nothing downstream would catch it. */ const sd = require('../../src/services/model/sprayDefense'); const pullGround = { pull_gb: 0.45, straight_gb: 0.10, oppo_gb: 0.05, pull_air: 0.20, straight_air: 0.12, oppo_air: 0.08, }; const pos = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { oaa: v, fielders: 2 }])); describe('handedness maps pull to the RIGHT side of the field', () => { it('a RIGHT-handed pull hitter meets 3B/SS', () => { // Elite left side, terrible right side. const d = pos({ '3B': 12, SS: 10, '1B': -10, '2B': -10, LF: 0, CF: 0, RF: 0 }); const r = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: d }); expect(r.multiplier).toBeLessThan(1); // suppressed expect(r.contributions.find((c) => c.cell === 'pull_gb').positions).toEqual(['3B', 'SS']); }); it('the SAME hitter batting LEFT meets 1B/2B — the mirror image', () => { const d = pos({ '3B': 12, SS: 10, '1B': -10, '2B': -10, LF: 0, CF: 0, RF: 0 }); const right = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: d }); const left = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'L', positionOaa: d }); // Against the same defence, pulling into the good side vs the bad side must // move the read in OPPOSITE directions. Getting this backwards would be // invisible downstream. expect(right.multiplier).toBeLessThan(1); expect(left.multiplier).toBeGreaterThan(1); }); it('a SWITCH hitter is unreadable, not guessed', () => { const d = pos({ '3B': 5, SS: 5, '1B': 5, '2B': 5 }); expect(sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'S', positionOaa: d })).toBeNull(); }); }); describe('this is what team-average could not express', () => { it('two teams with the SAME total defence read differently for a pull hitter', () => { // Both sum to +4 — indistinguishable to the team-average factor. const leftStrong = pos({ '3B': 12, SS: 8, '1B': -8, '2B': -8, LF: 0, CF: 0, RF: 0 }); const rightStrong = pos({ '3B': -8, SS: -8, '1B': 12, '2B': 8, LF: 0, CF: 0, RF: 0 }); const a = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: leftStrong }); const b = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: rightStrong }); expect(a.multiplier).toBeLessThan(b.multiplier); // The whole reason the crude version failed the gate. }); it('a ground-ball hitter and an air hitter read the SAME team differently', () => { const badInfieldGoodOutfield = pos({ '3B': -10, SS: -10, '1B': -8, '2B': -8, LF: 10, CF: 10, RF: 10 }); const grounder = { pull_gb: 0.5, straight_gb: 0.3, oppo_gb: 0.2, pull_air: 0, straight_air: 0, oppo_air: 0 }; const flyer = { pull_gb: 0, straight_gb: 0, oppo_gb: 0, pull_air: 0.5, straight_air: 0.3, oppo_air: 0.2 }; const g = sd.sprayDefenseMultiplier({ spray: grounder, bats: 'R', positionOaa: badInfieldGoodOutfield }); const f = sd.sprayDefenseMultiplier({ spray: flyer, bats: 'R', positionOaa: badInfieldGoodOutfield }); expect(g.multiplier).toBeGreaterThan(1); // his grounders find holes expect(f.multiplier).toBeLessThan(1); // his fly balls get run down }); }); describe('honesty — absent zones contribute nothing, not zero', () => { it('an unmeasured position is renormalised away, never treated as average', () => { const partial = { '3B': 12, SS: 12 }; // only the pull-ground zone known const r = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: pos(partial) }); expect(r).not.toBeNull(); // Coverage states honestly how much of his contact we could actually read. expect(r.coverage).toBeCloseTo(0.45 + 0.10, 2); // pull_gb + straight_gb(SS) expect(r.multiplier).toBeLessThan(1); }); it('nothing readable at all → null, never a 1.0 that looks measured', () => { expect(sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: {} })).toBeNull(); expect(sd.sprayDefenseMultiplier({ spray: null, bats: 'R', positionOaa: pos({ '3B': 5 }) })).toBeNull(); expect(sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: null })).toBeNull(); }); it('the effect is bounded — no stack of positions runs away', () => { const absurd = pos({ '3B': 500, SS: 500, '1B': 500, '2B': 500, LF: 500, CF: 500, RF: 500 }); const r = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: absurd }); expect(r.multiplier).toBeGreaterThanOrEqual(1 - sd.MAX_EFFECT - 1e-9); }); it('a league-average defence leaves the read untouched', () => { const r = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: pos({ '3B': 0, SS: 0, '1B': 0, '2B': 0, LF: 0, CF: 0, RF: 0 }) }); expect(r.multiplier).toBeCloseTo(1, 6); }); }); describe('the reasoning shown to a user is checkable, or absent', () => { it('names the zone and the direction', () => { const d = pos({ '3B': 12, SS: 10, '1B': 0, '2B': 0, LF: 0, CF: 0, RF: 0 }); const r = sd.sprayDefenseMultiplier({ spray: pullGround, bats: 'R', positionOaa: d }); const text = sd.explain(r, 'Chicago Cubs'); expect(text).toMatch(/pull gb/); expect(text).toMatch(/Chicago Cubs is strong/); }); it('NO read means NO sentence — never a fluent fallback', () => { expect(sd.explain(null, 'Cubs')).toBeNull(); }); });