Build opportunity_drift axis on challengerProjection (arch-v1)
Champion p_win and the live grade path are BYTE-IDENTICAL: the axis writes only to p_win_challenger / challenger_adjustments in the ledger. STEP 1 -- MAP THE INPUT. MLB_LOG_FIELD now maps at_bats -> 'atBats'. Deliberately NOT added to outcomeService's map or liveTracking's LIVE_BOX_FIELD: those exist to SETTLE and TRACK graded props, and nothing grades at-bats, so adding it there would imply a settlement path for a market we do not carry. A test asserts the settle map still lacks it. STEP 2 -- DRIFT, NOT LEVEL. opportunity_drift = mean(last-5 atBats) / (season atBats / games). The LEVEL is collinear with l20_avg (same games denominator; hits/game ~= (hits/AB) x (AB/game)), so the projection already embeds it multiplicatively and adding it would double-count. A deviation from the player's own baseline is the part the projection does not contain. HONEST ABSENCE throughout: fewer than 3 at-bat rows, no at-bats in the logs, or no season baseline all leave drift UNDEFINED -- never 1.0 by default and never 0. Number(null) === 0 here would read as "zero at-bats", the strongest possible fade, invented from missing data. Four tests cover the absent paths. STEP 3 -- THE AXIS. opportunityNudge composes in the same log-odds space as park and platoon (log of a ratio), with two guards the measured axes do not need: a +/-10% DEADBAND (a rest day or a blowout can move a 5-game window without any role change) and a tighter cap (0.15 vs the environment's 0.30) so a noisy PROXY cannot outvote measured signals. Every adjustment carries is_proxy: true and proxy_for: 'confirmed_batting_order' so nothing downstream can mistake it for a lineup feed. The axis can stand ALONE -- without it the early return would gate opportunity off on exactly the thin-classification rows it is most likely to help. Zero extra I/O: analyzeViaEngine1 attaches drift from the feature vector it has already built, and attachChallenger reads it off the grade. Nothing re-fetches in a loop that runs over hundreds of props. COLLINEARITY GUARD added to the coverage probe: Pearson r of drift against l20_avg / l5_avg / ab_per_game, returning null under n=8 rather than reporting a correlation on a handful of rows. If drift just re-encodes the projection, the axis is dead signal and gets shelved. Gates: 4,073 tests / 326 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
This commit is contained in:
@@ -384,6 +384,18 @@ function buildIntelFields(features = {}, opts = {}) {
|
||||
if (matchup) out.matchup_grade = matchup;
|
||||
|
||||
if (Number.isFinite(features.rest_days)) out.rest = features.rest_days === 0 ? 'B2B' : `${features.rest_days}d rest`;
|
||||
|
||||
// OPPORTUNITY DRIFT (2026-08-01) — carried onto the grade so the challenger
|
||||
// can read it WITHOUT a second fetch. The feature is already computed here;
|
||||
// re-resolving it downstream would add per-prop I/O to a path that grades
|
||||
// hundreds of props in a tight loop.
|
||||
//
|
||||
// Raw numbers only — no display string. This is a model input, not a card
|
||||
// field, and rendering an unvalidated proxy as if it were a finding is the
|
||||
// thing we keep removing.
|
||||
if (Number.isFinite(features.opportunity_drift)) out.opportunity_drift = Math.round(features.opportunity_drift * 1000) / 1000;
|
||||
if (Number.isFinite(features.recent_ab_per_game)) out.recent_ab_per_game = round1(features.recent_ab_per_game);
|
||||
if (Number.isFinite(features.ab_per_game)) out.ab_per_game = round1(features.ab_per_game);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user