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
+47 -4
View File
@@ -74,6 +74,11 @@ const FEEDS = Object.freeze({
// NOTE: BASE already ends in /leaderboard — do not repeat it here (a doubled
// path 404s, and because a failed feed degrades to an EMPTY index by design,
// it reports as "0 fielders" rather than as an error).
// SPRAY x TRAJECTORY — where a hitter actually puts the ball. Free, same host.
// This is what makes DEFENCE causally correct: a pull-ground hitter is
// suppressed by a rangy third baseman specifically, and team-average OAA
// cannot express that. Measured live 2026-08-04: 608 hitters.
batted_ball_direction: (y) => `${BASE}/batted-ball?type=batter&year=${y}&min=1&csv=true`,
fielding_oaa: (y) => `${BASE}/outs_above_average?type=Fielder&startYear=${y}&endYear=${y}&split=no&team=&range=year&min=1&pos=&roles=&viz=hide&csv=true`,
});
@@ -294,11 +299,11 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
}
};
const [bd, bbb, pd, pbb, mv, ars, velo, oaa] = await Promise.all([
const [bd, bbb, pd, pbb, mv, ars, velo, oaa, spray] = await Promise.all([
get('batter_discipline'), get('batter_batted_ball'),
get('pitcher_discipline'), get('pitcher_batted_ball'),
get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'),
get('fielding_oaa'),
get('fielding_oaa'), get('batted_ball_direction'),
]);
const [hands, roles] = await Promise.all([fetchHandedness(season, opts), fetchRoles(season, opts)]);
@@ -313,11 +318,13 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
roles,
fielding: indexFielding(oaa),
teamDefense: aggregateTeamDefense(oaa),
spray: indexSpray(spray),
counts: {
batter_discipline: bd.length, batter_batted_ball: bbb.length,
pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length,
pitch_movement: mv.length, pitch_arsenal: ars.length, pitch_velo: velo.length,
handedness: hands.size, pitcher_roles: roles.size, fielding_oaa: oaa.length,
batted_ball_direction: spray.length,
},
};
}
@@ -357,6 +364,32 @@ function indexFielding(rows) {
* measured fielders would otherwise look better merely for being measured more.
* A team with no measured fielders is ABSENT, never 0.
*/
/**
* SPRAY x TRAJECTORY per hitter → Map(mlbam id → six rates + gb/air split).
*
* The six cells (pull/straight/oppo x ground/air) are the whole point: "he pulls
* the ball" and "he pulls it ON THE GROUND" send it to completely different
* fielders, and only the second tells you whose range matters.
*/
function indexSpray(rows) {
const out = new Map();
for (const r of rows || []) {
const id = numOrNull(r.id);
if (id == null) continue;
out.set(Number(id), {
gb_rate: numOrNull(r.gb_rate),
air_rate: numOrNull(r.air_rate),
pull_gb: numOrNull(r.pull_gb_rate),
straight_gb: numOrNull(r.straight_gb_rate),
oppo_gb: numOrNull(r.oppo_gb_rate),
pull_air: numOrNull(r.pull_air_rate),
straight_air: numOrNull(r.straight_air_rate),
oppo_air: numOrNull(r.oppo_air_rate),
});
}
return out;
}
const INFIELD_POS = Object.freeze(new Set(['1B', '2B', '3B', 'SS']));
const OUTFIELD_POS = Object.freeze(new Set(['LF', 'CF', 'RF']));
@@ -368,7 +401,7 @@ function aggregateTeamDefense(rows) {
if (!team || oaa == null) continue;
const cur = acc.get(team) || {
oaa_sum: 0, fielders: 0, diff_sum: 0, diff_n: 0,
inf_sum: 0, inf_n: 0, of_sum: 0, of_n: 0,
inf_sum: 0, inf_n: 0, of_sum: 0, of_n: 0, byPos: {},
};
cur.oaa_sum += oaa;
cur.fielders += 1;
@@ -383,6 +416,12 @@ function aggregateTeamDefense(rows) {
const pos = String(r.primary_pos_formatted || '').toUpperCase();
if (INFIELD_POS.has(pos)) { cur.inf_sum += oaa; cur.inf_n += 1; }
else if (OUTFIELD_POS.has(pos)) { cur.of_sum += oaa; cur.of_n += 1; }
// PER POSITION — the unit a spray tendency actually maps onto. A pull-ground
// hitter meets the third baseman and the shortstop, not "the defence".
if (pos) {
const b = cur.byPos[pos] || { sum: 0, n: 0 };
b.sum += oaa; b.n += 1; cur.byPos[pos] = b;
}
acc.set(team, cur);
}
const out = new Map();
@@ -398,6 +437,10 @@ function aggregateTeamDefense(rows) {
infield_fielders: v.inf_n,
outfield_oaa_sum: v.of_n >= 3 ? v.of_sum : null,
outfield_fielders: v.of_n,
// Absent per position when nobody there was measured — never a zero,
// which would read as an exactly-average fielder.
position_oaa: Object.fromEntries(Object.entries(v.byPos)
.map(([pos, b]) => [pos, { oaa: b.sum, fielders: b.n }])),
});
}
return out;
@@ -450,7 +493,7 @@ async function fetchJson(url, opts = {}) {
module.exports = {
fetchSeason,
indexFielding, aggregateTeamDefense,
indexFielding, aggregateTeamDefense, indexSpray,
DEFAULT_SEASON,
FEEDS,
__internals: {
+127
View File
@@ -0,0 +1,127 @@
'use strict';
/**
* sprayDefense — THE CAUSALLY-CORRECT DEFENCE ATOM.
*
* Team-average OAA failed the two-part gate for hits (Brier 0.0043, corrected
* interval spanning zero), and the reason is that it is not the unit the causal
* story runs through. "The Cubs have a +56 defence" tells you nothing about a
* left-handed pull-ground hitter, because he is going to meet the first baseman
* and the second baseman and almost nobody else. A team total averages in five
* fielders who will never touch his ball.
*
* So the atom is built the way the play actually happens:
*
* where he hits it × who is standing there
*
* SPRAY x TRAJECTORY pull/straight/oppo × ground/air, per hitter (free
* Statcast feed, 608 hitters)
* POSITIONAL OAA per fielder position, per team (same free OAA feed,
* which carries each fielder's position)
*
* HANDEDNESS IS WHAT JOINS THEM, and getting it backwards would invert the whole
* atom. Pull for a RIGHT-handed hitter is the LEFT side (3B/SS/LF); pull for a
* LEFT-handed hitter is the RIGHT side (1B/2B/RF). A model that ignores `bats`
* here would send half the league's grounders to the wrong infielders and still
* look like it was reading defence.
*
* ── HONESTY ──────────────────────────────────────────────────────────────
* A zone with no measured fielder contributes NOTHING and its weight is
* renormalised away — it never contributes a zero, which would assert an exactly
* average fielder standing there. No spray profile, no handedness, or no
* positional data at all → null, and the caller falls back to the base rate
* rather than to an invented read.
*/
const { knownNumber, knownRate } = require('../../utils/known');
/**
* Which positions a batted ball in each spray/trajectory cell actually reaches,
* from the HITTER's point of view. `pull` and `oppo` are resolved to real
* positions by handedness at call time.
*/
const ZONES = Object.freeze({
pull_gb: { R: ['3B', 'SS'], L: ['1B', '2B'] },
straight_gb: { R: ['SS', '2B'], L: ['SS', '2B'] },
oppo_gb: { R: ['1B', '2B'], L: ['3B', 'SS'] },
pull_air: { R: ['LF'], L: ['RF'] },
straight_air: { R: ['CF'], L: ['CF'] },
oppo_air: { R: ['RF'], L: ['LF'] },
});
/** OAA is per-season and per-fielder; this is roughly its full-season spread. */
const OAA_SCALE = 12;
/** Bound on how much defence may move a hit probability, either way. */
const MAX_EFFECT = 0.12;
/**
* The defence multiplier this hitter actually faces tonight.
*
* @param {object} spray { pull_gb, straight_gb, oppo_gb, pull_air, straight_air, oppo_air }
* @param {string} bats 'R' | 'L' | 'S'
* @param {object} positionOaa { '3B': { oaa, fielders }, ... } for the OPPOSING team
* @returns {object|null} null when there is nothing to read — never a 1.0 that
* looks like a measured "average defence".
*/
function sprayDefenseMultiplier({ spray, bats, positionOaa } = {}) {
if (!spray || !positionOaa) return null;
const hand = String(bats || '').toUpperCase()[0];
// A switch hitter bats opposite the pitcher, which we do not resolve here, so
// his spray profile cannot be mapped to a side. Absent beats guessed.
if (hand !== 'R' && hand !== 'L') return null;
let weight = 0;
let weighted = 0;
const contributions = [];
for (const [cell, sides] of Object.entries(ZONES)) {
const share = knownRate(spray[cell]);
if (share === null || share <= 0) continue;
const positions = sides[hand];
const vals = [];
for (const pos of positions) {
const p = positionOaa[pos];
const oaa = p ? knownNumber(p.oaa) : null;
if (oaa === null) continue; // unmeasured zone contributes nothing
vals.push(oaa);
}
if (vals.length === 0) continue; // and its weight is renormalised away
const zoneOaa = vals.reduce((a, b) => a + b, 0) / vals.length;
weight += share;
weighted += share * zoneOaa;
contributions.push({ cell, positions, share, zone_oaa: round3(zoneOaa) });
}
if (weight <= 0) return null; // nothing readable at all
// Outs converted ABOVE average suppress hits; below average elevate them.
const exposure = weighted / weight;
const raw = -(exposure / OAA_SCALE) * MAX_EFFECT;
const effect = Math.max(-MAX_EFFECT, Math.min(MAX_EFFECT, raw));
return {
multiplier: round3(1 + effect),
weighted_oaa_exposure: round3(exposure),
coverage: round3(weight), // share of his batted balls we could read
contributions,
};
}
/**
* A one-line reason a user can check against the box score. Only emitted for a
* read that actually exists — there is no fallback string, because a fluent
* sentence about a defence we could not read is exactly the fabrication this
* codebase exists to avoid.
*/
function explain(result, teamName) {
if (!result) return null;
const strongest = [...result.contributions].sort((a, b) => b.share - a.share)[0];
if (!strongest) return null;
const dir = result.weighted_oaa_exposure > 1 ? 'strong' : result.weighted_oaa_exposure < -1 ? 'weak' : 'neutral';
return `${Math.round(strongest.share * 100)}% of his contact goes ${strongest.cell.replace('_', ' ')}`
+ `${teamName || 'the opposition'} is ${dir} there (${result.weighted_oaa_exposure} OAA weighted by where he hits it)`;
}
const round3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000);
module.exports = { sprayDefenseMultiplier, explain, ZONES, OAA_SCALE, MAX_EFFECT };
+26
View File
@@ -269,6 +269,7 @@ async function refreshSeason(opts = {}) {
oaa_sum: v.oaa_sum, oaa_mean: v.oaa_mean,
success_diff_mean: v.success_diff_mean, fielders: v.fielders,
infield_oaa_sum: v.infield_oaa_sum, infield_fielders: v.infield_fielders,
position_oaa: v.position_oaa || null,
outfield_oaa_sum: v.outfield_oaa_sum, outfield_fielders: v.outfield_fielders,
source: 'statcast_oaa',
}));
@@ -283,6 +284,31 @@ async function refreshSeason(opts = {}) {
summary.team_defense_error = e.message;
}
// ── BATTER SPRAY x TRAJECTORY ─────────────────────────────────────────
// Dated, like every other skill input. Best-effort: a spray failure must never
// fail the mechanism refresh.
try {
const spray = feeds.spray;
const asOfS = (opts.asOfDate || started).slice(0, 10);
if (spray && spray.size > 0) {
const byId = new Map();
for (const r of rows) if (r.source_id != null) byId.set(Number(r.source_id), r);
const sprayRows = [...spray.entries()].map(([id, v]) => {
const known = byId.get(Number(id));
return {
as_of_date: asOfS, sport, season, source_id: Number(id),
player_key: known ? known.player_key : null,
player_name: known ? known.player_name : null,
...v,
};
});
const { error } = await sb.from('batter_spray')
.upsert(sprayRows, { onConflict: 'as_of_date,sport,season,source_id' });
if (error) summary.spray_error = error.message;
else summary.spray_written = sprayRows.length;
} else summary.spray_written = 0;
} catch (e) { summary.spray_error = e.message; }
// ── POINT-IN-TIME RETENTION ───────────────────────────────────────────
// `statcast_aggregates` is upserted in place, so it holds exactly ONE as-of
// date and every prior version is destroyed. That silently makes any backtest
+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();
});
});