diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index e46b4c8..573e37b 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -29,6 +29,16 @@ const { getTeamInjuries } = require('./injuryParser'); const { getLineMovement } = require('./lineMovement'); const gameLogs = require('./gameLogService'); +/** + * How many games to request when asking for a player's history. + * + * A basketball season is 82 games; this is deliberately past it so a request + * never truncates a season into a "season rate". The window-bug class has cost + * this codebase four MLB paths and two basketball ones -- every instance was a + * fixed N standing in for a season. + */ +const SEASON_LOG_DEPTH = 100; + const VECTOR_TTL_SECONDS = 120; function avg(values) { @@ -225,7 +235,13 @@ function nbaGameLogFeatures(res, statType) { if (vals.length) { const m5 = avg(vals.slice(0, 5)); // most-recent first const m10 = avg(vals.slice(0, 10)); - const m20 = avg(vals.slice(0, 20)); + // SEASON, NOT TWENTY. `l20_avg` is the season per-game reference + // `projectionFor` reads, and slicing to 20 made it a twenty-game average + // wearing a season label -- the same defect as the MLB last10 bug + // (929fd81) and the MLB l20 bug (494c83c). The ESPN adapter now returns the + // full season log, so this takes all of it. Name kept: `l20_avg` is read in + // many places, and renaming it is a separate, wider change. + const m20 = avg(vals); const s10 = stddev(vals.slice(0, 10)); if (m5 != null) out.l5_avg = m5; if (m10 != null) out.l10_avg = m10; @@ -306,7 +322,11 @@ async function getStatRows(playerName, sport, statType) { // NBA/WNBA — Python service first (it's the richer source when it's up), // then the FREE ESPN per-athlete gamelog. Same order as gameLogFeatures. - const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20); + // SEASON DEPTH, NOT TWENTY. The count is a request PARAMETER, so asking for + // a season costs the same single call -- no extra request, no extra quota. + // The Python service is offline in prod, so this cannot be verified live; + // it is fixed now so basketball never launches on a twenty-game base rate. + const pyLogs = await gameLogs.getGameLogs(playerName, sp, SEASON_LOG_DEPTH); if (Array.isArray(pyLogs) && pyLogs.length) { // Python rows are already flat + most-recent-first. for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType)); @@ -354,7 +374,7 @@ async function gameLogFeatures(playerName, sport, statType) { } } - const logs = await gameLogs.getGameLogs(playerName, sport, 20); + const logs = await gameLogs.getGameLogs(playerName, sport, SEASON_LOG_DEPTH); // Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService) // is offline in prod, so `logs` is null and this branch used to return {} → diff --git a/src/services/intelligence/gameLogService.js b/src/services/intelligence/gameLogService.js index 095c9bc..5b5be94 100644 --- a/src/services/intelligence/gameLogService.js +++ b/src/services/intelligence/gameLogService.js @@ -26,7 +26,17 @@ function pythonPath(sport) { } } -async function getGameLogs(playerName, sport, count = 20) { +/** + * DEFAULT IS A SEASON, NOT TWENTY. + * + * A default of 20 meant any caller that omitted the count silently received a + * twenty-game window -- and every consumer of this function treats what it + * returns as the player's history. The window-bug class has cost six paths + * across two sports; a permissive default is how a seventh would arrive. + */ +const DEFAULT_LOG_DEPTH = 100; + +async function getGameLogs(playerName, sport, count = DEFAULT_LOG_DEPTH) { const path = pythonPath(sport); if (!path) return null; const cacheKey = `gamelogs:${sport}:${playerName}:${count}`; diff --git a/tests/unit/baseRateWindow.test.js b/tests/unit/baseRateWindow.test.js new file mode 100644 index 0000000..b2b72ea --- /dev/null +++ b/tests/unit/baseRateWindow.test.js @@ -0,0 +1,106 @@ +'use strict'; + +/** + * THE WINDOW GUARD — a fixed N must never stand in for a season. + * + * This class has now cost six paths across two sports: + * + * MLB getStatRows res.last10 -> a 10-game "season rate" + * MLB mlbGameLogFeatures res.last10 -> l20_avg was 10 games + * NBA espnStatsAdapter rows.slice(0, 20) -> every downstream rate capped at 20 + * NBA nbaGameLogFeatures vals.slice(0, 20) -> l20_avg was 20 games + * NBA getStatRows (python) getGameLogs(.., 20) -> a 20-game base rate + * NBA gameLogFeatures getGameLogs(.., 20) -> same + * + * Every one looked correct in isolation. `slice(0, 20)` is unremarkable code; + * what made it a defect was the QUESTION it was answering — "what is this + * player's season rate?" — and no test could see that mismatch, because the + * value it produced was always a plausible number. + * + * So this asserts the property directly on the source: the base-rate and + * season-reference paths must not narrow to a fixed window. Basketball is + * OFFLINE, which is exactly why it is guarded now — a regression there would + * otherwise surface only when it had already served grades. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +/** Body of a named function, for targeted assertions. */ +function fnBody(src, name) { + const i = src.indexOf(`function ${name}(`); + if (i < 0) return null; + let depth = 0; let started = false; + for (let j = i; j < src.length; j += 1) { + if (src[j] === '{') { depth += 1; started = true; } + else if (src[j] === '}') { depth -= 1; if (started && depth === 0) return src.slice(i, j + 1); } + } + return null; +} + +describe('no fixed window may stand in for a season', () => { + const featureCache = read('src/services/intelligence/featureCache.js'); + + it('the season log depth is a named constant past a full season', () => { + // A literal 20 buried in a call is how every instance of this bug looked. + expect(featureCache).toMatch(/const SEASON_LOG_DEPTH = (\d+);/); + const depth = Number(/const SEASON_LOG_DEPTH = (\d+);/.exec(featureCache)[1]); + expect(depth).toBeGreaterThanOrEqual(82); // a basketball season + }); + + it('no game-log request asks for a hardcoded small count', () => { + const calls = [...featureCache.matchAll(/getGameLogs\([^)]*\)/g)].map((m) => m[0]); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) { + const literal = /,\s*(\d+)\s*\)/.exec(c); + if (literal) expect(Number(literal[1])).toBeGreaterThanOrEqual(82); + } + }); + + it('the BASKETBALL season reference (l20_avg) is not a 20-game slice', () => { + const body = fnBody(featureCache, 'nbaGameLogFeatures'); + expect(body).not.toBeNull(); + // The exact pre-fix line. It read as ordinary code and was the defect. + expect(body).not.toMatch(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/); + expect(body).toMatch(/m20\s*=\s*avg\(vals\)/); + }); + + it('the MLB season reference is a real season aggregate, not a slice', () => { + const body = fnBody(featureCache, 'mlbGameLogFeatures'); + expect(body).not.toBeNull(); + expect(body).toMatch(/l20_avg\s*=\s*seasonTotal\s*\/\s*games/); + }); + + it('the MLB base-rate path reads the full log, not last10', () => { + const body = fnBody(featureCache, 'getStatRows'); + expect(body).toMatch(/res\.fullLog/); + }); + + it('the ESPN adapter does not cap the parsed game log', () => { + const adapter = read('src/services/adapters/espnStatsAdapter.js'); + const body = fnBody(adapter, 'parseGameLog'); + expect(body).not.toBeNull(); + // `return rows.slice(0, 20)` capped every downstream basketball rate. + expect(body).not.toMatch(/return\s+rows\.slice\(/); + }); +}); + +describe('the guard would have caught the real defects', () => { + it('flags the exact pre-fix basketball slice', () => { + const preFix = 'const m20 = avg(vals.slice(0, 20));'; + expect(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/.test(preFix)).toBe(true); + }); + + it('flags the exact pre-fix ESPN cap', () => { + expect(/return\s+rows\.slice\(/.test(' return rows.slice(0, 20);')).toBe(true); + }); + + it('flags a hardcoded small game-log request', () => { + const preFix = 'await gameLogs.getGameLogs(playerName, sp, 20);'; + const literal = /,\s*(\d+)\s*\)/.exec(/getGameLogs\([^)]*\)/.exec(preFix)[0]); + expect(Number(literal[1])).toBeLessThan(82); // i.e. would fail the guard + }); +});