Audit finds env/matchup axes DEAD in prod; fix the environment join

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
This commit is contained in:
Kev
2026-08-01 04:05:43 -04:00
parent c9d56d5668
commit 4435856f46
3 changed files with 66 additions and 1 deletions
+11 -1
View File
@@ -176,7 +176,17 @@ async function buildContext(sport, deps = {}) {
* playerId. Anything missing → that half is null.
*/
const contextFor = async (grade) => {
const teamAbbr = abbrOf(grade && grade.team);
// JOIN ON THE GAME FIRST (2026-08-01). This used to key ONLY off the
// player's roster team, which is null on the graded slate — measured at
// 0/416 — so the environment axis could never resolve a venue.
//
// The park and the weather belong to the GAME, so the game's own teams are
// both the more reliable key and the more correct one: they ride on the
// prop from the odds feed and do not depend on a stats-resolve that can
// legitimately fail. The roster team stays as a fallback.
const teamAbbr = abbrOf(grade && grade.team)
|| abbrOf(grade && grade.home_team)
|| abbrOf(grade && grade.away_team);
const game = teamAbbr ? gameByTeam.get(teamAbbr) : null;
const stat = grade && (grade.stat_type || grade.stat);
+11
View File
@@ -126,6 +126,17 @@ async function gradeBestSide(grade, prop, sport, opts = {}) {
// Strip the internal retention fields so they never reach a cache or payload.
delete winner._features;
delete winner._grade_11;
// CARRY THE GAME (2026-08-01). The legacy grade shape drops home/away, so by
// the time the challenger runs, nothing on the grade says WHICH GAME it is —
// measured: `team` was null on 416/416 stored grades, so the park/weather
// resolver could never find a venue and the environment axis fired on ZERO
// rows while all 14 weather forecasts sat resolved and unused.
//
// The park depends on the GAME, not on the player's roster team, so binding
// the game directly is both the fix and the more correct join: it does not
// depend on a stats-resolve that can legitimately fail.
if (winner.home_team == null && prop.home_team != null) winner.home_team = prop.home_team;
if (winner.away_team == null && prop.away_team != null) winner.away_team = prop.away_team;
return winner;
}
+44
View File
@@ -122,3 +122,47 @@ describe('attachChallenger — reads opportunity off the grade (zero extra I/O)'
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();
});
});