Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+268
View File
@@ -0,0 +1,268 @@
/**
* Feature cache — the central feature-vector builder for every prop.
*
* Philosophy: features are OMITTED when the underlying data source is
* unavailable, never zeroed. Engine 2 handles variable-length feature
* sets; a zero would lie to the model about what we actually know.
*
* Per-feature TTL categories (Redis):
* game_log: 4h — game logs refresh once per night
* team: 24h — opponent stats are daily
* coach: 30d — coach profiles are rare to change
* ref: 12h — assignments published morning of game day
* injury: 2h — injuries change at shootaround
* line: 2m — line state changes constantly during the day
* context: none — computed on demand (home/away, rest days)
*
* Cache key: features:{sport}:{playerId}:{statType}:{gameId}
* The full vector is cached for 2 minutes so repeated calls during the
* same grading cycle don't recompute. After 2 minutes, individual
* features get refreshed from their own caches.
*/
const { cacheGet, cacheSet } = require('../../utils/redis');
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
const { getRefImpact } = require('./refSignals');
const { getCoachImpact } = require('./coachSignals');
const { roleValue } = require('./lineupSignals');
const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService');
const VECTOR_TTL_SECONDS = 120;
function avg(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length === 0) return null;
return clean.reduce((a, b) => a + b, 0) / clean.length;
}
function stddev(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length < 2) return null;
const mean = avg(clean);
const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
return Math.sqrt(sq / (clean.length - 1));
}
// Extract a stat value from a single game-log entry by stat type. Game-log
// rows out of the Python service are keyed by stat name (points,
// rebounds, etc.) and combo stats need to be summed at read time.
function statFromGameLog(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast': {
const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
return s;
}
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function daysBetween(aIso, bIso) {
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
if (!Number.isFinite(ms)) return null;
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
async function gameLogFeatures(playerName, sport, statType) {
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
if (!logs || logs.length === 0) return {};
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
const l5 = valuesAll.slice(0, 5);
const l20 = valuesAll;
const l10 = valuesAll.slice(0, 10);
const out = {};
const m5 = avg(l5);
const m20 = avg(l20);
const s10 = stddev(l10);
if (m5 != null) out.l5_avg = m5;
if (m20 != null) out.l20_avg = m20;
if (s10 != null) out.l10_stddev = s10;
// Career playoff games is a separate endpoint.
const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
if (Number.isFinite(cp)) out.career_playoff_games = cp;
return out;
}
async function teamFeatures(sport, opponentAbbr, statType) {
const out = {};
if (!opponentAbbr) return out;
const oppStats = await getTeamStats(sport, opponentAbbr);
if (oppStats) {
if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
}
const rank = await getOpponentRank(sport, opponentAbbr, statType);
if (rank != null) out.opp_rank_stat = rank;
return out;
}
function contextFeatures(gameContext = {}) {
const out = {};
if (gameContext.home_away === 'home') out.home_away = 1.0;
else if (gameContext.home_away === 'away') out.home_away = 0.0;
if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
if (gameContext.season_type != null) out.season_type = gameContext.season_type;
if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
return out;
}
async function injuryFeatures(sport, teamId, knownStarterIds = []) {
const out = {};
if (!teamId) return out;
const list = await getTeamInjuries(sport, teamId);
if (!list || list.length === 0) {
out.injury_severity_score = 0;
return out;
}
const starterSet = new Set(knownStarterIds.map(String));
const missingStarters = list.filter(
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
);
out.injury_severity_score = Math.min(5, missingStarters.length);
// Teammate-absence bump: a league-average constant when we don't have
// with/without splits for this player. Engine 2 can replace this with
// a learned value over time.
if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
return out;
}
async function lineFeatures(gameId, playerName, statType) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return {};
return { line_delta: lm.movement };
}
async function refFeatures(gameId) {
const impact = await getRefImpact(gameId);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
return out;
}
async function coachFeatures(sport, teamAbbr, gameContext = {}) {
const impact = await getCoachImpact(sport, teamAbbr, gameContext);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
if (Number.isFinite(impact.without_primary_pace_shift)) {
out.coach_player_interaction = impact.without_primary_pace_shift;
}
return out;
}
function lineupFeatures(role) {
if (!role) return {};
return { lineup_ball_handler_role: roleValue(role) };
}
// Top-level: build the full vector. Each sub-call is independent so a
// failure in one (e.g. ref assignments not yet published) just omits its
// feature and the rest of the vector is still useful.
async function getFeatures(input = {}) {
const {
playerId,
playerName,
statType,
sport,
teamAbbr,
opponentAbbr,
teamId,
opponentTeamId,
gameId,
gameContext,
role,
knownStarterIds = [],
} = input;
const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
gameLogFeatures(playerName, sport, statType),
teamFeatures(sport, opponentAbbr, statType),
Promise.resolve(contextFeatures(gameContext)),
injuryFeatures(sport, teamId, knownStarterIds),
lineFeatures(gameId, playerName, statType),
refFeatures(gameId),
coachFeatures(sport, teamAbbr, gameContext),
Promise.resolve(lineupFeatures(role)),
]);
const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
const FEATURE_NAMES = [
'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
'opp_rank_stat', 'pace_factor', 'team_pace',
'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
'line_delta',
'ref_pace_adjustment', 'ref_foul_adjustment',
'coach_pace_delta', 'coach_player_interaction',
'lineup_ball_handler_role',
];
const available = FEATURE_NAMES.filter((n) => features[n] != null);
const missing = FEATURE_NAMES.filter((n) => features[n] == null);
const payload = {
features,
meta: {
computed_at: new Date().toISOString(),
features_available: available,
features_missing: missing,
},
};
await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
return payload;
}
async function clearCache(cacheKey) {
// Hook for tests + manual invalidation.
const { cacheDel } = require('../../utils/redis');
return cacheDel(cacheKey);
}
function getCacheStats() {
return { ttlSeconds: VECTOR_TTL_SECONDS };
}
module.exports = {
getFeatures,
clearCache,
getCacheStats,
// Internal helpers exported for unit tests + Engine 2 reuse.
__internals: {
gameLogFeatures,
teamFeatures,
contextFeatures,
injuryFeatures,
lineFeatures,
refFeatures,
coachFeatures,
lineupFeatures,
statFromGameLog,
avg,
stddev,
daysBetween,
},
};