6c34af3414
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
320 lines
15 KiB
JavaScript
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/);
|
|
}
|
|
});
|
|
});
|