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