'use strict'; /** * penQuality — the PROVEN half of Link 2. * * Link 2 was asked twice. Naming the individual reliever failed on merit (17.2% * accuracy — wrong five times in six), because managers mix and match and the * individual genuinely is noise. Asked at the COARSE grain the chain actually * needs, it proves: predicted pen quality separates a realized 2.70pp hit-rate * difference between the pens we call best and worst. * * The archetype grain did NOT prove (0.567 vs a 0.531 modal-guess baseline, * corrected interval spanning zero) and is deliberately absent from this module. * Two grains were tested; one earned a place. * * ── POINT-IN-TIME ON BOTH SIDES ────────────────────────────────────────── * An arm's quality is his allowed-hit-rate over appearances strictly BEFORE the * game in question, and a club's pen forecast comes only from its prior games. * The target is which KNOWN-quality arms appeared — never how they happened to * pitch that night, which would be scoring against the answer. * * ── ABSTAIN, NEVER IMPUTE ──────────────────────────────────────────────── * An arm below the appearance floor has no readable quality, and a club without * enough prior games has no readable pen. Both return null. A league-average * stand-in would assert "this is an ordinary bullpen", which is a claim, and * usually the wrong one for exactly the clubs whose pens have just turned over. */ const { knownNumber } = require('../../utils/known'); /** Appearances before an arm's quality is readable at all. */ const MIN_ARM_PA = 40; /** Prior games before a club's pen is readable at all. */ const MIN_PRIOR_GAMES = 5; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); /** * One arm's quality from his prior line. Null below the floor — a 12-batter * sample is not a scouting report. */ function armQuality(prior) { const n = knownNumber(prior && prior.pa); const h = knownNumber(prior && prior.hits); if (n === null || h === null || n < MIN_ARM_PA) return null; return h / n; } /** * The pen a hitter's later plate appearances will face. * * @param {Array} priorGames [{ quality }] this club's prior relief outings * @returns {object|null} null when unreadable — never a league-average guess. */ function projectPen(priorGames) { const qs = (priorGames || []).map((g) => knownNumber(g && g.quality)).filter((v) => v !== null); if (qs.length < MIN_PRIOR_GAMES) { return null; } return { readable: true, quality: round4(mean(qs)), games_read: qs.length, // Stated so a consumer cannot mistake this for a reliever-identity claim. grain: 'pen_quality', individual_arm_refused: 'naming the specific reliever did not prove (17.2% accuracy) — managers mix and match', archetype_refused: 'the archetype grain did not prove at the corrected bar', }; } /** * The measured relationship between pen quality and hit rate, for a consumer * that wants the consequence rather than the input. Anchored on the observed * league mean; the slope is the measured tercile separation, not a fitted * parameter, and the effect is bounded because it was measured over a range. */ const LEAGUE_PEN_QUALITY = 0.2261; const HIT_RATE_PER_QUALITY = 0.87; // 2.70pp realized over a 0.031 quality gap const MAX_SHIFT = 0.03; function hitRateShift(penQuality) { const q = knownNumber(penQuality); if (q === null) return null; // absent stays absent const raw = (q - LEAGUE_PEN_QUALITY) * HIT_RATE_PER_QUALITY; return round4(Math.max(-MAX_SHIFT, Math.min(MAX_SHIFT, raw))); } /** A checkable sentence, or nothing. */ function explain(pen) { if (!pen || !pen.readable) return null; const d = pen.quality - LEAGUE_PEN_QUALITY; if (Math.abs(d) < 0.005) return `bullpen reads league-average over ${pen.games_read} prior games`; return `bullpen reads ${d > 0 ? 'weaker' : 'stronger'} than league over ${pen.games_read} prior games`; } const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000); module.exports = { armQuality, projectPen, hitRateShift, explain, MIN_ARM_PA, MIN_PRIOR_GAMES, LEAGUE_PEN_QUALITY, MAX_SHIFT, };