843c8c6d4b
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
133 lines
6.1 KiB
JavaScript
133 lines
6.1 KiB
JavaScript
'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);
|
|
});
|
|
});
|