/* ============================================================ Session 78 — PLATOON POLARITY LOCK (standing regression). The opp_rank_stat lesson: polarity needs a PERMANENT assertion, not a one-time eyeball. A backwards opposing-pitcher resolution OR a backwards multiplier sign both look fine on a near-neutral hitter — so this pins BOTH on a real, materially-split hitter (Yordan Alvarez, LHB, 2026), with frozen numbers so it can never silently drift. If this test ever fails, platoon has been broken in one of two ways and the Session-77 wiring can no longer be trusted — do not "fix the test", find the regression. ============================================================ */ const pl = require('../../src/services/platoonSplits'); const ec = require('../../src/services/environmentContext'); // Yordan Alvarez, real 2026 splits — deep both hands, textbook LHB platoon. // vs RHP he slugs far above his overall (advantage); vs LHP below (disadvantage). const ALVAREZ = { vsL: { pa: 115, slg: 0.529, avg: 0.250 }, vsR: { pa: 327, slg: 0.695, avg: 0.290 }, overall: { pa: 442, slg: 0.652, avg: 0.278 }, }; describe('DIRECTION — signed against the real split AND platoon theory', () => { const est = (ph) => pl.platoonEstimate({ splits: ALVAREZ, batterHand: 'L', pitcherHand: ph, statType: 'total_bases' }); it('produces a MATERIAL multiplier both ways (fixture can reveal direction)', () => { // A neutral subject would reproduce the inconclusive Session-77 test. expect(est('L').material).toBe(true); expect(est('R').material).toBe(true); }); it('vs RHP nudges UP — favorable split (opposite hand) → over more likely', () => { // .695 slg vs RHP > .652 overall → favorable → UP. Backwards would be < 1. expect(est('R').multiplier).toBeGreaterThan(1); }); it('vs LHP nudges DOWN — unfavorable split (same hand)', () => { // .529 slg vs LHP < .652 overall → unfavorable → DOWN. Backwards would be > 1. expect(est('L').multiplier).toBeLessThan(1); }); it('the two hands move OPPOSITE directions', () => { const l = est('L').multiplier; const r = est('R').multiplier; expect((l < 1)).not.toBe((r < 1)); }); it('BOTH-BACKWARDS is ruled out — a mirrored-but-wrong sign fails here', () => { // If the whole thing were inverted, RHP would be < 1 and LHP > 1. Assert the // exact expected side, not merely "opposite". expect(est('R').multiplier).toBeGreaterThan(1); // must be the RHP side that's up expect(est('L').multiplier).toBeLessThan(1); // and the LHP side that's down }); it('a REVERSE-split hitter follows his real numbers, not the platoon prior', () => { // Brandon Nimmo (LHB) hits BETTER vs LHP than RHP — unusual. The estimate // must follow HIS data (up vs LHP), proving the sign is data-driven and not // a hardcoded LHB-vs-RHP assumption. const nimmo = { vsL: { pa: 104, slg: 0.510 }, vsR: { pa: 301, slg: 0.405 }, overall: { pa: 405, slg: 0.432 } }; const vsL = pl.platoonEstimate({ splits: nimmo, batterHand: 'L', pitcherHand: 'L', statType: 'total_bases' }); const vsR = pl.platoonEstimate({ splits: nimmo, batterHand: 'L', pitcherHand: 'R', statType: 'total_bases' }); expect(vsL.multiplier).toBeGreaterThan(1); // favorable vs LHP per HIS split expect(vsR.multiplier).toBeLessThan(1); }); }); describe('RESOLUTION — the OPPOSING team\'s pitcher, never the hitter\'s own', () => { // A backwards resolution runs platoon on the wrong hand — invisible on a // neutral hitter, so it is pinned independently of direction. const SCHEDULE = { games: [ { homeTeam: { name: 'Cleveland Guardians' }, awayTeam: { name: 'Minnesota Twins' }, gameTime: '2026-07-21T22:40Z', venue: 'Progressive Field' }, ] }; const PITCHERS = { games: [ { home: { team: 'Cleveland Guardians', pitcherId: 800048 }, away: { team: 'Minnesota Twins', pitcherId: 696070 } }, ] }; const PEOPLE = { people: [ { id: 800048, pitchHand: { code: 'L' } }, // CLE's own SP { id: 696070, pitchHand: { code: 'R' } }, // MIN's own SP ] }; const fetchJson = async (url) => { if (url.includes('/pitchers')) return PITCHERS; if (url.includes('personIds=')) return PEOPLE; if (url.includes('open-meteo')) return { hourly: { time: [], temperature_2m: [] } }; if (url.includes('statSplits')) return { stats: [] }; return SCHEDULE; }; it('a HOME hitter faces the AWAY team\'s pitcher hand', async () => { const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); // CLE hitter should resolve to MIN's pitcher (696070, R), NOT CLE's own (800048, L). const oppPid = ctx._internals.oppPitcherByTeam.get('CLE'); expect(oppPid).toBe(696070); expect(ctx._internals.handById.get(oppPid)).toBe('R'); }); it('an AWAY hitter faces the HOME team\'s pitcher hand', async () => { const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); const oppPid = ctx._internals.oppPitcherByTeam.get('MIN'); expect(oppPid).toBe(800048); // CLE's SP, not MIN's own expect(ctx._internals.handById.get(oppPid)).toBe('L'); }); it('the two teams get DIFFERENT pitchers — never the same (own) arm', async () => { const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); expect(ctx._internals.oppPitcherByTeam.get('CLE')).not.toBe(ctx._internals.oppPitcherByTeam.get('MIN')); }); });