# arch-v1 AXIS AUDIT — env/matchup WERE DEAD; ENVIRONMENT FIXED **Date:** 2026-08-01 · champion `p_win`, ranking, calibration and `opportunity_drift`'s accruing verdict all **untouched**. **Gates:** 4,077 tests / 326 suites green · `next build` exit 0 · audit run on prod · resolver verified live. --- ## STEP 0 — THE AUDIT: "already partly live" was half true **The code is wired. The axes were not firing.** Across **634 graded prod rows**, `challenger_adjustments` axis distribution: | axis | rows | % of slate | |---|---:|---:| | *(no axis fired)* | 296 | 46.7% | | **opportunity** *(new, ours)* | 142 | 22.4% | | power | 80 | 12.6% | | swing_miss | 69 | 10.9% | | contact | 56 | 8.8% | | launch · line_drive · patience · strikeout · contact_allowed · aggression · chase · velocity · ground_ball · wild | 51→1 | — | | **environment (park/weather)** | **0** | **0%** | | **matchup (platoon)** | **0** | **0%** | Every axis that fired is an **archetype trait**. The two condition axes this order is about fired on **nothing**. The ledger confirms it from the other side: `env_multiplier`, `env_park_base`, `env_weather_mod`, `wx_forecast`, `env_weather_state` — **all null on 634/634**. ### Root cause, located rather than inferred `buildContext` works fine — **15 games, 14 with weather, Coors Field composing to 1.241**. The failure is downstream. A drop-off audit against the live snapshot: | hop | count | |---|---:| | grades examined | 120 | | **with a `team` field** | **0** | | team resolves to an abbr | 0 | | abbr matches a game | 0 | | **environment produced** | **0** | | with `playerId` | 120 | | with `bats` | **0** | | opposing pitcher known | **0** | **`team` is a key on every stored grade and NULL on 416/416.** The resolver keyed off the player's roster team, so it could never find a venue — while `buildContext` sat there with all 30 teams mapped and 14 weather forecasts resolved and unused. **Nothing was broken in the park or weather code. The join key was absent.** *(Side consequence worth noting: the S59 slate JOIN INVARIANT also keys off this same `team` field, so it is currently inert too.)* --- ## STEP 1 — THE ENVIRONMENT FIX (and it is the more correct join) **The park and the weather belong to the GAME, not to the player's roster team** — and the game rides on the prop from the odds feed, where a stats-resolve can legitimately fail. 1. **`gradeSlateService.gradeBestSide`** now carries `home_team`/`away_team` onto the graded row. The legacy grade shape dropped them. 2. **`environmentContext.contextFor`** joins on the game first (`team → home_team → away_team`), keeping the roster team as a fallback. ### Verified live | | before | after | |---|---:|---:| | grades carrying `home_team` | 0 / 416 | **416 / 416** | | **environment resolves** | **0 / 120** | **105 / 120 (87.5%)** | Coors Field composes to **1.241** (park 1.20 × weather 1.034) the moment it gets a key. ### One honest limit on what is confirmed **The axis has not yet been observed writing to the ledger, and I am not claiming it has.** `recordPipelineGrades` upserts with `ignoreDuplicates: true` and dedupes on `(user_id, player_key, stat, line, side, game_id)` — correctly, so a re-run never overwrites the original lock. Today's 429 rows were written **before** the fix, so the environment axis **cannot** backfill onto them. ``` game_date 2026-08-01 — 429 rows: opportunity axis 142, environment axis 0 ``` **First ledger observation: tomorrow's slate.** What *is* directly verified is the resolver (105/120) and the join key (416/416), which are the two things that were broken. --- ## MATCHUP / PLATOON — NOT FIXED, AND NOT CLAIMED AS FIXED It needs the opposing starter and **both** hands. The audit shows **three separate absences**: ``` oppPitcherByTeam 0 (the probable-pitcher fetch returns nothing) handById 0 (so no pitcher handedness) with_bats 0/120 (no hitter handedness on the slate) ``` Per the order's own guard — *one axis at a time* — that is its **own order with its own diagnosis**, not a second fix smuggled into this one. Fixing the pitcher feed without the hands, or the hands without the feed, would still produce an axis that fires on zero rows. --- ## STEP 2 — HOLDOUTS: BOTH AXES ARE n-BLOCKED | axis | settled rows carrying it | earliest proof | |---|---:|---| | `opportunity` | **0** | first settle pass on 2026-08-01 games | | `environment` | **0** | first ledger write on tomorrow's slate | Neither can be proven today, and **a holdout filtered to axis-carrying rows cannot be run when that set is empty**. The committed query (`scripts/opportunity-axis-holdout.sql`) already filters that way, for the reason drift established: including untouched rows dilutes the comparison with rows where challenger ≡ champion **by construction, biasing toward a false positive.** **Both accrue in parallel on the same harness. Neither promotes until its own holdout says so.** --- ## WHAT IS UNTOUCHED Champion `p_win`, the live grade path, ranking, calibration, and `opportunity_drift`'s accruing verdict. The environment axis writes only to `p_win_challenger` / `challenger_adjustments`. ## TAGS **VERIFIED:** env + matchup axes fired on 0/634 prod rows · 13 archetype axes fire normally · `team` null on 416/416 · `buildContext` healthy (15 games, 14 weather) · post-fix environment resolves 105/120 and `home_team` present 416/416 · matchup blocked by three separate absences. **NOT YET OBSERVED:** the environment axis writing to the ledger — blocked by same-day dedupe, first visible on tomorrow's slate.