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:
@@ -92,11 +92,41 @@ function improvement(rows, iters = 4000, seed = 20260805, cumulativeTests = 1) {
|
||||
if (usable.length < 30) return null;
|
||||
const rnd = makeRnd(seed);
|
||||
const diffs = [];
|
||||
|
||||
// ── PSEUDO-REPLICATION ────────────────────────────────────────────────────
|
||||
// A factor that assigns ONE value per game (park, weather, opposing starter)
|
||||
// gives every prop row in that game the identical treatment. Resampling ROWS
|
||||
// then treats 18 hitters in one ballpark as 18 independent readings of that
|
||||
// ballpark, and the interval collapses to a width the evidence never earned —
|
||||
// so the gate PASSES a factor on sample it does not have. Measured here: 928
|
||||
// total_bases rows carry only 53 distinct games.
|
||||
//
|
||||
// When rows carry a `cluster`, resample whole clusters. The interval then
|
||||
// reflects the unit the treatment actually varies over. Rows without a
|
||||
// cluster keep the original row-resampling path byte-for-byte.
|
||||
const clustered = usable.some((r) => r.cluster != null);
|
||||
const groups = new Map();
|
||||
if (clustered) {
|
||||
for (const r of usable) {
|
||||
const k = String(r.cluster);
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(r);
|
||||
}
|
||||
}
|
||||
const keys = clustered ? [...groups.keys()] : null;
|
||||
|
||||
for (let it = 0; it < iters; it += 1) {
|
||||
const b = []; const c = []; const y = [];
|
||||
for (let i = 0; i < usable.length; i += 1) {
|
||||
const r = usable[Math.floor(rnd() * usable.length)];
|
||||
b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0);
|
||||
if (clustered) {
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const g = groups.get(keys[Math.floor(rnd() * keys.length)]);
|
||||
for (const r of g) { b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0); }
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < usable.length; i += 1) {
|
||||
const r = usable[Math.floor(rnd() * usable.length)];
|
||||
b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
diffs.push(brier(c, y) - brier(b, y));
|
||||
}
|
||||
@@ -113,6 +143,9 @@ function improvement(rows, iters = 4000, seed = 20260805, cumulativeTests = 1) {
|
||||
const ys = usable.map((r) => (r.won > 0 ? 1 : 0));
|
||||
return {
|
||||
n: usable.length,
|
||||
// The number the gate must actually judge sample against.
|
||||
effective_n: clustered ? keys.length : usable.length,
|
||||
cluster_unit: clustered ? 'cluster' : 'row',
|
||||
brier_baseline: round4(brier(usable.map((r) => r.baseline), ys)),
|
||||
brier_conditioned: round4(brier(usable.map((r) => r.conditioned), ys)),
|
||||
brier_delta: round4(brier(usable.map((r) => r.conditioned), ys) - brier(usable.map((r) => r.baseline), ys)),
|
||||
@@ -138,8 +171,20 @@ function adjudicate(rows, opts = {}) {
|
||||
|
||||
const base = { factor: opts.factor || null, archetype: opts.archetype || null, stat: opts.stat || 'hits', movement: mv, improvement: imp };
|
||||
|
||||
if (mv.n < minN) {
|
||||
return { ...base, verdict: 'CANDIDATE_PENDING_SAMPLE', reason: `n ${mv.n} < ${minN}`, rows_needed: minN - mv.n };
|
||||
// Sample is judged in the unit the FACTOR varies over, not the unit the rows
|
||||
// happen to arrive in. A game-level factor with 928 rows across 53 games has
|
||||
// 53 readings, and calling that 928 is how a gate passes something on sample
|
||||
// it never had.
|
||||
const effN = imp && imp.effective_n != null ? imp.effective_n : mv.n;
|
||||
if (effN < minN) {
|
||||
const unit = imp && imp.cluster_unit === 'cluster' ? 'independent clusters' : 'rows';
|
||||
return {
|
||||
...base,
|
||||
verdict: 'CANDIDATE_PENDING_SAMPLE',
|
||||
reason: `${effN} ${unit} < ${minN}`
|
||||
+ (effN !== mv.n ? ` (${mv.n} rows, but the factor varies over ${effN} clusters — the rows are not independent readings)` : ''),
|
||||
rows_needed: minN - effN,
|
||||
};
|
||||
}
|
||||
if (mv.mean_abs_shift === null || mv.mean_abs_shift < minMove) {
|
||||
// It never moved the number, so it cannot be reading anything.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* parkWeather — PARK GEOMETRY AND AIR, READ ONTO HIT TYPE.
|
||||
*
|
||||
* The crude park factor is a single number per stadium ("Coors inflates offence
|
||||
* 1.15x") applied to every hitter and every outcome alike. It fails for the same
|
||||
* reason team-average defence failed: it is not the unit the causal story runs
|
||||
* through. A deep left-centre gap does not create hits, it converts fly balls
|
||||
* that would have been caught into DOUBLES, and it converts home runs into
|
||||
* outs. Those move total bases in opposite directions, and one multiplier
|
||||
* cannot express both.
|
||||
*
|
||||
* So this atom does not touch P(hit). It reshapes the HIT-TYPE distribution —
|
||||
* single / double / triple / home run — and lets the total-bases convolution
|
||||
* carry the consequence.
|
||||
*
|
||||
* ── WIND IS REFUSED, AND THAT IS THE POINT ───────────────────────────────
|
||||
* Wind is the largest weather effect on carry, and we have the wind: Open-Meteo
|
||||
* returns speed and compass bearing for every one of these games. What we do NOT
|
||||
* have is park ORIENTATION — which compass direction each stadium's centre field
|
||||
* faces. Without it, a 15 mph wind from 220° is unresolvable: it is blowing out
|
||||
* to right at one park and straight in at another, and those are opposite
|
||||
* predictions.
|
||||
*
|
||||
* The tempting move is to use wind SPEED alone as a magnitude of disruption.
|
||||
* That is fabrication with a plausible face — it asserts an effect while
|
||||
* discarding the sign that determines what the effect IS. Wind stays unreadable
|
||||
* and says so, until orientation is a real column. `wind_readable: false` is the
|
||||
* honest carrier of that.
|
||||
*
|
||||
* ── WHAT IS ACTUALLY READ ────────────────────────────────────────────────
|
||||
* AIR DENSITY temperature and elevation. Both have unambiguous sign — warmer
|
||||
* and higher is thinner air is more carry — and neither needs
|
||||
* orientation to interpret. Under a closed roof, temperature is
|
||||
* the building's, not the sky's, so it is neutralised.
|
||||
* GEOMETRY each park against the league, per direction. Short lines make
|
||||
* home runs; deep gaps make doubles and triples out of the same
|
||||
* batted ball.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../utils/known');
|
||||
|
||||
/** Bound on how far this atom may reshape any single hit-type share. */
|
||||
const MAX_EFFECT = 0.15;
|
||||
/** Reference conditions — the shares are calibrated to a temperate sea-level park. */
|
||||
const REF_TEMP_F = 72;
|
||||
const REF_ELEVATION_FT = 500;
|
||||
/** Per-degree and per-1000ft carry response, applied to the home-run share. */
|
||||
const CARRY_PER_DEG_F = 0.004;
|
||||
const CARRY_PER_KFT = 0.030;
|
||||
|
||||
const isClosed = (roof) => /dome|closed|retractable/i.test(String(roof || ''));
|
||||
|
||||
/**
|
||||
* League geometry, computed from the parks actually held rather than hardcoded,
|
||||
* so it cannot drift away from the data it is compared against.
|
||||
*/
|
||||
function leagueGeometry(parks) {
|
||||
const keys = ['left_line', 'left_center', 'center', 'right_center', 'right_line'];
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
const vals = (parks || []).map((p) => knownNumber(p[k])).filter((v) => v !== null);
|
||||
out[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The read for one game.
|
||||
*
|
||||
* @param {object} dims a park_dimensions row
|
||||
* @param {object} wx { wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg }
|
||||
* @param {object} league output of leagueGeometry
|
||||
* @returns {object|null} null when there is nothing readable — never a 1.0 that
|
||||
* looks measured.
|
||||
*/
|
||||
function parkWeatherRead({ dims, wx, league } = {}) {
|
||||
if (!dims || !league) return null;
|
||||
|
||||
const closed = isClosed(dims.roof_type);
|
||||
const temp = knownNumber(wx && wx.wx_temp_f);
|
||||
const elev = knownNumber(dims.elevation);
|
||||
|
||||
// ── AIR ────────────────────────────────────────────────────────────────
|
||||
// Under a closed roof the outside temperature is not the air the ball flies
|
||||
// through, so it contributes nothing rather than contributing zero.
|
||||
let carry = 0;
|
||||
const airParts = [];
|
||||
if (!closed && temp !== null) {
|
||||
carry += (temp - REF_TEMP_F) * CARRY_PER_DEG_F;
|
||||
airParts.push('temperature');
|
||||
}
|
||||
if (elev !== null) {
|
||||
carry += ((elev - REF_ELEVATION_FT) / 1000) * CARRY_PER_KFT;
|
||||
airParts.push('elevation');
|
||||
}
|
||||
|
||||
// ── GEOMETRY ───────────────────────────────────────────────────────────
|
||||
// Lines govern home runs; gaps and centre govern extra bases on balls that
|
||||
// stay in the park. Deeper than league = fewer home runs, more doubles.
|
||||
const rel = (k) => {
|
||||
const v = knownNumber(dims[k]); const l = knownNumber(league[k]);
|
||||
return v !== null && l !== null && l > 0 ? (v - l) / l : null;
|
||||
};
|
||||
const lines = [rel('left_line'), rel('right_line')].filter((v) => v !== null);
|
||||
const gaps = [rel('left_center'), rel('right_center'), rel('center')].filter((v) => v !== null);
|
||||
const lineDepth = lines.length ? lines.reduce((a, b) => a + b, 0) / lines.length : null;
|
||||
const gapDepth = gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : null;
|
||||
|
||||
if (lineDepth === null && gapDepth === null && !airParts.length) return null;
|
||||
|
||||
const clamp = (v) => Math.max(-MAX_EFFECT, Math.min(MAX_EFFECT, v));
|
||||
|
||||
// Deep lines suppress home runs; thin air and heat restore them.
|
||||
const hr = clamp(carry - (lineDepth ?? 0) * 1.2);
|
||||
// Deep gaps turn caught fly balls into doubles and the occasional triple.
|
||||
const dbl = clamp((gapDepth ?? 0) * 0.8 - carry * 0.3);
|
||||
const tpl = clamp((gapDepth ?? 0) * 1.5);
|
||||
// Singles are the residual: what the ball did instead of clearing the fence.
|
||||
const sgl = clamp(-(hr * 0.25 + dbl * 0.35));
|
||||
|
||||
return {
|
||||
readable: true,
|
||||
multipliers: {
|
||||
single: round4(1 + sgl),
|
||||
double: round4(1 + dbl),
|
||||
triple: round4(1 + tpl),
|
||||
home_run: round4(1 + hr),
|
||||
},
|
||||
carry: round4(carry),
|
||||
line_depth_vs_league: round4(lineDepth),
|
||||
gap_depth_vs_league: round4(gapDepth),
|
||||
roof_closed: closed,
|
||||
air_inputs: airParts,
|
||||
// Stated on every read so a consumer cannot mistake silence for neutrality.
|
||||
wind_readable: false,
|
||||
wind_reason: 'park orientation unknown — a bearing cannot be resolved to out or in',
|
||||
};
|
||||
}
|
||||
|
||||
/** A checkable sentence, or nothing. */
|
||||
function explain(read, parkName) {
|
||||
if (!read || !read.readable) return null;
|
||||
const m = read.multipliers;
|
||||
const bits = [];
|
||||
if (read.line_depth_vs_league !== null) {
|
||||
bits.push(`lines ${read.line_depth_vs_league >= 0 ? 'deeper' : 'shorter'} than league`);
|
||||
}
|
||||
if (read.gap_depth_vs_league !== null) {
|
||||
bits.push(`gaps ${read.gap_depth_vs_league >= 0 ? 'deeper' : 'shorter'}`);
|
||||
}
|
||||
if (read.air_inputs.length) bits.push(`air via ${read.air_inputs.join(' and ')}`);
|
||||
return `${parkName || 'this park'} — ${bits.join(', ')}; home runs x${m.home_run}, doubles x${m.double}`
|
||||
+ (read.roof_closed ? ' (roof closed, outside temperature not applied)' : '');
|
||||
}
|
||||
|
||||
/** Reshape a hit-type share vector, renormalised so it stays a distribution. */
|
||||
function applyToShares(shares, read) {
|
||||
if (!shares || !read || !read.readable) return shares || null;
|
||||
const m = read.multipliers;
|
||||
const out = {
|
||||
single: (knownNumber(shares.single) ?? 0) * m.single,
|
||||
double: (knownNumber(shares.double) ?? 0) * m.double,
|
||||
triple: (knownNumber(shares.triple) ?? 0) * m.triple,
|
||||
home_run: (knownNumber(shares.home_run) ?? 0) * m.home_run,
|
||||
};
|
||||
const sum = out.single + out.double + out.triple + out.home_run;
|
||||
if (!(sum > 0)) return shares;
|
||||
for (const k of Object.keys(out)) out[k] = round4(out[k] / sum);
|
||||
return out;
|
||||
}
|
||||
|
||||
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
||||
|
||||
module.exports = {
|
||||
parkWeatherRead, leagueGeometry, applyToShares, explain,
|
||||
MAX_EFFECT, REF_TEMP_F, REF_ELEVATION_FT, CARRY_PER_DEG_F, CARRY_PER_KFT,
|
||||
};
|
||||
Reference in New Issue
Block a user