Files
vyndr/tests/unit/parkWeather.test.js
T
builtbykev 7b85934dc3 Under-querying vs out of data: the answer depends on the unit
The platoon test's n=452 described how much of the JOIN survived, not how
much data exists. There are 1,266 clean settled hits rows and zero
quarantined ones. platoon_splits had been ingested from tonight's lineups
only (315 players), so any hitter who settled a prop without appearing in
an ingest-day lineup was silently absent from every test.

Backfilled all 380 hitters (81 fetched, 0 unresolved). Re-ran on 1,059
rows, up from 452.

THE DEMOTION IS THE HEADLINE. pitcher_contact_profile, the strongest
proven factor in the programme (-0.0064, CI [-0.0113,-0.0014]), roughly
halved to -0.0034 on more than double the sample and its corrected
interval now spans zero. The Bonferroni denominator also rose to 55,
which widens every interval -- but a denominator cannot move a point
estimate, and that halved on its own.

platoon and platoon_severity now clear the bar and are NOT promoted.
Upper bound -0.0001, on season-to-date splits that contain the games they
predict: measured contamination is 4.5% median, 12.4% at p90, 137% worst.
I had assumed ~1%. They stay CANDIDATE pending point-in-time splits.

GAME-LEVEL IS A DIFFERENT PROBLEM. game_context held zero weather rows
ever -- not because the fetcher was wrong (it correctly targets
Open-Meteo's archive) but because ledger_entries keys a game as
mlb:2026-08-03:Away@Home and game_context keys it as mlb:823437. Every
lookup missed and NULL columns read as honest absence. Third occurrence
of that class.

Fixed the join: 96/101 settled games now carry actual archived weather,
park dimensions backfilled 15 -> 30 venues.

But 928 total_bases rows sit on 47 games at 17.6 rows per game. Park and
weather assign one value per game, so resampling rows would have
manufactured a pass. factorGate now resamples clusters when rows carry
one and judges sample against effective_n; unclustered rows keep the
original path byte-for-byte. Verdict: 47 clusters < 500, and the point
estimate is +0.0011 -- worse, not merely unproven.

Weather needs ~57 more days. Park dimensions need never: there are 30
ballparks in MLB, so a venue-constant factor can never reach 500
independent units. That bar was built for player-level factors and does
not transfer.

Wind is refused. We have speed and bearing for all 96 games; we lack park
orientation, and 220 degrees is blowing out at one park and in at
another. Using speed alone would assert an effect while discarding the
sign that decides what it is.

Counter and frozen clusters untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-05 19:30:17 -04:00

126 lines
6.1 KiB
JavaScript

'use strict';
/**
* Park geometry and air, read onto hit type.
*
* The failure these guard against is the one a single park multiplier cannot
* even express: a deep gap and a short line push total bases in OPPOSITE
* directions, and a model that collapses them to one number is confidently
* wrong at both ends.
*/
const pw = require('../../src/services/model/parkWeather');
const LEAGUE_PARKS = [
{ left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330 },
{ left_line: 335, left_center: 380, center: 410, right_center: 375, right_line: 325 },
{ left_line: 325, left_center: 370, center: 400, right_center: 370, right_line: 335 },
];
const league = pw.leagueGeometry(LEAGUE_PARKS);
const park = (o) => ({
left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330,
roof_type: 'Open', elevation: 500, ...o,
});
const wx = (t) => ({ wx_temp_f: t, wx_wind_speed_mph: 12, wx_wind_direction_deg: 220 });
describe('geometry separates the two things one park factor cannot', () => {
it('deep gaps make doubles and triples; short lines make home runs', () => {
const deepGaps = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410, center: 440 }), wx: wx(72), league });
const shortLines = pw.parkWeatherRead({ dims: park({ left_line: 300, right_line: 300 }), wx: wx(72), league });
expect(deepGaps.multipliers.double).toBeGreaterThan(1);
expect(deepGaps.multipliers.triple).toBeGreaterThan(1);
expect(shortLines.multipliers.home_run).toBeGreaterThan(1);
// The whole reason a single multiplier fails: these two parks both "inflate
// offence" and they inflate completely different offence.
expect(deepGaps.multipliers.home_run).toBeLessThan(shortLines.multipliers.home_run);
});
it('a deep park suppresses home runs relative to a shallow one', () => {
const deep = pw.parkWeatherRead({ dims: park({ left_line: 360, right_line: 360 }), wx: wx(72), league });
expect(deep.multipliers.home_run).toBeLessThan(1);
});
});
describe('air is read where it exists and nowhere else', () => {
it('heat adds carry, cold removes it', () => {
const hot = pw.parkWeatherRead({ dims: park(), wx: wx(95), league });
const cold = pw.parkWeatherRead({ dims: park(), wx: wx(45), league });
expect(hot.multipliers.home_run).toBeGreaterThan(cold.multipliers.home_run);
expect(hot.carry).toBeGreaterThan(0);
expect(cold.carry).toBeLessThan(0);
});
it('altitude carries on its own', () => {
const denver = pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league });
const sea = pw.parkWeatherRead({ dims: park({ elevation: 20 }), wx: wx(72), league });
expect(denver.multipliers.home_run).toBeGreaterThan(sea.multipliers.home_run);
});
it('a CLOSED roof does not apply the outside temperature', () => {
// The ball is not flying through the weather; pretending otherwise would
// read a dome game off the sky above it.
const domeHot = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(95), league });
const domeCold = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(45), league });
expect(domeHot.multipliers.home_run).toBeCloseTo(domeCold.multipliers.home_run, 6);
expect(domeHot.air_inputs).not.toContain('temperature');
expect(domeHot.air_inputs).toContain('elevation');
});
it('absent temperature contributes nothing rather than a reference value', () => {
const r = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: null }, league });
expect(r.air_inputs).not.toContain('temperature');
expect(r.readable).toBe(true);
});
});
describe('wind is refused, loudly', () => {
it('never reads wind, and says so on every read', () => {
// Speed and bearing are both present. They are still not enough: without
// park orientation the same bearing is blowing out at one park and in at
// another, and using speed alone would assert an effect while discarding
// the sign that decides what the effect is.
const calm = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 0, wx_wind_direction_deg: 0 }, league });
const gale = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 35, wx_wind_direction_deg: 220 }, league });
expect(gale.multipliers).toEqual(calm.multipliers);
expect(gale.wind_readable).toBe(false);
expect(gale.wind_reason).toMatch(/orientation/);
});
});
describe('honesty', () => {
it('no park at all → null, not a neutral-looking read', () => {
expect(pw.parkWeatherRead({ dims: null, wx: wx(72), league })).toBeNull();
expect(pw.parkWeatherRead({ dims: park(), wx: wx(72), league: null })).toBeNull();
});
it('a league-average park in reference air leaves the shape alone', () => {
const r = pw.parkWeatherRead({ dims: park({ left_line: league.left_line, right_line: league.right_line, left_center: league.left_center, right_center: league.right_center, center: league.center }), wx: wx(pw.REF_TEMP_F), league });
expect(r.multipliers.home_run).toBeCloseTo(1, 2);
expect(r.multipliers.double).toBeCloseTo(1, 2);
});
it('the effect is bounded however absurd the park', () => {
const absurd = pw.parkWeatherRead({ dims: park({ left_line: 200, right_line: 200, elevation: 30000 }), wx: wx(130), league });
for (const v of Object.values(absurd.multipliers)) {
expect(v).toBeLessThanOrEqual(1 + pw.MAX_EFFECT + 1e-9);
expect(v).toBeGreaterThanOrEqual(1 - pw.MAX_EFFECT - 1e-9);
}
});
it('reshaped shares remain a distribution', () => {
const r = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410 }), wx: wx(90), league });
const out = pw.applyToShares({ single: 0.66, double: 0.20, triple: 0.02, home_run: 0.12 }, r);
const sum = Object.values(out).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(1, 3);
expect(out.double).toBeGreaterThan(0.20);
});
it('NO read means NO sentence', () => {
expect(pw.explain(null, 'Coors Field')).toBeNull();
expect(pw.explain(pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league }), 'Coors Field'))
.toMatch(/Coors Field/);
});
});