287c1c047a
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.
Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
numeric athlete id via the v2 search (the v3 /search now returns
count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
league), fetches the per-athlete gamelog, and parses per-game rows
keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
blocks/turnovers + computed pra). Columns are indexed by the
response's own names[] array (NBA and WNBA orders DIFFER), never
positionally. Most-recent first, defensive (null on unrecognized
shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
nba/wnba when the Python source returns null/empty, producing
l5/l10/l20 + rest_days + minutes_per_game via a new local
NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
separate from MLB_LOG_FIELD).
Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.
Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
4.8 KiB
JavaScript
104 lines
4.8 KiB
JavaScript
// Wave 0 — THE PROOF: an NBA/WNBA prop now GRADES instead of refusing.
|
|
//
|
|
// Root cause (pre-fix): featureCache's non-MLB branch called gameLogService
|
|
// (Python nba_api, offline in prod) → null logs → no l5/l20 → projectionFor
|
|
// null → insufficientDataResult → the whole slate refused. This test injects
|
|
// the Python source as NULL (offline) and the ESPN gamelog as the free fallback,
|
|
// and asserts real l5_avg/l20_avg emerge → projectionFor returns a projection.
|
|
|
|
// Python game-log service is OFFLINE (returns null) — the prod failure mode.
|
|
jest.mock('../../src/services/intelligence/gameLogService', () => ({
|
|
getGameLogs: async () => null,
|
|
getCareerPlayoffGames: async () => null,
|
|
getWithWithoutStats: async () => null,
|
|
}));
|
|
|
|
// ESPN gamelog fallback — a normalized result (as espnStatsAdapter would return).
|
|
const espnLog = { res: null };
|
|
jest.mock('../../src/services/adapters/espnStatsAdapter', () => ({
|
|
getPlayerGameLog: async () => espnLog.res,
|
|
}));
|
|
|
|
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
|
|
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
|
|
|
|
// Most-recent first, matching getPlayerGameLog's contract.
|
|
const lebronLog = {
|
|
found: true,
|
|
id: '1966',
|
|
last10: [
|
|
{ date: '2026-01-05T00:30:00Z', stat: { points: 24, rebounds: 12, assists: 3, threes: 2, minutes: 40, pra: 39 } },
|
|
{ date: '2026-01-03T00:30:00Z', stat: { points: 28, rebounds: 10, assists: 7, threes: 3, minutes: 36, pra: 45 } },
|
|
{ date: '2026-01-01T00:30:00Z', stat: { points: 23, rebounds: 8, assists: 5, threes: 1, minutes: 32, pra: 36 } },
|
|
{ date: '2025-12-29T00:30:00Z', stat: { points: 30, rebounds: 9, assists: 6, threes: 4, minutes: 38, pra: 45 } },
|
|
{ date: '2025-12-27T00:30:00Z', stat: { points: 21, rebounds: 11, assists: 8, threes: 2, minutes: 34, pra: 40 } },
|
|
{ date: '2025-12-25T00:30:00Z', stat: { points: 26, rebounds: 7, assists: 9, threes: 3, minutes: 37, pra: 42 } },
|
|
],
|
|
};
|
|
|
|
describe('nbaGameLogFeatures (pure)', () => {
|
|
it('derives l5/l10/l20 + rest + usage for an NBA points prop', () => {
|
|
const f = fc.nbaGameLogFeatures(lebronLog, 'points');
|
|
// l5 = mean of the 5 most-recent points (24,28,23,30,21) = 25.2
|
|
expect(f.l5_avg).toBeCloseTo((24 + 28 + 23 + 30 + 21) / 5, 5);
|
|
expect(f.l20_avg).toBeGreaterThan(0);
|
|
expect(f.minutes_per_game).toBeGreaterThan(0);
|
|
// consecutive games (2d apart) → 1d gap → 0 rest? dates are 2d apart → gap 2 → rest 1
|
|
expect(f.rest_days).toBe(1);
|
|
});
|
|
|
|
it('handles combo (pra) + threes via the log-field map', () => {
|
|
expect(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg).toBeGreaterThan(0);
|
|
expect(fc.nbaGameLogFeatures(lebronLog, 'threes').l5_avg).toBeGreaterThan(0);
|
|
// pts_reb_ast combo sums components → equals the precomputed pra
|
|
expect(fc.nbaGameLogFeatures(lebronLog, 'pts_reb_ast').l5_avg)
|
|
.toBeCloseTo(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg, 5);
|
|
});
|
|
|
|
it('returns {} for an unfound player or unmapped stat', () => {
|
|
expect(fc.nbaGameLogFeatures({ found: false }, 'points')).toEqual({});
|
|
expect(fc.nbaGameLogFeatures(lebronLog, 'not_a_stat')).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('gameLogFeatures — ESPN fallback when Python is offline', () => {
|
|
it('emits non-null l5_avg/l20_avg for nba (Python null → ESPN fallback)', async () => {
|
|
espnLog.res = lebronLog;
|
|
const f = await fc.gameLogFeatures('LeBron James', 'nba', 'points');
|
|
expect(f.l5_avg).not.toBeNull();
|
|
expect(f.l5_avg).toBeGreaterThan(0);
|
|
expect(f.l20_avg).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('works for wnba too', async () => {
|
|
espnLog.res = lebronLog; // shape-identical
|
|
const f = await fc.gameLogFeatures("A'ja Wilson", 'wnba', 'rebounds');
|
|
expect(f.l5_avg).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('degrades to {} when ESPN also has nothing (still no fabrication)', async () => {
|
|
espnLog.res = { found: false };
|
|
const f = await fc.gameLogFeatures('Ghost Player', 'nba', 'points');
|
|
expect(f).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('THE UNLOCK — projectionFor now returns a grade, not insufficient_data', () => {
|
|
it('empty features (the old prod state) → projection null → REFUSE', () => {
|
|
expect(eng.projectionFor({}, { stat_type: 'points', line: 20 })).toBeNull();
|
|
});
|
|
|
|
it('ESPN-derived features → finite projection → the prop GRADES', async () => {
|
|
espnLog.res = lebronLog;
|
|
const features = await fc.gameLogFeatures('LeBron James', 'nba', 'points');
|
|
const projection = eng.projectionFor(features, { stat_type: 'points', line: 24.5 });
|
|
expect(projection).not.toBeNull();
|
|
expect(Number.isFinite(projection)).toBe(true);
|
|
// and the intel card lights up off the same vector
|
|
const intel = eng.buildIntelFields(features);
|
|
expect(intel.season_avg).toBeDefined();
|
|
expect(intel.last10_avg).toBeDefined();
|
|
expect(intel.form).toBeDefined();
|
|
});
|
|
});
|