Files
vyndr/scripts/cluster-prove.js
builtbykev ece2b9f5f9 Ingest defence, and make Bonferroni cumulative across the programme
Two things shipped that stand regardless of sample.

DEFENCE. Statcast Outs Above Average is free on the host we already pull six
feeds from, so there was nothing to decide. 514 fielders, aggregated to team
level -- the unit a batter's prop actually needs, the defence behind the
pitcher he faces -- and persisted as 31 team rows. Verified in production.
Cubs +56 best, Mariners -29 worst.

Unknown is not zero, and it bites unusually hard here: an OAA of 0 is a REAL
reading meaning exactly average, so coercing absence to 0 would assert that
every unmeasured fielder is league-average, which is the commonest defensive
profile there is. team_defense also carries as_of_date in its primary key from
the first row -- statcast_aggregates was built upsert-in-place and that
silently made every backtest leak the games it predicted, so point-in-time is
available here before it is needed rather than after a wrong answer.

A bug worth recording as a class: BASE already ends in /leaderboard, so the
new feed built a doubled path and 404'd. Because a failing feed degrades to an
empty index by design -- correct, so one broken source cannot fail the whole
pull -- it surfaced as "fielding_oaa: 0 rows", which reads exactly like
"Statcast has no fielding data". Graceful degradation makes a wiring bug look
like an honest absence.

CUMULATIVE CORRECTION. Bonferroni had been applied per session throughout: a
run testing eight features corrected by eight. Across a programme's lifetime
that is wrong in the dangerous direction, because every order gets a fresh
generous alpha and the false-positive rate compounds quietly. Correcting by 8
when sixty have been tried is how a noise result eventually gets recorded as
PROVEN with a p-value to point at. The denominator is now distinct hypotheses
ever tested, persisted, and it moved 19 -> 38 within this session alone, alpha
0.0026 -> 0.0013. Re-tests deliberately do not inflate it: re-asking the same
question on more data is not a new shot on goal, and counting it would punish
the discipline of waiting for sample.

THE MEASUREMENT. The differential the theory predicted is present: defence
correlates with the counter's residual at +0.130 for GHOST, the contact and
speed archetype, and -0.018 for BOMBER, the power archetype. A GHOST's hits
depend on whether anyone can range to the ball; a BOMBER's barrels clear the
defence entirely. So a flat BOMBER result is the theory working rather than
the test failing.

It is not a result. GHOST is n=104 against a 500 bar, with p=0.188 against a
corrected alpha of 0.0013 -- three orders of magnitude short. Both are
recorded as CANDIDATE with their measured lift, tagged contact-skill, so the
re-run at full sample compares against a recorded baseline.

Nothing proved, so nothing was recalibrated and nothing shipped.

4,228 tests green (336 suites); web build exit 0.

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

554 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
/**
* tb-solo-and-interactions — PROVE BOTH, with the solo pass as the control.
*
* A feature can carry signal alone, only in combination, or both. Testing only
* interactions misses solo-real features AND cannot tell whether an interaction
* ADDS anything or merely re-encodes its own parts. So the solo result is the
* baseline every interaction has to beat.
*
* ── HOW "ADDS OVER ITS PARTS" IS MEASURED ────────────────────────────────
* Not by comparing two correlations by eye. The interaction's incremental
* signal is the PARTIAL correlation of the interaction term with the counter's
* residual, CONTROLLING FOR both component features:
*
* resid_I = I OLS(I ~ A, B)
* resid_Y = Y OLS(Y ~ A, B)
* incremental r = corr(resid_I, resid_Y)
*
* If the interaction is just barrel-rate wearing a different hat, regressing out
* barrel rate removes it and the incremental r collapses to ~0. That is exactly
* the redundancy the order is guarding against, and it is the difference between
* PASSES-AND-ADDS and PASSES-BUT-REDUNDANT.
*
* ── WHY THE COUNTER'S RESIDUAL IS THE TARGET ─────────────────────────────
* Correlating with the raw outcome rewards a feature for knowing what the
* counter already knows. Only the part the counter MISSES is new information,
* and only new information can improve the product. Both are reported; the
* residual one is the one that decides.
*
* ── THEORY FIRST ─────────────────────────────────────────────────────────
* Every interaction below is declared with a MECHANISM before it is measured.
* No blind pairwise search — with 8 features there are 28 pairs, and at α=.05
* roughly one in twenty returns "significant" from noise alone.
*
* SUPABASE_URL=... node scripts/tb-solo-and-interactions.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const cv = require('../src/services/model/correlateValidator');
const sk = require('../src/services/model/skillProjection');
const reg = require('../src/services/model/featureRegistry');
const mlb = require('../src/services/adapters/mlbStatsAdapter');
const { knownRate, knownNumber } = require('../src/utils/known');
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 GAMES_SO_FAR = Number(process.env.STAGEA_GAMES_SO_FAR || 103);
const STAT = process.env.CLUSTER_STAT || 'total_bases';
/** Restrict every test to ONE archetype — the pooled result can hide an
* archetype-conditional effect entirely (the pitcher strata showed opposite
* signs cancelling to near-zero when pooled). */
const ARCH = process.env.CLUSTER_ARCHETYPE || null;
const r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
const brier = (ps, ys) => (ps.length ? ps.reduce((s, p, i) => s + (p - ys[i]) ** 2, 0) / ps.length : null);
/** OLS residuals of y on the given predictor columns (with intercept). */
function olsResiduals(y, Xcols) {
const n = y.length;
const p = Xcols.length + 1;
const X = [];
for (let i = 0; i < n; i += 1) {
const row = [1];
for (const c of Xcols) row.push(c[i]);
X.push(row);
}
// Normal equations (X'X) b = X'y, solved by Gauss-Jordan. p is 2-4 here.
const XtX = Array.from({ length: p }, () => new Array(p).fill(0));
const Xty = new Array(p).fill(0);
for (let i = 0; i < n; i += 1) {
for (let a = 0; a < p; a += 1) {
Xty[a] += X[i][a] * y[i];
for (let b = 0; b < p; b += 1) XtX[a][b] += X[i][a] * X[i][b];
}
}
const M = XtX.map((row, i) => [...row, Xty[i]]);
for (let col = 0; col < p; col += 1) {
let piv = col;
for (let r = col + 1; r < p; r += 1) if (Math.abs(M[r][col]) > Math.abs(M[piv][col])) piv = r;
if (Math.abs(M[piv][col]) < 1e-12) return null; // singular → cannot control honestly
[M[col], M[piv]] = [M[piv], M[col]];
const d = M[col][col];
for (let k = col; k <= p; k += 1) M[col][k] /= d;
for (let r = 0; r < p; r += 1) {
if (r === col) continue;
const f = M[r][col];
for (let k = col; k <= p; k += 1) M[r][k] -= f * M[col][k];
}
}
const beta = M.map((row) => row[p]);
return y.map((v, i) => v - X[i].reduce((s, xv, j) => s + xv * beta[j], 0));
}
/**
* Partial correlation of a with b, controlling for the columns in ctrl.
*
* COLLINEARITY IS CHECKED FIRST, and this is not pedantry — it caught a real
* error in this very script. The archetype-power proxy was defined as
* `barrel_pct / LEAGUE.barrel_pct`, an exact linear function of barrel_pct, so
* "control for both components" was rank-deficient and the partial correlation
* it produced (-0.132, the only one that looked like an incremental finding) was
* an artifact of a singular design matrix. The Gauss-Jordan pivot test missed it
* because the two columns differ by a scale factor, which keeps the pivot well
* above an absolute epsilon. Scale-free pairwise correlation catches it.
*/
function partialCorr(a, b, ctrl) {
for (let i = 0; i < ctrl.length; i += 1) {
for (let j = i + 1; j < ctrl.length; j += 1) {
const rr = cv.pearson(ctrl[i], ctrl[j]).r;
if (rr !== null && Math.abs(rr) > 0.999) return null; // same variable twice
}
}
const ra = olsResiduals(a, ctrl);
const rb = olsResiduals(b, ctrl);
if (!ra || !rb) return null;
return cv.pearson(ra, rb).r;
}
/** Rows where every named key is known — the honest common sample. */
function completeRows(rows, keys) {
return rows.filter((r) => keys.every((k) => knownNumber(r[k]) !== null));
}
function makeRnd(seed) {
let s = seed >>> 0;
return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}
function bootstrapDiff(rows, keyA, keyB, iters = 4000, seed = 20260804) {
if (rows.length < 30) return null;
const rnd = makeRnd(seed);
const n = rows.length;
const diffs = [];
for (let it = 0; it < iters; it += 1) {
const ys = []; const a = []; const b = [];
for (let i = 0; i < n; i += 1) {
const r = rows[Math.floor(rnd() * n)];
ys.push(r.won); a.push(r[keyA]); b.push(r[keyB]);
}
const ca = cv.pearson(a, ys).r; const cb = cv.pearson(b, ys).r;
if (ca == null || cb == null) continue;
diffs.push(ca - cb);
}
if (diffs.length < 100) return null;
diffs.sort((x, y) => x - y);
const q = (pp) => r4(diffs[Math.floor(pp * (diffs.length - 1))]);
const ci = [q(0.025), q(0.975)];
return {
point: r4(cv.pearson(rows.map((r) => r[keyA]), rows.map((r) => r.won)).r
- cv.pearson(rows.map((r) => r[keyB]), rows.map((r) => r.won)).r),
ci95: ci, ci_excludes_zero: ci[0] > 0 || ci[1] < 0,
};
}
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;
}
async function opposingStarters(dates) {
const m = new Map();
for (const d of dates) {
let games = [];
try { games = await mlb.getScheduleWithPitchers(d); } catch { games = []; }
for (const g of games) {
if (!g.home || !g.away) continue;
if (g.home.probablePitcher) m.set(`${d}|OPP:${g.home.team}`, g.home.probablePitcher.id);
if (g.away.probablePitcher) m.set(`${d}|OPP:${g.away.team}`, g.away.probablePitcher.id);
}
}
return m;
}
async function opponentByPlayerDate(players) {
const map = new Map();
for (const [key, name] of players) {
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) map.set(`${key}|${String(g.date).slice(0, 10)}`, g.opponent);
}
} catch { /* no log → no pitcher */ }
}
return map;
}
const SOLO = ['batter_barrel_pct', 'batter_hard_hit_pct', 'batter_exit_velo',
'batter_launch_angle', 'batter_k_pct', 'batter_bb_pct',
'pitcher_k_pct', 'pitcher_hard_hit_allowed',
'pitcher_gb_pct', 'pitcher_fb_pct', 'pitcher_breaking_share', 'team_defense'];
/**
* PER-STAT INTERACTION SETS — the total_bases conditioning map RE-WEIGHTED, not
* copied. Reuse speeds the search; it grants nothing. Each stat's features must
* independently earn their place FOR THAT STAT, and the mechanisms genuinely
* differ: barrel rate drives home runs through one channel (does the ball leave)
* and RBI through another (does anyone happen to be on base when it does).
*/
const STAT_INTERACTIONS = {
total_bases: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'barrel_x_power_archetype', 'batterK_x_pitcherK', 'launch_x_pitcher_gb', 'barrel_x_breaking_share', 'defense_x_contact', 'defense_x_speed_profile'],
hits: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'batterK_x_pitcherK', 'launch_x_pitcher_gb', 'barrel_x_breaking_share', 'defense_x_contact', 'defense_x_speed_profile'],
// HOME RUNS are the purest barrel stat: the ball must be hit hard AND at the
// right angle, and the pitcher must be the kind who allows that combination.
home_runs: ['launch_x_exit_velo', 'barrel_x_power_archetype', 'exitvelo_x_pitcher_suppression'],
// RBI is a POWER x OPPORTUNITY stat — a solo home run drives in one, the same
// swing with two on drives in three. We do not ingest baserunner state, so the
// opportunity half is genuinely missing and that is reported, not papered over.
rbi: ['barrel_x_power_archetype', 'exitvelo_x_pitcher_suppression', 'batterK_x_pitcherK'],
// RUNS scored is ON-BASE x what happens AFTER — mostly teammate-driven, which
// is the least self-contained stat in the cluster.
runs: ['batterK_x_pitcherK', 'exitvelo_x_pitcher_suppression'],
};
/** STEP 2 — theory first. Every interaction declares its mechanism. */
const INTERACTIONS = [
{
key: 'launch_x_exit_velo',
components: ['batter_launch_angle', 'batter_exit_velo'],
mechanism: 'Extra bases need BOTH conditions: hit hard AND hit in the air. A 105-mph ground ball is an out; a 25-degree popup is an out. Neither factor alone predicts bases, which is precisely why each may fail solo and the product may not.',
build: (r) => r.batter_launch_angle * r.batter_exit_velo,
},
{
key: 'exitvelo_x_pitcher_suppression',
components: ['batter_exit_velo', 'pitcher_hard_hit_allowed'],
mechanism: 'A hitter only realises his contact quality against a pitcher who permits contact quality. Elite suppression should attenuate a power bat; a contact-permitting arm should amplify it. The effect is conditional by construction.',
build: (r) => r.batter_exit_velo * r.pitcher_hard_hit_allowed,
},
{
key: 'barrel_x_power_archetype',
components: ['batter_barrel_pct', 'archetype_power'],
mechanism: 'ARCHETYPE-CONDITIONAL. Barrels convert to extra bases for hitters whose lane is power; for a speed/contact profile the same barrel rate is a rarer event on a swing built for something else. This is Discipline 2 stated as a testable interaction. NOTE: it is currently UNTESTABLE — statcast rows carry no archetype label, and the barrel-relative proxy is an exact linear function of barrel_pct, so controlling for both components is rank-deficient. It needs a real archetype classification joined in.',
build: (r) => r.batter_barrel_pct * r.archetype_power,
},
{
key: 'defense_x_contact',
components: ['team_defense', 'batter_hard_hit_pct'],
mechanism: 'DEFENCE. A ball in play becomes a hit or an out partly by who is standing behind the pitcher. This should matter MOST for hitters whose value is contact that stays in the park, and LEAST for power hitters whose barrels clear the defence entirely — so a DEAD result for BOMBER is not a failure, it is the differential the theory predicts.',
build: (r) => r.team_defense * r.batter_hard_hit_pct,
},
{
key: 'defense_x_speed_profile',
components: ['team_defense', 'batter_launch_angle'],
mechanism: 'DEFENCE x BATTED-BALL PROFILE. A low-launch (ground-ball) hitter puts the ball where fielders range; a high-launch hitter does not. Launch angle stands in for the profile, so defence should condition the ground-ball hitter far more.',
build: (r) => r.team_defense * r.batter_launch_angle,
},
{
key: 'launch_x_pitcher_gb',
components: ['batter_launch_angle', 'pitcher_gb_pct'],
mechanism: 'PITCHER BATTED-BALL TYPE. A ground-ball arm takes the air away, and a hitter whose value lives in the air needs the air. An air hitter against a sinkerballer and a ground-ball hitter against a fly-ball arm are both mismatches that neither factor states alone.',
build: (r) => r.batter_launch_angle * r.pitcher_gb_pct,
},
{
key: 'barrel_x_breaking_share',
components: ['batter_barrel_pct', 'pitcher_breaking_share'],
mechanism: 'ARSENAL MATCHUP. Barrel rate is far more a fastball skill than a breaking-ball skill, so a power bat facing a breaking-heavy arm should convert less of it. The pitch mix is already ingested, so this costs nothing to test.',
build: (r) => r.batter_barrel_pct * r.pitcher_breaking_share,
},
{
key: 'batterK_x_pitcherK',
components: ['batter_k_pct', 'pitcher_k_pct'],
mechanism: 'Strikeout risk compounds multiplicatively (log5 is exactly this shape). A high-K bat against a high-K arm loses plate appearances to strikeouts, and a PA lost is a base opportunity that never happens — so it suppresses total bases through OPPORTUNITY, not contact quality.',
build: (r) => r.batter_k_pct * r.pitcher_k_pct,
},
];
/**
* Breaking-ball share of a pitcher's mix, from `pitch_mix` already ingested.
* Sliders/curves/sweepers/cutters vs fastballs — absent mix -> null, never 0.
*/
function breakingShare(mix) {
if (!mix || typeof mix !== 'object') return null;
const rows = Array.isArray(mix) ? mix : Object.values(mix);
let breaking = 0; let total = 0;
for (const p of rows) {
if (!p) continue;
const type = String(p.type || p.pitch_type || '').toUpperCase();
const usage = knownNumber(p.usage_pct ?? p.usage ?? p.pct);
if (!type || usage === null || usage < 0) continue;
total += usage;
if (['SL', 'CU', 'KC', 'ST', 'SV', 'FC', 'SC'].includes(type)) breaking += usage;
}
if (total <= 0) return null;
return breaking / total;
}
/** Latest settled game date in the pull — used to detect that the profile
* freeze now sits AFTER the data, i.e. no clean out-of-sample window exists. */
function clean0Max(rows) {
return (rows || []).reduce((mx, r) => (String(r.game_date) > mx ? String(r.game_date) : mx), '');
}
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 freezeDate = statcast.reduce((mx, r) => (String(r.updated_at) > mx ? String(r.updated_at) : mx), '').slice(0, 10);
const batters = new Map(); const pitchersById = new Map();
for (const r of statcast) {
// pitch_mix is NOT part of fromStatcastRow's output (it maps pct/raw fields
// only), so it must be attached explicitly — without it the arsenal category
// silently measures nothing and reports n=0.
if (r.role === 'pitcher' && r.source_id != null) {
pitchersById.set(Number(r.source_id), { ...sk.fromStatcastRow(r), pitch_mix: r.pitch_mix });
}
if (r.player_key && r.role === 'batter') {
const prev = batters.get(r.player_key);
if (!prev || Number(r.sample_pa || 0) > Number(prev.rawPa || 0)) {
batters.set(r.player_key, Object.assign(sk.fromStatcastRow(r), { rawPa: Number(r.sample_pa || 0) }));
}
}
}
// REAL ARCHETYPE LABELS. The barrel-relative proxy was a clipped monotone
// transform of barrel_pct, so `barrel x proxy` measured NONLINEARITY IN BARREL,
// not an archetype interaction — it could never have tested Discipline 2.
// model_snapshots carries the actual classification per prop, so the
// conditioning variable is now a genuine BOMBER indicator, which is
// categorical and therefore not a transform of barrel at all.
const snaps = await page(sb, 'model_snapshots', 'player_key, game_date, archetype',
(q) => q.eq('sport', 'mlb').eq('stat', STAT).not('archetype', 'is', null));
const archetypeBy = new Map();
for (const r of snaps) if (r.player_key && r.game_date) archetypeBy.set(`${r.player_key}|${r.game_date}`, r.archetype);
const led = await page(sb, 'ledger_entries',
'player_key, player_name, stat, line, side, outcome, game_date, p_win, quarantine_reason',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', STAT)
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
// POINT-IN-TIME IS NO LONGER AVAILABLE FROM THIS TABLE.
//
// `statcast_aggregates` is upserted in place and keeps one as-of date. The
// first skill backtest was honest only by accident: the nightly refresh was
// unreachable code, so the table sat frozen at 2026-07-21 — BEFORE the settled
// window. Repairing that cron (correct for production) refreshed it to today,
// and every prior version is gone.
//
// So scoring a 2026-07-25 game now uses a season aggregate that CONTAINS that
// game. `statcast_history` (added this session) fixes it going forward; it has
// one day of data, which is not yet a window. Until it fills, results here are
// DIRECTIONAL AND CONTAMINATED, labelled as such, and are NOT gate verdicts.
const contaminated = String(freezeDate) >= String(clean0Max(led));
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book')
&& (contaminated ? true : String(r.game_date) > freezeDate));
const dates = [...new Set(clean.map((r) => r.game_date))].sort();
const starters = await opposingStarters(dates);
const players = new Map();
for (const r of clean) if (!players.has(r.player_key)) players.set(r.player_key, r.player_name);
const oppByPlayerDate = await opponentByPlayerDate(players);
if (process.env.TB_DEBUG === '1') {
console.error(`[debug] statcast rows=${statcast.length} freeze=${freezeDate} batters=${batters.size} pitchers=${pitchersById.size}`);
console.error(`[debug] ledger tb rows=${led.length} clean(after freeze)=${clean.length}`);
const sampleKeys = clean.slice(0, 5).map((r) => r.player_key);
console.error(`[debug] sample ledger player_keys=${JSON.stringify(sampleKeys)}`);
console.error(`[debug] sample statcast keys=${JSON.stringify([...batters.keys()].slice(0, 5))}`);
console.error(`[debug] matches in sample=${sampleKeys.filter((k) => batters.has(k)).length}/5`);
}
// TEAM DEFENCE — the newly-ingested Statcast OAA, per team, dated.
const defRows = await page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb'));
const defByTeam = new Map();
for (const d of defRows) {
const prev = defByTeam.get(d.team);
if (!prev || String(d.as_of_date) > String(prev.as_of_date)) defByTeam.set(d.team, d);
}
const allowed = reg.candidateFeaturesForStat('mlb', STAT);
const rowsAll = [];
const rows = rowsAll;
for (const r of clean) {
const bat = batters.get(r.player_key);
if (!bat) continue;
const faced = oppByPlayerDate.get(`${r.player_key}|${r.game_date}`) || null;
const pit = faced ? pitchersById.get(Number(starters.get(`${r.game_date}|OPP:${faced}`))) || null : null;
const paRate = bat.rawPa > 0 ? Math.min(5.2, Math.max(2.0, bat.rawPa / GAMES_SO_FAR)) : null;
const under = String(r.side).toLowerCase() === 'under';
const won = r.outcome === 'hit' ? 1 : 0;
const champ = Number(r.p_win);
const proj = sk.projectSkill({
batter: bat, pitcher: pit, park: 1, archetype: null,
statType: STAT, line: Number(r.line), expectedPa: paRate, allowed,
});
// The REAL archetype for this prop — a 0/1 power indicator, categorical and
// independent of barrel_pct by construction.
const arch = archetypeBy.get(`${r.player_key}|${r.game_date}`) || null;
const archetypePower = arch == null ? null : (String(arch).toUpperCase() === 'BOMBER' ? 1 : 0);
rows.push({
won, champ, residual: won - champ,
skill: proj ? (under ? 1 - proj.p_over_line : proj.p_over_line) : null,
had_pitcher: !!pit,
batter_barrel_pct: knownRate(bat.barrel_pct),
batter_hard_hit_pct: knownRate(bat.hard_hit_pct),
batter_exit_velo: knownRate(bat.avg_exit_velo),
batter_launch_angle: knownRate(bat.avg_launch_angle),
batter_k_pct: knownRate(bat.k_pct),
batter_bb_pct: knownRate(bat.bb_pct),
pitcher_k_pct: pit ? knownRate(pit.k_pct) : null,
pitcher_hard_hit_allowed: pit ? knownRate(pit.hard_hit_pct) : null,
archetype_power: archetypePower,
archetype: arch,
// ── CONDITIONING CATEGORIES (this order) ──────────────────────────
// PITCHER BATTED-BALL TYPE: a ground-ball arm suppresses air contact, so
// it should matter differently to a hitter whose value is in the air.
pitcher_gb_pct: pit ? knownRate(pit.gb_pct) : null,
pitcher_fb_pct: pit ? knownRate(pit.fb_pct) : null,
// ARSENAL: breaking-ball share, from the pitch mix already ingested. A
// power hitter's barrel rate is a fastball skill far more than a
// breaking-ball skill, so the mix should condition it.
pitcher_breaking_share: pit && pit.pitch_mix ? breakingShare(pit.pitch_mix) : null,
// DEFENSE: NOT DERIVABLE from what we ingest — see the report. Recorded as
// null rather than proxied by something that is really pitching quality.
// DEFENCE behind the pitcher he faces. knownRate: an unmeasured team is
// ABSENT, never league-average — OAA 0 is a real "exactly average" reading
// and the two must stay distinguishable.
team_defense: (() => {
if (!faced) return null;
// team_defense keys on Savant's display name (a nickname, "Cubs"),
// while the game log gives the full name ("Chicago Cubs"). Try both.
const nick = String(faced).split(' ').pop();
const d = defByTeam.get(faced) || defByTeam.get(nick);
return d ? knownNumber(d.oaa_sum) : null;
})(),
});
}
// ARCHETYPE RESTRICTION — applied AFTER building rows so coverage is visible.
const archRows = ARCH ? rowsAll.filter((r) => String(r.archetype || '').toUpperCase() === ARCH) : rowsAll;
rows.length = 0; rows.push(...archRows);
// ── CUMULATIVE BONFERRONI ─────────────────────────────────────────────
// The denominator is every DISTINCT hypothesis this programme has tested,
// not just this run's. Correcting by 8 in a session that tries 8, forever,
// while the programme as a whole has tried sixty, is how a noise result
// eventually gets recorded as PROVEN with a p-value to point at.
const tl = require('../src/services/model/testLedger');
const store = tl.supabaseStore(sb);
const chosenKeys = new Set(STAT_INTERACTIONS[STAT] || []);
const entries = [
...SOLO.map((f) => ({ sport: 'mlb', stat: STAT, archetype: ARCH, interaction: `solo:${f}`, target: 'counter_residual' })),
...INTERACTIONS.filter((x) => chosenKeys.has(x.key))
.map((x) => ({ sport: 'mlb', stat: STAT, archetype: ARCH, interaction: x.key, target: 'counter_residual' })),
];
const mc = await tl.recordAndCount(store, entries);
const TESTS = mc.cumulative_tests;
// ── STEP 1 — SOLO PASS (the control) ────────────────────────────────────
const solo = {};
for (const f of SOLO) {
const rs = completeRows(rows, [f]);
solo[f] = {
n: rs.length,
vs_outcome: cv.validateFactor(rs.map((r) => r[f]), rs.map((r) => r.won), TESTS),
vs_counter_residual: cv.validateFactor(rs.map((r) => r[f]), rs.map((r) => r.residual), TESTS),
};
}
// ── STEP 3 — INTERACTIONS, each against its own solo baseline ───────────
const interactions = {};
const chosen = new Set(STAT_INTERACTIONS[STAT] || []);
for (const ix of INTERACTIONS.filter((x) => chosen.has(x.key))) {
const keys = [...ix.components];
const rs = completeRows(rows, keys);
if (rs.length < 30) { interactions[ix.key] = { mechanism: ix.mechanism, n: rs.length, verdict: 'UNTESTABLE — no common sample' }; continue; }
const I = rs.map(ix.build);
const Y = rs.map((r) => r.residual);
const ctrl = keys.map((k) => rs.map((r) => r[k]));
const gate = cv.validateFactor(I, Y, TESTS);
const incremental = partialCorr(I, Y, ctrl);
// The best solo |r| among its own components, on the SAME rows.
const componentSolo = keys.map((k) => ({
feature: k, r: r4(cv.pearson(rs.map((r) => r[k]), Y).r),
}));
const bestComponent = Math.max(...componentSolo.map((c) => Math.abs(c.r ?? 0)));
let verdict;
if (incremental === null) verdict = 'UNTESTABLE — controls are collinear';
else if (gate.validated && Math.abs(incremental) >= cv.VALIDATION_REQUIREMENTS.min_pearson_r) verdict = 'PASSES-AND-ADDS';
else if (gate.validated) verdict = 'PASSES-BUT-REDUNDANT';
else if (rs.length < cv.VALIDATION_REQUIREMENTS.min_historical_instances) verdict = 'UNDERPOWERED — n below the gate';
else verdict = 'FAILS';
interactions[ix.key] = {
mechanism: ix.mechanism,
components: keys,
n: rs.length,
raw_r_vs_residual: gate.pearson_r,
gate: { validated: gate.validated, reason: gate.reason, p_value: gate.p_value, corrected_alpha: gate.corrected_alpha, underpowered: !!gate.underpowered },
component_solo_r_same_rows: componentSolo,
best_component_abs_r: r4(bestComponent),
INCREMENTAL_partial_r: r4(incremental),
adds_over_components: incremental !== null && Math.abs(incremental) > bestComponent,
verdict,
};
}
// ── STEP 4 — COMBINED vs COUNTER (valid at this n; the gate is not) ─────
const h2h = rows.filter((r) => r.skill != null);
const ys = h2h.map((r) => r.won);
const bs = bootstrapDiff(h2h, 'skill', 'champ');
console.log(JSON.stringify({
stat: STAT,
archetype_restriction: ARCH || 'none (pooled)',
VALIDITY: contaminated
? 'CONTAMINATED / DIRECTIONAL ONLY — statcast_aggregates now carries a single as-of date (' + freezeDate + ') that is AFTER the settled games, so season profiles contain the games being predicted. These are NOT gate verdicts. statcast_history (new) makes point-in-time possible from tomorrow.'
: `CLEAN out-of-sample: profiles frozen ${freezeDate}; only game_date > ${freezeDate} scored`,
contaminated,
rows_scored: rows.length,
gate_spec: cv.VALIDATION_REQUIREMENTS,
bonferroni_tests: TESTS,
multiple_comparisons: { ...mc, note: 'denominator is DISTINCT hypotheses across the programme lifetime, not this session' },
n_gap_note: `the gate needs ${cv.VALIDATION_REQUIREMENTS.min_historical_instances} rows; this run has ${rows.length}`,
archetype_coverage: {
labelled: rows.filter((r) => r.archetype).length,
bomber: rows.filter((r) => r.archetype_power === 1).length,
other: rows.filter((r) => r.archetype_power === 0).length,
},
step1_solo_baseline: solo,
step3_interactions: interactions,
step4_combined_vs_counter: {
n: h2h.length,
pitcher_coverage: r4(mean(h2h.map((r) => (r.had_pitcher ? 1 : 0)))),
base_rate: r4(mean(ys)),
resolution: { skill_tb: r4(cv.pearson(h2h.map((r) => r.skill), ys).r), counter: r4(cv.pearson(h2h.map((r) => r.champ), ys).r) },
brier: { skill_tb: r4(brier(h2h.map((r) => r.skill), ys)), counter: r4(brier(h2h.map((r) => r.champ), ys)) },
delta: bs,
verdict: !bs ? 'N-BLOCKED'
: (bs.ci_excludes_zero && bs.point > 0) ? 'SKILL TB BEATS THE COUNTER'
: (bs.ci_excludes_zero && bs.point < 0) ? 'LOSES to the counter'
: 'INCONCLUSIVE',
},
}, null, 2));
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });