-- 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. -- -- CONTAMINATION EXCLUSION (2026-08-02, MANDATORY). Rows whose price/book/takeable -- were stamped from a NON-TAKEABLE book (DFS / offshore / exchange) between -- 2026-08-01 and the write-path fix are tagged `quarantine_reason LIKE -- 'nontakeable_book%'`. They are EXCLUDED here and must never be pooled with -- clean rows: their locked price -- and therefore the `takeable` flag computed -- from it -- describes a market you could not have bet. 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.quarantine_reason is null or l.quarantine_reason not like 'nontakeable_book%') 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;