Files
vyndr/src/services/projection/matchupRead.js
T
builtbykev 316b79733e proj-v1 sanity fixes (caught in the real-data induction)
1. matchupRead fly-ball signal: the batter metrics `gb_pct_bb`/`fb_ld_pct` are
   MISLABELED — they're exit velocities by batted-ball type (Judge fb_ld_pct =
   100.3 mph, not a rate), not ground/fly RATES. Switched fly-ball lean to
   avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°), the correct signal.
2. Absolute rate now fits the FULL season (recency-weighted), not a 20-game
   window: the window under-sampled rare stats — Judge HR projected 0.11 vs his
   0.28 season rate (a fake -32pt edge). Now point=0.27 (matches season); the
   last-5-2x recency lean is preserved.

Post-fix induction (real statsapi logs + real statcast): Judge HR 0.27 (P>=1
0.235 vs book 0.42 -> flags the juiced over), Judge TB P>=2 0.548 vs 0.48
(+6.8pt), thin-hot 3-game P>=1 0.726 / P>=3 0.164 (credible low, thin high),
.300 hitter != 3.0. proj-v1 suites 23/23.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-23 04:05:23 -04:00

159 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 },
};