Fix the dormant basketball window-bug before it ships; guard the class

PHASE 0 — audit. espnStatsAdapter's slice(0,20) was already fixed at
494c83c, so the ESPN branch of getStatRows inherits a full log and is
CORRECT. MLB's l20_avg is a real seasonTotal/games aggregate, verified,
also CORRECT. TWO basketball defects remained:

  DEFECTIVE  nbaGameLogFeatures   m20 = avg(vals.slice(0, 20))
             -- l20_avg is the season reference projectionFor reads, and
             slicing to 20 made it a twenty-game average wearing a season
             label. The MLB l20 bug, unfixed for basketball.
  DEFECTIVE  getStatRows python   getGameLogs(playerName, sp, 20)

PHASE 1 — both fixed via a named SEASON_LOG_DEPTH = 100, past an 82-game
season so a request can never truncate one. API COST: ZERO. The count is a
request parameter, so asking for a season is the same single call. No
extra request, no extra quota.

MEASUREMENT DEFERRED, EXPLICITLY: basketball is offline, there are no
settled basketball rows, and resolution before/after CANNOT be measured
now. This is a code fix, not a measured improvement -- exactly like the
deferred MLB sibling paths.

PHASE 2 — the window guard asserts the property on source: no fixed N may
stand in for a season. It immediately caught TWO MORE instances I had
missed in Phase 1 -- a hardcoded 20 at featureCache:377 and
gameLogService's own `count = 20` DEFAULT, which would have handed a
twenty-game window to any caller that omitted the argument. That is a
seventh path, found by the guard rather than by me.

Retro-confirmed: run unchanged against 981a05c it goes 3 failed / 6
passed, flagging the basketball slice, the game-log request and the
season-reference check.

The class in full, now six paths across two sports, every one of which
looked like ordinary code. `slice(0, 20)` is unremarkable; what made it a
defect was the QUESTION it answered -- "what is this player's season
rate?" -- and no test could see that mismatch because the value produced
was always a plausible number.

PHASE 3 — THIS CLOSES THE NON-ACCRUAL ARC. Everything buildable without
settled rows is built: push unblocked (it was the wrong remote, not the
firewall), grade surface honest and rendering, reachability guarded,
champion repaired across every path including dormant basketball, and the
window class guarded so it cannot return.

The program is now correctly IDLE on modeling. Today's count: 0 eligible
dates, all four re-audit items WAITING. FIRST TRIGGER: 10 eligible
calibration dates on the repaired champion, at which point the resumption
order is calibration re-fit, hits factor lift, prior verdict re-audit,
rbi lineup-slot gate.

No basketball measurement claimed. No NBA chain/archetype build. MLB
serving verified byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-07 15:00:24 -04:00
parent 981a05cbd6
commit 55b210cb95
3 changed files with 140 additions and 4 deletions
+23 -3
View File
@@ -29,6 +29,16 @@ const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement'); const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService'); 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; const VECTOR_TTL_SECONDS = 120;
function avg(values) { function avg(values) {
@@ -225,7 +235,13 @@ function nbaGameLogFeatures(res, statType) {
if (vals.length) { if (vals.length) {
const m5 = avg(vals.slice(0, 5)); // most-recent first const m5 = avg(vals.slice(0, 5)); // most-recent first
const m10 = avg(vals.slice(0, 10)); 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)); const s10 = stddev(vals.slice(0, 10));
if (m5 != null) out.l5_avg = m5; if (m5 != null) out.l5_avg = m5;
if (m10 != null) out.l10_avg = m10; 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), // 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. // 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) { if (Array.isArray(pyLogs) && pyLogs.length) {
// Python rows are already flat + most-recent-first. // Python rows are already flat + most-recent-first.
for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType)); 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) // 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 {} → // is offline in prod, so `logs` is null and this branch used to return {} →
+11 -1
View File
@@ -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); const path = pythonPath(sport);
if (!path) return null; if (!path) return null;
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`; const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
+106
View File
@@ -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
});
});