Files
vyndr/specs/pitcher-engine-strikeouts.md
T
builtbykev 843c8c6d4b Build the pitcher engine, and find the cap was eating the whole board
Strikeouts are NOT proven -- n=57 against a bar of 500. But the finding that
matters is not a correlation.

THE CAP. Measured on the live slate via the refusal diagnostic: 1,244 unique
gradeable props exist, the 500 cap graded about 334, and because dedupeProps
takes first-row-wins in FEED ORDER, what survives is decided by feed position
rather than value. Pitchers are 2.6% of a batter-dominated feed, so we were
grading SIX strikeout props a slate against 32 available -- putting n>=500
three months away for every pitcher stat. Pitcher props were never being
refused (graded 5, refused 0, suppressed 0); it was truncation.

Raised 500 -> 1500 on measured cost: 721ms per prop at concurrency 5 is about
179 seconds for the full board, against a cron that runs five times a day and
a fire-and-forget caller that never holds an HTTP response. statsapi is free
and unlimited. Concurrency stays at 5 -- one variable at a time. This unblocks
every n-blocked stat in the programme, not just pitchers.

THE ENGINE. pitcherEngine.js is its own engine, not the batter engine pointed
at pitchers: the batter model asks whether contact becomes a hit and reads
contact quality, the pitcher model asks whether the plate appearance ends
without contact at all and reads stuff. Archetypes are FLAME (whiff-led),
SCALPEL (chase-led), SINKER (pitches to contact) and DEFAULT, and a test
asserts the weight keys are not the batter engine's. The projection is K% by
log5 against THIS lineup, times batters faced, through a binomial. An
unclassifiable arm gets the balanced map, never a guessed archetype.

THE MEASUREMENT, at n=57 and contaminated. Four solo features clear the 0.15
effect bar and fail only on sample: arm angle at -0.250 -- the largest
correlation measured anywhere in this programme -- then whiff +0.213, k rate
+0.206, chase +0.195. The batter cluster's best was 0.135. Head to head,
pitch-v1 resolves 0.1285 against the counter's -0.0639, delta +0.192 with a CI
spanning zero.

That negative is the interesting number. The counter is ANTI-PREDICTIVE on
strikeouts: counting a pitcher's recent Ks is worse than useless, because his
recent totals track which lineups he drew and how long he was left in rather
than his skill. It is the one stat where the incumbent has no defensible edge.

A bug caught on the way. resolveTeam wants an abbreviation and the game log
supplies full team names, so the roster join silently resolved nothing and the
first run reported 0% lineup coverage -- the theorized stuff x lineup carrier
was never being tested, not failing. Fixed; coverage is now 94.7%. The carrier
still shows no incremental signal over whiff alone, and adding the lineup term
lowered head-to-head resolution, which is recorded rather than dropped.

Calibration was not reached: nothing passed the first bar. The batter model
and the counter are byte-identical, verified by diff.

4,221 tests green (335 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 18:43:32 -04:00

175 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.