Files
vyndr/scripts/pitcher-prove-k.js
T
builtbykev ff037e40c2 Re-adjudicate: nothing to demote, and close the hole that would have mattered
There is nothing to re-adjudicate. The proven set is empty and always has
been -- verified three ways: proven-status reports EMPTY, validatedSkills()
returns {} for every archetype, and zero conditioning entries have ever
reached PROVEN. The one PROVEN feature is recent_frequency_prior, which is the
incumbent counter itself, proven by the S78 ablation as ~100% of the
champion's resolution. It is the baseline every challenger is measured
against, not a conditioning interaction, and demoting it would leave the model
with nothing to grade from.

A correction to the premise: the cumulative gate did NOT catch a false
positive last session. It caught nothing, because there was nothing in the
proven set to catch. What it did was tighten alpha from 0.0026 to 0.0013
within one session, which demonstrated the mechanism working rather than a
demotion. So steps 3 and 4 -- demote, recalibrate -- are vacuous here, and
readjudicateAll says so plainly rather than glossing a no-op.

But the worry behind the order was well founded, and the audit found the real
exposure: promote() did not require the cumulative denominator. It checked n,
lift and CI, and nothing stopped a future session from testing eight
hypotheses, correcting by eight, and promoting on a p-value that would not
survive the programme's real denominator. That is precisely the hole that
makes a retroactive re-adjudication pass necessary later, so it is closed at
promotion time instead. isSufficient now refuses evidence carrying no
correction, evidence corrected against fewer tests than the cumulative count,
and any p-value that does not clear 0.05 over its own test count. The same
rule guards a PROVEN conditioning entry.

The second audit found two of four analysis scripts still correcting
per-session; pitcher-prove-k and tb-solo-and-interactions now use the
cumulative ledger, so the correction is native on every path.

reAblation.js is the standing second line: pure and injectable, so the
decision rule cannot drift from the gate's, and every verdict records both
p-values and both test counts so a demotion is re-derivable by anyone. A
feature promoted at alpha 0.05/20 can demote on the same p-value once the bar
is 0.05/60 -- correct, because the bar rose only after the programme had more
chances to get lucky. No fresh measurement is PENDING_RETEST and never a
demotion: absence of a re-test is not evidence, and demoting on it would
punish whichever stat happens to be off-season.

Net effect on the proven set is zero. No demotions, no recalibrations, and no
public ledger event -- announcing "recalibrated after re-adjudication" when
nothing changed would itself be a false signal of rigour.

4,238 tests green (337 suites); web build exit 0; counter byte-identical.

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

361 lines
18 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* pitcher-prove-k — STRIKEOUTS through the both-ways gate.
*
* Same bar as everything else: solo pass as the control, theory-first
* interactions each measured against their own components, gate at n>=500 /
* |r|>=0.15 / p<0.05 / Bonferroni, then head-to-head vs the counter.
*
* THE THEORIZED SIGNAL-CARRIER is `stuff x opposing-lineup K-rate`. An elite
* strikeout arm against a contact lineup that never whiffs is a different bet
* from the same arm against a three-true-outcomes lineup, and neither side says
* it alone — the pitcher analogue of the batter model's contact-quality term.
* The lineup rate is built from the OPPOSING TEAM'S OWN BATTERS (roster join to
* their statcast K%), not from a league constant, or the interaction would be a
* relabelled copy of the pitcher's own rate.
*
* VALIDITY: statcast_aggregates still carries one as-of date (2026-08-03) and
* `statcast_history` has one day, so there is no point-in-time window yet.
* Results here are CONTAMINATED / DIRECTIONAL and are not gate verdicts.
*
* SUPABASE_URL=... node scripts/pitcher-prove-k.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const cv = require('../src/services/model/correlateValidator');
const pe = require('../src/services/model/pitcherEngine');
const sk = require('../src/services/model/skillProjection');
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 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);
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); }
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;
[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));
}
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;
}
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, kA, kB, iters = 4000, seed = 20260805) {
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[kA]); b.push(r[kB]); }
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[kA]), rows.map((r) => r.won)).r - cv.pearson(rows.map((r) => r[kB]), 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;
}
const SOLO = ['pitcher_whiff_pct', 'pitcher_k_pct', 'pitcher_chase_pct', 'pitcher_gb_pct',
'pitcher_arm_angle', 'opposing_lineup_k_rate'];
const INTERACTIONS = [
{
key: 'stuff_x_lineup_k_rate',
components: ['pitcher_whiff_pct', 'opposing_lineup_k_rate'],
mechanism: 'THE theorized carrier. Strikeouts need a pitcher who can miss bats AND a lineup that can be missed. An elite arm against a contact lineup and a modest arm against a whiff-prone one can produce the same count, so neither factor alone orders the props — the product should.',
build: (r) => r.pitcher_whiff_pct * r.opposing_lineup_k_rate,
},
{
key: 'stuff_x_power_archetype',
components: ['pitcher_whiff_pct', 'archetype_flame'],
mechanism: 'ARCHETYPE-CONDITIONAL. Stuff should govern strikeouts more for a power arm than for a finesse arm, whose Ks come from chase and sequencing. Discipline 2 as a testable claim, with a categorical conditioner independent of whiff by construction.',
build: (r) => r.pitcher_whiff_pct * r.archetype_flame,
},
{
key: 'chase_x_lineup_k_rate',
components: ['pitcher_chase_pct', 'opposing_lineup_k_rate'],
mechanism: 'The finesse channel: expanding the zone only works against a lineup that will chase. Same shape as the stuff term, different mechanism, so it is tested separately rather than assumed to be the same effect.',
build: (r) => r.pitcher_chase_pct * r.opposing_lineup_k_rate,
},
];
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 pitchByKey = new Map(); const batterByKey = new Map();
for (const r of statcast) {
const prof = sk.fromStatcastRow(r);
if (r.role === 'pitcher' && r.player_key) pitchByKey.set(r.player_key, prof);
if (r.role === 'batter' && r.player_key) batterByKey.set(r.player_key, prof);
}
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', 'strikeouts')
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
// ── OPPOSING LINEUP K-RATE, from the opposing team's OWN batters ────────
// Roster join, not a league constant: a constant would make the interaction a
// rescaled copy of the pitcher's own rate and guarantee a false "redundant".
const { nameKey } = require('../src/utils/playerName');
const teamKRate = new Map();
async function lineupKFor(teamName) {
if (!teamName) return null;
if (teamKRate.has(teamName)) return teamKRate.get(teamName);
let val = null;
try {
// The game log gives a full team NAME ("Cincinnati Reds"); resolveTeam
// wants an ABBREVIATION. Passing the name straight through silently
// resolved nothing and produced 0% lineup coverage on the first run — the
// theorized signal-carrier was not failing, it was never being tested.
const { NAME_TO_ABBR } = require('../src/services/environmentContext');
const abbr = /^[A-Z]{2,3}$/.test(String(teamName).trim())
? String(teamName).trim().toUpperCase()
: NAME_TO_ABBR[String(teamName).toLowerCase()];
if (!abbr) { teamKRate.set(teamName, null); return null; }
const team = await mlb.resolveTeam(abbr);
const roster = team && team.id ? await mlb.getTeamRoster(team.id) : null;
// PA-WEIGHTED, not a flat roster average. An unweighted mean counts a
// 12-PA September call-up the same as an everyday starter, which is not
// the lineup a pitcher faces. Weighting by each batter's own sample_pa is
// the closest honest approximation of "who actually bats" from data we
// already hold — and it needs no new sourcing at all.
let wSum = 0; let wK = 0; let counted = 0;
for (const p of roster || []) {
const prof = batterByKey.get(nameKey(p.name || p.fullName || ''));
if (!prof) continue;
const k = knownRate(prof.k_pct);
const pa = knownRate(prof.sample_pa);
if (k === null) continue;
const w = pa === null ? 0 : pa; // no PA read -> contributes nothing
if (w <= 0) continue;
wSum += w; wK += w * k; counted += 1;
}
if (counted >= 5 && wSum > 0) val = wK / wSum;
} catch { val = null; }
teamKRate.set(teamName, val);
return val;
}
// The opponent a pitcher faced on a date, from his own game log.
const oppBy = new Map();
const names = new Map();
for (const r of clean) if (!names.has(r.player_key)) names.set(r.player_key, r.player_name);
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, undefined, 'pitching');
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 → no lineup term */ }
}
const rows = [];
for (const r of clean) {
const prof = pitchByKey.get(r.player_key);
if (!prof) continue;
const opp = oppBy.get(`${r.player_key}|${r.game_date}`) || null;
const lineupK = opp ? await lineupKFor(opp) : null;
const cls = pe.classifyPitcher(prof);
const arch = cls ? cls.primary : null;
const under = String(r.side).toLowerCase() === 'under';
const won = r.outcome === 'hit' ? 1 : 0;
const champ = Number(r.p_win);
const proj = pe.projectStrikeouts({
pitcher: prof, lineupKRate: lineupK, archetype: arch,
role: 'starter', line: Number(r.line),
});
// The SAME model with the lineup term switched off, so the term's
// contribution is isolated rather than inferred.
const projNo = pe.projectStrikeouts({
pitcher: prof, lineupKRate: null, archetype: arch,
role: 'starter', line: Number(r.line),
});
rows.push({
won, champ, residual: won - champ,
pitch: proj ? (under ? 1 - proj.p_over_line : proj.p_over_line) : null,
pitch_nolineup: projNo ? (under ? 1 - projNo.p_over_line : projNo.p_over_line) : null,
lineup_applied: !!lineupK,
archetype: arch,
archetype_flame: arch == null ? null : (arch === 'FLAME' ? 1 : 0),
pitcher_whiff_pct: knownRate(prof.whiff_pct),
pitcher_k_pct: knownRate(prof.k_pct),
pitcher_chase_pct: knownRate(prof.chase_pct),
pitcher_gb_pct: knownRate(prof.gb_pct),
pitcher_arm_angle: knownRate(prof.arm_angle),
opposing_lineup_k_rate: lineupK,
});
}
// ── CUMULATIVE BONFERRONI ─────────────────────────────────────────────
// The denominator is every DISTINCT hypothesis this programme has tested, not
// this run's. A per-session count gives each new order a fresh, generous alpha
// and lets the false-positive rate compound silently.
const tl = require('../src/services/model/testLedger');
const mcStore = tl.supabaseStore(sb);
const mc = await tl.recordAndCount(mcStore, [
...SOLO.map((f) => ({ sport: 'mlb', stat: 'strikeouts', archetype: null, interaction: `solo:${f}`, target: 'counter_residual' })),
...INTERACTIONS.map((x) => ({ sport: 'mlb', stat: 'strikeouts', archetype: null, interaction: x.key, target: 'counter_residual' })),
]);
const TESTS = mc.cumulative_tests;
const complete = (keys) => rows.filter((r) => keys.every((k) => knownNumber(r[k]) !== null));
const solo = {};
for (const f of SOLO) {
const rs = complete([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),
};
}
const interactions = {};
for (const ix of INTERACTIONS) {
const rs = complete(ix.components);
if (rs.length < 20) { 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 = ix.components.map((k) => rs.map((r) => r[k]));
const gate = cv.validateFactor(I, Y, TESTS);
const incr = partialCorr(I, Y, ctrl);
const parts = ix.components.map((k) => ({ feature: k, r: r4(cv.pearson(rs.map((r) => r[k]), Y).r) }));
const best = Math.max(...parts.map((p) => Math.abs(p.r ?? 0)));
interactions[ix.key] = {
mechanism: ix.mechanism, components: ix.components, n: rs.length,
raw_r_vs_residual: gate.pearson_r,
gate: { validated: gate.validated, reason: gate.reason, underpowered: !!gate.underpowered, rows_needed: gate.rows_needed ?? null },
component_solo_r: parts, best_component_abs_r: r4(best),
INCREMENTAL_partial_r: r4(incr),
adds_over_components: incr !== null && Math.abs(incr) > best,
verdict: incr === null ? 'UNTESTABLE — collinear controls'
: (gate.validated && Math.abs(incr) >= 0.15) ? 'PASSES-AND-ADDS'
: gate.validated ? 'PASSES-BUT-REDUNDANT'
: rs.length < 500 ? 'UNDERPOWERED — n below the gate' : 'FAILS',
};
}
// ── WITHIN-ARCHETYPE: the order's sharper hypothesis ────────────────────
// The interaction should matter MORE for finesse arms (SCALPEL/SINKER), whose
// strikeouts need a lineup that will chase or can be beaten, than for power
// arms (FLAME) whose stuff whiffs regardless of who is standing there. Pooling
// the two would average a real conditional effect toward zero — which is
// exactly the failure mode "test within archetype" exists to prevent.
const strata = {};
for (const [label, pred] of [
['FLAME (power)', (r) => r.archetype === 'FLAME'],
['non-FLAME (finesse/contact)', (r) => r.archetype && r.archetype !== 'FLAME'],
]) {
const rs = rows.filter((r) => pred(r)
&& knownNumber(r.pitcher_whiff_pct) !== null
&& knownNumber(r.opposing_lineup_k_rate) !== null);
if (rs.length < 15) { strata[label] = { n: rs.length, verdict: 'UNTESTABLE — stratum too thin' }; continue; }
const I = rs.map((r) => r.pitcher_whiff_pct * r.opposing_lineup_k_rate);
const Y = rs.map((r) => r.residual);
const ctrl = [rs.map((r) => r.pitcher_whiff_pct), rs.map((r) => r.opposing_lineup_k_rate)];
const incr = partialCorr(I, Y, ctrl);
const parts = [
{ feature: 'pitcher_whiff_pct', r: r4(cv.pearson(rs.map((r) => r.pitcher_whiff_pct), Y).r) },
{ feature: 'opposing_lineup_k_rate', r: r4(cv.pearson(rs.map((r) => r.opposing_lineup_k_rate), Y).r) },
];
const best = Math.max(...parts.map((p) => Math.abs(p.r ?? 0)));
strata[label] = {
n: rs.length,
raw_r_vs_residual: r4(cv.pearson(I, Y).r),
lineup_solo_r: parts[1].r,
component_solo_r: parts,
best_component_abs_r: r4(best),
INCREMENTAL_partial_r: r4(incr),
adds_over_components: incr !== null && Math.abs(incr) > best,
gate: cv.validateFactor(I, Y, TESTS),
verdict: incr === null ? 'UNTESTABLE — collinear'
: rs.length < 500 ? 'UNDERPOWERED — n below the gate'
: (Math.abs(incr) >= 0.15 ? 'ADDS' : 'REDUNDANT'),
};
}
const h2h = rows.filter((r) => r.pitch != null);
const ys = h2h.map((r) => r.won);
const bs = bootstrapDiff(h2h, 'pitch', 'champ');
const bsNoLineup = bootstrapDiff(h2h.filter((r) => r.pitch_nolineup != null), 'pitch_nolineup', 'champ');
console.log(JSON.stringify({
stat: 'strikeouts',
VALIDITY: `CONTAMINATED / DIRECTIONAL — statcast carries one as-of date (${freezeDate}); statcast_history has no window yet. NOT gate verdicts.`,
rows_scored: rows.length,
lineup_coverage: r4(mean(rows.map((r) => (r.lineup_applied ? 1 : 0)))),
archetype_mix: rows.reduce((a, r) => { const k = r.archetype || 'unclassified'; a[k] = (a[k] || 0) + 1; return a; }, {}),
gate_spec: cv.VALIDATION_REQUIREMENTS,
bonferroni_tests: TESTS,
multiple_comparisons: { ...mc, note: 'cumulative across the programme lifetime, not this session' },
step1_solo_baseline: solo,
step3_interactions: interactions,
step3b_within_archetype_carrier: strata,
step4_vs_counter: {
n: h2h.length,
base_rate: r4(mean(ys)),
resolution: { pitch_v1: r4(cv.pearson(h2h.map((r) => r.pitch), ys).r), counter: r4(cv.pearson(h2h.map((r) => r.champ), ys).r) },
brier: { pitch_v1: r4(brier(h2h.map((r) => r.pitch), ys)), counter: r4(brier(h2h.map((r) => r.champ), ys)) },
delta: bs,
// Isolating the lineup term: does including it help or hurt?
without_lineup_term: {
resolution: r4(cv.pearson(h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.pitch_nolineup),
h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.won)).r),
delta_vs_counter: bsNoLineup,
},
verdict: !bs ? 'N-BLOCKED — too few rows to bootstrap'
: (bs.ci_excludes_zero && bs.point > 0) ? '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); });