proj-v1: absolute matchup projection challenger (distribution + full ladder)

A THIRD challenger (after arch-v1, contact-v1), MLB batting v1. Champion is
market-relative P(stat>LINE); proj-v1 is ABSOLUTE — what the hitter will DO —
emitted as a full distribution from which the WHOLE LADDER (P≥1,P≥2,P≥3) derives.
Champion untouched; nothing claimed; the ledger decides per rung, per stat.

- projection/distribution.js — Bayesian Gamma-Poisson → negative-binomial
  predictive. Admits over-dispersion; under-dispersion → Poisson approx
  (conservative, documented). Uncertainty scales with sample by construction
  (r=α): thin → WIDE (real mass on P≥1, honestly thin P≥3), thick → tight.
  NEVER abstains — width carries the honesty.
- projection/matchupRead.js — the input the book doesn't use. HONEST FIDELITY:
  pitcher repertoire is rich (97% pitch-mix) but hitters have NO pitch-type
  performance, so TRUE repertoire-vs-profile is impossible today. This is the
  COARSE version (arsenal buckets fastball/sinker/breaking + whiff/hard-hit
  tendency × hitter whiff/chase/gb-fb/hard-hit) — beats generic L/R, derived +
  documented + TESTED two-sided. A hitter pitch-type feed unlocks the true form.
- projectionChallenger.js — park RELATIVE to the player's own log exposure
  (isHome→own park, away→opp park; Phase B's raw-multiply bug solved), recency-
  weighted fit, per-factor breakdown (form/park/weather/platoon/matchup — show
  your work), full rung set + book-implied per rung. Combined non-form
  multiplier bounded.
- Wired after contact-v1, own try, flag PROJ_V1_ENABLED, reusing arch-v1's
  already-computed park/weather/platoon (no duplicate env I/O). Own ledger
  columns (migration 032, applied to prod): distribution, ladder, point, line,
  our-P, book-implied, factor breakdown — measurable per rung/stat after settle.

Phase 0 (prod-verified): venue join via isHome; NB family; uncertainty-as-width;
coarse matchup honest fidelity; no lineup-slot (per-game rate, volume implicit).
Sanity: thin-hot → wide (credible low rung, thin high rung); .300 hitter ≠ 3.0;
matchup two-sided; champion byte-identical. proj-v1 suites 23/23; snapshot/
ledger/siblings 74 green. Forward-only, version-stamped, PROJ_V1_ENABLED kill.

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-23 03:59:36 -04:00
parent b6f12daa98
commit 6386e737b9
8 changed files with 815 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
'use strict';
/**
* proj-v1 MATCHUP READ — coarse repertoire-vs-profile (Phase 2).
*
* This is the input the book DOESN'T use. Books price generic L/R handedness
* splits; they do not read THIS pitcher's arsenal against THIS hitter's profile.
*
* ── HONEST FIDELITY (Phase 0.4) ──────────────────────────────────────────
* We have RICH pitcher repertoire (statcast pitch_mix: per-pitch type, usage%,
* whiff%, hard_hit%). We do NOT have hitter pitch-type performance (no "damage
* vs sliders"). So TRUE repertoire-vs-profile is impossible today. This is the
* COARSE version: bucket the arsenal (fastball / sinker / breaking / offspeed
* heavy, + its whiff & hard-hit tendency) and read it against the hitter's
* AGGREGATE profile (whiff%, chase%, ground-ball vs fly-ball lean, hard-hit).
* It beats the generic L/R split; it is not pitch-type-damage matching. A
* hitter pitch-type feed would unlock the true version — see the roadmap.
*
* Every rule below is DERIVED from what the feeds carry and DOCUMENTED so the
* number's meaning is knowable later; matchupRead.test.js locks each direction.
* The output is a small, bounded rate MULTIPLIER per stat plus a per-component
* breakdown (so the terminal can show its work).
*/
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
const num = (v) => (isNum(v) ? v : (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null)));
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// Pitch-type families (statcast `type` codes).
const FASTBALL = new Set(['FF', 'FC']); // 4-seam, cutter (true rising/hard)
const SINKER = new Set(['SI', 'FT']); // sinker / 2-seam (ground-ball inducing)
const BREAKING = new Set(['SL', 'CU', 'ST', 'SV', 'KC', 'SC']);
const OFFSPEED = new Set(['CH', 'FS', 'FO']);
/**
* classifyArsenal(pitch_mix) — usage-weighted buckets + tendency. Returns null
* when the mix is absent (honest-absent → no matchup contribution).
*/
function classifyArsenal(pitchMix) {
if (!Array.isArray(pitchMix) || !pitchMix.length) return null;
let total = 0; const b = { fastball: 0, sinker: 0, breaking: 0, offspeed: 0 };
let whiffW = 0; let hardW = 0;
for (const pt of pitchMix) {
const use = num(pt && pt.usage_pct);
const type = String(pt && pt.type || '').toUpperCase();
if (use == null || use <= 0) continue;
total += use;
if (FASTBALL.has(type)) b.fastball += use;
else if (SINKER.has(type)) b.sinker += use;
else if (BREAKING.has(type)) b.breaking += use;
else if (OFFSPEED.has(type)) b.offspeed += use;
const w = num(pt.whiff_pct); if (w != null) whiffW += use * w;
const h = num(pt.hard_hit_pct); if (h != null) hardW += use * h;
}
if (total <= 0) return null;
return {
fastball_pct: b.fastball / total,
sinker_pct: b.sinker / total,
breaking_pct: b.breaking / total,
offspeed_pct: b.offspeed / total,
whiff_tendency: whiffW / total, // arsenal-wide whiff% (swing-and-miss stuff)
hard_hit_tendency: hardW / total, // contact quality the arsenal allows
};
}
/**
* hitterProfile(batterRow) — the aggregate tendencies we DO have. gb/fb lean
* comes from gb_pct_bb vs fb_ld_pct in the metrics blob (batted-ball type).
*/
function hitterProfile(row) {
if (!row) return null;
const m = row.metrics || {};
const gb = num(m.gb_pct_bb);
const fb = num(m.fb_ld_pct);
let flyLean = null;
if (gb != null && fb != null && (gb + fb) > 0) flyLean = fb / (gb + fb); // 0..1, high = fly-ball
return {
whiff: num(row.whiff_pct) ?? num(m.whiff_pct),
chase: num(row.chase_pct) ?? num(m.chase_pct),
hard_hit: num(row.hard_hit_pct) ?? num(m.hard_hit_pct),
fly_lean: flyLean, // fraction of batted balls in the air
};
}
// League anchors (2026 percentiles, verified in prod) — the neutral point each
// tendency is measured against, so "high"/"low" means relative to the league.
const ANCHOR = Object.freeze({
arsenal_whiff: 22, hitter_whiff: 24, hard_hit: 38, fly_lean: 0.5,
});
// Max single-component lean and total matchup lean — coarse read, small effect.
const COMP_MAX = 0.05;
const TOTAL_MAX = Number(process.env.PROJ_MATCHUP_MAX || 0.10);
const z = (v, anchor, scale) => (v == null ? 0 : clamp((v - anchor) / scale, -1, 1));
/**
* matchupMultiplier({ arsenal, hitter, statType }) — per-stat rate multiplier
* (≈1.0 neutral) + a documented per-component breakdown. Coarse, bounded, and
* ABSENT (multiplier 1, components []) when either side is missing.
*/
function matchupMultiplier({ arsenal, hitter, statType } = {}) {
const stat = String(statType || '').toLowerCase();
const none = { multiplier: 1, components: [], reason: 'insufficient_matchup_data' };
if (!arsenal || !hitter) return none;
const comps = [];
const add = (label, lean) => { if (lean) comps.push({ label, lean: Math.round(lean * 1000) / 1000 }); };
// (1) WHIFF axis — a high-whiff arsenal vs a whiff-prone hitter suppresses
// balls in play (fewer hits), and raises strikeouts. DERIVED: arsenal
// whiff tendency × hitter whiff, both league-relative.
const arsWhiff = z(arsenal.whiff_tendency, ANCHOR.arsenal_whiff, 8);
const hitWhiff = z(hitter.whiff, ANCHOR.hitter_whiff, 8);
const whiffPress = clamp(arsWhiff * (0.5 + 0.5 * Math.max(0, hitWhiff)), -1, 1); // stuff bites vulnerable hitters harder
// (2) GROUND/AIR axis — a sinker/GB arsenal keeps the ball down, suppressing a
// fly-ball hitter's POWER (HR/TB) but yielding weak contact. A fastball/
// fly-ball arsenal lets a fly-ball hitter elevate. DERIVED: sinker share
// vs hitter fly lean.
const groundy = clamp(arsenal.sinker_pct - 0.20, -0.5, 0.6) / 0.6; // >20% sinker = groundy
const flyBat = z(hitter.fly_lean, ANCHOR.fly_lean, 0.2);
const airSuppress = clamp(groundy * Math.max(0, flyBat), -1, 1); // groundy arm vs fly bat → power down
// (3) HARD-CONTACT axis — an arsenal that allows hard contact, faced by a
// hard-hit hitter, lifts extra-base outcomes.
const arsHard = z(arsenal.hard_hit_tendency, ANCHOR.hard_hit, 6);
const hitHard = z(hitter.hard_hit, ANCHOR.hard_hit, 6);
const hardLift = clamp((arsHard + hitHard) / 2, -1, 1);
let lean = 0;
if (stat === 'hits') {
add('whiff_vs_stuff', -COMP_MAX * whiffPress); lean += -COMP_MAX * whiffPress;
add('contact_quality', 0.4 * COMP_MAX * hardLift); lean += 0.4 * COMP_MAX * hardLift;
} else if (stat === 'strikeouts') {
add('whiff_vs_stuff', COMP_MAX * whiffPress); lean += COMP_MAX * whiffPress; // K prop moves WITH whiff pressure
} else if (stat === 'home_runs' || stat === 'total_bases' || stat === 'doubles' || stat === 'triples') {
add('air_suppression', -COMP_MAX * airSuppress); lean += -COMP_MAX * airSuppress;
add('hard_contact', COMP_MAX * hardLift); lean += COMP_MAX * hardLift;
add('whiff_vs_stuff', -0.4 * COMP_MAX * whiffPress); lean += -0.4 * COMP_MAX * whiffPress;
} else {
return none; // unmapped stat → no matchup lean
}
const capped = clamp(lean, -TOTAL_MAX, TOTAL_MAX);
return {
multiplier: Math.round((1 + capped) * 1000) / 1000,
components: comps,
capped: capped !== lean,
reason: comps.length ? null : 'matchup_neutral',
};
}
module.exports = {
classifyArsenal, hitterProfile, matchupMultiplier,
COMP_MAX, TOTAL_MAX, ANCHOR,
__internals: { FASTBALL, SINKER, BREAKING, OFFSPEED, z },
};