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); + }); +});