diff --git a/specs/MASTER-PLAN.md b/specs/MASTER-PLAN.md index bbea355..4871479 100644 --- a/specs/MASTER-PLAN.md +++ b/specs/MASTER-PLAN.md @@ -78,6 +78,8 @@ multi-book data. | **soccer** | **settles** — ~15 competitions, 30d | "grades into a void" is a **$19/mo Pro-tier** problem, not a data problem | | **CLV + results feeds** | `/odds/closing` + `/movement` **redacted**; `/results` **403 `required_tier: hobby`**; `/exports/resolved-props` **403 `required_tier: pro`** | **verified on our keys** — plain tier exclusion, not a key or plan fault. **$9/mo** buys CLV + steam + results; **$19/mo** adds the 90-day settlement export | | **books SERVED** | 5 → **13**; props rendered **546 → 2,780** (5.1×); mean **4.22** books/prop | **AGGREGATOR widening is LIVE.** Of 2,234 newly-visible props, **31.2% carry a real non-DFS price**; **68.8% are DFS-only** — shown, tagged, never a market | +| **arch-v1 condition axes** | **env + matchup fired on 0/634 prod rows** — `team` was null on 416/416, so the venue join had no key. **Environment FIXED** (joins on the game; resolves 105/120). **Matchup still dead** — needs opposing SP + both hands, three separate absences | `specs/arch-v1-axis-audit.md` | +| **opportunity_drift** | built, orthogonal (r≈0), live as a challenger on 142 rows/slate | verdict n-blocked; `scripts/opportunity-axis-holdout.sql` | | **opportunity layer** | **NOT BUILT** — `ab_per_game` is display-only; engine1 has no usage factor; MLB batting order unavailable in every wired source | Step 0 stopped before wiring. Recommended instead: an `opportunity_drift` axis on the EXISTING `challengerProjection` (arch-v1). See `specs/connect-opportunity-step0.md` | | **MLB board size** | **7 → 365 graded props** (52×) in 114s | the 25-cap discarded 95.7% of the slate. Raised to 500 on measured cost. Refusals were **43.8% deliberate policy suppression**, not a data gap | | **model input** | **byte-identical** — 546 gradeable props, `v1_first_book` | `MODEL_BOOKS` gate in `dedupeProps` + `indexOddsProps`. Lifts only on the re-run | diff --git a/specs/arch-v1-axis-audit.md b/specs/arch-v1-axis-audit.md new file mode 100644 index 0000000..7294100 --- /dev/null +++ b/specs/arch-v1-axis-audit.md @@ -0,0 +1,150 @@ +# 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.