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;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,16 @@ const MLB_LOG_FIELD = {
|
||||
innings_pitched: 'inningsPitched',
|
||||
// Session 56 audit — real boxscore/game-log fields (Braves@Pirates verified).
|
||||
doubles: 'doubles', triples: 'triples', outs: 'outs',
|
||||
// OPPORTUNITY INPUT (2026-08-01) — at-bats is NOT a gradeable stat_type and no
|
||||
// market exists for it. It is mapped here only so `mlbStatValue` can read
|
||||
// per-game at-bats out of a log row for the opportunity-drift axis.
|
||||
//
|
||||
// DELIBERATELY NOT ADDED to the other two MLB maps (outcomeService's
|
||||
// MLB_LOG_FIELD, liveTrackingService's LIVE_BOX_FIELD). Those exist to SETTLE
|
||||
// and to TRACK graded props; nothing grades at-bats, so adding it there would
|
||||
// imply a settlement path for a market we do not carry. The three-map split
|
||||
// is intentional — see CLAUDE.md.
|
||||
at_bats: 'atBats',
|
||||
};
|
||||
|
||||
function mlbStatValue(statObj, statType) {
|
||||
@@ -153,6 +163,42 @@ function mlbGameLogFeatures(res, statType) {
|
||||
if (Number.isFinite(ab) && Number.isFinite(games) && games > 0) {
|
||||
out.ab_per_game = ab / games;
|
||||
}
|
||||
|
||||
// OPPORTUNITY DRIFT (2026-08-01) — recent at-bats per game against the
|
||||
// player's OWN season baseline.
|
||||
//
|
||||
// opportunity_drift = mean(last 5 games' atBats) / (season atBats / games)
|
||||
//
|
||||
// WHY A RATIO AND NOT THE LEVEL: `l20_avg` is seasonTotal/games — the SAME
|
||||
// denominator as `ab_per_game` — so for a batter
|
||||
// hits/game ~= (hits/AB) x (AB/game), and the projection ALREADY embeds the
|
||||
// opportunity LEVEL multiplicatively. Adding that level as another input
|
||||
// double-counts it. A deviation from the player's own baseline is the part
|
||||
// the projection does not already contain.
|
||||
//
|
||||
// > 1 batting higher / playing more than his baseline
|
||||
// < 1 reduced role, platoon, lower slot
|
||||
//
|
||||
// HONEST ABSENCE: no at-bat rows, or no season baseline, leaves this
|
||||
// UNDEFINED. It is never 1.0-by-default and never 0 — `Number(null) === 0`
|
||||
// here would read as "no opportunity at all", the strongest possible signal,
|
||||
// from missing data.
|
||||
//
|
||||
// THIS IS A PROXY. The real driver of plate appearances is tonight's
|
||||
// confirmed batting order, which no wired source exposes (depthChartService
|
||||
// returns battingOrder: null for MLB; PropLine /context carries only a
|
||||
// lineup_confirmed boolean). Replace this with the real slot when a lineup
|
||||
// feed exists — do not present it as one.
|
||||
const abRows = logs.map((g) => mlbStatValue(g.stat, 'at_bats')).filter((v) => v != null);
|
||||
if (abRows.length >= 3) {
|
||||
const recentAb = avg(abRows.slice(-5));
|
||||
if (recentAb != null) {
|
||||
out.recent_ab_per_game = recentAb;
|
||||
if (Number.isFinite(out.ab_per_game) && out.ab_per_game > 0) {
|
||||
out.opportunity_drift = recentAb / out.ab_per_game;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -521,6 +567,9 @@ module.exports = {
|
||||
statFromGameLog,
|
||||
mlbGameLogFeatures,
|
||||
mlbStatValue,
|
||||
// Exported so the opportunity-input test can assert at_bats is mapped HERE
|
||||
// and deliberately NOT in the settlement map (the three-map split).
|
||||
MLB_LOG_FIELD,
|
||||
nbaGameLogFeatures,
|
||||
NBA_LOG_FIELD,
|
||||
avg,
|
||||
|
||||
Reference in New Issue
Block a user