From 4c9707ffbb4b0451cc049f3c9a6425c518cdaa8d Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 21 Jul 2026 23:08:11 -0400 Subject: [PATCH] Platoon polarity: VERIFIED correct, pinned by a standing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report-only verification of the two unproven claims from the Session-77 wiring, which fired platoon on synthetic split-less hitters where the K=600 regression zeroed the multiplier and masked both direction and resolution. No product code changed — this adds one standing regression test. FIXTURE — Yordan Alvarez (LHB), real 2026 splits, deep both hands: 115 PA vs LHP at .529 slg, 327 PA vs RHP at .695, overall .652. The regression leaves a material multiplier both ways (0.97 vs LHP, 1.023 vs RHP), so unlike the last test this fixture can actually reveal direction. RESOLUTION — verified on the live slate that the pitcher-hand attached to a hitter is the OPPOSING team's probable, not his own. CLE (home) resolved to Minnesota's away starter 696070; MIN (away) resolved to Cleveland's home starter 800048. The chain — hitter's team, the game, the other team, that team's probable, that pitcher's hand — is correct, and it is pinned independently of direction because a backwards resolution is invisible on a neutral hitter. DIRECTION — deterministic L-vs-R on the frozen Alvarez fixture. Facing RHP nudges UP (1.023) because he slugs .695 there, above his .652 overall — a favorable opposite-hand matchup, exactly what platoon theory predicts for a left-handed bat. Facing LHP nudges DOWN (0.97). The two move opposite directions, and crucially the SPECIFIC sides are asserted, not merely "opposite" — a mirrored-but-inverted implementation would put RHP below 1 and fails here. Both- backwards is ruled out. The test also pins a REVERSE-split hitter, Brandon Nimmo, who hits better vs LHP than RHP. His multiplier goes up vs LHP, following his real numbers rather than a hardcoded LHB-vs-RHP assumption — proof the sign is data-driven, which is the correct design. VERDICT: PASS. Resolution correct, direction correctly signed against both the real split and platoon theory. Pinned by tests/unit/platoonPolarity.test.js so the polarity cannot silently regress — the opp_rank_stat lesson applied. Tests 3750 passed / 302 suites. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- tests/unit/platoonPolarity.test.js | 110 +++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/unit/platoonPolarity.test.js diff --git a/tests/unit/platoonPolarity.test.js b/tests/unit/platoonPolarity.test.js new file mode 100644 index 0000000..92c55ff --- /dev/null +++ b/tests/unit/platoonPolarity.test.js @@ -0,0 +1,110 @@ +/* ============================================================ + 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')); + }); +});