Files
vyndr/specs/connect-opportunity-step0.md
builtbykev 8a02c75aec Step 0 input check: stop before wiring opportunity, and why
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
2026-08-01 02:40:46 -04:00

174 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.