From 092f8f09cd2510740272a971fafcfac4bee1c63d Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 1 Aug 2026 03:15:47 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- src/services/challengerProjection.js | 70 +++++++++- src/services/featureCoverage.js | 27 ++++ .../intelligence/analyzeViaEngine1.js | 12 ++ src/services/intelligence/featureCache.js | 49 +++++++ tests/unit/opportunityDrift.test.js | 124 ++++++++++++++++++ 5 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 tests/unit/opportunityDrift.test.js diff --git a/src/services/challengerProjection.js b/src/services/challengerProjection.js index 347e4cb..f958a50 100644 --- a/src/services/challengerProjection.js +++ b/src/services/challengerProjection.js @@ -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, diff --git a/src/services/featureCoverage.js b/src/services/featureCoverage.js index e428b4d..caf3438 100644 --- a/src/services/featureCoverage.js +++ b/src/services/featureCoverage.js @@ -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, diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 57c05af..80108ad 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -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; } diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index a2aa18f..18530bc 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -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, diff --git a/tests/unit/opportunityDrift.test.js b/tests/unit/opportunityDrift.test.js new file mode 100644 index 0000000..b61a04b --- /dev/null +++ b/tests/unit/opportunityDrift.test.js @@ -0,0 +1,124 @@ +'use strict'; + +/** + * opportunity_drift — the axis, its input, and the guards that keep it honest + * (2026-08-01). + * + * The three properties that matter more than the arithmetic: + * 1. UNDEFINED drift is a NO-OP. `Number(null) === 0` would read as "zero + * at-bats" — the strongest possible fade — invented from missing data. + * 2. It is a RATIO, not a level. The level is collinear with l20_avg (same + * denominator), so using it would double-count opportunity the projection + * already contains. + * 3. It is labelled a PROXY. The real input is the confirmed batting order, + * which no wired source exposes. + */ + +const { __internals } = require('../../src/services/intelligence/featureCache'); +const { mlbGameLogFeatures, MLB_LOG_FIELD } = __internals; +const challenger = require('../../src/services/challengerProjection'); +const { adjust, opportunityNudge, OPPORTUNITY_DEADBAND, MAX_OPPORTUNITY_NUDGE } = challenger; + +const g = (atBats, hits) => ({ stat: { atBats, hits } }); +const season = (hits, atBats, games) => ({ hits, atBats, gamesPlayed: games }); + +describe('per-game at-bats is mapped for the OPPORTUNITY input only', () => { + it('MLB_LOG_FIELD maps at_bats to the real boxscore field', () => { + expect(MLB_LOG_FIELD.at_bats).toBe('atBats'); + }); + + it('is NOT added to the settlement map — nothing grades at-bats', () => { + // The three MLB maps are split on purpose (CLAUDE.md). Adding at_bats to + // the settle map would imply a settlement path for a market we don't carry. + const outcome = require('../../src/services/outcomeService'); + const settleMap = (outcome.__internals && outcome.__internals.MLB_LOG_FIELD) || {}; + expect(settleMap.at_bats).toBeUndefined(); + }); +}); + +describe('mlbGameLogFeatures — drift computation', () => { + const logs = [g(4, 1), g(4, 2), g(4, 1), g(2, 0), g(2, 1), g(2, 0), g(2, 1), g(2, 0)]; + + it('computes recent-5 AB/G over the season baseline', () => { + const out = mlbGameLogFeatures({ found: true, last10: logs, season: season(60, 300, 75) }, 'hits'); + expect(out.ab_per_game).toBe(4); // 300/75 + expect(out.recent_ab_per_game).toBe(2); // last five games are 2 AB each + expect(out.opportunity_drift).toBeCloseTo(0.5, 3); + }); + + it('is UNDEFINED when the logs carry no at-bats — never 1.0, never 0', () => { + const noAb = [{ stat: { hits: 1 } }, { stat: { hits: 2 } }, { stat: { hits: 0 } }]; + const out = mlbGameLogFeatures({ found: true, last10: noAb, season: season(60, undefined, 75) }, 'hits'); + expect(out.opportunity_drift).toBeUndefined(); + expect(out.recent_ab_per_game).toBeUndefined(); + }); + + it('is UNDEFINED without a season baseline to drift FROM', () => { + const out = mlbGameLogFeatures({ found: true, last10: logs, season: season(60, undefined, 75) }, 'hits'); + expect(out.recent_ab_per_game).toBe(2); // recent is knowable + expect(out.opportunity_drift).toBeUndefined(); // the ratio is not + }); + + it('needs at least 3 at-bat rows — two games is noise, not a role change', () => { + const out = mlbGameLogFeatures({ found: true, last10: [g(4, 1), g(4, 2)], season: season(60, 300, 75) }, 'hits'); + expect(out.opportunity_drift).toBeUndefined(); + }); +}); + +describe('opportunityNudge — the guards', () => { + it('an ABSENT drift is a no-op, not a maximal fade', () => { + for (const v of [null, undefined, '', NaN, 0, -1]) expect(opportunityNudge(v, 1)).toBe(0); + }); + + it('a drift inside the deadband is treated as noise', () => { + expect(opportunityNudge(1, 1)).toBe(0); + expect(opportunityNudge(1 + (OPPORTUNITY_DEADBAND / 2), 1)).toBe(0); + expect(opportunityNudge(1 - (OPPORTUNITY_DEADBAND / 2), 1)).toBe(0); + }); + + it('is capped so a noisy 5-game proxy cannot outvote the measured axes', () => { + expect(opportunityNudge(0.01, 1)).toBe(-MAX_OPPORTUNITY_NUDGE); + expect(opportunityNudge(100, 1)).toBe(MAX_OPPORTUNITY_NUDGE); + }); + + it('flips sign with direction — less opportunity helps the UNDER', () => { + expect(opportunityNudge(0.5, 1)).toBeLessThan(0); + expect(opportunityNudge(0.5, -1)).toBeGreaterThan(0); + }); +}); + +describe('adjust — the opportunity axis on the challenger', () => { + const base = { pWin: 0.55, direction: 'over', statType: 'hits' }; + + it('stands ALONE when there is no archetype, park or platoon', () => { + const r = adjust({ ...base, opportunity: { drift: 0.5, recent_ab_per_game: 2, season_ab_per_game: 4 } }); + expect(r.adjustments.map((a) => a.axis)).toEqual(['opportunity']); + expect(r.p_win_challenger).toBeLessThan(0.55); + }); + + it('labels itself a PROXY, so nothing downstream can mistake it for a lineup', () => { + const r = adjust({ ...base, opportunity: { drift: 1.6 } }); + expect(r.adjustments[0]).toMatchObject({ is_proxy: true, proxy_for: 'confirmed_batting_order', label: 'ROLE UP' }); + }); + + it('leaves the challenger IDENTICAL to the champion when drift is absent', () => { + const r = adjust({ ...base, opportunity: { drift: null } }); + expect(r.p_win_challenger).toBe(0.55); + expect(r.delta).toBe(0); + expect(r.adjustments).toEqual([]); + }); +}); + +describe('attachChallenger — reads opportunity off the grade (zero extra I/O)', () => { + it('uses the drift the grader already attached', async () => { + const out = await challenger.attachChallenger([ + { player: 'A', stat_type: 'hits', direction: 'over', p_win: 0.6, opportunity_drift: 0.62, recent_ab_per_game: 2.5, ab_per_game: 4 }, + { player: 'B', stat_type: 'hits', direction: 'over', p_win: 0.6 }, + ], () => null, null); + expect(out[0].challenger_delta).toBeLessThan(0); + expect(out[0].challenger_adjustments[0].axis).toBe('opportunity'); + // No drift on the grade => challenger is the champion, exactly. + expect(out[1].p_win_challenger).toBe(0.6); + expect(out[1].challenger_delta).toBe(0); + }); +});