Files
vyndr/src/services/intelligence/featureCache.js
T
builtbykev 494c83cf76 Hunt the window-bug class: three more paths, and the forward re-audit rule
in code

PHASE 0 — getStatRows is the single base-rate path, so every branch is
audited, plus the feature builders since l20_avg is the season reference
projectionFor reads:

  getStatRows MLB -> estimator base    fullLog            CORRECT (929fd81)
  mlbGameLogFeatures l5/l10/l20        last10 = 10        DEFECTIVE
  espnStatsAdapter.parseGameLog        slice(0,20)        DEFECTIVE
  getStatRows NBA/WNBA ESPN branch     inherits 20-cap    DEFECTIVE via source
  getStatRows NBA/WNBA python branch   getGameLogs(...,20) dormant (offline)
  pitcherEngine / skillProjection      statcast profiles  N/A
  pitcher props via getStatRows MLB    fullLog            CORRECT
  settleSource                         full log (S64)     CORRECT

THE PITCHER ANSWER IS GOOD NEWS: pitcher props run through the same
getStatRows MLB branch, so 929fd81 repaired them too. There is no separate
defective pitcher base-rate path.

THE ONE HIDING IN PLAIN SIGHT: mlbGameLogFeatures carries the comment
"l20 = all available (the season per-game reference projectionFor needs)"
while building from last10 -- so l20_avg was a TEN-GAME AVERAGE WEARING A
SEASON LABEL, feeding both the consistency pull inside the estimator and
projectionFor, which decides refusals. It survived the previous repair
because that fix touched only getStatRows.

PHASE 1 — mlbGameLogFeatures now reads fullLog; espnStatsAdapter drops its
slice(0,20) cap. ZERO new API calls on both: each widens data already
fetched and then discarded, the same shape as the original repair. The
python branch is left alone -- the service is offline in prod and fixing it
would be speculative.

Their before/after resolution is NOT measured, deliberately: the only way
to measure today is to reconstruct the repaired forecast over old rows,
which is the reconstruction-vs-served trap this order refuses. Code fix
now, measurement at accrual.

PHASE 2 — MODEL_VERSION bumped to engine1@2026-08-07-fullwindow, so every
forward snapshot is self-identifying (retentionService already stamps it;
no new plumbing). model/reAuditEligibility.js encodes the rule: isEligible
accepts only the repaired marker, assess counts eligible DATES not rows,
and ACCRUAL is frozen at calibration 10 / hits-lift 10 / verdict-reaudit
14 / rbi-gate 14. A test locks the invisible case -- a MIXED table of 330
rows with 30 repaired returns eligible_dates 3, not 330 rows of false
confidence. Once both generations share a table a naive count would fit a
map on a blend of two forecasters.

PHASE 3 — the board, each consequence labelled: calibration WITHDRAWN
(refits at 10 dates, never on reconstructions); factor verdicts SUSPECT
(all measured against a champion worse than a frequency table, direction
UNKNOWN, not pre-priced, 14 dates); hits factor lift UN-REMEASURABLE (10
dates, factors still wired and transmitting); rbi lineup-slot RE-QUEUED
(14 dates). Pre-registered order: calibration, hits lift, verdict
re-audit, rbi gate.

Then STOP and accrue. Nothing further can be honestly measured until the
board fills with rows the repaired champion produced.

Serving-path changes by design for the MLB feature path and NBA/WNBA logs;
eleven frozen model modules verified unchanged. p_win never mutated. No
Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 03:40:20 -04:00

600 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
}
// MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row.
const MLB_LOG_FIELD = {
total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi',
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
innings_pitched: 'inningsPitched',
// Session 56 audit — real boxscore/game-log fields (Braves@Pirates verified).
doubles: 'doubles', triples: 'triples', outs: 'outs',
// OPPORTUNITY INPUT (2026-08-01) — at-bats is NOT a gradeable stat_type and no
// market exists for it. It is mapped here only so `mlbStatValue` can read
// per-game at-bats out of a log row for the opportunity-drift axis.
//
// DELIBERATELY NOT ADDED to the other two MLB maps (outcomeService's
// MLB_LOG_FIELD, liveTrackingService's LIVE_BOX_FIELD). Those exist to SETTLE
// and to TRACK graded props; nothing grades at-bats, so adding it there would
// imply a settlement path for a market we do not carry. The three-map split
// is intentional — see CLAUDE.md.
at_bats: 'atBats',
};
function mlbStatValue(statObj, statType) {
const f = MLB_LOG_FIELD[statType];
if (!f || !statObj) return null;
const n = parseFloat(statObj[f]);
return Number.isFinite(n) ? n : null;
}
// Wave 0 — NBA/WNBA stat_type → the per-game field on an espnStatsAdapter
// gamelog row's `stat` object. A SEPARATE local map from MLB_LOG_FIELD (the
// S11 three-map-split rule — never merge). Combo stats route to statFromGameLog
// (which sums points/rebounds/assists at read time); `pra`/`threes` are already
// normalized fields on the row. A stat_type absent here does NOT grade via this
// path (absent beats a fabricated projection). Add a new NBA/WNBA stat here to
// unlock it.
const NBA_LOG_FIELD = {
points: 'points', rebounds: 'rebounds', assists: 'assists',
threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers',
pra: 'pra',
// combos (statFromGameLog sums the raw components)
pts_reb_ast: 'pts_reb_ast', pts_reb: 'pts_reb', pts_ast: 'pts_ast',
reb_ast: 'reb_ast', stl_blk: 'stl_blk',
};
/**
* Session 46 — derive recent/season averages from a real MLB game log
* (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
* the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season
* per-game reference) — exactly the fields buildIntelFields consumes, which the
* NBA/WNBA-only Python game-log path never populated for MLB.
*/
function mlbGameLogFeatures(res, statType) {
if (!res || !res.found) return {};
const out = {};
// SAME WINDOW BUG AS THE BASE RATE. `l20_avg` is documented as "the season
// per-game reference projectionFor needs" and is read by the consistency
// pull — but built from last10 it was a TEN-game average wearing a season
// label. fullLog is already in this same response, so widening is free.
const logs = (Array.isArray(res.fullLog) && res.fullLog.length)
? res.fullLog : (Array.isArray(res.last10) ? res.last10 : []);
const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null);
if (vals.length) {
const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last)
const m10 = avg(vals.slice(-10));
const s10 = stddev(vals.slice(-10));
if (m5 != null) out.l5_avg = m5;
if (m10 != null) out.l10_avg = m10;
if (s10 != null) out.l10_stddev = s10;
}
const seasonTotal = mlbStatValue(res.season, statType);
const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched));
if (seasonTotal != null && Number.isFinite(games) && games > 0) {
out.l20_avg = seasonTotal / games;
} else if (out.l10_avg != null) {
out.l20_avg = out.l10_avg; // baseline so form has a reference
}
// Session 47 — complete VYNDR INTELLIGENCE for MLB:
// - rest_days: days between the two most recent games (0 = back-to-back).
// - ab_per_game: at-bats per game, the MLB "usage" equivalent.
const dated = logs.filter((g) => g && g.date);
if (dated.length >= 2) {
const last = new Date(dated[dated.length - 1].date).getTime();
const prev = new Date(dated[dated.length - 2].date).getTime();
const gap = Math.round((last - prev) / 86_400_000);
// rest_days = days OFF (0 = played the day before = B2B), matching the
// NBA convention buildIntelFields uses. Consecutive calendar days → 0.
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
}
const ab = parseFloat(res.season && res.season.atBats);
if (Number.isFinite(ab) && Number.isFinite(games) && games > 0) {
out.ab_per_game = ab / games;
}
// OPPORTUNITY DRIFT (2026-08-01) — recent at-bats per game against the
// player's OWN season baseline.
//
// opportunity_drift = mean(last 5 games' atBats) / (season atBats / games)
//
// WHY A RATIO AND NOT THE LEVEL: `l20_avg` is seasonTotal/games — the SAME
// denominator as `ab_per_game` — so for a batter
// hits/game ~= (hits/AB) x (AB/game), and the projection ALREADY embeds the
// opportunity LEVEL multiplicatively. Adding that level as another input
// double-counts it. A deviation from the player's own baseline is the part
// the projection does not already contain.
//
// > 1 batting higher / playing more than his baseline
// < 1 reduced role, platoon, lower slot
//
// HONEST ABSENCE: no at-bat rows, or no season baseline, leaves this
// UNDEFINED. It is never 1.0-by-default and never 0 — `Number(null) === 0`
// here would read as "no opportunity at all", the strongest possible signal,
// from missing data.
//
// THIS IS A PROXY. The real driver of plate appearances is tonight's
// confirmed batting order, which no wired source exposes (depthChartService
// returns battingOrder: null for MLB; PropLine /context carries only a
// lineup_confirmed boolean). Replace this with the real slot when a lineup
// feed exists — do not present it as one.
const abRows = logs.map((g) => mlbStatValue(g.stat, 'at_bats')).filter((v) => v != null);
if (abRows.length >= 3) {
const recentAb = avg(abRows.slice(-5));
if (recentAb != null) {
out.recent_ab_per_game = recentAb;
if (Number.isFinite(out.ab_per_game) && out.ab_per_game > 0) {
out.opportunity_drift = recentAb / out.ab_per_game;
}
}
}
return out;
}
/**
* Wave 0 — derive recent/season averages from an ESPN NBA/WNBA gamelog
* (espnStatsAdapter.getPlayerGameLog result). PURE + unit-testable. The result's
* last10 is MOST-RECENT-FIRST, so l5 = first 5, l20 = all available (the season
* per-game reference projectionFor needs). Emits the SAME fields the Python
* path did, plus rest_days + minutes_per_game (usage), so the grade card lights
* up. Returns {} when the log is missing or the stat_type isn't mapped.
*/
function nbaGameLogFeatures(res, statType) {
if (!res || !res.found) return {};
const field = NBA_LOG_FIELD[statType];
if (!field) return {};
const logs = Array.isArray(res.last10) ? res.last10 : [];
const vals = logs.map((g) => statFromGameLog(g && g.stat, field)).filter((v) => v != null);
const out = {};
if (vals.length) {
const m5 = avg(vals.slice(0, 5)); // most-recent first
const m10 = avg(vals.slice(0, 10));
const m20 = avg(vals.slice(0, 20));
const s10 = stddev(vals.slice(0, 10));
if (m5 != null) out.l5_avg = m5;
if (m10 != null) out.l10_avg = m10;
if (m20 != null) out.l20_avg = m20;
if (s10 != null) out.l10_stddev = s10;
}
// rest_days from the two most-recent dated games (0 = back-to-back), mirroring
// the MLB branch + the NBA convention buildIntelFields reads.
const dated = logs.filter((g) => g && g.date);
if (dated.length >= 2) {
const last = new Date(dated[0].date).getTime(); // most recent
const prev = new Date(dated[1].date).getTime();
const gap = Math.round((last - prev) / 86_400_000);
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
}
// minutes_per_game — the NBA "usage" equivalent buildIntelFields surfaces.
const mins = logs.map((g) => g && g.stat && Number(g.stat.minutes)).filter((v) => Number.isFinite(v));
const mpg = avg(mins);
if (mpg != null) out.minutes_per_game = mpg;
return out;
}
/**
* Session 63 — NORMALIZED PER-GAME STAT ROWS.
*
* The probability estimator (`probabilityEstimator.estimateProbability`) and the
* consistency scorer both read a game-log row as `row[statType]`. The ONLY
* producer wired to them was `gameLogService.getGameLogs`, which returns null for
* MLB by construction and depends on the offline Python service for NBA/WNBA —
* so `meta.gameLogs` was `[]` for every sport in production and every
* probability-derived output (p_win, ev_pct, kelly, model_odds, value) was
* silently skipped, along with the ±1.0 consistency factor.
*
* This is the S46 fix applied to the SECOND location: same adapters, same maps
* (no new stat map — the three-map-split rule stands), emitting rows in the shape
* those two consumers already expect:
*
* [{ date, [statType]: value }, ...] MOST-RECENT-FIRST
*
* Most-recent-first matters: the estimator treats `values.slice(0, 5)` as the
* recency window. Returns [] (never null) when no real log exists — absent beats
* a fabricated distribution.
*/
async function getStatRows(playerName, sport, statType) {
const sp = String(sport || '').toLowerCase();
const rows = [];
const push = (date, value) => {
if (value == null || !Number.isFinite(Number(value))) return;
rows.push({ date: date || null, [statType]: Number(value) });
};
try {
if (sp === 'mlb') {
const mlbStats = require('../adapters/mlbStatsAdapter');
const res = await mlbStats.getPlayerStats(playerName);
// THE FULL SEASON LOG, NOT last10.
//
// This is the one line that made the champion worse than a frequency
// table. `estimateProbability` computes its base rate as the frequency
// over EVERY row it is given, so feeding it ten games meant the "season
// rate" was a ten-game rate — and then 0.4 of the forecast was the last
// five OF THOSE TEN. Measured point-in-time, a true season frequency
// out-resolved the served champion on all four stats (hits 0.00774 vs
// 0.00251, rbi 0.03133 vs 0.02481).
//
// `fullLog` is already fetched in the same adapter call that produced
// last10, so this costs nothing: no extra request, no new dependency.
const logs = (res && res.found)
? (Array.isArray(res.fullLog) && res.fullLog.length ? res.fullLog
: (Array.isArray(res.last10) ? res.last10 : []))
: [];
// MLB logs are chronological (most recent LAST) — reverse to match.
for (const g of [...logs].reverse()) push(g && g.date, mlbStatValue(g && g.stat, statType));
return rows;
}
// 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.
const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20);
if (Array.isArray(pyLogs) && pyLogs.length) {
// Python rows are already flat + most-recent-first.
for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType));
return rows;
}
if (sp === 'nba' || sp === 'wnba') {
const espnStats = require('../adapters/espnStatsAdapter');
const res = await espnStats.getPlayerGameLog(playerName, sp);
const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : [];
const field = NBA_LOG_FIELD[statType];
if (!field) return rows; // unmapped stat → no rows, never a guess
// ESPN last10 is most-recent-first already.
for (const g of logs) push(g && g.date, statFromGameLog(g && g.stat, field));
}
return rows;
} catch (e) {
console.warn('[featureCache] getStatRows failed:', e.message);
return [];
}
}
/** Games played in the trailing `days` window, from normalized rows. Powers the
* `heavy_workload_7d` factor, whose feature nothing populated. */
function gameCountInWindow(statRows, days = 7, now = Date.now()) {
if (!Array.isArray(statRows)) return null;
const cutoff = now - days * 86_400_000;
const dated = statRows.filter((r) => r && r.date && !Number.isNaN(new Date(r.date).getTime()));
if (dated.length === 0) return null;
return dated.filter((r) => new Date(r.date).getTime() >= cutoff).length;
}
async function gameLogFeatures(playerName, sport, statType) {
// MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
// gameLogService only covers NBA/WNBA, so MLB props had no recent/season
// averages and the grade card's intel sections stayed empty.
if (sport === 'mlb') {
try {
const mlbStats = require('../adapters/mlbStatsAdapter');
const res = await mlbStats.getPlayerStats(playerName);
return mlbGameLogFeatures(res, statType);
} catch (e) {
console.warn('[featureCache] MLB game-log features failed:', e.message);
return {};
}
}
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
// 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 {} →
// no l5/l20 → projectionFor null → the ENTIRE slate refused. Fall back to the
// FREE ESPN per-athlete gamelog so NBA (off-season) + WNBA (in-season) grade.
if ((!logs || logs.length === 0) && (sport === 'nba' || sport === 'wnba')) {
try {
const espnStats = require('../adapters/espnStatsAdapter');
const res = await espnStats.getPlayerGameLog(playerName, sport);
return nbaGameLogFeatures(res, statType);
} catch (e) {
console.warn('[featureCache] ESPN game-log fallback failed:', e.message);
return {};
}
}
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;
// Session 64 — MLB opp_rank_stat. ESPN's MLB team endpoint carries NO
// defensive metric, so this field was permanently null for MLB and engine1's
// ±1.0 opponent factor never fired for the sport carrying most of our volume.
// Derived instead from statsapi team pitching splits (one free call, all 30
// teams), normalized to the SHARED CONTRACT: 01, HIGH = weak opponent —
// identical to WNBA, so engine1 reads one field with one meaning.
// Isolated + best-effort: a failure leaves the field ABSENT (honest null),
// never a guessed rank.
if (out.opp_rank_stat == null && String(sport).toLowerCase() === 'mlb') {
try {
const mlb = require('../adapters/mlbStatsAdapter');
const strength = require('../opponentStrength');
const [teams, oppTeam] = await Promise.all([
mlb.getTeamPitchingStats(),
mlb.resolveTeam(opponentAbbr),
]);
if (oppTeam && oppTeam.id) {
const d = strength.deriveMlbOppRank(teams, oppTeam.id, statType);
if (d.value != null) {
out.opp_rank_stat = d.value;
} else if (process.env.OPP_RANK_DEBUG === '1') {
console.log(`[featureCache] mlb opp_rank null for ${opponentAbbr}/${statType}: ${d.reason}`);
}
}
} catch (e) {
console.warn('[featureCache] mlb opponent-strength derivation failed:', e.message);
}
}
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,
getStatRows,
gameCountInWindow,
clearCache,
getCacheStats,
// Internal helpers exported for unit tests + Engine 2 reuse.
__internals: {
gameLogFeatures,
teamFeatures,
contextFeatures,
injuryFeatures,
lineFeatures,
refFeatures,
coachFeatures,
lineupFeatures,
statFromGameLog,
mlbGameLogFeatures,
mlbStatValue,
// Exported so the opportunity-input test can assert at_bats is mapped HERE
// and deliberately NOT in the settlement map (the three-map split).
MLB_LOG_FIELD,
nbaGameLogFeatures,
NBA_LOG_FIELD,
avg,
stddev,
daysBetween,
},
};