# 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.