diff --git a/src/services/contactChallenger.js b/src/services/contactChallenger.js new file mode 100644 index 0000000..e8d522d --- /dev/null +++ b/src/services/contactChallenger.js @@ -0,0 +1,206 @@ +'use strict'; + +/** + * CONTACT-QUALITY CHALLENGER (Phase A #2) — a SECOND challenger, nominated not + * swapped. The champion grade (l5/l20 result-based form → `p_win`) is READ, + * never written. This computes a separate probability from SEASON contact + * quality (Statcast) and retains it beside the champion and the arch-v1 + * challenger, so the settled ledger — not belief — decides whether contact + * quality beats result-based form, PER PROP TYPE. + * + * ── DISTINCT FROM arch-v1, ON PURPOSE ──────────────────────────────────── + * The archetype challenger (`challengerProjection`, `arch-v1`) writes + * `p_win_challenger`. This writes `p_win_contact` under `contact-v1`. Kept + * SEPARATE so each challenger's marginal contribution is measurable on its own + * — folding contact into arch-v1 would contaminate a clean A/B. + * + * ── SEASON, NOT RECENT ─────────────────────────────────────────────────── + * `statcast_aggregates` is per-season cumulative (refreshed nightly), so this + * is a season contact-quality signal — a challenger to the season baseline, + * not a rolling recent-form window. Because it is a season aggregate it is + * robust to a day or two of ingestion lag (~8 PA out of 600+ moves nothing). + * + * ── METRIC → PROP MAPPING IS THE WHOLE GAME ────────────────────────────── + * Contact metrics are NOT interchangeable across prop types. Barrels predict + * HOME RUNS and TOTAL BASES; they say little about SINGLES. A "hits over 0.5" + * prop resolves mostly on contact FREQUENCY, so it reads k_pct (inverse), not + * barrels. A wrong mapping degrades the model while looking sophisticated, so + * only mechanically-defensible pairs are encoded — opportunity stats (rbi / + * runs) and pure-discipline stats (walks) ABSTAIN rather than guess. + * + * ── HONEST-ABSENT ──────────────────────────────────────────────────────── + * No coverage, thin sample (< MIN_PA), an unmapped stat, or a non-batter + * profile → the challenger returns NO projection (`p_win_contact === null`), + * never a silent fallback to a different signal. An abstention is data; a + * fallback corrupts the comparison. "Measured but unremarkable" (mid-pack + * metric) is DIFFERENT: it is a real no-lean projection equal to the champion. + * + * ── ISOLATION ──────────────────────────────────────────────────────────── + * `contactAdjust()` is pure — same inputs, same output, no I/O, no shared + * state. The nudge is applied in LOG-ODDS space (a lean, never a re-forecast) + * and capped, so it can neither run away nor push a probability past 0/1. + */ + +const CONTACT_VERSION = 'contact-v1'; + +/** Below this plate-appearance sample the contact metric is too thin to trust + * → honest abstention (no projection), matching the harness's refusal rule. */ +const MIN_PA = Number(process.env.CONTACT_MIN_PA) || 50; + +/** Log-odds nudges. `elite` = league p90+ favorable, `hi` = p75+. Small — a + * lean on a real signal, not a re-forecast. Capped by MAX_NUDGE. */ +const NUDGE = Object.freeze({ elite: 0.22, hi: 0.11 }); +const MAX_NUDGE = Number(process.env.CONTACT_MAX_NUDGE) || 0.30; + +/** + * stat → { metric, sign }. sign +1 = a HIGHER metric means MORE of the stat; + * -1 = a higher metric means LESS. Only mechanically-defensible pairs. Stats + * absent from this map ABSTAIN (rbi/runs = opportunity-driven; walks = + * discipline; every pitcher stat = wrong role). + */ +const STAT_METRIC = Object.freeze({ + home_runs: { metric: 'barrel_pct', sign: +1 }, // a barrel IS a home run + total_bases: { metric: 'hard_hit_pct', sign: +1 }, // hard contact → extra bases + doubles: { metric: 'hard_hit_pct', sign: +1 }, + triples: { metric: 'hard_hit_pct', sign: +1 }, + hits: { metric: 'k_pct', sign: -1 }, // singles resolve on CONTACT — low K = more balls in play + strikeouts: { metric: 'k_pct', sign: +1 }, // batter strikeout prop +}); + +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); +const toLogOdds = (p) => Math.log(p / (1 - p)); +const fromLogOdds = (l) => 1 / (1 + Math.exp(-l)); + +function num(v) { + if (v == null || v === '') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** percentileOf(sortedAsc, x) — fraction of the reference below x, 0..1. */ +function percentileOf(sortedAsc, x) { + if (!sortedAsc || !sortedAsc.length) return null; + let lo = 0; let hi = sortedAsc.length; + while (lo < hi) { const m = (lo + hi) >> 1; if (sortedAsc[m] < x) lo = m + 1; else hi = m; } + return lo / sortedAsc.length; +} + +/** + * buildRefs(rows) — PURE. league reference distributions (sorted ascending) for + * each mapped metric, from sufficient-sample BATTERS only. Percentile-anchored, + * so no magic league-average constants and the tiers adapt to the real slate. + */ +function buildRefs(rows) { + const refs = {}; + const metrics = new Set(Object.values(STAT_METRIC).map((m) => m.metric)); + for (const metric of metrics) { + const vals = []; + for (const r of rows || []) { + if (!r || r.role !== 'batter') continue; + const pa = num(r.sample_pa); + if (pa == null || pa < MIN_PA) continue; + const v = num(r[metric]); + if (v != null) vals.push(v); + } + vals.sort((a, b) => a - b); + refs[metric] = vals; + } + return refs; +} + +/** + * contactAdjust({ pWin, direction, statType, row, refs }) — PURE. + * Returns { p_win_contact, contact_delta, contact_adjustments, reason, version }. + * - p_win_contact === null → ABSTAINED (no usable input). NOT a projection. + * - p_win_contact === pWin → measured but unremarkable (real no-lean). + * - otherwise → a real contact-quality lean. + */ +function contactAdjust({ pWin, direction, statType, row, refs } = {}) { + const p = num(pWin); + // ABSTAIN: no projection made (null), distinct from a no-lean equal-to-champ. + const abstain = (reason) => ({ + p_win_contact: null, contact_delta: null, contact_adjustments: null, reason, version: CONTACT_VERSION, + }); + // NO LEAN: measured, mid-pack → a real projection that equals the champion. + const noLean = (reason) => ({ + p_win_contact: p, contact_delta: 0, contact_adjustments: null, reason, version: CONTACT_VERSION, + }); + + if (p == null || p <= 0 || p >= 1) return abstain('no_champion_probability'); + const stat = String(statType || '').toLowerCase(); + const map = STAT_METRIC[stat]; + if (!map) return abstain('stat_not_mapped'); // rbi/runs/walks/pitcher props + if (!row || row.role !== 'batter') return abstain('no_batter_profile'); + const pa = num(row.sample_pa); + if (pa == null || pa < MIN_PA) return abstain('thin_sample'); // honest-absent, NO fallback + const v = num(row[map.metric]); + if (v == null) return abstain('metric_absent'); + const sorted = refs && refs[map.metric]; + if (!sorted || !sorted.length) return abstain('no_reference'); + + const pct = percentileOf(sorted, v); // player's league percentile + // goodness: 1 = best contact FOR THIS STAT. Inverse metrics (k_pct on hits) flip. + const goodness = map.sign > 0 ? pct : 1 - pct; + + let mag = 0; let tier = null; + if (goodness >= 0.90) { mag = NUDGE.elite; tier = 'elite'; } + else if (goodness >= 0.75) { mag = NUDGE.hi; tier = 'hi'; } + else if (goodness <= 0.10) { mag = -NUDGE.elite; tier = 'poor'; } + else if (goodness <= 0.25) { mag = -NUDGE.hi; tier = 'lo'; } + else return noLean('metric_unremarkable'); // mid pack → no lean, equals champion + + const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1; + const nudge = clamp(mag * dirSign, -MAX_NUDGE, MAX_NUDGE); + const challenger = clamp(fromLogOdds(toLogOdds(p) + nudge), 0.01, 0.99); + const rounded = Math.round(challenger * 1000) / 1000; + + return { + p_win_contact: rounded, + contact_delta: Math.round((rounded - p) * 1000) / 1000, + contact_adjustments: [{ + metric: map.metric, stat, tier, + value: Math.round(v * 100) / 100, + percentile: Math.round(pct * 100) / 100, + nudge: Math.round(nudge * 1000) / 1000, + }], + reason: null, + version: CONTACT_VERSION, + }; +} + +/** + * attachContactChallenger(grades, rowFor, refs) — map a slate's grades to the + * same grades PLUS the contact-v1 fields. `rowFor(playerName)` returns that + * hitter's statcast aggregate row (or null); injected so this does no I/O. The + * champion (`p_win`) and the arch-v1 fields are NEVER touched. + */ +async function attachContactChallenger(grades, rowFor, refs) { + const out = []; + for (const g of grades || []) { + if (!g) { out.push(g); continue; } + const row = typeof rowFor === 'function' ? rowFor(g.player || g.player_name) : null; + const res = contactAdjust({ + pWin: g.p_win, direction: g.direction, statType: g.stat_type || g.stat, row, refs, + }); + out.push({ + ...g, + p_win_contact: res.p_win_contact, + contact_delta: res.contact_delta, + contact_adjustments: res.contact_adjustments, + contact_version: CONTACT_VERSION, + contact_reason: res.reason, + }); + } + return out; +} + +module.exports = { + contactAdjust, + attachContactChallenger, + buildRefs, + percentileOf, + CONTACT_VERSION, + STAT_METRIC, + MIN_PA, + NUDGE, +}; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index a43cb85..41daa54 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -255,6 +255,14 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { challenger_delta: numOrNull(g.challenger_delta), challenger_adjustments: g.challenger_adjustments || null, challenger_version: g.challenger_version || null, + // Phase A #2 — the SECOND challenger (season contact quality), retained + // SEPARATELY from arch-v1 on the same row so both join to the same outcome + // and the same close. null p_win_contact = honest abstention (no + // projection), distinct from a no-lean equal-to-champion. + p_win_contact: numOrNull(g.p_win_contact), + contact_delta: numOrNull(g.contact_delta), + contact_adjustments: g.contact_adjustments || null, + contact_version: g.contact_version || null, // Session 75 — the ENVIRONMENT that drove this projection. The FORECAST, // not the actual: this is what we knew when we projected, and it is what // the instrument measures. The actual lands in game_context and is never diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 21c7141..6a9aafc 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -554,6 +554,22 @@ async function runSnapshot(sport, opts = {}) { const envMoved = withChallenger.filter((g) => g.env_multiplier != null).length; const platoonMoved = withChallenger.filter((g) => (g.challenger_adjustments || []).some((a) => a.axis === 'matchup')).length; console.log(`[challenger] ${sp} — ${moved}/${withChallenger.length} adjusted (env ${envMoved}, platoon ${platoonMoved})`); + + // Phase A #2 — SECOND challenger: SEASON contact quality (contact-v1), + // tagged separately from arch-v1 so each is measured independently. Reuses + // the statcast rows already loaded; champion + arch-v1 fields untouched. + // Its own try — a second challenger must never break the pipeline either. + try { + const contact = deps.contactChallenger || require('./contactChallenger'); + const refs = contact.buildRefs([...rowsByKey.values()]); + const rowForContact = (name) => rowsByKey.get(nameKey(name || '')); + withChallenger = await contact.attachContactChallenger(withChallenger, rowForContact, refs); + const cNudged = withChallenger.filter((g) => g.contact_delta).length; + const cAbstain = withChallenger.filter((g) => g.p_win_contact == null).length; + console.log(`[contact-challenger] ${sp} — ${cNudged} nudged, ${cAbstain} abstained / ${withChallenger.length}`); + } catch (e) { + console.warn(`[contact-challenger] ${sp} skipped:`, e.message); + } } } catch (e) { // The challenger must NEVER break the pipeline it is measured inside. diff --git a/supabase/migrations/031_contact_challenger.sql b/supabase/migrations/031_contact_challenger.sql new file mode 100644 index 0000000..7dd038c --- /dev/null +++ b/supabase/migrations/031_contact_challenger.sql @@ -0,0 +1,21 @@ +-- Phase A #2 — CONTACT-QUALITY CHALLENGER (contact-v1). +-- A SECOND challenger retained beside the champion (p_win) and the archetype +-- challenger (p_win_challenger / arch-v1). Kept in its OWN columns so each +-- challenger's marginal contribution is measurable independently — folding it +-- into p_win_challenger would contaminate a clean A/B. +-- +-- null p_win_contact = HONEST ABSTENTION (no coverage / thin sample / unmapped +-- stat) — a real datum, distinct from a no-lean projection equal to the +-- champion. Segmentable by prop type via the existing ledger_entries.stat. +-- +-- Additive + forward-only: existing rows get null contact_version (pre-nomination); +-- new rows carry 'contact-v1'. Champion and settled/locked grades are untouched. +-- +-- NOTE: migrations 023-029 were applied directly to prod and are not in this +-- repo; this file resumes tracked migrations and is idempotent so it is safe to +-- (re)run against the live schema. +alter table ledger_entries + add column if not exists p_win_contact numeric null, + add column if not exists contact_delta numeric null, + add column if not exists contact_adjustments jsonb null, + add column if not exists contact_version text null; diff --git a/tests/unit/contactChallenger.test.js b/tests/unit/contactChallenger.test.js new file mode 100644 index 0000000..0ba8100 --- /dev/null +++ b/tests/unit/contactChallenger.test.js @@ -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 + }); +});