From 9ebd77b68e29a4ce0c3599fe37360eaffeb95a51 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 2 Aug 2026 01:11:05 -0400 Subject: [PATCH] Build the matchup/platoon axis: three joins fixed, axis now FIRES The axis was already wired and firing on 0/634 prod rows. Three separate absences kept it silent, and all three are now joined: 1. oppPitcherByTeam 0 -> the self-origin /api/schedule/mlb/pitchers route returned nothing in prod. Added the statsapi probable-pitcher hydrate as a fallback, mirroring the one the schedule step already uses. 29/30 team-sides, one free request. 2. handById 0 -> follows from (1); the batched people call now has ids. 3. bats 0/120 -> batter hand rode ONLY on statcast aggregate rows, which do not cover the slate. The season player list we ALREADY fetch and cache carries batSide on 1342/1342, so this is a join, not a fetch. Switch-hitters ('S') are preserved as-is; platoonSplits decides what to do with them, not the map. Verified end-to-end against the live API: opp_declared 29, pitchers_with_hand 29, batters_with_hand 1342, and a real read -- multiplier 0.966, L vs R, 287 observed PA, weight 0.324 -- composing alongside environment in one challenger. FALLBACK LADDER, and a deliberate deviation from the order. Shipped tier: `batter_own_split` (the hitter's OWN vs-L/vs-R line, regressed toward HIS OWN overall rate), labelled on every adjustment. `league_generic` is deliberately NOT implemented. platoonSplits already handles thin evidence by regressing toward the hitter's own rate, which covers the thin case per-player; its own doc-comment argues a hitter with no split evidence should get NO adjustment. A league split applied to such a hitter models the LEAGUE, not the player -- the doctrine breach the order itself names in the same step. Adding it would have produced more firing rows and a weaker signal. `archetype_x_archetype` is scoped, not built: it needs the opposing starter classified per game, which is real work and a separate order. The tier vocabulary is in place for it. Honest-absent on every join: no starter, no pitcher hand, or no batter hand -> NO matchup adjustment, never a fabricated neutral. A neutral multiplier produces no adjustment row at all. Holdout committed (scripts/matchup-axis-holdout.sql), filtered to matchup-carrying rows, and it keeps MATCHUP'S OWN nudge visible rather than only the combined challenger -- arch-v1 composes four axes into one p_win_challenger, so a combined-only view could not tell which axis earned the movement, or which one is dragging. Champion p_win, ranking, calibration, the armed invariant and the two accruing verdicts are untouched. Gates: 4,093 tests / 328 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- scripts/matchup-axis-holdout.sql | 52 +++++++++++++++++ src/services/challengerProjection.js | 3 +- src/services/environmentContext.js | 56 +++++++++++++++++- tests/unit/matchupAxis.test.js | 86 ++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 scripts/matchup-axis-holdout.sql create mode 100644 tests/unit/matchupAxis.test.js diff --git a/scripts/matchup-axis-holdout.sql b/scripts/matchup-axis-holdout.sql new file mode 100644 index 0000000..8c7a80a --- /dev/null +++ b/scripts/matchup-axis-holdout.sql @@ -0,0 +1,52 @@ +-- matchup-axis-holdout.sql — per-axis proof, RUN WHEN n IS ADEQUATE. +-- +-- Filtered to MATCHUP-CARRYING rows only. Including untouched rows would +-- dilute the comparison with rows where challenger === champion BY +-- CONSTRUCTION, biasing toward a false positive (the trap opportunity_drift +-- established). +-- +-- MATCHUP'S OWN CONTRIBUTION IS KEPT VISIBLE, not just the combined challenger: +-- arch-v1 composes archetype + environment + opportunity + matchup into ONE +-- p_win_challenger, so a combined-only view cannot tell which axis earned the +-- movement. `matchup_nudge` is pulled out of the adjustments array so the axis +-- can be judged on its own terms and, if it is the one dragging, shelved alone. +-- +-- BOTH reliability AND resolution must improve for the axis to promote. + +with rows_ as ( + select + l.game_date, l.id, + l.p_win::numeric champ, + l.p_win_challenger::numeric chal, + (l.outcome='hit')::int won, + (select (a->>'nudge')::numeric + from jsonb_array_elements(l.challenger_adjustments) a + where a->>'axis' = 'matchup' limit 1) matchup_nudge, + (select a->>'tier' + from jsonb_array_elements(l.challenger_adjustments) a + where a->>'axis' = 'matchup' limit 1) matchup_tier + from public.ledger_entries l + where l.sport='mlb' and l.user_id is null + and l.outcome in ('hit','miss') + and l.p_win is not null and l.p_win_challenger is not null + and l.challenger_adjustments::text like '%matchup%' +), +split as ( + select *, case when ntile(2) over (order by game_date, id) = 1 then 'train' else 'holdout' end split + from rows_ +), +b_champ as (select split, width_bucket(champ,0,1,10) bkt, count(*) n, avg(champ) pred, avg(won::numeric) actual from split group by 1,2), +b_chal as (select split, width_bucket(chal ,0,1,10) bkt, count(*) n, avg(chal ) pred, avg(won::numeric) actual from split group by 1,2) +select + s.split, + count(*) n, + count(distinct s.matchup_tier) tiers, + round(avg(abs(s.matchup_nudge))::numeric,4) mean_abs_matchup_nudge, + round((select sum(n*abs(pred-actual))/nullif(sum(n),0) from b_champ c where c.split=s.split),4) reliability_champion, + round((select sum(n*abs(pred-actual))/nullif(sum(n),0) from b_chal c where c.split=s.split),4) reliability_challenger, + round(corr(s.champ, s.won::numeric)::numeric,4) resolution_champion, + round(corr(s.chal , s.won::numeric)::numeric,4) resolution_challenger, + -- does the matchup nudge ITSELF point the right way? + round(corr(s.matchup_nudge, s.won::numeric)::numeric,4) matchup_nudge_vs_outcome, + round(avg(s.won::numeric),4) base_rate +from split s group by s.split order by s.split desc; diff --git a/src/services/challengerProjection.js b/src/services/challengerProjection.js index f958a50..65ba5a5 100644 --- a/src/services/challengerProjection.js +++ b/src/services/challengerProjection.js @@ -215,7 +215,8 @@ function adjust({ pWin, direction, statType, classification, environment, matchu total += n; adjustments.push({ axis: 'matchup', label: matchup.label || 'PLATOON', - tier: 'matchup', nudge: Math.round(n * 1000) / 1000, + // Which rung of the fallback ladder produced this read. + tier: matchup.tier || 'matchup', nudge: Math.round(n * 1000) / 1000, multiplier: Math.round(mMult * 1000) / 1000, batter_hand: matchup.batter_hand ?? null, pitcher_hand: matchup.pitcher_hand ?? null, diff --git a/src/services/environmentContext.js b/src/services/environmentContext.js index 1f523b8..d384ce7 100644 --- a/src/services/environmentContext.js +++ b/src/services/environmentContext.js @@ -124,6 +124,26 @@ async function buildContext(sport, deps = {}) { if (homeAbbr && awayPid) { oppPitcherByTeam.set(homeAbbr, awayPid); pitcherIds.add(awayPid); } if (awayAbbr && homePid) { oppPitcherByTeam.set(awayAbbr, homePid); pitcherIds.add(homePid); } } + // STATSAPI FALLBACK (2026-08-02). The self-origin route returned nothing in + // prod — measured oppPitcherByTeam = 0 — which alone kept the matchup axis + // at 0/634 rows. statsapi's own schedule hydrate carries probables at 29/30 + // team-sides, mirrors the fallback the schedule step above already uses, and + // costs one free request. + if (oppPitcherByTeam.size === 0) { + const day = (deps.today || new Date().toISOString().slice(0, 10)); + const sched2 = deps.probableSchedule + || await fetchJson(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${day}&hydrate=probablePitcher`, deps); + for (const dt of (sched2 && sched2.dates) || []) { + for (const g of dt.games || []) { + const homeAbbr = abbrOf(g.teams?.home?.team?.name); + const awayAbbr = abbrOf(g.teams?.away?.team?.name); + const homePid = num(g.teams?.home?.probablePitcher?.id); + const awayPid = num(g.teams?.away?.probablePitcher?.id); + if (homeAbbr && awayPid) { oppPitcherByTeam.set(homeAbbr, awayPid); pitcherIds.add(awayPid); } + if (awayAbbr && homePid) { oppPitcherByTeam.set(awayAbbr, homePid); pitcherIds.add(homePid); } + } + } + } } catch { /* platoon honest-absents */ } // ── 3. pitcher handedness: ONE batched statsapi call ──────────────────── @@ -139,6 +159,22 @@ async function buildContext(sport, deps = {}) { } catch { /* platoon honest-absents */ } } + // ── 3b. BATTER handedness (2026-08-02) ───────────────────────────────── + // Measured: `bats` was absent on 120/120 graded rows because it rode only + // on the statcast aggregate rows, which do not cover the slate. The season + // player list — already fetched and cached — carries `batSide` on + // 1342/1342, so this is a join, not a fetch. Switch-hitters ('S') are kept + // as-is: platoonSplits decides what to do with them, not this map. + const batsById = new Map(); + try { + const list = deps.seasonPlayers + || await fetchJson(`https://statsapi.mlb.com/api/v1/sports/1/players?season=${season}`, deps); + for (const p of (list && list.people) || []) { + const code = p.batSide?.code; + if (code && p.id != null) batsById.set(num(p.id), String(code).toUpperCase()); + } + } catch { /* batter hand honest-absents → matchup abstains */ } + // ── 4. weather forecast: ONE Open-Meteo call per HOME park ────────────── const homeAbbrs = new Set([...gameByTeam.values()].map((r) => r.homeAbbr).filter(Boolean)); const forecastByHome = new Map(); @@ -168,6 +204,7 @@ async function buildContext(sport, deps = {}) { sport: sp, applicable: true, games: gameByTeam.size / 2, venues_with_weather: forecastByHome.size, pitchers_with_hand: handById.size, opp_declared: oppPitcherByTeam.size, + batters_with_hand: batsById.size, }; /** @@ -211,7 +248,9 @@ async function buildContext(sport, deps = {}) { // MATCHUP = platoon split, hitter hand + opposing-SP hand. let matchup = null; - const batterHand = grade && grade.bats; + // Grade-supplied hand wins (statcast rows carry it when present); otherwise + // fall back to the season list join. Absent in both → the axis ABSTAINS. + const batterHand = (grade && grade.bats) || batsById.get(num(grade && grade.playerId)) || null; const oppPid = teamAbbr ? oppPitcherByTeam.get(teamAbbr) : null; const pitcherHand = oppPid != null ? handById.get(oppPid) : null; if (batterHand && pitcherHand && grade.playerId != null) { @@ -222,6 +261,19 @@ async function buildContext(sport, deps = {}) { if (e.multiplier !== 1) { matchup = { multiplier: e.multiplier, label: 'PLATOON', + // TIER LABEL (2026-08-02). The ladder is explicit so no consumer has + // to guess how strong a matchup read is: + // batter_own_split — the hitter's OWN vs-L/vs-R line, regressed + // toward HIS OWN overall rate. This is the only tier shipped. + // archetype_x_archetype — functional matchup vs the opposing + // starter's profile. Scoped, NOT built (see the report). + // league_generic — deliberately NOT implemented: platoonSplits + // regresses thin evidence toward the hitter's own rate, which + // already covers the thin case and does so per-player. A league + // split applied to a hitter with no split evidence models the + // LEAGUE, not the player — the doctrine breach the order itself + // names. + tier: 'batter_own_split', batter_hand: e.batter_hand, pitcher_hand: e.pitcher_hand, observed_pa: e.observed_pa, observed_weight: e.observed_weight, }; @@ -232,7 +284,7 @@ async function buildContext(sport, deps = {}) { return { environment, matchup }; }; - return { contextFor, stats, _internals: { gameByTeam, forecastByHome, handById, oppPitcherByTeam } }; + return { contextFor, stats, _internals: { gameByTeam, forecastByHome, handById, oppPitcherByTeam, batsById } }; } module.exports = { diff --git a/tests/unit/matchupAxis.test.js b/tests/unit/matchupAxis.test.js new file mode 100644 index 0000000..2ae867c --- /dev/null +++ b/tests/unit/matchupAxis.test.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * MATCHUP (platoon) axis — 2026-08-02. + * + * It was wired and firing on 0/634 prod rows. Three separate absences kept it + * silent: oppPitcherByTeam 0, handById 0, bats 0/120. These lock the joins that + * fixed each, and the honest-absent behaviour when any one is missing. + */ + +const env = require('../../src/services/environmentContext'); +const { adjust } = require('../../src/services/challengerProjection'); + +const GAME = { + teams: { + home: { team: { name: 'Philadelphia Phillies' }, probablePitcher: { id: 111 } }, + away: { team: { name: 'New York Mets' }, probablePitcher: { id: 222 } }, + }, +}; +const SCHED = { dates: [{ games: [GAME] }] }; +const PEOPLE = { people: [{ id: 111, pitchHand: { code: 'L' } }, { id: 222, pitchHand: { code: 'R' } }] }; +const PLAYERS = { people: [{ id: 900, batSide: { code: 'L' } }, { id: 901, batSide: { code: 'S' } }] }; + +const deps = (over = {}) => ({ + origin: '', + schedule: { games: [] }, + pitchers: { games: [] }, // self-origin route returns nothing, as in prod + probableSchedule: SCHED, + people: PEOPLE, + seasonPlayers: PLAYERS, + fetchJson: async () => { throw new Error('no network in tests'); }, + ...over, +}); + +describe('buildContext — the three joins that were absent', () => { + it('falls back to the statsapi probable-pitcher hydrate when the self-origin route is empty', async () => { + const ctx = await env.buildContext('mlb', deps()); + expect(ctx._internals.oppPitcherByTeam.get('PHI')).toBe(222); // opposing = away SP + expect(ctx._internals.oppPitcherByTeam.get('NYM')).toBe(111); + expect(ctx.stats.opp_declared).toBe(2); + }); + + it('resolves pitcher handedness for the declared starters', async () => { + const ctx = await env.buildContext('mlb', deps()); + expect(ctx._internals.handById.get(222)).toBe('R'); + expect(ctx.stats.pitchers_with_hand).toBe(2); + }); + + it('joins BATTER handedness off the cached season player list', async () => { + const ctx = await env.buildContext('mlb', deps()); + expect(ctx._internals.batsById.get(900)).toBe('L'); + expect(ctx._internals.batsById.get(901)).toBe('S'); // switch-hitters preserved + expect(ctx.stats.batters_with_hand).toBe(2); + }); + + it('ABSTAINS — no starter, no hand, or no batter hand produces NO matchup', async () => { + const noSp = await env.buildContext('mlb', deps({ probableSchedule: { dates: [] } })); + const r1 = await noSp.contextFor({ stat_type: 'hits', team: 'PHI', playerId: 900 }); + expect(r1.matchup).toBeNull(); + + const ctx = await env.buildContext('mlb', deps()); + // known team + known starter, but the batter's hand is unknown + const r2 = await ctx.contextFor({ stat_type: 'hits', team: 'PHI', playerId: 999 }); + expect(r2.matchup).toBeNull(); + }); +}); + +describe('the fallback ladder is LABELLED, and never invents a higher rung', () => { + it('a firing matchup carries its tier through to the challenger adjustment', () => { + const r = adjust({ + pWin: 0.55, direction: 'over', statType: 'hits', + matchup: { multiplier: 0.95, label: 'PLATOON', tier: 'batter_own_split', batter_hand: 'L', pitcher_hand: 'R' }, + }); + const m = r.adjustments.find((a) => a.axis === 'matchup'); + expect(m.tier).toBe('batter_own_split'); + expect(r.p_win_challenger).toBeLessThan(0.55); + }); + + it('a neutral multiplier produces NO adjustment — never a fabricated zero-nudge row', () => { + const r = adjust({ + pWin: 0.55, direction: 'over', statType: 'hits', + matchup: { multiplier: 1, label: 'PLATOON', tier: 'batter_own_split' }, + }); + expect(r.adjustments.find((a) => a.axis === 'matchup')).toBeUndefined(); + }); +});