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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user