Build the causally-correct defence atom: spray x positional OAA

Team-average defence failed the two-part gate for hits, and the reason was the
unit rather than the signal. A left-handed pull-ground hitter meets the first
baseman and the second baseman and almost nobody else, so a team total averages
in five fielders who will never touch his ball.

Both halves were already free on the host we pull from. Statcast publishes
spray x trajectory per hitter -- pull/straight/oppo crossed with ground/air,
608 hitters -- and the OAA feed already carries each fielder's position, so
per-position defence is a regrouping of data ingested last week rather than a
new source. Zero new sourcing, as the order expected.

Handedness is what joins them and getting it backwards would be invisible: pull
for a right-handed hitter is the left side, pull for a left-handed hitter is the
right side, so a model ignoring bats would send half the league's grounders to
the wrong infielders and still look like it was reading defence. A switch hitter
bats opposite the pitcher, which this does not resolve, so he is unreadable
rather than guessed.

Two properties the crude version could not express, both locked by test: two
teams with the SAME total defence read differently for a pull hitter, and a
ground-ball hitter and an air hitter read the same team in opposite directions.

Unmeasured zones are renormalised away rather than contributing a zero, which
would assert an exactly-average fielder standing there, and  states
honestly what share of a hitter's contact we could actually read. Nothing
readable at all returns null, so the caller falls back to the base rate instead
of to an invented 1.0 that looks measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-04 19:53:07 -04:00
parent a9ee55550b
commit 405180e791
4 changed files with 307 additions and 4 deletions
+107
View File
@@ -0,0 +1,107 @@
'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();
});
});