Files
vyndr/tests/unit/wnbaUsageFeed.test.js
builtbykev 6c34af3414 checkpoint: chain shadow, WNBA possession feed, baseball chain
Backup commit of uncommitted working-tree state found during Legion
recon (Tony resurrection, STEP 0). This work existed only on the
laptop disk.

- chain shadow accrual + probe script (038_chain_shadow.sql)
- WNBA possession feed: ESPN adapter, usage service, verify script
  (039_wnba_player_game.sql)
- baseball chain
- retention/snapshot service updates, tableKeys, matchupKeys
- specs: chain-v1, wnba-possession-feed, wnba-source-survey
- unit tests for the above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnvJAkC3h5QGmb6dipoiWn
2026-08-14 16:53:37 -04:00

320 lines
15 KiB
JavaScript

'use strict';
/**
* The WNBA possession/usage feed — the source, the derivations, the as-of rule.
*
* Two failures this suite exists to prevent. First, a derived rate that is not
* actually the identity it claims to be: usage and possessions feed the chain's
* opportunity term directly, so a wrong constant or a transposed term would be
* invisible downstream and wrong everywhere. Second — and this is the one that
* has bitten this codebase repeatedly — a read that quietly includes the game it
* is trying to predict.
*/
const adapter = require('../../src/services/adapters/espnWnbaAdapter');
const usage = require('../../src/services/wnbaUsageService');
/** A real-shaped ESPN summary payload, trimmed to what the parser reads. */
const PLAYER_KEYS = [
'minutes', 'points', 'fieldGoalsMade-fieldGoalsAttempted',
'threePointFieldGoalsMade-threePointFieldGoalsAttempted',
'freeThrowsMade-freeThrowsAttempted', 'rebounds', 'assists', 'turnovers',
'steals', 'blocks', 'offensiveRebounds', 'defensiveRebounds', 'fouls', 'plusMinus',
];
const athlete = (id, name, stats, over = {}) => ({
athlete: { id, displayName: name }, starter: true, stats, ...over,
});
const teamStats = (fg, ft, tov, oreb) => [
{ name: 'fieldGoalsMade-fieldGoalsAttempted', displayValue: fg },
{ name: 'freeThrowsMade-freeThrowsAttempted', displayValue: ft },
{ name: 'totalTurnovers', displayValue: String(tov) },
{ name: 'offensiveRebounds', displayValue: String(oreb) },
];
function summary(over = {}) {
return {
header: {
id: '401857134',
season: { year: 2026 },
competitions: [{
date: '2026-08-11T23:30Z',
status: { type: { name: 'STATUS_FINAL' } },
competitors: [
{ homeAway: 'home', score: '106', team: { id: '5', abbreviation: 'IND' } },
{ homeAway: 'away', score: '92', team: { id: '9', abbreviation: 'NY' } },
],
}],
...over.header,
},
boxscore: {
players: [
{
team: { id: '9', abbreviation: 'NY' },
statistics: [{
keys: PLAYER_KEYS,
athletes: [
// MIN PTS FG 3PT FT REB AST TO STL BLK OREB DREB PF +/-
athlete('1', 'Breanna Stewart', ['30', '19', '8-21', '1-5', '2-2', '7', '5', '0', '1', '0', '2', '5', '2', '-2']),
athlete('2', 'Sabrina Ionescu', ['34', '22', '7-18', '4-9', '4-4', '4', '6', '3', '2', '0', '0', '4', '1', '-5']),
athlete('3', 'Deep Bench', null, { didNotPlay: true, stats: [] }),
],
}],
},
],
teams: [
{ team: { id: '9', abbreviation: 'NY' }, statistics: teamStats('35-76', '11-13', 13, 13) },
{ team: { id: '5', abbreviation: 'IND' }, statistics: teamStats('37-69', '16-19', 11, 4) },
],
},
...over,
};
}
describe('THE SOURCE — ESPN returns components, and the parser reads them', () => {
it('parses a real-shaped box score into per-player rows', () => {
const { rows, game } = adapter.parseSummary(summary());
expect(game.game_id).toBe('401857134');
expect(rows).toHaveLength(2);
const s = rows.find((r) => r.player_name === 'Breanna Stewart');
expect(s.minutes).toBe(30);
expect(s.fga).toBe(21);
expect(s.fta).toBe(2);
expect(s.tov).toBe(0);
expect(s.team).toBe('NY');
expect(s.opponent).toBe('IND');
expect(s.home_away).toBe('away');
});
it('a DNP is OMITTED, not recorded as a zero line', () => {
// A zero-minute row asserts he was available and produced nothing. That is a
// different fact from "he did not play", and the usage denominator would
// divide by his zero minutes anyway.
const { rows } = adapter.parseSummary(summary());
expect(rows.find((r) => r.player_name === 'Deep Bench')).toBeUndefined();
});
it('ET dating — a 23:30Z tip is the SAME ET evening, not the next day', () => {
// Using the UTC date would file half of every slate a day late.
const { rows } = adapter.parseSummary(summary());
expect(rows[0].game_date).toBe('2026-08-11');
});
it('an UNFINISHED game is refused — a partial box score is a moving denominator', () => {
const live = summary();
live.header.competitions[0].status.type.name = 'STATUS_IN_PROGRESS';
const out = adapter.parseSummary(live);
expect(out.rows).toHaveLength(0);
expect(out.skipped).toMatch(/not_final/);
});
it('a malformed made-attempted pair is ABSENT, never zero', () => {
expect(adapter.madeAtt('8-21')).toEqual({ made: 8, att: 21 });
expect(adapter.madeAtt('--')).toEqual({ made: null, att: null });
expect(adapter.madeAtt(null)).toEqual({ made: null, att: null });
});
});
describe('THE DERIVATIONS — exact identities, not proxies', () => {
const t = { fga: 76, fta: 13, tov: 13, oreb: 13 };
it('possessions = FGA - OREB + TOV + 0.44*FTA', () => {
expect(adapter.possessions(t)).toBeCloseTo(76 - 13 + 13 + 0.44 * 13, 6);
});
it('a missing term makes possessions ABSENT — never a partial sum', () => {
// Treating an absent turnover count as zero understates possessions and
// inflates every usage rate computed against it.
expect(adapter.possessions({ ...t, tov: null })).toBeNull();
expect(adapter.possessions(null)).toBeNull();
});
it('usage matches the standard identity, recomputed independently', () => {
const p = { fga: 21, fta: 2, tov: 0, minutes: 30 };
const teamMinutes = 200;
const expected = 100 * ((21 + 0.44 * 2 + 0) * (teamMinutes / 5))
/ (30 * (76 + 0.44 * 13 + 13));
expect(adapter.usageRate(p, t, teamMinutes)).toBeCloseTo(expected, 9);
});
it('usage is ABSENT on a zero-minute or missing-term row, never Infinity or 0', () => {
expect(adapter.usageRate({ fga: 5, fta: 0, tov: 1, minutes: 0 }, t, 200)).toBeNull();
expect(adapter.usageRate({ fga: null, fta: 0, tov: 1, minutes: 20 }, t, 200)).toBeNull();
expect(adapter.usageRate({ fga: 5, fta: 0, tov: 1, minutes: 20 }, t, 0)).toBeNull();
});
it('pace handles OVERTIME through team minutes, with no special case', () => {
// 5 players x 40 min = 200 team-minutes in regulation; an OT game has more,
// and the divisor follows rather than being branched on.
const reg = adapter.pace(100, 200);
const ot = adapter.pace(100, 225);
expect(reg).toBeCloseTo(100, 6); // 100 poss per 40 min
expect(ot).toBeLessThan(reg); // same possessions over more time
});
it('true shooting and eFG are absent without attempts', () => {
expect(adapter.trueShooting({ points: 19, fga: 21, fta: 2 })).toBeCloseTo(19 / (2 * (21 + 0.88)), 9);
expect(adapter.trueShooting({ points: 0, fga: 0, fta: 0 })).toBeNull();
expect(adapter.efgPct({ fgm: 8, fg3m: 1, fga: 21 })).toBeCloseTo(8.5 / 21, 9);
expect(adapter.efgPct({ fgm: 0, fg3m: 0, fga: 0 })).toBeNull();
});
it('END TO END: the stored rate equals the identity recomputed from the stored components', () => {
// This is the factorFreeze rule applied to a feed: every component is stored
// beside its derived value, so the rate is checkable rather than asserted.
const { rows } = adapter.parseSummary(summary());
for (const r of rows) {
const expected = 100 * ((r.fga + 0.44 * r.fta + r.tov) * (r.team_minutes / 5))
/ (r.minutes * (r.team_fga + 0.44 * r.team_fta + r.team_tov));
expect(r.usage_rate).toBeCloseTo(expected, 3);
expect(r.team_possessions)
.toBeCloseTo(r.team_fga - r.team_oreb + r.team_tov + 0.44 * r.team_fta, 3);
}
});
it('carries the game state the redistribute hook reads', () => {
const { rows } = adapter.parseSummary(summary());
const ny = rows[0];
expect(ny.team_score).toBe(92);
expect(ny.opp_score).toBe(106);
expect(ny.final_margin).toBe(-14); // a blowout the hook can see
expect(ny.starter).toBe(true);
});
});
describe('LEAGUE MEMBERSHIP — exhibitions and All-Star are not league games', () => {
const teamsPayload = {
sports: [{ leagues: [{ teams: [{ team: { abbreviation: 'NY' } }, { team: { abbreviation: 'IND' } }] }] }],
};
const fetchImpl = (url) => Promise.resolve(url.includes('/teams') ? teamsPayload : summary());
it('a league game passes through', async () => {
const out = await adapter.getGameRows('401857134', { fetchImpl, force: true });
expect(out.rows.length).toBe(2);
});
it('a game against a NON-LEAGUE side is refused, and names who', async () => {
// The season pull surfaced exactly two: an exhibition vs Nigeria and the
// All-Star game. Their usage/pace context is meaningless for a forward
// projection, so counting them would pollute every profile spanning them.
const exhibition = summary();
exhibition.header.competitions[0].competitors[0].team.abbreviation = 'NIGER';
exhibition.boxscore.teams[1].team.abbreviation = 'NIGER';
const f = (url) => Promise.resolve(url.includes('/teams') ? teamsPayload : exhibition);
const out = await adapter.getGameRows('401867794', { fetchImpl: f, force: true });
expect(out.rows).toHaveLength(0);
expect(out.skipped).toMatch(/^not_a_league_game:/);
expect(out.skipped).toContain('NIGER');
});
it('an EMPTY teams response does not silently filter the whole league away', async () => {
// A failed feed that degrades to an empty index looks exactly like an honest
// absence — the fielding_oaa lesson. Unknown membership means NO filtering,
// never "nothing is in the league".
const empty = { sports: [{ leagues: [{ teams: [] }] }] };
const f = (url) => Promise.resolve(url.includes('/teams') ? empty : summary());
const out = await adapter.getGameRows('401857134', { fetchImpl: f, force: true });
expect(out.rows.length).toBe(2);
});
});
describe('THE AS-OF RULE — point-in-time is structural here', () => {
const games = [
{ game_date: '2026-08-01', minutes: 30, usage_rate: 24, team_pace: 96, team_possessions: 80, ts_pct: 0.55, efg_pct: 0.5, starter: true, final_margin: 5, team: 'NY', game_id: 'g1', source_id: '1' },
{ game_date: '2026-08-04', minutes: 34, usage_rate: 28, team_pace: 98, team_possessions: 82, ts_pct: 0.58, efg_pct: 0.52, starter: true, final_margin: -3, team: 'NY', game_id: 'g2', source_id: '1' },
{ game_date: '2026-08-07', minutes: 20, usage_rate: 30, team_pace: 94, team_possessions: 78, ts_pct: 0.50, efg_pct: 0.48, starter: false, final_margin: 12, team: 'NY', game_id: 'g3', source_id: '1' },
{ game_date: '2026-08-11', minutes: 32, usage_rate: 26, team_pace: 99, team_possessions: 83, ts_pct: 0.60, efg_pct: 0.55, starter: true, final_margin: -14, team: 'NY', game_id: 'g4', source_id: '1' },
].map((g) => ({ sport: 'wnba', player_key: 'test player', season: 2026, ...g }));
/** A stub client driving the REAL paginate + read path. */
const client = (rows) => ({
from() {
const st = { rows: rows.slice(), order: [] };
const q = {
select: () => q,
eq: (c, v) => { st.rows = st.rows.filter((r) => r[c] === v); return q; },
lt: (c, v) => { st.rows = st.rows.filter((r) => String(r[c]) < String(v)); return q; },
order: (c, o) => { st.order.push([c, !o || o.ascending !== false]); return q; },
range: async (a, b) => {
const sorted = st.rows.slice().sort((x, y) => {
for (const [c, asc] of st.order) {
const cmp = String(x[c]).localeCompare(String(y[c]));
if (cmp) return asc ? cmp : -cmp;
}
return 0;
});
return { data: sorted.slice(a, b + 1), error: null };
},
};
return q;
},
});
it('reads STRICTLY before the cutoff — a game ON the date is excluded', async () => {
// The leak this prevents: a 2026-08-11 game may tip after grade time, so
// counting it feeds the evening being predicted into the prediction. Same
// rule as snapshotSettlementService.isPreGame.
const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-11' });
expect(p.games).toBe(3);
expect(p.last_game_date).toBe('2026-08-07');
});
it('a later cutoff sees more games — the profile MOVES with the date', async () => {
const early = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-08' });
const late = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-12' });
expect(early.games).toBe(3);
expect(late.games).toBe(4);
// The whole point of a point-in-time read: the same player reads differently
// on different dates, because the history behind him is different.
expect(early.usage_rate).not.toBe(late.usage_rate);
expect(early.last_game_date).toBe('2026-08-07');
expect(late.last_game_date).toBe('2026-08-11');
});
it('REFUSES on thin history rather than returning a league-average player', async () => {
// Usage feeds the chain's opportunity term directly; a stand-in profile
// would assert a usage rate about someone never observed.
expect(await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-04' })).toBeNull();
expect(await usage.profileAsOf(client(games), { playerKey: 'nobody', asOf: '2026-12-01' })).toBeNull();
});
it('usage is MINUTES-WEIGHTED, not a flat mean across games', async () => {
// The lineup-K-rate lesson: an unweighted aggregate counts a 20-minute
// cameo like a 34-minute start, and unweighted HURT that model.
const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-12-01' });
const flat = (24 + 28 + 30 + 26) / 4;
const weighted = (24 * 30 + 28 * 34 + 30 * 20 + 26 * 32) / (30 + 34 + 20 + 32);
expect(p.usage_rate).toBeCloseTo(weighted, 3);
expect(p.usage_rate).not.toBeCloseTo(flat, 3);
});
it('delivers every chainFn input, and the redistribute game-state', async () => {
const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-12-01' });
for (const f of ['usage_rate', 'minutes_per_game', 'team_pace', 'team_possessions', 'ts_pct', 'efg_pct']) {
expect(p[f]).not.toBeNull();
}
expect(p.starter_rate).toBeCloseTo(0.75, 3);
expect(p.avg_final_margin).toBeCloseTo(0, 3);
expect(p.as_of).toBe('2026-12-01'); // the cutoff is reported, not implied
});
});
describe('the feed is registered and scoped', () => {
it('its unique key is in tableKeys, so safePaginate can walk it', () => {
const { uniqueKeyFor } = require('../../src/utils/tableKeys');
expect(uniqueKeyFor('wnba_player_game')).toEqual(['game_id', 'source_id']);
});
it('MLB and the chain shadow are untouched by this order', () => {
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..', '..');
// The WNBA feed must not have reached into the MLB engine or the served path.
for (const f of ['src/services/model/chainShadow.js', 'src/services/model/baseballChain.js',
'src/services/intelligence/analyzeViaEngine1.js']) {
expect(fs.readFileSync(path.join(root, f), 'utf8')).not.toMatch(/wnbaUsage|espnWnbaAdapter|wnba_player_game/);
}
});
});