8a02c75aec
READ-ONLY. Live grade path byte-identical -- no layer wired, no threshold
moved, no challenger added, no holdout run.
INPUTS ARE 100% POPULATED (n=80 real MLB props, through the grader's own
path): ab_per_game, rest_days, l5_avg, l20_avg, l10_stddev and
game_count_in_7d all 100%; opp_rank_stat 65% overall and 0% on
stolen_bases. So there is no honest-degradation problem to solve.
FOUR FINDINGS THAT STOP THE WIRING, three of which would have made the
work unmeasurable or wrong:
1. THE PREMISE IS WRONG. There is no built opportunity layer to connect.
ab_per_game is consumed in exactly one place -- analyzeViaEngine1:379,
which renders "4.3 AB/G" on the grade card. engine1 has NO opportunity
or usage factor at all. A projected opportunity was never built;
building one is construction, not connection.
2. THE INPUT IS THE WRONG SHAPE. ab_per_game = season atBats/games. It is
a per-player CONSTANT (measured: varies for 3 of 20 players, and those
cannot be legitimate since the value can't depend on stat_type), so it
can only move all of a player's props together, never separate them.
And it is collinear with the projection: l20_avg = seasonTotal/games,
the SAME denominator, so l20_avg already embeds opportunity
multiplicatively. Adding it additively double-counts.
3. THE REAL INPUT DOES NOT EXIST. depthChartService returns battingOrder:
null for MLB ("the one lineup slot the free schedule feed exposes") and
PropLine /context carries lineup_confirmed as a BOOLEAN, not the order.
4. ARCHITECTURE: wiring it into engine1 would be unmeasurable BY THIS
ORDER'S OWN TEST. Step 2 proves reliability and resolution, both
measured on p_win. engine1 factors move the grade LETTER and never
touch p_win. The layer belongs in probabilityEstimator, which already
adjusts on opp_rank_stat, home_away and a consistency pull.
SEQUENCING IS ALSO STALE: challengerProjection (arch-v1) is already live
with archetype, matchup (platoon) and environment (park) axes, writing
p_win_challenger to the ledger. Step 2 of the order's sequence is partly
done -- and the harness this order needed already exists.
RECOMMENDED INSTEAD, as its own order: an `opportunity` axis on that
harness driven by DRIFT, not level -- recent AB/G (last 5) over season
AB/G. A deviation is not collinear the way the level is. Per-game atBats
is present in the statsapi log rows but MLB_LOG_FIELD never maps it, so it
is a small contained BUILD, which is why it gets its own order. Honest
caveat carried forward: it is still a proxy, not tonight's opportunity.
PROBE BUG RECORDED: the first run reported 0% for every feature including
l5_avg, on a pipeline that had just graded 365 props -- impossible, so the
probe was wrong. getFeatures takes camelCase and returns { features: {} };
I passed snake_case and read the top level. Fixed to call
computeFeaturesForProp. Same class as the earlier silent-false harness: a
measurement that makes working code look broken invites you to "fix"
something that was never broken.
Gates: 4,059 tests / 325 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
174 lines
7.5 KiB
Markdown
174 lines
7.5 KiB
Markdown
# 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.
|