Retain raw weather, and record platoon severity 48 rows short

Two fixes in the weather path, and the second was hiding behind the first. The
scalar weather_mod cannot express a hit-TYPE conversion at all -- wind out and
warm turning fly balls into extra bases, and cold heavy air turning them into
outs, collapse to the same number once multiplied -- so the raw temperature,
wind speed and wind direction are now retained alongside it.

And the old guard only kept the environment when the multiplier was not 1,
which silently discarded the forecast for every ordinary night. That is the
majority of games, and precisely the rows a hit-type model would need in order
to learn what ordinary looks like.

Platoon severity is built and measured at n=452, which is 48 rows short of the
gate: CANDIDATE_PENDING, neither proven nor theatre. It moves less than flat
platoon (0.021 against 0.026), consistent with the pattern, and its Brier point
estimate is favourable but the corrected interval still spans zero.

Worth naming: the refusal costs sample, and that is the design working. Flat
platoon scores 741 rows because it will happily apply a boost to anyone;
severity scores 452 because the other 289 are hitters whose split we cannot
actually read at 60 plate appearances on the short side. Buying those rows back
by shrinking instead of refusing would have produced a number indistinguishable
from a measured league-average split, which is a different claim from the one
the data supports.

Park dimensions are ingested and verified in production across fifteen venues,
joined by the venue the game is actually at rather than inferred from the home
team -- neutral-site and international games break that assumption without
surfacing an error.

The park-and-weather-to-hit-type atom is NOT built. Its inputs landed this
session and carry a single as_of date, so testing it on total_bases would be
scoring games with inputs that postdate them. Building it now would produce
something plausible rather than something proven.

Proven factors for hits remain pitcher_contact_profile and
defense_by_direction. 4,307 tests green (344 suites); web build exit 0.

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 20:40:56 -04:00
parent de0077f6f9
commit 6452926732
5 changed files with 100 additions and 1 deletions
+21
View File
@@ -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.
+30
View File
@@ -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)
+27
View File
@@ -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;
+3
View File
@@ -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;
+19 -1
View File
@@ -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,
};
}
}