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,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,
|
||||||
|
};
|
||||||
@@ -255,6 +255,14 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
|
|||||||
challenger_delta: numOrNull(g.challenger_delta),
|
challenger_delta: numOrNull(g.challenger_delta),
|
||||||
challenger_adjustments: g.challenger_adjustments || null,
|
challenger_adjustments: g.challenger_adjustments || null,
|
||||||
challenger_version: g.challenger_version || 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,
|
// 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
|
// 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
|
// the instrument measures. The actual lands in game_context and is never
|
||||||
|
|||||||
@@ -554,6 +554,22 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
const envMoved = withChallenger.filter((g) => g.env_multiplier != null).length;
|
const envMoved = withChallenger.filter((g) => g.env_multiplier != null).length;
|
||||||
const platoonMoved = withChallenger.filter((g) => (g.challenger_adjustments || []).some((a) => a.axis === 'matchup')).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})`);
|
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) {
|
} catch (e) {
|
||||||
// The challenger must NEVER break the pipeline it is measured inside.
|
// The challenger must NEVER break the pipeline it is measured inside.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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