Contact-quality challenger (contact-v1) — nominate, don't swap
Phase A #2: the champion grade (l5/l20 result-based form) is a HYPOTHESIS that contact quality predicts better — unmeasured on our props, with zero settled p_win yet. Swapping l5/l20 (the champion's two heaviest ±1.0 factors) blind could degrade the core grade undetectably for weeks. So this NOMINATES contact quality as a second challenger, records what it WOULD project per prop, and lets the settled ledger decide. Nothing users see changes; the champion is untouched. - src/services/contactChallenger.js — pure, mirrors challengerProjection. Log- odds lean (capped, never a re-forecast) from SEASON contact quality vs league percentiles. Metric→prop mapping is the whole game: barrel_pct→HR, hard_hit_pct→TB/doubles, k_pct-INVERSE→hits (singles resolve on contact frequency, not barrels), k_pct→batter K. rbi/runs/walks ABSTAIN (opportunity/ discipline — no clean contact predictor). Honest-absent: thin (<50 PA)/absent/ unmapped/non-batter → p_win_contact NULL (no projection), never a fallback; "measured but unremarkable" is distinct (equals champion, delta 0). - Wired in snapshotService AFTER arch-v1, reusing the already-loaded statcast rows; its own try so a second challenger can't break the pipeline. Reads g.p_win, never writes it. - Retained SEPARATELY on the ledger (p_win_contact/contact_delta/ contact_adjustments/contact_version='contact-v1') so each challenger's marginal contribution is measured independently; ledger_entries.stat gives per-prop-type segmentation. Migration 031 (applied to prod). Phase 0 (prod-verified): statcast_aggregates is SEASON cumulative (not rolling), 48h stale now but season-scoped so ~8 PA/600 is negligible; 100% of graded hitters covered, 92% at ≥50 PA; no xBA/xwOBA in the feed. Forward-only, version-stamped (contact_version null on pre-nomination rows). Promotion is a LATER decision on settled evidence, per prop type — never asserted here. contactChallenger 14/14; snapshot/ledger/arch-v1 suites 80 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/* ============================================================
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user