/** * MLB matchup context helpers (Session 15). * * Two pure functions, no I/O, no state: * - platoonAdvantage(pitcherHand, batterHand) * - projectedPA(lineupPosition) * * Sources: * - Platoon splits: the standard MLB convention — opposite-handed * matchups favor the batter (LHP vs RHB, RHP vs LHB). Switch * hitters get the advantage in either matchup because they bat * opposite-handed by definition. * - Lineup → projected plate appearances: derived from MLB-average * team PA distributions per lineup slot (2024 league composite). * A leadoff hitter sees ~4.7 PA in a 9-inning game; the 9-hole * sees ~3.5. Numbers from baseball-reference team batting tables * averaged across all 30 teams. * * Callers (computeFeatures MLB branch) attach the outputs to the * feature vector as `platoon_advantage` (bool|null) and * `projected_pa` (number|null). When inputs are absent or invalid * the helpers return null — engine1 treats null as a neutral signal * and reasoning omits the line gracefully. */ const HAND_VALUES = new Set(['L', 'R', 'S']); function normalizeHand(hand) { if (!hand) return null; const h = String(hand).trim().toUpperCase().charAt(0); return HAND_VALUES.has(h) ? h : null; } /** * platoonAdvantage — true when the batter has the platoon edge. * * LHP vs RHB → true (right-handed batter sees pitches better) * LHP vs LHB → false * RHP vs RHB → false * RHP vs LHB → true * any vs Switch hitter → true (switch hits opposite of pitcher) * * Returns null when either hand is unknown. The grading engine reads * `null` as "no signal" — same treatment as `false` for confidence * arithmetic, but reasoning will skip the line. */ function platoonAdvantage(pitcherHand, batterHand) { const p = normalizeHand(pitcherHand); const b = normalizeHand(batterHand); if (!p || !b) return null; if (b === 'S') return true; // switch hitter — always has edge if (p === b) return false; // same-handed → pitcher edge return true; // opposite-handed → batter edge } // League-average plate appearances per lineup slot in 9-inning games. // Source: 2024 MLB composite (baseball-reference team batting tables). // Slot 1 (leadoff) leads the team; later slots see fewer PAs because // they may not bat in the bottom of innings already wrapped up. const PA_BY_SLOT = Object.freeze({ 1: 4.70, 2: 4.55, 3: 4.43, 4: 4.31, 5: 4.19, 6: 4.07, 7: 3.95, 8: 3.83, 9: 3.71, }); /** * projectedPA — expected plate appearances by lineup position. * * @param {number|string} position 1..9 * @returns {number|null} * * Out-of-range or invalid → null. This keeps reasoning honest when * the odds payload doesn't carry batting order yet (the more common * case today — odds-api doesn't expose lineup position in the prop * envelope). */ function projectedPA(position) { if (position == null) return null; const p = Number(position); if (!Number.isInteger(p) || p < 1 || p > 9) return null; return PA_BY_SLOT[p]; } module.exports = { platoonAdvantage, projectedPA, __internals: { normalizeHand, HAND_VALUES, PA_BY_SLOT }, };