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
+17 -1
View File
@@ -41,10 +41,26 @@ const { isModelBook } = require('../config/bookRoles');
// What it was costing: the live MLB slate carries 585 unique gradeable props.
// The cap graded 25 of them and silently discarded 560 — 95.7% of the product.
//
// RAISED 500 -> 1500 on 2026-08-03, and this one is about SAMPLE, not display.
//
// Measured on the live board (internal/diagnose-refusals, n=300 sample):
// unique gradeable props 1,244 · graded after suppression/refusal ~70%
// So a 500 cap grades ~334 and discards ~744 — and because dedupeProps takes
// FIRST-ROW-WINS IN FEED ORDER, what survives is decided by feed position, not
// by value. Pitcher props are ~2.6% of the feed, so the cap was handing us SIX
// strikeout props a slate against 32 available. At six a slate, the gate's
// n>=500 is three months away for every pitcher stat, and the entire
// prove-it programme is blocked on an arbitrary truncation.
//
// Cost, measured not guessed: 721ms/prop at concurrency 5 -> ~179s for the full
// 1,244. Both callers tolerate it (the snapshot cron runs 5x/day;
// recordDownstream is fire-and-forget and never holds an HTTP response), and
// statsapi is free and unlimited. Concurrency stays at 5 — one variable at a time.
//
// Env-tunable so the ceiling can move without a deploy: GRADE_SLATE_LIMIT.
const DEFAULT_LIMIT = Number(process.env.GRADE_SLATE_LIMIT) > 0
? Number(process.env.GRADE_SLATE_LIMIT)
: 500;
: 1500;
// Unchanged at 5 deliberately: raising the cap already multiplies total load by
// 20x, and concurrency is the knob that decides how hard we hit statsapi at
// once. One variable at a time.
+261
View File
@@ -0,0 +1,261 @@
'use strict';
/**
* pitcherEngine — A SEPARATE ENGINE, not the batter engine pointed at pitchers.
*
* PER-ROLE DOCTRINE, one level below per-sport. A pitcher's strikeouts and a
* hitter's hits are not the same prediction problem wearing different labels:
*
* - the batter model asks "does contact become a hit", and its inputs are
* contact quality (exit velo, launch, barrels);
* - the pitcher model asks "does the plate appearance end without contact at
* all", and its inputs are STUFF (velocity, movement, whiff) and the
* OPPORTUNITY to use it (batters faced, which is workload, not skill).
*
* Reusing the batter feature map here would be the exact "one model fit to all"
* error the doctrine forbids.
*
* ── PITCHER ARCHETYPES (own set, classified by FUNCTION) ─────────────────
* A power arm's strikeouts come from stuff — he beats you whether you chase or
* not. A finesse arm's come from sequencing and expansion: he needs you to
* swing at his pitch. Those are different mechanisms, so they weight different
* inputs, and that is what makes the archetype a FEATURE SELECTOR rather than a
* label.
*
* ── THE PROJECTION ───────────────────────────────────────────────────────
* K% (this pitcher vs THIS lineup, log5 against league)
* x batters faced (workload, from role: starter vs reliever)
* -> strikeouts ~ Binomial(BF, k_rate), mixed over a BF distribution
*
* The lineup term is the one that matters and it is the pitcher analogue of the
* batter model's contact quality: an elite strikeout arm against a contact
* lineup that never whiffs is a materially different bet from the same arm
* against a three-true-outcomes lineup. Neither side alone says it.
*
* ── HONESTY ──────────────────────────────────────────────────────────────
* Unknown is not zero everywhere. No usable stuff profile -> no read (null), and
* the caller falls back rather than being handed a confident guess. A missing
* lineup K-rate leaves the pitcher's own rate untouched; it never substitutes
* league and calls the result a matchup.
*/
const { knownRate } = require('../../utils/known');
/** League baselines, MLB pitching. Denominator of the odds ratio only. */
const LEAGUE = Object.freeze({
k_pct: 0.222, // share of plate appearances ending in a strikeout
bb_pct: 0.085,
whiff_pct: 0.245, // swings and misses per swing
bf_starter: 23.5, // batters faced, average start
bf_reliever: 4.3,
});
const BF_CAP = 34;
const K_CAP = 20;
/**
* PITCHER ARCHETYPES — classified by what the arm actually does.
*
* `weights` select which stuff inputs drive the strikeout rate for THIS kind of
* pitcher; they are relative within an archetype and documented by mechanism,
* not fitted (fitting them on 58 settled rows would be curve-fitting, and the
* feature registry exists so they get measured instead).
*/
const ARCHETYPES = Object.freeze({
// Overpowering stuff. Whiff rate is the engine; he does not need you to chase.
FLAME: {
describes: 'power arm — high velocity, high whiff, strikeouts come from stuff',
weights: { whiff: 0.65, k_rate: 0.30, chase: 0.05 },
},
// Command and weak contact. Strikeouts come from expanding the zone late.
SCALPEL: {
describes: 'finesse arm — command and sequencing, strikeouts come from chase',
weights: { whiff: 0.30, k_rate: 0.30, chase: 0.40 },
},
// Ground-ball machine. Does not chase strikeouts; contact is the plan.
SINKER: {
describes: 'ground-ball arm — pitches to contact, strikeout rate suppressed by design',
weights: { whiff: 0.35, k_rate: 0.50, chase: 0.15 },
},
DEFAULT: {
describes: 'unclassified — balanced weighting, no invented lean',
weights: { whiff: 0.45, k_rate: 0.40, chase: 0.15 },
},
});
/**
* classifyPitcher(profile) — FUNCTION, not reputation.
*
* Returns null when there is nothing to classify on. An unclassifiable pitcher
* gets the DEFAULT map from `weightsFor`, not a guessed archetype: "we do not
* know his type" and "he is a power arm" are different statements and must not
* collapse into each other.
*/
function classifyPitcher(profile = {}) {
const whiff = knownRate(profile.whiff_pct);
const k = knownRate(profile.k_pct);
const gb = knownRate(profile.gb_pct);
const chase = knownRate(profile.chase_pct);
if (whiff === null && k === null && gb === null) return null;
const signals = [];
// POWER: whiff and strikeout rate both meaningfully above league.
if (whiff !== null && whiff >= LEAGUE.whiff_pct * 1.12) signals.push(['FLAME', 2]);
if (k !== null && k >= LEAGUE.k_pct * 1.15) signals.push(['FLAME', 2]);
// GROUND-BALL: heavy grounders with an ordinary-or-lower strikeout rate.
if (gb !== null && gb >= 0.48) signals.push(['SINKER', 2]);
if (k !== null && k <= LEAGUE.k_pct * 0.9) signals.push(['SINKER', 1]);
// FINESSE: gets chases without overpowering stuff.
if (chase !== null && chase >= 0.32 && (whiff === null || whiff < LEAGUE.whiff_pct * 1.12)) signals.push(['SCALPEL', 2]);
if (signals.length === 0) return null;
const score = {};
for (const [name, w] of signals) score[name] = (score[name] || 0) + w;
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
return {
primary: ranked[0][0],
secondary: ranked.length > 1 ? ranked[1][0] : null,
scores: score,
};
}
function weightsFor(archetype) {
const key = String(archetype || '').toUpperCase();
return (ARCHETYPES[key] || ARCHETYPES.DEFAULT).weights;
}
/** log5 odds ratio — this pitcher against THIS lineup, relative to league. */
function oddsRatio(a, b, league) {
const x = knownRate(a); const y = knownRate(b); const l = knownRate(league);
if (x === null || y === null || l === null) return null;
if (x <= 0 || y <= 0) return 0;
if (x >= 1 || y >= 1) return 1;
if (l <= 0 || l >= 1) return null;
const odds = ((x / (1 - x)) * (y / (1 - y))) / (l / (1 - l));
return Number.isFinite(odds) ? odds / (1 + odds) : null;
}
/**
* Per-batter-faced strikeout probability, from ARCHETYPE-SELECTED stuff inputs
* and the opposing lineup's own strikeout tendency.
*
* Each stuff input is a RATIO to league, so a league-average arm lands on league
* K% and the model says "ordinary" rather than inventing a lean. Absent inputs
* are SILENT — they drop out and the remaining weights renormalise — never a
* measured zero.
*/
function strikeoutRate({ pitcher, lineupKRate = null, archetype = null, allowed = null } = {}) {
if (!pitcher) return null;
const can = (k) => !allowed || allowed.has(k);
const w = weightsFor(archetype);
const parts = [];
const push = (weight, value, leagueValue, gateKey) => {
if (!weight || !can(gateKey)) return;
const v = knownRate(value); const l = knownRate(leagueValue);
if (v === null || l === null || l <= 0) return; // SILENT, not zero
parts.push({ weight, ratio: v / l });
};
push(w.whiff, pitcher.whiff_pct, LEAGUE.whiff_pct, 'pitcher_whiff_pct');
push(w.k_rate, pitcher.k_pct, LEAGUE.k_pct, 'pitcher_k_pct');
push(w.chase, pitcher.chase_pct, 0.30, 'pitcher_chase_pct');
if (parts.length === 0) return null; // no stuff read → no projection
const wSum = parts.reduce((a, p) => a + p.weight, 0);
if (wSum <= 0) return null;
const stuffRatio = parts.reduce((a, p) => a + p.weight * p.ratio, 0) / wSum;
// Bounded: no stack of ratios may claim more than a ±45% swing in K rate.
const own = Math.min(0.65, Math.max(0.03, LEAGUE.k_pct * Math.min(1.45, Math.max(0.55, stuffRatio))));
// THE MATCHUP TERM. Absent lineup → the pitcher's own rate stands unchanged;
// substituting league here would pull every unknown matchup toward average and
// call the result a read.
const lk = can('opposing_lineup_k_rate') ? knownRate(lineupKRate) : null;
if (lk === null) return { k_rate: own, lineup_applied: false, stuff_ratio: stuffRatio };
const combined = oddsRatio(own, lk, LEAGUE.k_pct);
return {
k_rate: combined === null ? own : combined,
lineup_applied: combined !== null,
stuff_ratio: stuffRatio,
};
}
/** Batters-faced distribution. Workload, not skill — role decides the centre. */
function bfDistribution(expectedBf, role = 'starter') {
const centre = knownRate(expectedBf)
?? (String(role).toLowerCase() === 'starter' ? LEAGUE.bf_starter : LEAGUE.bf_reliever);
const m = Math.min(BF_CAP, Math.max(1, centre));
const lo = Math.floor(m);
const hi = Math.min(BF_CAP, lo + 1);
const wHi = m - lo;
const out = new Array(BF_CAP + 1).fill(0);
if (hi === lo) { out[lo] = 1; return out; }
out[lo] += 1 - wHi;
out[hi] += wHi;
return out;
}
function binomialPmf(n, p) {
const nn = Math.max(0, Math.round(n));
const pp = Math.min(1, Math.max(0, p));
const out = new Array(nn + 1).fill(0);
let term = (1 - pp) ** nn;
out[0] = term;
for (let x = 1; x <= nn; x += 1) {
if (pp >= 1) { out[x] = x === nn ? 1 : 0; continue; }
term = (term * (nn - x + 1) * pp) / (x * (1 - pp));
out[x] = term;
}
return out;
}
function atLeast(pmf, k) {
const kk = Math.max(0, Math.ceil(k));
if (kk === 0) return 1;
let s = 0;
for (let i = kk; i < pmf.length; i += 1) s += pmf[i];
return Math.min(1, Math.max(0, s));
}
/**
* THE PITCHER FORWARD READ — P(strikeouts >= line).
* Returns null when there is no usable stuff profile; never a guess.
*/
function projectStrikeouts({
pitcher, lineupKRate = null, archetype = null, role = 'starter',
line, expectedBf = null, allowed = null,
} = {}) {
const rate = strikeoutRate({ pitcher, lineupKRate, archetype, allowed });
if (!rate) return null;
const target = Math.max(1, Math.ceil(Number(line)));
if (!Number.isFinite(target)) return null;
const bfPmf = bfDistribution(expectedBf, role);
const kPmf = new Array(K_CAP + 1).fill(0);
for (let n = 0; n < bfPmf.length; n += 1) {
if (!bfPmf[n]) continue;
const bp = binomialPmf(n, rate.k_rate);
for (let x = 0; x < bp.length; x += 1) kPmf[Math.min(K_CAP, x)] += bfPmf[n] * bp[x];
}
const meanK = kPmf.reduce((a, p, i) => a + p * i, 0);
const r3 = (v) => Math.round(v * 1000) / 1000;
return {
version: 'pitch-v1',
stat: 'strikeouts',
p_over_line: r3(atLeast(kPmf, target)),
projected_value: r3(meanK),
distribution: kPmf.map(r3),
k_rate_per_bf: r3(rate.k_rate),
stuff_ratio: r3(rate.stuff_ratio),
lineup_applied: rate.lineup_applied,
archetype: String(archetype || 'DEFAULT').toUpperCase(),
role,
family: 'bf_binomial_log5_lineup',
};
}
module.exports = {
classifyPitcher, weightsFor, strikeoutRate, projectStrikeouts,
oddsRatio, bfDistribution, binomialPmf, atLeast,
ARCHETYPES, LEAGUE, BF_CAP, K_CAP,
};