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:
Kev
2026-08-01 03:15:47 -04:00
parent 8a02c75aec
commit 092f8f09cd
5 changed files with 279 additions and 3 deletions
+67 -3
View File
@@ -121,7 +121,36 @@ function envNudge(env, dirSign) {
return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE);
}
function adjust({ pWin, direction, statType, classification, environment, matchup } = {}) {
/**
* OPPORTUNITY nudge (2026-08-01) — the drift ratio, in the same log-odds space
* as the environment and matchup nudges.
*
* `opportunity_drift` is recent-5 AB/G over the player's OWN season AB/G, so
* 1.0 is "exactly his baseline" = no signal = no nudge, exactly like a park
* multiplier of 1.0.
*
* DELIBERATELY SMALLER-CAPPED THAN THE ENVIRONMENT AXIS. Drift is a PROXY for
* tonight's batting order, not a measurement of it, and a 5-game window is
* noisy: a rest day, a pinch-hit appearance or a blowout can halve it without
* any change in role. `MAX_OPPORTUNITY_NUDGE` keeps a noisy proxy from
* outvoting the measured axes. A drift below the deadband is treated as noise
* and ignored outright.
*/
const OPPORTUNITY_SCALE = Number(process.env.OPPORTUNITY_NUDGE_SCALE) || 1.0;
const MAX_OPPORTUNITY_NUDGE = 0.15;
const OPPORTUNITY_DEADBAND = 0.10; // ignore drift within +/-10% of baseline
function opportunityNudge(drift, dirSign) {
const d = num(drift);
// Undefined drift MUST be a no-op. `Number(null) === 0` would read as
// "zero at-bats", the strongest possible fade, invented from missing data.
if (d == null || d <= 0) return 0;
if (Math.abs(d - 1) < OPPORTUNITY_DEADBAND) return 0;
const raw = Math.log(d) * OPPORTUNITY_SCALE * dirSign;
return clamp(raw, -MAX_OPPORTUNITY_NUDGE, MAX_OPPORTUNITY_NUDGE);
}
function adjust({ pWin, direction, statType, classification, environment, matchup, opportunity } = {}) {
const p = num(pWin);
const identical = (reason) => ({
p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION,
@@ -136,7 +165,12 @@ function adjust({ pWin, direction, statType, classification, environment, matchu
// what lets the instrument attribute them independently.
const matchupPresent = num(matchup && matchup.multiplier) != null
&& num(matchup.multiplier) !== 1;
if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent) {
// Opportunity can stand ALONE: a prop with no archetype, no park and no
// platoon but a real role change is still a prop the challenger has something
// to say about. Without this the axis would be silently gated off on exactly
// the thin-classification rows it is most likely to help.
const oppPresent = opportunityNudge(opportunity && opportunity.drift, 1) !== 0;
if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent && !oppPresent) {
return identical('archetype_absent_or_thin');
}
@@ -146,7 +180,7 @@ function adjust({ pWin, direction, statType, classification, environment, matchu
const map = ((classification && classification.sufficient)
? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]
: null) || {};
if (!Object.keys(map).length && !envPresent && !matchupPresent) return identical('stat_not_mapped');
if (!Object.keys(map).length && !envPresent && !matchupPresent && !oppPresent) return identical('stat_not_mapped');
// Direction: a trait that raises the stat raises P(over) and lowers P(under).
const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1;
@@ -154,6 +188,25 @@ function adjust({ pWin, direction, statType, classification, environment, matchu
const adjustments = [];
let total = 0;
// ── OPPORTUNITY (drift) — a PROXY for tonight's batting order ───────────
const oppNudge = opportunityNudge(opportunity && opportunity.drift, dirSign);
if (oppNudge) {
total += oppNudge;
const d = num(opportunity.drift);
adjustments.push({
axis: 'opportunity',
label: d > 1 ? 'ROLE UP' : 'ROLE DOWN',
tier: 'opportunity',
nudge: Math.round(oppNudge * 1000) / 1000,
drift: Math.round(d * 1000) / 1000,
recent_ab_per_game: opportunity.recent_ab_per_game ?? null,
season_ab_per_game: opportunity.season_ab_per_game ?? null,
// Never let a consumer mistake this for the real thing.
is_proxy: true,
proxy_for: 'confirmed_batting_order',
});
}
// ── MATCHUP (platoon) — independent of the environment ─────────────────
const mMult = num(matchup && matchup.multiplier);
if (mMult != null && mMult !== 1) {
@@ -247,6 +300,14 @@ async function attachChallenger(grades, classifyFor, contextFor) {
classification: cls,
environment: ctx.environment,
matchup: ctx.matchup,
// Opportunity rides ON THE GRADE (analyzeViaEngine1 attaches it from the
// feature vector it already built), so this adds zero I/O to a loop that
// runs over hundreds of props. `ctx.opportunity` can override for tests.
opportunity: ctx.opportunity || {
drift: g.opportunity_drift,
recent_ab_per_game: g.recent_ab_per_game ?? null,
season_ab_per_game: g.ab_per_game ?? null,
},
});
out.push({
...g,
@@ -271,6 +332,9 @@ module.exports = {
adjust,
attachChallenger,
CHALLENGER_VERSION,
opportunityNudge,
MAX_OPPORTUNITY_NUDGE,
OPPORTUNITY_DEADBAND,
NUDGE,
MAX_TOTAL_NUDGE,
BATTER_MAP,