Files
vyndr/specs/connect-opportunity-step0.md
T
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

7.5 KiB
Raw Blame History

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_confirmeda 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.