Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source
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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// Wave 0 — ESPN per-athlete gamelog parser + resolver (the NBA/WNBA grade
|
||||
// unlock's free source). Hermetic: fetch is injected, no network.
|
||||
|
||||
// Redis is a no-op here so the cache read/write paths don't touch a live server.
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => {},
|
||||
cacheDel: async () => {},
|
||||
}));
|
||||
|
||||
const espn = require('../../src/services/adapters/espnStatsAdapter');
|
||||
|
||||
// Real ESPN NBA gamelog shape (verified live 2026-07-13): a per-response
|
||||
// `names[]` array + seasonTypes[].categories[].events[{eventId, stats[]}], with
|
||||
// dates/opponents in a top-level `events` map keyed by eventId. Column order is
|
||||
// indexed by name token — NBA and WNBA differ, so this is not positional.
|
||||
const NBA_NAMES = [
|
||||
'minutes', 'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct',
|
||||
'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct',
|
||||
'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'totalRebounds',
|
||||
'assists', 'blocks', 'steals', 'fouls', 'turnovers', 'points',
|
||||
];
|
||||
// min FG FG% 3PT 3P% FT FT% REB AST BLK STL PF TO PTS
|
||||
const G_OLD = ['32', '9-19', '47.4', '1-4', '25.0', '4-4', '100', '8', '5', '0', '2', '3', '2', '23'];
|
||||
const G_MID = ['36', '10-20', '50.0', '3-7', '42.9', '5-6', '83.3', '10', '7', '1', '1', '2', '4', '28'];
|
||||
const G_NEW = ['40', '8-18', '44.4', '2-6', '33.3', '6-8', '75.0', '12', '3', '1', '0', '1', '4', '24'];
|
||||
|
||||
const NBA_FIXTURE = {
|
||||
names: NBA_NAMES,
|
||||
labels: ['MIN', 'FG', 'FG%', '3PT', '3P%', 'FT', 'FT%', 'REB', 'AST', 'BLK', 'STL', 'PF', 'TO', 'PTS'],
|
||||
seasonTypes: [
|
||||
{
|
||||
displayName: '2025-26 Regular Season',
|
||||
categories: [
|
||||
{
|
||||
displayName: 'January',
|
||||
events: [
|
||||
{ eventId: 'E_OLD', stats: G_OLD },
|
||||
{ eventId: 'E_MID', stats: G_MID },
|
||||
{ eventId: 'E_NEW', stats: G_NEW },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
events: {
|
||||
E_OLD: { id: 'E_OLD', gameDate: '2026-01-01T00:30:00.000+00:00', atVs: '@', opponent: { abbreviation: 'BOS' } },
|
||||
E_MID: { id: 'E_MID', gameDate: '2026-01-03T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'GSW' } },
|
||||
E_NEW: { id: 'E_NEW', gameDate: '2026-01-05T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'HOU' } },
|
||||
},
|
||||
};
|
||||
|
||||
const V2_SEARCH = {
|
||||
results: [
|
||||
{
|
||||
type: 'player',
|
||||
contents: [
|
||||
{ uid: 's:40~l:46~a:1966', id: 'guid-abc', displayName: 'LeBron James', sport: 'basketball', defaultLeagueSlug: 'nba' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function fetchImplFor(searchPayload, gamelogPayload) {
|
||||
return async (url) => {
|
||||
if (url.includes('/search/v2')) return searchPayload;
|
||||
if (url.includes('/gamelog')) return gamelogPayload;
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => espn.__internals.gameLogMem.clear());
|
||||
|
||||
describe('parseGameLog (pure)', () => {
|
||||
it('normalizes per-game rows keyed by VYNDR stat names, most-recent first', () => {
|
||||
const rows = espn.parseGameLog(NBA_FIXTURE);
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows).toHaveLength(3);
|
||||
// most-recent first → E_NEW leads
|
||||
expect(rows[0].date).toBe('2026-01-05T00:30:00.000+00:00');
|
||||
expect(rows[0].opponent).toBe('HOU');
|
||||
expect(rows[0].isHome).toBe(true);
|
||||
expect(rows[0].stat).toMatchObject({
|
||||
points: 24, rebounds: 12, assists: 3, threes: 2, steals: 0, blocks: 1, turnovers: 4, minutes: 40,
|
||||
});
|
||||
// pra computed, not a raw column
|
||||
expect(rows[0].stat.pra).toBe(24 + 12 + 3);
|
||||
// oldest last, away game
|
||||
expect(rows[2].date).toBe('2026-01-01T00:30:00.000+00:00');
|
||||
expect(rows[2].isHome).toBe(false);
|
||||
});
|
||||
|
||||
it('indexes columns by the response names[] (WNBA order differs from NBA)', () => {
|
||||
const wnbaNames = [
|
||||
'minutes', 'points', 'totalRebounds', 'assists', 'steals', 'blocks', 'turnovers',
|
||||
'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct',
|
||||
'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct',
|
||||
'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'fouls',
|
||||
];
|
||||
// min PTS REB AST STL BLK TO FG FG% 3PT 3P% FT FT% PF
|
||||
const row = ['31', '20', '12', '2', '1', '2', '3', '9-23', '39.1', '2-5', '40.0', '2-2', '100.0', '2'];
|
||||
const wnbaFixture = {
|
||||
names: wnbaNames,
|
||||
seasonTypes: [{ categories: [{ events: [{ eventId: 'W1', stats: row }] }] }],
|
||||
events: { W1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } },
|
||||
};
|
||||
const rows = espn.parseGameLog(wnbaFixture);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].stat).toMatchObject({ points: 20, rebounds: 12, assists: 2, steals: 1, blocks: 2, turnovers: 3, threes: 2 });
|
||||
});
|
||||
|
||||
it('omits a stat ESPN did not report (absent, never 0)', () => {
|
||||
// Drop the points column entirely.
|
||||
const noPtsNames = NBA_NAMES.slice(0, -1); // remove 'points'
|
||||
const fixture = {
|
||||
names: noPtsNames,
|
||||
seasonTypes: [{ categories: [{ events: [{ eventId: 'X', stats: G_NEW.slice(0, -1) }] }] }],
|
||||
events: { X: { gameDate: '2026-01-05T00:30:00.000+00:00' } },
|
||||
};
|
||||
const rows = espn.parseGameLog(fixture);
|
||||
expect(rows[0].stat).not.toHaveProperty('points');
|
||||
expect(rows[0].stat).not.toHaveProperty('pra'); // pra needs all three → absent
|
||||
expect(rows[0].stat.rebounds).toBe(12);
|
||||
});
|
||||
|
||||
it('returns null on an unrecognized shape and never throws', () => {
|
||||
expect(espn.parseGameLog(null)).toBeNull();
|
||||
expect(espn.parseGameLog({})).toBeNull();
|
||||
expect(espn.parseGameLog({ names: [], seasonTypes: [] })).toBeNull();
|
||||
expect(espn.parseGameLog({ names: NBA_NAMES, seasonTypes: [] })).toBeNull();
|
||||
expect(() => espn.parseGameLog({ names: NBA_NAMES, seasonTypes: 'bad', events: 5 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAthleteId', () => {
|
||||
it('extracts the numeric athlete id from the v2 uid', async () => {
|
||||
const id = await espn.resolveAthleteId('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) });
|
||||
expect(id).toBe('1966');
|
||||
});
|
||||
|
||||
it('disambiguates by league (a WNBA query never returns the NCAA namesake)', async () => {
|
||||
const dual = {
|
||||
results: [{
|
||||
type: 'player',
|
||||
contents: [
|
||||
{ uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' },
|
||||
{ uid: 's:40~l:54~a:4412077', displayName: "A'Ja Wilson", defaultLeagueSlug: 'womens-college-basketball' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const id = await espn.resolveAthleteId("A'ja Wilson", 'wnba', { fetchImpl: fetchImplFor(dual, NBA_FIXTURE) });
|
||||
expect(id).toBe('3149391');
|
||||
});
|
||||
|
||||
it('returns null when no player section matches', async () => {
|
||||
const id = await espn.resolveAthleteId('Nobody', 'nba', { fetchImpl: fetchImplFor({ results: [] }, NBA_FIXTURE) });
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerGameLog', () => {
|
||||
it('resolves → fetches → normalizes into { found, id, last10 }', async () => {
|
||||
const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) });
|
||||
expect(res.found).toBe(true);
|
||||
expect(res.id).toBe('1966');
|
||||
expect(res.last10).toHaveLength(3);
|
||||
expect(res.last10[0].stat.points).toBe(24);
|
||||
});
|
||||
|
||||
it('returns { found:false } for an unknown sport or empty name (no throw)', async () => {
|
||||
await expect(espn.getPlayerGameLog('X', 'nfl')).resolves.toEqual({ found: false });
|
||||
await expect(espn.getPlayerGameLog('', 'nba')).resolves.toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns { found:false } when the gamelog shape is unrecognized', async () => {
|
||||
const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, { garbage: true }) });
|
||||
expect(res.found).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user