From 843c8c6d4ba4ec296001add65a1ef30b052b9550 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 3 Aug 2026 18:43:32 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 --- BUILD-STATE.md | 24 +++ CLAUDE.md | 32 ++++ scripts/pitcher-prove-k.js | 284 ++++++++++++++++++++++++++++ specs/pitcher-engine-strikeouts.md | 174 +++++++++++++++++ src/services/gradeSlateService.js | 18 +- src/services/model/pitcherEngine.js | 261 +++++++++++++++++++++++++ tests/unit/pitcherEngine.test.js | 132 +++++++++++++ 7 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 scripts/pitcher-prove-k.js create mode 100644 specs/pitcher-engine-strikeouts.md create mode 100644 src/services/model/pitcherEngine.js create mode 100644 tests/unit/pitcherEngine.test.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 6f49981..9661ba0 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 43218cf..eab7936 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/scripts/pitcher-prove-k.js b/scripts/pitcher-prove-k.js new file mode 100644 index 0000000..87cdee5 --- /dev/null +++ b/scripts/pitcher-prove-k.js @@ -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); }); diff --git a/specs/pitcher-engine-strikeouts.md b/specs/pitcher-engine-strikeouts.md new file mode 100644 index 0000000..d5dfb1c --- /dev/null +++ b/specs/pitcher-engine-strikeouts.md @@ -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. diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 5a78524..874882f 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -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. diff --git a/src/services/model/pitcherEngine.js b/src/services/model/pitcherEngine.js new file mode 100644 index 0000000..f284369 --- /dev/null +++ b/src/services/model/pitcherEngine.js @@ -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, +}; diff --git a/tests/unit/pitcherEngine.test.js b/tests/unit/pitcherEngine.test.js new file mode 100644 index 0000000..e204fa4 --- /dev/null +++ b/tests/unit/pitcherEngine.test.js @@ -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); + }); +});