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:
@@ -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,
|
||||
|
||||
@@ -22,6 +22,8 @@ const DEFAULT_CONCURRENCY = 5;
|
||||
// they would sit alongside (so the report shows relative coverage, not an
|
||||
// isolated number that looks fine until you compare it).
|
||||
const TRACKED = Object.freeze([
|
||||
'opportunity_drift', // recent-5 AB/G / season AB/G — the new axis's input
|
||||
'recent_ab_per_game',
|
||||
'ab_per_game', // MLB "usage" — season atBats / games
|
||||
'rest_days',
|
||||
'l5_avg',
|
||||
@@ -97,6 +99,10 @@ async function coverage(opts = {}) {
|
||||
|
||||
const overall = {};
|
||||
const byStat = {};
|
||||
// COLLINEARITY GUARD — if drift just re-encodes what the projection already
|
||||
// has, the axis is dead signal and must be shelved rather than added as a
|
||||
// redundant input. Pearson r against each existing projection input.
|
||||
const pairs = { l20_avg: [], l5_avg: [], ab_per_game: [] };
|
||||
const distinctValuesPerPlayer = {}; // is the feature prop-specific or per-player constant?
|
||||
for (const key of TRACKED) overall[key] = 0;
|
||||
|
||||
@@ -109,6 +115,11 @@ async function coverage(opts = {}) {
|
||||
if (ok) overall[key] += 1;
|
||||
byStat[stat][key] = (byStat[stat][key] || 0) + (ok ? 1 : 0);
|
||||
}
|
||||
if (populated(f.opportunity_drift)) {
|
||||
for (const k of Object.keys(pairs)) {
|
||||
if (populated(f[k])) pairs[k].push([Number(f.opportunity_drift), Number(f[k])]);
|
||||
}
|
||||
}
|
||||
if (populated(f.ab_per_game)) {
|
||||
const pk = String(p.player).toLowerCase();
|
||||
distinctValuesPerPlayer[pk] = distinctValuesPerPlayer[pk] || new Set();
|
||||
@@ -126,9 +137,25 @@ async function coverage(opts = {}) {
|
||||
const multiPropPlayers = Object.values(distinctValuesPerPlayer).filter((s) => s.size > 0);
|
||||
const playersWithVaryingValue = multiPropPlayers.filter((s) => s.size > 1).length;
|
||||
|
||||
const pearson = (xy) => {
|
||||
const n = xy.length;
|
||||
if (n < 8) return null; // never report a correlation on a handful of rows
|
||||
const mx = xy.reduce((a, [x]) => a + x, 0) / n;
|
||||
const my = xy.reduce((a, [, y]) => a + y, 0) / n;
|
||||
let sxy = 0; let sxx = 0; let syy = 0;
|
||||
for (const [x, y] of xy) {
|
||||
sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2;
|
||||
}
|
||||
if (sxx <= 0 || syy <= 0) return null;
|
||||
return Math.round((sxy / Math.sqrt(sxx * syy)) * 1000) / 1000;
|
||||
};
|
||||
|
||||
return {
|
||||
read_only: true,
|
||||
sport,
|
||||
collinearity_guard: Object.fromEntries(Object.entries(pairs).map(([k, xy]) => [
|
||||
`drift_vs_${k}`, { n: xy.length, r: pearson(xy) },
|
||||
])),
|
||||
generated_at: new Date().toISOString(),
|
||||
sampled: batch.length,
|
||||
unique_gradeable: unique.length,
|
||||
|
||||
@@ -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