diff --git a/src/services/adapters/statcastAdapter.js b/src/services/adapters/statcastAdapter.js index 508f738..576a74b 100644 --- a/src/services/adapters/statcastAdapter.js +++ b/src/services/adapters/statcastAdapter.js @@ -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: { diff --git a/src/services/model/sprayDefense.js b/src/services/model/sprayDefense.js new file mode 100644 index 0000000..fb8c88f --- /dev/null +++ b/src/services/model/sprayDefense.js @@ -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 }; diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index b99ff44..91b8f5b 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -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 diff --git a/tests/unit/sprayDefense.test.js b/tests/unit/sprayDefense.test.js new file mode 100644 index 0000000..1350908 --- /dev/null +++ b/tests/unit/sprayDefense.test.js @@ -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(); + }); +});