From 20c45cbcd1f805ad6c6931a41a29e7885d3b8649 Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 4 Aug 2026 20:01:09 -0400 Subject: [PATCH] The causally-correct defence atom proves where the crude one did not Kev's insight holds, and the data says so cleanly. defense_by_direction PROVES on hits -- n=528, Brier -0.0034, interval [-0.0059, -0.0009] at the 99.9% level the cumulative correction now demands -- while team-average defence remains not proven, its interval still spanning zero. Same signal, same rows, different unit. The detail worth keeping is that the causally-correct atom moves the number LESS THAN HALF as much as the crude one, 0.013 against 0.030, and is the one that is reliably right. The team average was moving more and knowing less. Big movement is not evidence of a good factor; it is frequently the tell. Both halves turned out to be free, as the order expected. Savant's batted-ball leaderboard carries pull/straight/oppo crossed with ground/air for 609 hitters -- the statcast leaderboard we already pull does not, it has nineteen columns and no direction at all -- and the OAA feed already carries each fielder's position, so per-position defence is a regrouping of last week's ingest rather than a new source. Verified in production: 609 spray profiles, 31 teams. Handedness is what joins them and getting it backwards would have been invisible. Pull for a right-handed hitter is the left side; for a left-handed hitter it is the right side. A model that ignored `bats` would send half the league's grounders to the wrong infielders and still look like it was reading defence, and nothing downstream would have caught it. Switch hitters bat opposite the pitcher, which this does not resolve, so they are unreadable rather than guessed. Unmeasured zones are renormalised away rather than contributing a zero, since a zero asserts an exactly-average fielder standing there, and coverage states honestly what share of a hitter's contact we could actually read. ATOM 2 is input-blocked rather than sample-blocked, and the distinction matters because waiting will not fix it. The weather free-source check passes -- Open-Meteo is already wired and exposes temperature, wind speed, wind direction and precipitation -- but those raw fields are collapsed into a single scalar modifier and wx_forecast is empty on all 1,119 settled rows. Park DIMENSIONS are not ingested at all; parkFactors holds coefficients, not wall heights or fence distances. A park-and-weather-to-hit-type conversion needs both, so it is scoped rather than half-built: retaining the raw weather fields is the cheap half, dimensions are the missing one. Proven factors for hits are now pitcher_contact_profile and defense_by_direction, both pooled; every per-archetype slot remains sample-blocked. 4,297 tests green (342 suites); web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 --- BUILD-STATE.md | 21 +++++++++++++++++++++ CLAUDE.md | 30 ++++++++++++++++++++++++++++++ scripts/prove-hit-factors.js | 22 ++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 2574dcc..d5d2622 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,27 @@ ## Last Updated 2026-08-03 +## Session 93 (2026-08-04) — Causally-correct defence atom PROVES ✅ +4,297 tests / 342 suites green, build exit 0. Counter + frozen clusters +byte-identical. +- **ATOM 1 BUILT AND PROVEN.** `defense_by_direction` (spray×trajectory × + positional OAA, joined by handedness) **PROVES** on hits: n=528, Brier −0.0034, + CI [−0.0059,−0.0009] at 99.9%. Team-average `defense` remains NOT PROVEN. + Kev's insight confirmed: the crude operationalization was the problem. +- **It moves the number LESS (0.013 vs 0.030) and is more reliably right** — + bigger movement is often the tell, not the signal. +- **Zero new sourcing**, as predicted: Savant `leaderboard/batted-ball` has + pull/straight/oppo × gb/air (609 hitters, free); per-position OAA is a + regrouping of the fielding feed. Prod-verified: spray 609, team_defense 31. +- **Proven set for hits now: `pitcher_contact_profile` + `defense_by_direction`.** + Both pooled; per-archetype still sample-blocked (BOMBER 225–294, GHOST 86–125). +- **ATOM 2 INPUT-BLOCKED.** Weather free source PASSES (Open-Meteo wired, exposes + temp/wind_mph/wind_dir/precip) but raw fields are collapsed to a scalar and + `wx_forecast` is 0/1119. **Park dimensions are not ingested at all** — a + hit-type conversion needs them. Not half-built. +- **Next:** retain raw weather fields (cheap), source park dimensions, then build + ATOM 2. Sequence/reliever layer stays gated behind proven atoms. + ## Session 92 (2026-08-04) — The two-part factor gate; one factor proves ✅ 4,286 tests / 340 suites green, build exit 0. Counter + frozen clusters byte-identical. diff --git a/CLAUDE.md b/CLAUDE.md index 17ecccc..a95af46 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1689,6 +1689,36 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). `parkFactors.STAT_BASE` maps `hits → run_base`, so there is **no hits-specific park factor**: a park that turns outs into hits without scoring is invisible. +## Causally-correct atoms (Session 93 — non-obvious) +- **KEV'S INSIGHT IS CONFIRMED ON DATA: crude operationalizations under-prove.** + `defense_by_direction` **PROVES** (n=528, Brier −0.0034, CI [−0.0059,−0.0009] + at 99.9%) where team-average `defense` does NOT (CI spans zero). And the + causally-correct atom **MOVES THE NUMBER LESS THAN HALF AS MUCH** (0.013 vs + 0.030) while being reliably right — the crude version was moving more and + knowing less. Bigger movement is not better; it is often the tell. +- **`src/services/model/sprayDefense.js`** = spray×trajectory × positional OAA, + joined by HANDEDNESS. Pull for a RHB is the LEFT side (3B/SS/LF); for a LHB the + RIGHT side (1B/2B/RF). Getting that backwards sends half the league's grounders + to the wrong infielders and **still looks like it's reading defence** — nothing + downstream would catch it. Switch hitters are UNREADABLE (they bat opposite the + pitcher, unresolved here), not guessed. +- **Both halves were already free.** Savant's `leaderboard/batted-ball` carries + pull/straight/oppo × ground/air (609 hitters) — the `statcast` leaderboard does + NOT (19 cols, no direction). And the OAA feed already carries each fielder's + position, so per-position defence is a REGROUPING of last week's ingest. + `team_defense.position_oaa` + `batter_spray` (dated). Prod-verified 609/31. +- **Unmeasured zones are RENORMALISED AWAY, never zero** — a zero asserts an + exactly-average fielder standing there. `coverage` states what share of a + hitter's contact we could actually read; nothing readable → null. +- **ATOM 2 (park+weather→hit-type) is INPUT-BLOCKED, not sample-blocked.** + Weather's free source check PASSES (Open-Meteo already wired via + `weatherService`, exposing temp_f/wind_mph/wind_dir/precip_mm) — but those raw + fields are collapsed into a scalar `env_weather_mod` and `wx_forecast` is + **0/1119** on settled rows. And **park DIMENSIONS are not ingested at all** + (parkFactors holds coefficients, not wall heights or fence distances). A + hit-type conversion needs both; retaining the raw weather fields is the cheap + half, dimensions are the missing one. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/scripts/prove-hit-factors.js b/scripts/prove-hit-factors.js index 8a159dd..3edf727 100644 --- a/scripts/prove-hit-factors.js +++ b/scripts/prove-hit-factors.js @@ -25,6 +25,7 @@ const tl = require('../src/services/model/testLedger'); const mlb = require('../src/services/adapters/mlbStatsAdapter'); const { knownNumber, knownRate } = require('../src/utils/known'); const { nameKey } = require('../src/utils/playerName'); +const sd = require('../src/services/model/sprayDefense'); const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; @@ -49,6 +50,12 @@ async function page(sb, table, select, apply) { * than nudge it toward some default. */ const FACTORS = [ + { + key: 'defense_by_direction', + needs: ['spray_multiplier'], + mechanism: 'CAUSALLY-CORRECT DEFENCE. Where the hitter puts the ball (pull/straight/oppo x ground/air) crossed with the OAA of the fielders actually standing in those zones, joined by handedness. Team-average failed the gate because it averages in five fielders who will never touch his ball.', + apply: (r) => r.spray_multiplier, + }, { key: 'defense', needs: ['team_defense'], @@ -88,6 +95,14 @@ async function main() { if (r.role === 'pitcher' && r.source_id != null) pitchersById.set(Number(r.source_id), prof); if (r.role === 'batter' && r.player_key) batters.set(r.player_key, prof); } + const sprayRows = await page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb')); + const sprayByKey = new Map(); + for (const r of sprayRows) { + if (!r.player_key) continue; + const prev = sprayByKey.get(r.player_key); + if (!prev || String(r.as_of_date) > String(prev.as_of_date)) sprayByKey.set(r.player_key, r); + } + const defRows = await page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb')); const defByTeam = new Map(); for (const d of defRows) defByTeam.set(d.team, d); @@ -155,6 +170,13 @@ async function main() { team_defense: def ? knownNumber(def.oaa_sum) : null, pitcher_hard_hit_allowed: pit ? knownRate(pit.hard_hit_pct) : null, park_factor: knownNumber(r.env_park_base), + spray_multiplier: (() => { + const sp = sprayByKey.get(r.player_key); + const posOaa = def && def.position_oaa ? def.position_oaa : null; + if (!sp || !posOaa || !bat || !bat.bats) return null; + const out = sd.sprayDefenseMultiplier({ spray: sp, bats: bat.bats, positionOaa: posOaa }); + return out ? out.multiplier : null; + })(), platoon_edge: (bat && pit && bat.bats && pit.throws) ? (String(bat.bats)[0] !== String(pit.throws)[0] ? 1 : -1) : null, });