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
@@ -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;