7b85934dc3
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
264 lines
12 KiB
JavaScript
264 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* prove-hit-factors — does the hit grade read tonight's game, or say "he's due"?
|
|
*
|
|
* Each factor is conditioned against the player's OWN base rate and put through
|
|
* the two-part gate: it must MOVE the prediction and the moved prediction must
|
|
* be MORE ACCURATE out-of-sample. Movement alone is THEATER — a grade that
|
|
* swings on park and platoon looks like it read the matchup, and a user cannot
|
|
* tell the difference from outside.
|
|
*
|
|
* The baseline is deliberately the honest null this order describes: the
|
|
* player's base rate, i.e. "he's due" with no reading of tonight at all. A
|
|
* factor earns its place only by beating that.
|
|
*
|
|
* SUPABASE_URL=... node scripts/prove-hit-factors.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const fg = require('../src/services/model/factorGate');
|
|
const sk = require('../src/services/model/skillProjection');
|
|
const tl = require('../src/services/model/testLedger');
|
|
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;
|
|
const PAGE = 1000;
|
|
const ARCHS = (process.env.HF_ARCHETYPES || 'BOMBER,GHOST,ALL').split(',');
|
|
|
|
async function page(sb, table, select, apply) {
|
|
const out = [];
|
|
for (let from = 0; ; from += PAGE) {
|
|
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
|
if (error) throw error;
|
|
if (!data || data.length === 0) break;
|
|
out.push(...data);
|
|
if (data.length < PAGE) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* THE FACTORS. Each returns a MULTIPLIER on the base rate, or null when the
|
|
* input is absent — an absent factor must leave the baseline untouched rather
|
|
* than nudge it toward some default.
|
|
*/
|
|
const FACTORS = [
|
|
{
|
|
key: 'defense_by_direction',
|
|
needs: ['spray_multiplier'],
|
|
mechanism: 'CAUSALLY-CORRECT DEFENCE. Where the hitter puts the ball (pull/straight/oppo x ground/air) crossed with the OAA of the fielders actually standing in those zones, joined by handedness. Team-average failed the gate because it averages in five fielders who will never touch his ball.',
|
|
apply: (r) => r.spray_multiplier,
|
|
},
|
|
{
|
|
key: 'defense',
|
|
needs: ['team_defense'],
|
|
mechanism: 'A ball in play becomes a hit or an out partly by who is standing behind the pitcher. Should matter most where contact stays in the park.',
|
|
// More outs converted above average -> fewer hits.
|
|
apply: (r) => 1 - Math.max(-0.12, Math.min(0.12, r.team_defense / 250)),
|
|
},
|
|
{
|
|
key: 'pitcher_contact_profile',
|
|
needs: ['pitcher_hard_hit_allowed'],
|
|
mechanism: 'A contact-allowing arm concedes better contact than a bat-misser; hit probability should follow the quality of contact he permits.',
|
|
apply: (r) => 1 + Math.max(-0.15, Math.min(0.15, (r.pitcher_hard_hit_allowed - 0.389) * 1.2)),
|
|
},
|
|
{
|
|
key: 'park_hits',
|
|
needs: ['park_factor'],
|
|
mechanism: 'Some parks turn outs into hits without producing runs — big outfields, high walls, deep gaps.',
|
|
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'],
|
|
mechanism: 'Handedness advantage — a hitter facing the opposite hand sees the ball better and hits it harder.',
|
|
apply: (r) => (r.platoon_edge > 0 ? 1.06 : 0.96),
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
|
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
|
|
|
const statcast = await page(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb'));
|
|
const batters = new Map(); const pitchersById = new Map();
|
|
for (const r of statcast) {
|
|
const prof = sk.fromStatcastRow(r);
|
|
if (r.role === 'pitcher' && r.source_id != null) pitchersById.set(Number(r.source_id), prof);
|
|
if (r.role === 'batter' && r.player_key) batters.set(r.player_key, prof);
|
|
}
|
|
const sprayRows = await page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb'));
|
|
const sprayByKey = new Map();
|
|
for (const r of sprayRows) {
|
|
if (!r.player_key) continue;
|
|
const prev = sprayByKey.get(r.player_key);
|
|
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);
|
|
|
|
const snaps = await page(sb, 'model_snapshots', 'player_key, game_date, archetype, stat',
|
|
(q) => q.eq('sport', 'mlb').eq('stat', 'hits').not('archetype', 'is', null));
|
|
const archOf = new Map();
|
|
for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype);
|
|
|
|
const led = await page(sb, 'ledger_entries',
|
|
'id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
|
|
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
|
|
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
|
|
|
// Opponent faced, from each hitter's own game log.
|
|
const names = new Map();
|
|
for (const r of clean) if (!names.has(r.player_key)) names.set(r.player_key, r.player_name);
|
|
const oppBy = new Map(); const startersBy = new Map();
|
|
const dates = [...new Set(clean.map((r) => r.game_date))].sort();
|
|
for (const d of dates) {
|
|
try {
|
|
const games = await mlb.getScheduleWithPitchers(d);
|
|
for (const g of games) {
|
|
if (!g.home || !g.away) continue;
|
|
if (g.home.probablePitcher) startersBy.set(`${d}|OPP:${g.home.team}`, g.home.probablePitcher.id);
|
|
if (g.away.probablePitcher) startersBy.set(`${d}|OPP:${g.away.team}`, g.away.probablePitcher.id);
|
|
}
|
|
} catch { /* absent slate */ }
|
|
}
|
|
for (const [key, name] of names) {
|
|
try {
|
|
const found = await mlb.searchPlayer(name);
|
|
if (!found || !found.id) continue;
|
|
const log = await mlb.getPlayerGameLog(found.id);
|
|
for (const g of log || []) if (g && g.date && g.opponent) oppBy.set(`${key}|${String(g.date).slice(0, 10)}`, g.opponent);
|
|
} catch { /* no log */ }
|
|
}
|
|
|
|
// Per-player base rate — the honest null: "he's due", no reading of tonight.
|
|
const byPlayer = new Map();
|
|
for (const r of clean) {
|
|
const cur = byPlayer.get(r.player_key) || { n: 0, w: 0 };
|
|
cur.n += 1; cur.w += r.outcome === 'hit' ? 1 : 0;
|
|
byPlayer.set(r.player_key, cur);
|
|
}
|
|
|
|
const loss = { no_batter_profile: 0, thin_base_rate: 0, no_opponent: 0, no_pitcher: 0, kept: 0 };
|
|
const rows = [];
|
|
for (const r of clean) {
|
|
const bat = batters.get(r.player_key);
|
|
const bp = byPlayer.get(r.player_key);
|
|
if (!bat) loss.no_batter_profile += 1;
|
|
if (!bp || bp.n < 3) { loss.thin_base_rate += 1; continue; }
|
|
// Leave-one-out so a row never contributes to its own baseline.
|
|
const baseline = (bp.w - (r.outcome === 'hit' ? 1 : 0)) / (bp.n - 1);
|
|
const faced = oppBy.get(`${r.player_key}|${r.game_date}`) || null;
|
|
const nick = faced ? String(faced).split(' ').pop() : null;
|
|
const def = faced ? (defByTeam.get(faced) || defByTeam.get(nick)) : null;
|
|
if (!faced) loss.no_opponent += 1;
|
|
const starterId = faced ? startersBy.get(`${r.game_date}|OPP:${faced}`) : null;
|
|
const pit = starterId != null ? pitchersById.get(Number(starterId)) : null;
|
|
if (faced && !pit) loss.no_pitcher += 1;
|
|
loss.kept += 1;
|
|
rows.push({
|
|
id: r.id,
|
|
archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null,
|
|
won: r.outcome === 'hit' ? 1 : 0,
|
|
baseline,
|
|
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;
|
|
if (!sp || !posOaa || !bat || !bat.bats) return null;
|
|
const out = sd.sprayDefenseMultiplier({ spray: sp, bats: bat.bats, positionOaa: posOaa });
|
|
return out ? out.multiplier : null;
|
|
})(),
|
|
platoon_edge: (bat && pit && bat.bats && pit.throws)
|
|
? (String(bat.bats)[0] !== String(pit.throws)[0] ? 1 : -1) : null,
|
|
});
|
|
}
|
|
|
|
// Cumulative Bonferroni across the programme lifetime.
|
|
const store = tl.supabaseStore(sb);
|
|
const mc = await tl.recordAndCount(store, FACTORS.flatMap((f) =>
|
|
ARCHS.map((a) => ({ sport: 'mlb', stat: 'hits', archetype: a === 'ALL' ? null : a, interaction: `factor:${f.key}`, target: 'outcome' }))));
|
|
|
|
const results = [];
|
|
for (const arch of ARCHS) {
|
|
const slot = arch === 'ALL' ? rows : rows.filter((r) => String(r.archetype || '').toUpperCase() === arch);
|
|
for (const f of FACTORS) {
|
|
const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null));
|
|
const paired = usable.map((r) => {
|
|
const mult = f.apply(r);
|
|
const cond = mult === null ? null : Math.min(0.99, Math.max(0.01, r.baseline * mult));
|
|
return { baseline: r.baseline, conditioned: cond, won: r.won };
|
|
});
|
|
const v = fg.adjudicate(paired, {
|
|
factor: f.key, archetype: arch, stat: 'hits',
|
|
cumulativeTests: mc.cumulative_tests, // native cumulative correction
|
|
});
|
|
results.push({
|
|
archetype: arch, factor: f.key, n: v.movement.n,
|
|
mean_abs_shift: v.movement.mean_abs_shift,
|
|
brier_delta: v.improvement ? v.improvement.brier_delta : null,
|
|
ci: v.improvement ? v.improvement.ci : null,
|
|
ci_level: v.improvement ? v.improvement.ci_level : null,
|
|
verdict: v.verdict,
|
|
reason: v.reason,
|
|
...(f.caveat ? { input_caveat: f.caveat } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null",
|
|
total_rows: rows.length,
|
|
clean_settled_rows_available: clean.length,
|
|
row_loss: loss,
|
|
cumulative_bonferroni: mc,
|
|
gate: 'a factor must MOVE the prediction AND improve out-of-sample Brier; movement alone is THEATER',
|
|
results,
|
|
proven: results.filter((r) => r.verdict === 'PROVES'),
|
|
theater: results.filter((r) => r.verdict === 'THEATER'),
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|