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:
Kev
2026-07-22 22:42:55 -04:00
parent 125919f86a
commit b6f12daa98
5 changed files with 384 additions and 0 deletions
+16
View File
@@ -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.