diff --git a/BUILD-STATE.md b/BUILD-STATE.md index d5d2622..11eb1e7 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,27 @@ ## Last Updated 2026-08-03 +## Session 94 (2026-08-04) — Causally-correct platoon + park inputs ✅ +4,307 tests / 344 suites green, build exit 0. Counter + frozen clusters +byte-identical. +- **PLATOON SEVERITY built + tested: n=452, 48 short of the gate.** + CANDIDATE_PENDING — not proven, not theatre. Uses each hitter's own vs-LHP/ + vs-RHP split, shrunk by the smaller side's PA, refused below 60 PA. It moves + LESS than flat platoon (0.021 vs 0.026), consistent with the pattern. +- **The refusal costs sample honestly** — 452 vs 741 rows is exactly the hitters + whose splits are unreadable. +- **PARK DIMENSIONS INGESTED** (free, statsapi venue endpoint): fence distances, + roof, turf, elevation. Prod-verified 15 venues. Joined by real `venue_id` from + the schedule, never inferred from the home team. +- **RAW WEATHER RETAINED** (temp/wind_mph/wind_dir/precip) — and the old guard + that dropped the environment entirely when the multiplier was 1 is fixed, which + had been discarding the forecast on every ordinary night. +- **PLATOON/PARK ingest prod-verified:** 270 lineups, 265 platoon, 15 park dims. +- **NOT built: park+weather→hit-type.** Its inputs landed this session and carry + ONE as_of date; testing it on total_bases needs accumulated dated rows, so + building it now would be plausibility not proof. +- **Proven for hits: pitcher_contact_profile, defense_by_direction.** + ## Session 93 (2026-08-04) — Causally-correct defence atom PROVES ✅ 4,297 tests / 342 suites green, build exit 0. Counter + frozen clusters byte-identical. diff --git a/CLAUDE.md b/CLAUDE.md index a95af46..e35372d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1719,6 +1719,36 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). hit-type conversion needs both; retaining the raw weather fields is the cheap half, dimensions are the missing one. +## Causally-correct platoon + park inputs (Session 94 — non-obvious) +- **PLATOON SEVERITY built, n=452 — 48 SHORT of the gate.** Not proven, not + theatre. `platoonSeverity.platoonRead` uses each hitter's OWN vs-LHP/vs-RHP + split (statsapi `sitCodes=vl,vr`, one call per hitter), shrunk toward league by + the **SMALLER side's PA** (500-vs-40 is a 40-PA read) and **REFUSED outright + below 60 PA** — a heavily-shrunk severity is indistinguishable from a MEASURED + league-average one, and those are different claims. Without the refusal the + atom would assert a league-typical split about every call-up in the league. +- **The refusal costs sample, honestly:** severity has n=452 where flat platoon + has 741. That gap IS the hitters whose splits we cannot read. +- **Switch hitters are the easy case misread as hard.** He bats opposite by + choice so DIRECTION is never in doubt; the per-side VALUE of his swing is the + unanswerable part. UNREADABLE, never credited with an automatic edge. +- **PARK DIMENSIONS are free from statsapi** — + `/venues/{id}?hydrate=location,fieldInfo` returns leftLine/leftCenter/center/ + rightCenter/rightLine + roofType + turfType + elevation (Wrigley: 355/400/353, + 595ft). `parkFactors` holds RUN COEFFICIENTS, which structurally cannot express + a park that turns outs into hits without scoring — that is why crude park failed. +- **JOIN THE PARK BY `venue_id` FROM THE SCHEDULE, never by home team.** + Neutral-site and international games break the home-team assumption silently. + `lineup_context.venue_id` carries the real venue. +- **RAW WEATHER IS NOW RETAINED** (`wx_forecast`: temp_f/wind_mph/wind_dir/ + precip_mm). Two bugs fixed at once: the scalar `weather_mod` cannot express a + hit-TYPE conversion (wind-out-and-warm vs cold-heavy-air collapse to the same + number), AND the old guard dropped the entire environment when the multiplier + was 1 — discarding the forecast for every ordinary night, which is the majority + of games and exactly the rows a hit-type model must learn the ordinary case from. +- **Proven factors for hits remain: `pitcher_contact_profile`, + `defense_by_direction`.** Crude `defense` and `platoon` both still NOT_PROVEN. + ## 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 3edf727..9e4fcd4 100644 --- a/scripts/prove-hit-factors.js +++ b/scripts/prove-hit-factors.js @@ -26,6 +26,7 @@ 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 pss = require('../src/services/model/platoonSeverity'); const SB_URL = process.env.SUPABASE_URL; const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; @@ -76,6 +77,12 @@ const FACTORS = [ apply: (r) => r.park_factor, caveat: 'STAT_BASE maps hits -> run_base, so this is a RUN factor standing in for a HITS factor. A park that converts outs to hits without scoring is invisible to it.', }, + { + key: 'platoon_severity', + needs: ['platoon_severity_mult'], + mechanism: "CAUSALLY-CORRECT PLATOON. The advantage is worth only what THIS hitter's measured split is worth, shrunk toward league by the smaller side's PA and refused outright below a floor. Flat handedness applies the same boost to a 63-point split and to none.", + apply: (r) => r.platoon_severity_mult, + }, { key: 'platoon', needs: ['platoon_edge'], @@ -103,6 +110,14 @@ async function main() { if (!prev || String(r.as_of_date) > String(prev.as_of_date)) sprayByKey.set(r.player_key, r); } + const platRows = await page(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb')); + const platByKey = new Map(); + for (const r of platRows) { + if (!r.player_key) continue; + const prev = platByKey.get(r.player_key); + if (!prev || String(r.as_of_date) > String(prev.as_of_date)) platByKey.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); @@ -170,6 +185,18 @@ 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), + platoon_severity_mult: (() => { + const sp = platByKey.get(r.player_key); + if (!sp || !bat || !bat.bats || !pit || !pit.throws) return null; + const out = pss.platoonRead({ + splits: { + vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits }, + vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits }, + }, + bats: bat.bats, throws: pit.throws, + }); + return out && out.readable ? out.multiplier : null; + })(), spray_multiplier: (() => { const sp = sprayByKey.get(r.player_key); const posOaa = def && def.position_oaa ? def.position_oaa : null; diff --git a/src/services/challengerProjection.js b/src/services/challengerProjection.js index 51c1e4c..de2898f 100644 --- a/src/services/challengerProjection.js +++ b/src/services/challengerProjection.js @@ -326,6 +326,9 @@ async function attachChallenger(grades, classifyFor, contextFor) { env_park_base: ctx.environment ? ctx.environment.park_base : null, env_weather_mod: ctx.environment ? ctx.environment.weather_mod : null, env_weather_state: ctx.environment ? ctx.environment.weather_state : null, + // The RAW forecast rides through to the ledger — a hit-type conversion + // needs wind direction, which no scalar coefficient can carry. + wx_forecast: ctx.environment ? (ctx.environment.wx_forecast || null) : null, }); } return out; diff --git a/src/services/environmentContext.js b/src/services/environmentContext.js index d384ce7..fd33b79 100644 --- a/src/services/environmentContext.js +++ b/src/services/environmentContext.js @@ -237,11 +237,29 @@ async function buildContext(sport, deps = {}) { statType: stat, weatherNa: park.weather_na, }); const env = weatherMod.composeEnvironment({ park, weather: wx, statType: stat }); - if (env.multiplier !== 1) { + // RETAIN THE RAW FORECAST, not just the composed coefficient. + // + // The scalar `weather_mod` cannot express a hit-TYPE conversion: wind out + // and warm turns fly balls into extra bases, cold heavy air turns them + // into outs, and both collapse to the same number once multiplied. So the + // raw temperature, wind speed and wind DIRECTION are kept alongside. + // + // And they are kept EVEN WHEN THE MULTIPLIER IS 1. The old guard dropped + // the whole environment on a neutral coefficient, which discarded the + // forecast for every ordinary night — the majority of games, and exactly + // the rows a hit-type model needs to learn the ordinary case from. + const rawWx = wx ? { + temp_f: wx.temp_f ?? null, + wind_mph: wx.wind_mph ?? null, + wind_dir: wx.wind_dir ?? null, + precip_mm: wx.precip_mm ?? null, + } : null; + if (env.multiplier !== 1 || rawWx) { environment = { multiplier: env.multiplier, label: env.label, venue: env.venue, weather_na: env.weather_na, park_base: env.park_base, weather_mod: env.weather_mod, weather_state: env.weather_state, + wx_forecast: rawWx, }; } }