494c83cf76
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, so929fd81repaired 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
600 lines
24 KiB
JavaScript
600 lines
24 KiB
JavaScript
/**
|
||
* 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: 0–1, 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,
|
||
},
|
||
};
|