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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user