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:
Kev
2026-08-05 19:30:17 -04:00
parent 6452926732
commit 7b85934dc3
10 changed files with 965 additions and 7 deletions
+50 -5
View File
@@ -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.