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
This commit is contained in:
@@ -153,3 +153,54 @@ describe('the measurements themselves', () => {
|
||||
expect(fg.adjudicate(r, { factor: 'backwards' }).verdict).toBe('THEATER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pseudo-replication — sample counted in the unit the factor varies over', () => {
|
||||
// A game-level factor (park, weather, opposing starter) hands every prop row
|
||||
// in a game the identical treatment. Eighteen hitters in one ballpark are one
|
||||
// reading of that ballpark, not eighteen.
|
||||
const build = (games, perGame, seed = 1) => {
|
||||
let s = seed;
|
||||
const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const rows = [];
|
||||
for (let g = 0; g < games; g += 1) {
|
||||
const shift = (rnd() - 0.5) * 0.06; // the game's treatment
|
||||
// Outcomes are correlated WITHIN a game — a high-scoring night lifts every
|
||||
// hitter in it. That shared component is exactly what row-resampling
|
||||
// cannot see and what makes 18 rows worth far less than 18 readings.
|
||||
const gameLevel = (rnd() - 0.5) * 0.5;
|
||||
for (let i = 0; i < perGame; i += 1) {
|
||||
const base = 0.3 + rnd() * 0.4;
|
||||
const p = Math.max(0.02, Math.min(0.98, base + gameLevel));
|
||||
rows.push({ cluster: `g${g}`, baseline: base, conditioned: base + shift, won: rnd() < p ? 1 : 0 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
it('judges sample by CLUSTERS, so 900 rows over 50 games is 50 readings', () => {
|
||||
const rows = build(50, 18);
|
||||
const v = fg.adjudicate(rows, { factor: 'park', minN: 500 });
|
||||
expect(rows.length).toBeGreaterThan(500); // looks like plenty
|
||||
expect(v.verdict).toBe('CANDIDATE_PENDING_SAMPLE'); // and is not
|
||||
expect(v.improvement.effective_n).toBe(50);
|
||||
expect(v.improvement.cluster_unit).toBe('cluster');
|
||||
expect(v.reason).toMatch(/not independent readings/);
|
||||
});
|
||||
|
||||
it('the clustered interval is WIDER than the row interval on the same rows', () => {
|
||||
// This is the whole hazard: resampling rows would have manufactured a
|
||||
// confidence the evidence never supported.
|
||||
const rows = build(40, 20, 7);
|
||||
const clustered = fg.adjudicate(rows, { factor: 'park', minN: 10 });
|
||||
const flat = fg.adjudicate(rows.map(({ cluster, ...r }) => r), { factor: 'park', minN: 10 });
|
||||
const width = (v) => v.improvement.ci[1] - v.improvement.ci[0];
|
||||
expect(width(clustered)).toBeGreaterThan(width(flat));
|
||||
});
|
||||
|
||||
it('rows with no cluster keep the original row-resampling behaviour', () => {
|
||||
const rows = build(40, 20, 3).map(({ cluster, ...r }) => r);
|
||||
const v = fg.adjudicate(rows, { factor: 'x', minN: 10 });
|
||||
expect(v.improvement.cluster_unit).toBe('row');
|
||||
expect(v.improvement.effective_n).toBe(v.improvement.n);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user