4435856f46
STEP 0 AUDIT -- the "already partly live" premise was half true: the CODE is wired, the axes are NOT firing. Across 634 graded prod rows the environment and matchup axes fired on ZERO rows, while 13 archetype axes fired normally (power 80, swing_miss 69, contact 56, launch 51, line_drive 43, ...) plus opportunity 142. Ledger confirms it from the other side: env_multiplier, env_park_base, env_weather_mod, wx_forecast and env_weather_state are ALL null on 634/634. ROOT CAUSE, located rather than inferred. A drop-off audit against the live snapshot: with_team_field 0/120, with_bats 0/120, with_playerId 120/120, oppPitcherByTeam 0, handById 0. `team` is a KEY on every stored grade and NULL on 416/416 -- so an environment resolver keyed off the player's roster team could never find a venue, while buildContext sat there with all 30 teams mapped and 14 weather forecasts resolved and unused. Coors composes to 1.241 the moment it gets a key. FIX -- and it is the more correct join, not just a workaround. The park and the weather belong to the GAME, not to the player's roster team, and the game rides on the prop from the odds feed. gradeBestSide now carries home_team/away_team onto the graded row (the legacy grade shape dropped them), and contextFor joins on the game first, keeping the roster team as a fallback. This no longer depends on a stats-resolve that can legitimately fail. MATCHUP/PLATOON IS NOT FIXED HERE and is not claimed as fixed: it needs the opposing starter and both hands, and the audit shows oppPitcherByTeam=0, handById=0 and bats=0 on the slate -- three separate absences. Per "one axis at a time" that is its own order with its own diagnosis, not a second fix smuggled into this one. Champion p_win, ranking, calibration and opportunity_drift's accruing verdict are all untouched. Gates: 4,077 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
169 lines
7.8 KiB
JavaScript
169 lines
7.8 KiB
JavaScript
'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);
|
|
});
|
|
});
|
|
|
|
describe('environment context — joins on the GAME, not the roster team', () => {
|
|
// Measured 2026-08-01: `team` was null on 416/416 stored grades, so an
|
|
// environment resolver keyed only off the roster team could never find a
|
|
// venue — the axis fired on ZERO rows while all 14 weather forecasts sat
|
|
// resolved and unused. The park belongs to the GAME, so the game's own teams
|
|
// are both the more reliable key and the more correct one.
|
|
const envCtx = require('../../src/services/environmentContext');
|
|
|
|
it('abbrOf resolves both a full team name and an abbreviation', () => {
|
|
expect(envCtx.abbrOf('Colorado Rockies')).toBe('COL');
|
|
expect(envCtx.abbrOf('COL')).toBe('COL');
|
|
});
|
|
|
|
it('a grade with no team AND no game resolves to nothing — never a default park', () => {
|
|
// A fabricated park factor would be a silent multiplier on every prop.
|
|
expect(envCtx.abbrOf(null)).toBeFalsy();
|
|
expect(envCtx.abbrOf(undefined)).toBeFalsy();
|
|
expect(envCtx.abbrOf('')).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('gradeSlateService — the graded row carries its game', () => {
|
|
it('binds home/away from the prop so the challenger can find the venue', async () => {
|
|
const { gradeBestSide } = require('../../src/services/gradeSlateService').__internals;
|
|
const grade = async (b) => ({
|
|
player: b.player, stat_type: b.stat_type, line: b.line,
|
|
direction: b.direction, grade: 'B', confidence: 60,
|
|
});
|
|
const out = await gradeBestSide(grade, {
|
|
player: 'A', stat_type: 'hits', line: 1.5, book: 'draftkings',
|
|
home_team: 'Colorado Rockies', away_team: 'Chicago Cubs',
|
|
}, 'mlb', {});
|
|
expect(out.home_team).toBe('Colorado Rockies');
|
|
expect(out.away_team).toBe('Chicago Cubs');
|
|
});
|
|
|
|
it('does not invent a game when the prop has none', async () => {
|
|
const { gradeBestSide } = require('../../src/services/gradeSlateService').__internals;
|
|
const grade = async (b) => ({ ...b, grade: 'B', confidence: 60 });
|
|
const out = await gradeBestSide(grade, { player: 'A', stat_type: 'hits', line: 1.5 }, 'mlb', {});
|
|
expect(out.home_team ?? null).toBeNull();
|
|
});
|
|
});
|