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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-02 01:11:05 -04:00
parent 9fc17a4689
commit 9ebd77b68e
4 changed files with 194 additions and 3 deletions
+52
View File
@@ -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;