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:
@@ -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 },
|
||||
};
|
||||
Reference in New Issue
Block a user