/* ============================================================ Contact-quality challenger (contact-v1) — nominate, don't swap. Two-sided: elite contact leans the over, poor contact leans against, thin/unmapped ABSTAIN (null, never a silent fallback), champion untouched. ============================================================ */ const cc = require('../../src/services/contactChallenger'); // A league population spanning the real 2026 percentiles (barrel p50 7.4, // hard_hit p50 38.4, k p50 22.4), all sufficient-sample batters. const POP = []; for (let i = 0; i < 21; i++) { const f = i / 20; // 0..1 POP.push({ role: 'batter', sample_pa: 300, barrel_pct: 2 + f * 13, // 2 .. 15 hard_hit_pct: 26 + f * 24, // 26 .. 50 k_pct: 12 + f * 24, // 12 .. 36 }); } const REFS = cc.buildRefs(POP); const row = (over) => ({ role: 'batter', sample_pa: 300, ...over }); describe('buildRefs', () => { it('builds sorted refs per mapped metric from sufficient-sample batters only', () => { const refs = cc.buildRefs([ ...POP, { role: 'batter', sample_pa: 10, barrel_pct: 99 }, // thin → excluded { role: 'pitcher', sample_ip: 100, barrel_pct: 99 }, // wrong role → excluded ]); expect(refs.barrel_pct.length).toBe(POP.length); expect(refs.barrel_pct).not.toContain(99); // sorted ascending expect([...refs.k_pct].sort((a, b) => a - b)).toEqual(refs.k_pct); }); }); describe('metric → prop mapping (barrels for HR/TB, k_pct-inverse for hits)', () => { it('HOME RUNS reads barrel_pct — elite barrels lean the over UP', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', row: row({ barrel_pct: 15 }), refs: REFS }); expect(res.p_win_contact).toBeGreaterThan(0.5); expect(res.contact_adjustments[0].metric).toBe('barrel_pct'); expect(res.contact_adjustments[0].tier).toBe('elite'); }); it('TOTAL BASES reads hard_hit_pct (not barrels)', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'total_bases', row: row({ hard_hit_pct: 50 }), refs: REFS }); expect(res.contact_adjustments[0].metric).toBe('hard_hit_pct'); expect(res.p_win_contact).toBeGreaterThan(0.5); }); it('HITS reads k_pct INVERSE — a low-K contact hitter leans the over UP', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'hits', row: row({ k_pct: 12 }), refs: REFS }); expect(res.contact_adjustments[0].metric).toBe('k_pct'); expect(res.p_win_contact).toBeGreaterThan(0.5); // low K = more contact = more hits }); it('HITS — a high-K hitter leans the over DOWN (barrels would mislead here)', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'hits', row: row({ k_pct: 36 }), refs: REFS }); expect(res.p_win_contact).toBeLessThan(0.5); }); it('UNDER flips the sign', () => { const over = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', row: row({ barrel_pct: 15 }), refs: REFS }); const under = cc.contactAdjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', row: row({ barrel_pct: 15 }), refs: REFS }); expect(over.p_win_contact).toBeGreaterThan(0.5); expect(under.p_win_contact).toBeLessThan(0.5); }); }); describe('honest abstention — null projection, never a silent fallback', () => { it('thin sample (< MIN_PA) → abstains (p_win_contact null)', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'hits', row: { role: 'batter', sample_pa: 20, k_pct: 12 }, refs: REFS }); expect(res.p_win_contact).toBeNull(); expect(res.contact_delta).toBeNull(); expect(res.reason).toBe('thin_sample'); }); it('unmapped stat (rbi — opportunity-driven) → abstains, never guesses', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'rbi', row: row({ barrel_pct: 15 }), refs: REFS }); expect(res.p_win_contact).toBeNull(); expect(res.reason).toBe('stat_not_mapped'); }); it('non-batter profile → abstains (no wrong-role contact read)', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'hits', row: { role: 'pitcher', sample_ip: 100, k_pct: 12 }, refs: REFS }); expect(res.p_win_contact).toBeNull(); expect(res.reason).toBe('no_batter_profile'); }); it('absent coverage (no row) → abstains', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'hits', row: null, refs: REFS }); expect(res.p_win_contact).toBeNull(); }); }); describe('measured-but-unremarkable ≠ abstention', () => { it('a mid-pack metric is a real no-lean projection equal to the champion (delta 0)', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', row: row({ barrel_pct: 7.4 }), refs: REFS }); expect(res.p_win_contact).toBe(0.5); // equals champion — a projection, not an abstention expect(res.contact_delta).toBe(0); expect(res.reason).toBe('metric_unremarkable'); }); }); describe('distinct + isolated challenger', () => { it('is tagged contact-v1 (distinguishable from the arch-v1 challenger)', () => { expect(cc.CONTACT_VERSION).toBe('contact-v1'); const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', row: row({ barrel_pct: 15 }), refs: REFS }); expect(res.version).toBe('contact-v1'); }); it('attachContactChallenger NEVER mutates the champion or the arch-v1 fields', async () => { const grades = [{ player: 'Slugger', stat_type: 'home_runs', direction: 'over', p_win: 0.5, p_win_challenger: 0.55, challenger_version: 'arch-v1', grade: 'B', // champion + arch-v1 }]; const rowFor = () => row({ barrel_pct: 15 }); const out = await cc.attachContactChallenger(grades, rowFor, REFS); expect(out[0].p_win).toBe(0.5); // champion untouched expect(out[0].p_win_challenger).toBe(0.55); // arch-v1 untouched expect(out[0].challenger_version).toBe('arch-v1'); expect(out[0].grade).toBe('B'); // published grade untouched expect(out[0].contact_version).toBe('contact-v1'); expect(out[0].p_win_contact).toBeGreaterThan(0.5); }); it('the nudge is capped — no re-forecast', () => { const res = cc.contactAdjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', row: row({ barrel_pct: 15 }), refs: REFS }); expect(Math.abs(res.p_win_contact - 0.5)).toBeLessThanOrEqual(0.12); // ~elite nudge at p=0.5 }); });