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, });