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
7.5 KiB
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_avgisseasonTotal ÷ games— the same denominator. For a batter,hits/game ≈ (hits/AB) × (AB/game), sol20_avgalready embeds opportunity multiplicatively. Addingab_per_gameas 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.getLineupreturns, for MLB, only the probable pitcher, withbattingOrder: null. Its own comment: "the one lineup slot the free schedule feed exposes."- PropLine
/context(free, verified this session) carrieslineup_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.