Files
vyndr/tests/unit/platoonPolarity.test.js
builtbykev 4c9707ffbb Platoon polarity: VERIFIED correct, pinned by a standing test
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 23:08:11 -04:00

111 lines
5.5 KiB
JavaScript

/* ============================================================
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'));
});
});