Build the pitcher engine, and find the cap was eating the whole board

Strikeouts are NOT proven -- n=57 against a bar of 500. But the finding that
matters is not a correlation.

THE CAP. Measured on the live slate via the refusal diagnostic: 1,244 unique
gradeable props exist, the 500 cap graded about 334, and because dedupeProps
takes first-row-wins in FEED ORDER, what survives is decided by feed position
rather than value. Pitchers are 2.6% of a batter-dominated feed, so we were
grading SIX strikeout props a slate against 32 available -- putting n>=500
three months away for every pitcher stat. Pitcher props were never being
refused (graded 5, refused 0, suppressed 0); it was truncation.

Raised 500 -> 1500 on measured cost: 721ms per prop at concurrency 5 is about
179 seconds for the full board, against a cron that runs five times a day and
a fire-and-forget caller that never holds an HTTP response. statsapi is free
and unlimited. Concurrency stays at 5 -- one variable at a time. This unblocks
every n-blocked stat in the programme, not just pitchers.

THE ENGINE. pitcherEngine.js is its own engine, not the batter engine pointed
at pitchers: the batter model asks whether contact becomes a hit and reads
contact quality, the pitcher model asks whether the plate appearance ends
without contact at all and reads stuff. Archetypes are FLAME (whiff-led),
SCALPEL (chase-led), SINKER (pitches to contact) and DEFAULT, and a test
asserts the weight keys are not the batter engine's. The projection is K% by
log5 against THIS lineup, times batters faced, through a binomial. An
unclassifiable arm gets the balanced map, never a guessed archetype.

THE MEASUREMENT, at n=57 and contaminated. Four solo features clear the 0.15
effect bar and fail only on sample: arm angle at -0.250 -- the largest
correlation measured anywhere in this programme -- then whiff +0.213, k rate
+0.206, chase +0.195. The batter cluster's best was 0.135. Head to head,
pitch-v1 resolves 0.1285 against the counter's -0.0639, delta +0.192 with a CI
spanning zero.

That negative is the interesting number. The counter is ANTI-PREDICTIVE on
strikeouts: counting a pitcher's recent Ks is worse than useless, because his
recent totals track which lineups he drew and how long he was left in rather
than his skill. It is the one stat where the incumbent has no defensible edge.

A bug caught on the way. resolveTeam wants an abbreviation and the game log
supplies full team names, so the roster join silently resolved nothing and the
first run reported 0% lineup coverage -- the theorized stuff x lineup carrier
was never being tested, not failing. Fixed; coverage is now 94.7%. The carrier
still shows no incremental signal over whiff alone, and adding the lineup term
lowered head-to-head resolution, which is recorded rather than dropped.

Calibration was not reached: nothing passed the first bar. The batter model
and the counter are byte-identical, verified by diff.

4,221 tests green (335 suites); web build exit 0.

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-03 18:43:32 -04:00
parent c0621e7aa2
commit 843c8c6d4b
7 changed files with 924 additions and 1 deletions
+284
View File
@@ -0,0 +1,284 @@
#!/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;
const rates = [];
for (const p of roster || []) {
const prof = batterByKey.get(nameKey(p.name || p.fullName || ''));
const k = prof ? knownRate(prof.k_pct) : null;
if (k !== null) rates.push(k);
}
if (rates.length >= 5) val = rates.reduce((a, b) => a + b, 0) / rates.length;
} 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),
});
rows.push({
won, champ, residual: won - champ,
pitch: proj ? (under ? 1 - proj.p_over_line : proj.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,
});
}
const TESTS = SOLO.length + INTERACTIONS.length;
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',
};
}
const h2h = rows.filter((r) => r.pitch != null);
const ys = h2h.map((r) => r.won);
const bs = bootstrapDiff(h2h, 'pitch', '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,
step1_solo_baseline: solo,
step3_interactions: interactions,
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,
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); });