/** * 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, }, };