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
+24
View File
@@ -3,6 +3,30 @@
## Last Updated
2026-08-03
## Session 84 (2026-08-03) — Pitcher engine built; the cap was eating the board ✅
Spec: `specs/pitcher-engine-strikeouts.md`. 4,221 tests / 335 suites green, build
exit 0. Batter model + counter byte-identical (verified by diff).
- **THE REAL FIND: the grade cap, not pitcher data.** 1,244 unique gradeable
props/slate; the 500 cap graded ~334, and first-row-wins-in-feed-order gave
pitchers 6 props a slate. Raised 500 -> 1500 on measured cost (~179s for the
full board at concurrency 5, cron 5x/day). Unblocks EVERY n-blocked stat.
Pitcher props were never being refused (graded 5, refused 0, suppressed 0).
- **`pitcherEngine.js` — own archetypes (FLAME/SCALPEL/SINKER/DEFAULT), own
inputs (stuff), own projection** (log5 K% vs THIS lineup x batters faced).
Test asserts its weight keys differ from the batter engine's. 17 tests.
- **Strikeouts NOT proven** (n=57 vs 500): pitch-v1 0.1285 vs counter 0.0639,
delta +0.192 CI [0.098,+0.509]. Four solo features clear the |r|>=0.15 bar and
fail only on n — arm_angle 0.250 (largest in the programme), whiff +0.213,
k_pct +0.206, chase +0.195.
- **The counter is ANTI-PREDICTIVE on Ks (0.064)** — recent K counts track
opponent and workload, not skill.
- **Bug caught:** `resolveTeam` needs an abbreviation; the game log gives names,
so lineup coverage was 0% and the theorized carrier was never tested. Fixed via
NAME_TO_ABBR → 94.7%. The carrier still shows no incremental signal (n=54).
- **Calibration not reached** — nothing passed BAR 1.
- **Next:** let the cap accrue (~2 weeks to n>=500), re-run with a point-in-time
window from statcast_history; give arm_angle a registry entry + mechanism.
## Session 83 (2026-08-03) — Batter cluster measured; the proven set is EMPTY ✅
Spec: `specs/batter-cluster-prove.md`. 4,204 tests / 334 suites green, build exit
0. skillProjection byte-identical (TB frozen, verified by diff); counter untouched.
+32
View File
@@ -1405,6 +1405,38 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
`CLUSTER_STAT=`. Per-stat interaction sets are the TB map RE-WEIGHTED, never
copied — reuse speeds the search and grants no pass.
## Pitcher engine + the cap that was eating the board (Session 84 — non-obvious)
- **THE GRADE CAP WAS THE BINDING CONSTRAINT ON EVERY STAT.** `dedupeProps` takes
FIRST-ROW-WINS IN FEED ORDER and stops at `GRADE_SLATE_LIMIT`. Measured via
`GET /api/internal/diagnose-refusals`: **1,244 unique gradeable props/slate**,
a 500 cap graded ~334, and pitchers (2.6% of the feed) got **6 props a slate**
— putting n>=500 three months out. RAISED 500 -> 1500 (measured: 721ms/prop at
concurrency 5 ≈ 179s for the full board; cron runs 5x/day; statsapi is free).
Expect pitcher Ks ~6 -> ~32/slate, so n>=500 in ~2 weeks. Concurrency stays 5.
- **Pitcher props were NEVER being refused** — `strikeouts: graded 5, refused 0,
suppressed 0`. Don't hunt for a data gap here; it was truncation.
- **`src/services/model/pitcherEngine.js` is its OWN engine** (per-role doctrine):
archetypes FLAME (whiff .65) / SCALPEL (chase .40) / SINKER (k_rate .50) /
DEFAULT, and the projection is `K% (log5 vs THIS lineup) x batters faced ->
Binomial(BF, k)`. A test asserts its weight keys are NOT the batter engine's.
Unclassifiable -> DEFAULT map, never a guessed archetype.
- **THE COUNTER IS ANTI-PREDICTIVE ON STRIKEOUTS: resolution 0.064.** Recent K
counts are dominated by which lineups a pitcher drew and how long he was left
in, not by skill. This is the one stat where the incumbent has no defensible
edge — the strongest theoretical case for the skill model in the programme.
- **Strikeouts NOT proven** (n=57 vs 500): pitch-v1 0.1285 vs counter 0.0639,
delta +0.192, CI [0.098,+0.509]. But FOUR solo features exceed the |r|>=0.15
bar and fail only on n: **arm_angle 0.250** (largest in the programme),
whiff +0.213, k_pct +0.206, chase +0.195. Batter cluster's best was 0.135.
- **`resolveTeam` wants an ABBREVIATION, not a team name.** The game log supplies
full names ("Cincinnati Reds"), so the roster join silently resolved nothing
and the first run showed 0% lineup coverage — the theorized stuff x lineup
carrier was never tested, not failing. Use `environmentContext.NAME_TO_ABBR`;
coverage went 0% -> 94.7%.
- The stuff x lineup-K-rate carrier shows NO incremental signal so far (its raw r
is explained by whiff alone), and adding the lineup term LOWERED head-to-head
resolution (0.174 -> 0.1285). n=54, so not a verdict — but recorded, not dropped.
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+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); });
+174
View File
@@ -0,0 +1,174 @@
# THE PITCHER ENGINE — built, and strikeouts measured
**2026-08-03.** Challenger-only. Batter model and counter byte-identical
(verified by diff). **Strikeouts NOT proven — n=57 against a 500 bar.**
> **The result that matters is not a correlation. It is that the cap was
> throwing away the board.** 1,244 unique gradeable props exist per slate; the
> 500 cap graded ~334, and because selection is first-row-wins in FEED ORDER,
> pitchers — 2.6% of the feed — were getting **six props a slate**. At six a
> slate, n≥500 for any pitcher stat is three months away. Raised to 1500 on
> measured cost. That unblocks every n-blocked stat in the programme, not just
> this one.
>
> **And the counter is ANTI-PREDICTIVE on strikeouts: resolution 0.064.**
> Counting a pitcher's recent strikeouts is worse than useless, which is the
> strongest theoretical case for a skill model anywhere in this work.
---
## 0. Premise note
The order refers to "the SAME BAR the proven batter cluster cleared" and to the
batter cluster as frozen-proven. **There is no proven batter cluster** — the
proven set came back empty last session (total_bases inconclusive at parity,
hits a well-powered negative). The bar used here is therefore the gate itself
(n≥500, |r|≥0.15, p<0.05, Bonferroni) plus a head-to-head CI excluding zero —
which is the bar total_bases *failed*, not one it set.
Also: "Ks first — high-volume" did not hold in our ledger. Strikeouts had **58
settled rows** and only started being graded 2026-07-31. §1 is why.
## 1. Why pitcher volume was six a slate — and it was us, not supply
`GET /api/internal/diagnose-refusals` on the live slate:
```
rows in feed 10,224
unique gradeable props 1,244
graded after suppression ~70%
strikeouts: graded 5 · refused 0 · suppressed 0
```
**Pitcher props were not being refused.** Supply was fine too — 32 strikeout
props covering 33 pitchers, with full model-book coverage (DK, FD, BetMGM,
BetRivers). The loss was the **cap**: `dedupeProps` takes first-row-wins in feed
order and stops at the limit, so what survives is decided by feed position, not
value, and pitchers are a thin slice of a batter-dominated feed.
**Raised 500 → 1500** on measured cost, not taste: 721ms/prop at concurrency 5
→ ~179s for the full 1,244, against a cron that runs 5×/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.
Expected effect: pitcher strikeouts from ~6 to ~32 a slate, so n≥500 moves from
~3 months to **~2 weeks**.
## 2. The engine (STEP 1) — its own archetypes, its own inputs
`src/services/model/pitcherEngine.js`. Not the batter engine pointed at
pitchers: the batter model asks *does contact become a hit* and reads contact
quality; the pitcher model asks *does the plate appearance end without contact
at all* and reads stuff. A test asserts the two weight-key sets are **not
equal**.
| archetype | function | weights (whiff / k-rate / chase) |
|---|---|---|
| **FLAME** | power arm — Ks come from stuff | 0.65 / 0.30 / 0.05 |
| **SCALPEL** | finesse — Ks come from chase and sequencing | 0.30 / 0.30 / 0.40 |
| **SINKER** | ground-ball arm — pitches to contact by design | 0.35 / 0.50 / 0.15 |
| DEFAULT | unclassified — balanced, no invented lean | 0.45 / 0.40 / 0.15 |
Projection: `K% (log5: this arm vs THIS lineup) × batters faced`
`Binomial(BF, k_rate)` mixed over a BF distribution. Workload is opportunity,
not skill, so role sets the BF centre. **Unclassifiable → DEFAULT map, never a
guessed archetype** — "we don't know his type" and "he's a power arm" are
different statements.
**Coverage on live data:** 776 pitchers — whiff 736, k_pct 736, pitch_mix 753,
arm_angle 734. Classified on the settled sample: 21 FLAME, 17 SINKER, 7 SCALPEL,
12 unclassified.
## 3. STEP 2 — both ways, at n=57
Bonferroni across 9 tests. **Every result is refused on sample size**; r and p
are shown because "not enough data yet" and "nothing here" need opposite
decisions.
### Solo (control)
| feature | r vs outcome | **r vs counter residual** | p |
|---|---|---|---|
| **pitcher_arm_angle** | 0.247 | **0.250** | 0.061 |
| **pitcher_whiff_pct** | +0.168 | **+0.213** | 0.112 |
| **pitcher_k_pct** | +0.157 | **+0.206** | 0.125 |
| **pitcher_chase_pct** | +0.143 | **+0.195** | 0.146 |
| pitcher_gb_pct | +0.077 | +0.042 | 0.755 |
| opposing_lineup_k_rate | +0.052 | +0.027 | 0.846 |
**Four features exceed the |r| ≥ 0.15 effect bar and fail only on n.** For
contrast, the entire batter cluster's best was 0.135. This is the strongest
solo signal measured anywhere in the programme.
### Interactions (theory-first, each vs its own parts)
| interaction | n | raw r | best part | **incremental** | verdict |
|---|---|---|---|---|---|
| stuff × lineup K-rate | 54 | 0.175 | 0.198 | 0.102 | UNDERPOWERED |
| stuff × power archetype | 45 | 0.172 | 0.217 | 0.053 | UNDERPOWERED |
| chase × lineup K-rate | 54 | 0.169 | 0.194 | +0.047 | UNDERPOWERED |
**The theorized carrier does not yet show incremental signal** — its raw
correlation is entirely explained by the pitcher's own whiff rate. At n=54 that
is not a verdict, but it is not encouraging either.
A bug worth recording: the first run reported **0% lineup coverage** and would
have read as "the carrier fails". The game log supplies a full team *name* while
`resolveTeam` wants an *abbreviation*, so the roster join silently resolved
nothing — the carrier was never being tested, not failing. Fixed via
`NAME_TO_ABBR`; coverage is now **94.7%**.
## 4. Head-to-head vs the counter
```
n 57 · base rate 0.597
resolution pitch-v1 0.1285 counter 0.0639
brier pitch-v1 0.2412 counter 0.2703
delta +0.1924 CI95 [0.0978, +0.5090] → INCONCLUSIVE
```
**NOT PROVEN.** The CI spans zero and n is a ninth of the bar.
But two things are worth stating precisely:
1. **The counter's resolution is NEGATIVE.** Recent strikeout counts are
*anti-predictive* of whether a pitcher clears his line — which makes sense,
because a pitcher's recent K totals are dominated by which lineups he drew and
how long he was left in, not by his skill. **This is the one stat where the
incumbent has no defensible edge**, and it is exactly where a skill model
should win.
2. **Adding the lineup term made it worse** (0.174 → 0.1285). At n=57 that is
noise, but it is reported rather than quietly dropped, and it is consistent
with the interaction result above.
## 5. STEP 3 — calibration
**Not reached.** Strikeouts did not pass BAR 1, so there is nothing to calibrate.
Calibrating a model that has not beaten counting would produce an "A" backed by
nothing — the failure mode BAR 2 exists to prevent.
## 6. Frozen surfaces verified
`git diff` against the prior commit is **empty** for
`skillProjection.js`, `probabilityEstimator.js` and `analyzeViaEngine1.js`. The
batter model and the counter are byte-identical.
## 7. Next
1. **Let the cap change accrue.** Pitcher strikeouts should reach n≥500 in ~2
weeks rather than ~3 months. This was the binding constraint and it is now
released.
2. **Re-run `scripts/pitcher-prove-k.js`** at n≥500, with a point-in-time window
from `statcast_history` (which starts producing usable comparisons
2026-08-04).
3. **arm_angle is the feature to watch** — the largest |r| in the programme
(0.250), and a plausible mechanism (release height and approach angle drive
swing-and-miss). It is currently a CANDIDATE in name only; it deserves its own
registry entry and a stated mechanism before it is tested for real.
4. Other pitcher props (outs, earned runs, hits allowed) through the same engine
once strikeouts resolves — they are 47/17/10 settled rows today and will
benefit from the same cap change.
**Not recommended:** promoting on an inconclusive result, calibrating anything,
or reading the four above-threshold solo correlations as findings. They fail on
n, and n is the thing that just got fixed.
+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,
};
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* THE PITCHER ENGINE per-role doctrine asserted as behaviour.
*
* The thing these tests exist to prevent is the batter engine quietly becoming
* the pitcher engine: same weights, same inputs, different label. A pitcher's
* strikeouts come from stuff against a lineup, not from contact quality.
*/
const pe = require('../../src/services/model/pitcherEngine');
const FLAME = { whiff_pct: 0.34, k_pct: 0.31, chase_pct: 0.31, gb_pct: 0.38 };
const SCALPEL = { whiff_pct: 0.21, k_pct: 0.20, chase_pct: 0.36, gb_pct: 0.42 };
const SINKER = { whiff_pct: 0.20, k_pct: 0.17, chase_pct: 0.28, gb_pct: 0.55 };
describe('pitcher archetypes are classified by FUNCTION', () => {
it('overpowering stuff is a FLAME', () => {
expect(pe.classifyPitcher(FLAME).primary).toBe('FLAME');
});
it('chase without overpowering stuff is a SCALPEL', () => {
expect(pe.classifyPitcher(SCALPEL).primary).toBe('SCALPEL');
});
it('heavy grounders with an ordinary strikeout rate is a SINKER', () => {
expect(pe.classifyPitcher(SINKER).primary).toBe('SINKER');
});
it('nothing to classify on → null, NOT a guessed archetype', () => {
expect(pe.classifyPitcher({})).toBeNull();
expect(pe.classifyPitcher({ bb_pct: 0.09 })).toBeNull();
// and an unclassified pitcher still gets a balanced map, not a lean
expect(pe.weightsFor(null)).toEqual(pe.ARCHETYPES.DEFAULT.weights);
});
it('the archetypes are NOT the batter engine — they weight stuff, not contact', () => {
const sk = require('../../src/services/model/skillProjection');
const pitcherKeys = Object.keys(pe.weightsFor('FLAME')).sort();
const batterKeys = Object.keys(sk.featureMapFor('BOMBER').hitWeights).sort();
expect(pitcherKeys).not.toEqual(batterKeys);
expect(pitcherKeys).toEqual(['chase', 'k_rate', 'whiff']);
});
it('a FLAME leans on whiff; a SCALPEL leans on chase', () => {
expect(pe.weightsFor('FLAME').whiff).toBeGreaterThan(pe.weightsFor('SCALPEL').whiff);
expect(pe.weightsFor('SCALPEL').chase).toBeGreaterThan(pe.weightsFor('FLAME').chase);
});
});
describe('THE MATCHUP TERM — stuff against THIS lineup', () => {
it('the same arm projects lower against a contact lineup than a whiff-prone one', () => {
const vsContact = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.16, line: 5.5, expectedBf: 24 });
const vsWhiffy = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.29, line: 5.5, expectedBf: 24 });
expect(vsContact.p_over_line).toBeLessThan(vsWhiffy.p_over_line);
expect(vsContact.projected_value).toBeLessThan(vsWhiffy.projected_value);
});
it('a league-average lineup leaves the pitcher near his own rate', () => {
const own = pe.strikeoutRate({ pitcher: FLAME });
const vsLeague = pe.strikeoutRate({ pitcher: FLAME, lineupKRate: pe.LEAGUE.k_pct });
expect(vsLeague.k_rate).toBeCloseTo(own.k_rate, 6);
});
it('an ABSENT lineup leaves the rate untouched and says so — never substitutes league', () => {
const r = pe.strikeoutRate({ pitcher: FLAME, lineupKRate: null });
expect(r.lineup_applied).toBe(false);
expect(r.k_rate).toBeGreaterThan(0);
});
it('better stuff means a higher rate, at the same lineup', () => {
const flame = pe.strikeoutRate({ pitcher: FLAME, lineupKRate: 0.22 });
const sink = pe.strikeoutRate({ pitcher: SINKER, lineupKRate: 0.22 });
expect(flame.k_rate).toBeGreaterThan(sink.k_rate);
});
});
describe('workload is opportunity, not skill', () => {
it('a starter faces more batters than a reliever, so projects more strikeouts', () => {
const starter = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.22, line: 4.5, role: 'starter' });
const reliever = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.22, line: 4.5, role: 'reliever' });
expect(starter.projected_value).toBeGreaterThan(reliever.projected_value);
expect(starter.p_over_line).toBeGreaterThan(reliever.p_over_line);
});
it('more batters faced at the SAME rate means more strikeouts', () => {
const short = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.22, line: 5.5, expectedBf: 18 });
const long = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.22, line: 5.5, expectedBf: 28 });
expect(long.k_rate_per_bf).toBeCloseTo(short.k_rate_per_bf, 6); // rate unchanged
expect(long.p_over_line).toBeGreaterThan(short.p_over_line); // opportunity changed
});
});
describe('honesty — abstain rather than guess', () => {
it('no stuff profile → NO read', () => {
expect(pe.projectStrikeouts({ pitcher: null, line: 5.5 })).toBeNull();
expect(pe.projectStrikeouts({ pitcher: {}, line: 5.5 })).toBeNull();
});
it('a missing stuff input is SILENT, not a measured zero', () => {
const full = pe.strikeoutRate({ pitcher: FLAME, lineupKRate: 0.22 });
const noWhiff = pe.strikeoutRate({ pitcher: { ...FLAME, whiff_pct: null }, lineupKRate: 0.22 });
const zeroWhiff = pe.strikeoutRate({ pitcher: { ...FLAME, whiff_pct: 0 }, lineupKRate: 0.22 });
expect(noWhiff).not.toBeNull();
expect(zeroWhiff.k_rate).toBeLessThan(noWhiff.k_rate); // a real 0 is a fact
expect(Math.abs(noWhiff.k_rate - full.k_rate)).toBeLessThan(Math.abs(zeroWhiff.k_rate - full.k_rate));
});
it('the registry gate applies — with nothing allowed, it REFUSES', () => {
const out = pe.projectStrikeouts({
pitcher: FLAME, lineupKRate: 0.22, line: 5.5, allowed: new Set(['something_else']),
});
expect(out).toBeNull();
});
it('the distribution is a real distribution and P(>=k) is monotone', () => {
const out = pe.projectStrikeouts({ pitcher: FLAME, lineupKRate: 0.22, line: 5.5, expectedBf: 24 });
expect(out.distribution.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 2);
let prev = 1;
for (let k = 1; k <= 12; k += 1) {
const p = pe.atLeast(out.distribution, k);
expect(p).toBeLessThanOrEqual(prev + 1e-9);
prev = p;
}
});
it('the rate stays a probability however the ratios stack', () => {
const wild = pe.strikeoutRate({ pitcher: { whiff_pct: 0.95, k_pct: 0.9, chase_pct: 0.9 }, lineupKRate: 0.9 });
expect(wild.k_rate).toBeLessThanOrEqual(1);
expect(wild.k_rate).toBeGreaterThan(0);
});
});