'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. * * FLY-BALL lean comes from avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1° * verified in prod). NOTE: the metrics blob's `gb_pct_bb` / `fb_ld_pct` are * mislabeled — they are EXIT VELOCITIES by batted-ball type (Judge fb_ld_pct = * 100.3 mph, not a rate), NOT ground/fly RATES. Launch angle is the correct, * unambiguous air-vs-ground signal. (Caught in the proj-v1 sanity induction.) */ function hitterProfile(row) { if (!row) return null; const m = row.metrics || {}; 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), launch_angle: num(row.avg_launch_angle) ?? num(m.avg_launch_angle), // ° — higher = more 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, launch_angle: 13.9, }); // 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.launch_angle, ANCHOR.launch_angle, 6); // p90 20° → ~+1, p10 7° → ~-1 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 }, };