# CONNECT PROJECTION LAYERS — STEP 0: OPPORTUNITY/USAGE INPUT CHECK **Date:** 2026-08-01 · **READ-ONLY** · live grade path byte-identical · probe committed (`src/services/featureCoverage.js`, `GET /api/internal/feature-coverage`). --- ## VERDICT: STOPPED AT STEP 0 — AND THE REASON IS THE DELIVERABLE **Inputs are 100% populated. The layer still should not be wired as specified**, for four reasons the input check surfaced. Three of them would have made the work either unmeasurable or wrong. --- ## 1. INPUT COVERAGE (MLB, n=80 real props, through the grader's own path) | feature | populated | rate | |---|---:|---:| | `ab_per_game` | 80/80 | **100%** | | `rest_days` | 80/80 | 100% | | `l5_avg` | 80/80 | 100% | | `l20_avg` | 80/80 | 100% | | `l10_stddev` | 80/80 | 100% | | `game_count_in_7d` | 80/80 | 100% | | `opp_rank_stat` | 52/80 | 65% | | `minutes_per_game` / `usage_rate` | 0/80 | 0% *(NBA-only — correct for MLB)* | **No honest-degradation problem exists** for the opportunity input: there is nothing sparse to fall back from. The one real hole is `opp_rank_stat`, which is **0% for `stolen_bases`** (no opponent SB-defence rank) and ~84% elsewhere. --- ## 2. 🔴 THE PREMISE: there is no built opportunity layer to connect The order says the expensive layers are *"BUILT and DISCONNECTED"* and that this is *"CONNECTION, not construction."* **For opportunity, that is not the case.** `ab_per_game` is computed in `featureCache` and consumed in exactly one place — `analyzeViaEngine1:379`, which renders **"4.3 AB/G"** on the grade card. It is a **display field**. `engine1` has **no opportunity or usage factor at all**. **A projected opportunity — expected plate appearances with its own uncertainty — was never built.** §10.3 of MASTER-PLAN said exactly this and called it *"the single biggest modelling upgrade available."* Building it is construction. ## 3. 🔴 The available input is the wrong shape for the job `ab_per_game = season atBats ÷ games`. Two consequences: - **It is a per-player constant.** Measured: it varies across a player's own props for **3 of 20 players** (and those three are almost certainly a cache / refresh artifact, since the value cannot legitimately depend on `stat_type`). **A constant can only move all of a player's props together — it cannot separate them**, which is what a per-prop opportunity signal has to do. - **It is collinear with the projection that already exists.** `l20_avg` is `seasonTotal ÷ games` — the *same denominator*. For a batter, `hits/game ≈ (hits/AB) × (AB/game)`, so **`l20_avg` already embeds opportunity multiplicatively.** Adding `ab_per_game` as an independent additive factor double-counts it rather than adding information. **What is actually missing is tonight's deviation from the season baseline** — batting-order slot, a platoon sit, a role change. ## 4. 🔴 That input does not exist in any wired source - `depthChartService.getLineup` returns, for MLB, **only the probable pitcher**, with `battingOrder: null`. Its own comment: *"the one lineup slot the free schedule feed exposes."* - PropLine `/context` (free, verified this session) carries `lineup_confirmed` — **a boolean**, not the order. **Tonight's batting order, the actual driver of MLB plate appearances, is not available from anything we have wired.** ## 5. 🔴 ARCHITECTURE: wiring it into `engine1` would be unmeasurable by this order's own test Step 2 requires proving **reliability and resolution** improve. Both are measured on **`p_win`**. **`engine1` factors move the grade LETTER. They do not touch `p_win`.** `p_win` comes from `probabilityEstimator.estimateProbability`, which builds from `frequencyOver(gameLogs, line)` plus feature adjustments. So an opportunity factor added to `engine1` would produce a change that **Step 2 literally cannot measure**. The layer belongs in `probabilityEstimator` (or the challenger below), which *does* consume features — it already adjusts on `opp_rank_stat`, `home_away`, and a consistency pull off `l10_stddev/l20_avg`. --- ## 6. THE SEQUENCING ASSUMPTION IS STALE — matchup is already connected The order sequences *opportunity → matchup granularity (archetype × opponent, park/weather/platoon) → distribution ladder.* **`challengerProjection` (`arch-v1`) is already live in production** and already carries three axes: **archetype**, **matchup (platoon)** and **environment (park)**. It writes `p_win_challenger`, `challenger_delta`, `challenger_adjustments` and `challenger_version` to the ledger on every graded prop. So step 2 of the sequence is **partly done**, and — more usefully — **the harness this order needed already exists.** Any new axis should be added there, not invented. --- ## WHAT I RECOMMEND INSTEAD (its own order, per "one layer at a time") **An `opportunity` axis on `challengerProjection`, driven by opportunity DRIFT rather than by the season level:** ``` opportunity_drift = recent AB/G (last 5) ÷ season AB/G ``` - **>1** — batting higher / playing more than his baseline → lean over - **<1** — reduced role, platoon, lower slot → lean under - **null** — no at-bat data → **no adjustment**, challenger ≡ champion on that row This is a **deviation**, so it is not collinear with `l20_avg` the way the raw level is, and it *does* vary per player over time. **The input exists but is not extracted.** Per-game `atBats` is present in the statsapi game-log rows (the Session-56 box-score audit lists `atBats` among the batting fields), but `MLB_LOG_FIELD` has no entry for it and nothing computes a recent AB/G. That is a small, contained build — **and it is a build**, which is why it belongs in its own order rather than being smuggled into a connection order. **Honest caveat to carry into it:** this is still a *proxy* for tonight's opportunity, not tonight's opportunity. The real input is the confirmed batting order, and that needs a lineup source we do not have. --- ## WHAT DID NOT HAPPEN, DELIBERATELY No layer was wired. **The live grade path is byte-identical.** No threshold moved, no challenger was added, no holdout was run — running one would have measured a change that could not have occurred. ## A PROBE BUG WORTH RECORDING The first coverage run reported **0% for every feature, including `l5_avg`** — on a pipeline that had just graded 365 props, which `projectionFor` cannot do without a positive `l5_avg` or `l20_avg`. **Impossible, therefore the probe was wrong.** Two bugs, both mine: `featureCache.getFeatures` takes **camelCase** (`playerName`/`statType`) and I passed the prop's snake_case shape; and it returns **`{ features: {...} }`** while I read the top level. Either alone yields all zeros. Fixed by calling `computeFeaturesForProp` — the grader's own entry point. Same class as the earlier harness that returned a silent `false`: **a measurement that makes working code look broken is more dangerous than no measurement**, because it invites you to "fix" something that was never broken. ## TAGS **VERIFIED:** `ab_per_game` 100% populated · per-player constant (varies for 3/20) · `opp_rank_stat` 0% on stolen_bases · engine1 has no opportunity factor · `ab_per_game` is display-only · MLB batting order unavailable in depthChart and `/context` · `probabilityEstimator` consumes features, `engine1` does not affect `p_win` · `challengerProjection` arch-v1 already carries archetype/matchup/ environment. **CORRECTED:** "the opportunity layer is built and disconnected" — it is not built. "Connect opportunity, then matchup" — matchup is already connected.