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