Files
vyndr/src/services/intelligence/computeFeatures.js
T
builtbykev 6415751f2e Grading binds opponent features to the REAL game, not ESPN's "today"
Order 1.6 Phase 1. This is a MODEL-OUTPUT fix, not bookkeeping.

computeFeatures.lookupTodayGame called the ESPN scoreboard with NO date
param and took whatever ESPN calls "today". Renamed to lookupGameOnDate
and now sends ?dates=YYYYMMDD from the prop's BOUND game — the same game
the ledger, retention and settlement use, so all four finally agree.

PROVEN against live ESPN (before/after, same instant):
  dateless "today"      CLE->PIT  NYY->LAD  LAD->NYY   (Jul 19 card)
  bound to 2026-07-20   CLE->MIN  NYY->PIT  LAD->PHI   (the real games)
  bound to 2026-07-19   CLE->PIT  NYY->LAD  LAD->NYY   (reproduces OLD)
Every opponent was wrong. opponentAbbr feeds opp_rank_stat (a +/-1.0
factor) and isHome feeds home_away (+0.5), so late-slot grades were
scored against the wrong matchup.

Note the window is WIDER than the 01:00/03:00 UTC slots: this ran at
07:5x UTC = 03:5x ET and ESPN's dateless scoreboard was STILL returning
the previous day's card.

HONEST DEGRADATION: with no bound game date the grader does NOT fall back
to a dateless lookup — it records 'no_bound_game_date' and leaves
opponentAbbr/isHome/gameId null, so engine1 simply omits the opponent and
home/away factors rather than scoring a wrong matchup. Tests assert both
directions.

Same class of bug fixed alongside: the Tank01 augmentation used TODAY's
UTC date for its cache key; it now uses the bound game date.

gradeSlateService threads game_date/game_time/home_team/away_team into the
grader so the binding reaches computeFeatures at all.

Audited the rest of the feature path for dateless/"today" lookups — none
remain (weather is current-conditions by venue, park/pace are static).

Suite 282/3383 green, build exit 0.

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

468 lines
20 KiB
JavaScript

/**
* computeFeaturesForProp — the ONE permitted architectural addition of
* Session 7f. Bridges raw single-prop input (`{player, stat_type, line,
* direction, book, sport}`) to the feature-vector shape `engine1.gradeProp()`
* expects.
*
* The orchestrator does this same work inline, tied to its batch loop +
* grade_history persistence. This module lifts only the per-prop logic
* so single-prop callers (`/api/analyze/prop`, batch entries,
* `/api/scan/parlay` legs, `/api/bets/*`) can produce engine1 input
* without re-implementing the resolution chain.
*
* Never throws. Every step is independently fault-tolerant:
* - player_id_map miss → team/opponent unknown, features still partial
* - no game tonight → no gameId, gameId-dependent features omitted
* - feature fetch fails → features {} returned, engine1 lands C
* - trap fetch fails → trap defaults to no signals firing
* - game logs unavailable → consistency defaults to 'unknown'
*
* The caller (analyzeViaEngine1) reads the returned `errors` array and
* surfaces them in the reasoning string. NOTE (Session 63): this comment used
* to claim confidence is "downgraded accordingly" — it never was. No
* data-sufficiency penalty exists in the live path; confidence is a pure
* function of the grade letter (see gradeAdapter `confidence_basis`). The one
* real penalty lived in the dead `mlbGrader.js`, now removed. Insufficient data
* produces a REFUSAL (grade null + insufficient_data), not a softened grade.
*
* ─────────────────────────────────────────────────────────────────────
* Signal provenance (Session 15 audit)
* ─────────────────────────────────────────────────────────────────────
* Every signal the engine reads has a documented source. Phantom
* signals — referenced in reasoning but populated by nothing — would
* be a trust failure. As of Session 15 there are none.
*
* • injury_severity_score (engine1.js:126 reads it; analyzeViaEngine1
* surfaces it in reasoning at line 156)
* ← `src/services/intelligence/injuryParser.js` (ESPN injury feed)
* Populated by the grading orchestrator in batch mode; in the
* single-prop path it lives in the `featureCache` payload.
*
* • coach_pace_delta + coach_player_interaction
* ← `src/services/intelligence/coachSignals.js` reads the
* `coach_profiles` Supabase table (migration 017), with a
* `src/config/coaches.json` seed file as the cold-start fallback.
*
* • consistency (boom_bust / reliable / elite labels + numeric score)
* ← `src/services/intelligence/consistencyScore.js` operating on
* game logs from `gameLogService` (ESPN). When game logs are
* unavailable, defaults to `{consistency:'unknown', score:null}`
* which engine1 treats as neutral (does not penalize).
*
* • Tank01 t01_* fields (added Session 14)
* ← `src/services/intelligence/tank01Augment.js` reads cache keys
* written by `scripts/tank01-prefetch.js` (Session 15 — added
* this session) which calls the Tank01 NBA/MLB RapidAPI adapters.
*
* • Soccer features (10 of them — goals_per_90, xG, altitude, etc.)
* ← `src/services/intelligence/soccerFeatureExtractor.js` cascade
* across api-football → footapi → football-data cache keys.
*
* • Park factors (Session 15 — MLB)
* ← `src/data/parkFactors.js` — static FanGraphs 2024-25 data.
*
* • Weather (Session 15 — MLB + soccer)
* ← `src/services/weatherService.js` calls Open-Meteo (no key),
* cached 1h in Redis. Skipped for dome stadiums.
*
* • Pace factors (Session 15 — NBA)
* ← `src/data/paceFactors.js` — static NBA team pace data.
*
* No signal currently surfaces in user-facing reasoning that isn't
* populated by one of the sources above. When a source is down, the
* signal returns null and reasoning omits it gracefully — never
* fabricated.
*/
const axios = require('axios');
const { getSportConfig } = require('../../config/sports');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const { normalizeName } = require('../../utils/normalize');
const featureCache = require('./featureCache');
const trapDetection = require('./trapDetection');
const consistencyScore = require('./consistencyScore');
const gameLogService = require('./gameLogService');
// Session 7j — soccer branch. The extractor reads from prefetched
// Redis cache; no external HTTP on the user request path.
const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor');
// Session 14 — Tank01 augmentor. Reads cache keys the Tank01
// adapters write; no network from this path. Daily prefetch (future)
// populates the cache. Until that lands, the augmentor returns
// empty objects and the existing ESPN-derived features stand alone.
const tank01Augment = require('./tank01Augment');
// Session 15 — static lookup tables (MLB park factors, NBA pace
// factors). Pure synchronous reads, no network, no cache. Merged
// into the feature map alongside the per-sport ESPN payload.
const { getParkFactor } = require('../../data/parkFactors');
const { getPaceFactor } = require('../../data/paceFactors');
// Session 15 — Open-Meteo weather fetch. 1h Redis cache, 5s timeout,
// silent on failure. Skipped for dome stadiums via the venue index.
const weatherService = require('../weatherService');
const { getMlbVenue, getWcVenueCoords } = require('../../data/venueCoordinates');
const HTTP_TIMEOUT_MS = 8_000;
// Resolve a free-form player + sport to a roster row. Returns null on
// any failure so callers can still proceed with partial features.
async function lookupPlayer({ player, sport }) {
if (!player || !sport) return null;
try {
const supabase = getSupabaseServiceClient();
const norm = normalizeName(player);
const { data, error } = await supabase
.from('player_id_map')
.select('display_name, normalized_name, espn_id, team_abbr, sport')
.eq('sport', sport)
.eq('normalized_name', norm)
.limit(1)
.maybeSingle();
if (error || !data) return null;
return data;
} catch (err) {
console.warn('[computeFeatures] player lookup failed:', err.message);
return null;
}
}
// Pull today's scoreboard for the sport and find the game the player's
// team plays in. Returns { gameId, opponentAbbr, isHome } or null.
/**
* Session 64 (Order 1.6) — resolve the player's game for a SPECIFIC DATE.
*
* This used to be `lookupTodayGame`, calling the ESPN scoreboard with NO date
* param — it took whatever ESPN calls "today". At the late slots (01:00/03:00
* UTC = 21:00/23:00 ET) that is the PREVIOUS day's card, so a prop for
* tonight bound `opponentAbbr` and `home_away` to YESTERDAY'S opponent. Those
* feed the ±1.0 opponent-defense factor and the home/away factor, so it is a
* MODEL-OUTPUT bug, not bookkeeping.
*
* `gameDate` (YYYY-MM-DD, ET) comes from the prop's BOUND game — the same game
* the ledger, retention and settlement now use, so all four agree.
* Absent gameDate → we do NOT guess a day; the caller degrades honestly.
*/
/** ET calendar date of an ISO timestamp. */
function dateETOf(iso) {
if (!iso) return null;
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(t);
}
async function lookupGameOnDate({ sport, teamAbbr, gameDate }) {
if (!sport || !teamAbbr) return null;
let sportCfg;
try { sportCfg = getSportConfig(sport); } catch { return null; }
try {
const url = gameDate
? `${sportCfg.espnScoreboard}${sportCfg.espnScoreboard.includes('?') ? '&' : '?'}dates=${String(gameDate).replace(/-/g, '')}`
: sportCfg.espnScoreboard;
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
const events = res.data?.events || [];
for (const ev of events) {
const comp = ev?.competitions?.[0];
if (!comp) continue;
const competitors = comp.competitors || [];
const home = competitors.find((c) => c.homeAway === 'home');
const away = competitors.find((c) => c.homeAway === 'away');
const homeAbbr = home?.team?.abbreviation;
const awayAbbr = away?.team?.abbreviation;
if (homeAbbr === teamAbbr) {
return { gameId: String(ev.id), opponentAbbr: awayAbbr, isHome: true };
}
if (awayAbbr === teamAbbr) {
return { gameId: String(ev.id), opponentAbbr: homeAbbr, isHome: false };
}
}
return null;
} catch (err) {
console.warn('[computeFeatures] scoreboard fetch failed:', err.message);
return null;
}
}
async function safeGetFeatures(input) {
try {
const payload = await featureCache.getFeatures(input);
return payload?.features || {};
} catch (err) {
console.warn('[computeFeatures] feature fetch failed:', err.message);
return {};
}
}
async function safeGetTrap(input) {
const fallback = { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' };
try {
return (await trapDetection.getTrapScore(input)) || fallback;
} catch (err) {
console.warn('[computeFeatures] trap detection failed:', err.message);
return fallback;
}
}
async function safeGetConsistency({ playerName, sport, statType, statRows }) {
const fallback = { consistency: 'unknown', score: null, games: 0 };
try {
// Session 63 — normalized rows from the REAL per-sport sources (MLB
// statsapi / ESPN gamelog), not the NBA-WNBA-only Python service. This one
// call feeds BOTH the consistency factor and (via meta.gameLogs) the
// probability estimator, which had no rows at all in production.
// `statRows` is passed in by computeFeaturesForProp so the fetch happens
// ONCE per prop (it also powers game_count_in_7d, built before features).
const logs = Array.isArray(statRows)
? statRows
: await featureCache.getStatRows(playerName, sport, statType);
if (!logs || logs.length === 0) return { result: fallback, gameLogs: [] };
const result = await consistencyScore.getConsistency({
playerName, sport, statType, gameLogs: logs,
});
return { result: result || fallback, gameLogs: logs };
} catch (err) {
console.warn('[computeFeatures] consistency failed:', err.message);
return { result: fallback, gameLogs: [] };
}
}
async function computeFeaturesForProp(rawProp = {}) {
// Default to NBA when caller omits — matches what legacy analyzeProp does.
const sport = String(rawProp.sport || 'nba').toLowerCase();
// Soccer routes to a different extractor — different data sources
// (football-data.org + cache vs ESPN scoreboard), different feature
// set (xG, altitude, referee, set-piece role). The extractor returns
// the same {features, trap, consistency, prop, meta} shape engine1
// consumes, so analyzeViaEngine1 is sport-agnostic downstream.
if (isSoccerSport(sport)) {
const soccerResult = await extractSoccerFeatures(rawProp);
// Soccer extractor returns a placeholder trap object. Run the real
// soccer-branch trap detection here using the freshly computed
// features so analyzeViaEngine1 sees a populated trap composite.
const soccerTrap = await safeGetTrap({
sport: 'soccer',
playerName: rawProp.player,
statType: soccerResult.meta?.statType,
gameId: null,
gameContext: { home_away: soccerResult.features?.home_away === 1.0 ? 'home' : (soccerResult.features?.home_away === 0.0 ? 'away' : null) },
features: soccerResult.features,
odds: { playerLine: soccerResult.prop?.line, consensus: null },
});
return { ...soccerResult, trap: soccerTrap };
}
const errors = [];
const player = rawProp.player;
const statType = rawProp.stat_type || rawProp.statType;
const line = Number(rawProp.line);
const direction = rawProp.direction || 'over';
const book = rawProp.book || 'unknown';
if (!player || !statType || !Number.isFinite(line)) {
errors.push('missing required fields (player, stat_type, or line)');
}
const roster = await lookupPlayer({ player, sport });
if (!roster) errors.push('player_not_found_in_id_map');
const teamAbbr = roster?.team_abbr ?? null;
const playerId = roster?.espn_id ?? null;
// Session 64 (Order 1.6) — bind opponent/home-away features to the prop's
// REAL game. `gameBinder` attaches game_date in snapshotService before
// grading, so grading references the same game as the ledger/retention.
// With no bound date we do NOT fall back to a dateless "today" lookup —
// that is exactly what bound the wrong opponent. The features simply stay
// absent, and engine1 omits the factors rather than scoring a wrong matchup.
const boundGameDate = rawProp.game_date
|| (rawProp.game_time ? dateETOf(rawProp.game_time) : null);
const game = (teamAbbr && boundGameDate)
? await lookupGameOnDate({ sport, teamAbbr, gameDate: boundGameDate })
: null;
if (teamAbbr && !boundGameDate) errors.push('no_bound_game_date');
if (!game) errors.push('no_game_scheduled_today');
// Session 63 — fetch the normalized per-game rows ONCE. They feed three
// consumers that were all starving: the consistency factor, the probability
// estimator (via meta.gameLogs), and game_count_in_7d below.
const statRows = await featureCache.getStatRows(player, sport, statType);
const gameContext = {
home_away: game ? (game.isHome ? 'home' : 'away') : null,
// `game_count_in_7d` gates engine1's heavy_workload_7d (-0.5). Nothing ever
// populated it, so that factor could not fire. Derived from real logged
// game dates; null (omitted) when we have no dated rows.
game_count_in_7d: featureCache.gameCountInWindow(statRows, 7),
// DELIBERATELY NOT SET: `teamId`. It was tempting to thread it here to
// unlock injuryFeatures, but that would be dead code dressed as a fix —
// three things block that factor and none is solved by a teamId here:
// 1. getFeatures reads `teamId` as a TOP-LEVEL input, not off gameContext;
// 2. `player_id_map` has no team_id column (lookupPlayer selects
// espn_id/team_abbr only), so there is no id to pass;
// 3. injury_severity_score counts MISSING KNOWN STARTERS and no starter-id
// list exists, so it resolves to 0 and engine1's factor (needs >= 2)
// still cannot fire.
// There is also an unresolved semantic: the factor is documented as
// OPPONENT injuries but getFeatures passes `teamId`, with `opponentTeamId`
// sitting unused beside it. Left alone on purpose — see
// specs/audit-data/grade-collapse.md.
// DELIBERATELY NOT SET: `season_type`. engine1's playoff factors gate on
// `season_type >= 2`, but ESPN's season_type 2 means REGULAR season — so
// threading it raw would fire "veteran_in_playoffs" in July. The factor also
// needs career_playoff_games, which only the offline Python service
// provides. Left unset on purpose; see specs/audit-data/grade-collapse.md.
};
const features = await safeGetFeatures({
playerId,
playerName: player,
statType,
sport,
teamAbbr,
opponentAbbr: game?.opponentAbbr ?? null,
gameId: game?.gameId ?? null,
gameContext,
});
if (!features || Object.keys(features).length === 0) {
errors.push('no_features_computed');
}
// Session 14 — Tank01 augmentation. Sport-specific. Both calls are
// cache-only (no network), Promise.allSettled-style isolated so a
// Redis hiccup on the Tank01 read doesn't fail the whole grade.
// The `t01_*` fields land alongside the ESPN-derived features;
// grading + reasoning + trap detection read them when present and
// ignore them when absent.
// Session 64 (Order 1.6) — same class of bug as the scoreboard lookup: this
// was TODAY's UTC date, so a late-slot grade read the wrong day's Tank01
// cache. Use the prop's BOUND game date; fall back to today only when there
// is no bound game (the t01_* fields are additive and simply stay absent).
const ymd = (boundGameDate || new Date().toISOString().slice(0, 10)).replace(/-/g, '');
try {
if (sport === 'nba') {
const aug = await tank01Augment.augmentNbaFeatures({
gameId: game?.gameId ?? null,
playerName: player,
ymd,
});
Object.assign(features, aug);
} else if (sport === 'mlb') {
const aug = await tank01Augment.augmentMlbFeatures({
gameId: game?.gameId ?? null,
batterName: player,
// batterId/pitcherId/pitcherName not yet plumbed through
// computeFeatures — the augmentor returns name-only markers
// when IDs are absent.
ymd,
});
Object.assign(features, aug);
}
} catch (err) {
// Never let augmentation failure poison the grade.
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
}
// Session 15 — static context augmentation. Park factors (MLB),
// pace factors (NBA). Synchronous, can't fail; the lookups return
// null on miss, which we treat as "no signal — drop the field".
try {
if (sport === 'mlb') {
// Home team in this matchup hosts the game; if the player's
// team is home, use their abbr — otherwise use the opponent's.
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
const park = getParkFactor(homeAbbr);
if (park) {
features.park_hr = park.hr;
features.park_h = park.h;
features.park_r = park.r;
features.park_home = homeAbbr;
}
} else if (sport === 'nba') {
// Pace factors are per-team — use the player's own team (fast
// teams up the count regardless of opponent, slow teams
// compress). Opponent pace effect is a separate signal we
// could layer in a follow-up.
const pace = getPaceFactor(teamAbbr);
if (pace != null) features.pace_factor = pace;
const oppPace = getPaceFactor(game?.opponentAbbr);
if (oppPace != null) features.opp_pace_factor = oppPace;
}
} catch (err) {
console.warn('[computeFeatures] static context augmentation skipped:', err.message);
}
// Session 15 — weather. Open-Meteo via weatherService. 5s timeout,
// 1h Redis cache, dome-aware skip. Outdoor MLB + soccer benefit;
// basketball indoor venues skip entirely.
try {
if (sport === 'mlb') {
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
const venue = homeAbbr ? getMlbVenue(homeAbbr) : null;
if (venue && !venue.dome && Number.isFinite(venue.lat) && Number.isFinite(venue.lon)) {
const w = await weatherService.getWeather(venue.lat, venue.lon);
if (w) {
features.weather_temp_f = w.temp_f ?? null;
features.weather_wind_mph = w.wind_mph ?? null;
features.weather_wind_dir = w.wind_dir ?? null;
features.weather_precip = w.precip_mm ?? null;
}
}
}
// Soccer weather slots in via the soccer branch (handled earlier
// for the soccer sport — the venue is part of the cascade).
} catch (err) {
console.warn('[computeFeatures] weather lookup skipped:', err.message);
}
const trap = await safeGetTrap({
playerName: player,
statType,
sport,
gameId: game?.gameId ?? null,
gameContext,
features,
odds: { playerLine: line, consensus: null },
});
const { result: consistency, gameLogs } = await safeGetConsistency({
playerName: player, sport, statType, statRows,
});
return {
// Shape engine1.gradeProp() consumes.
features,
trap,
consistency,
prop: { line, direction },
// Extra context the wiring helper (Fix 2) uses to build human-readable
// reasoning sentences. Not consumed by engine1 itself.
meta: {
player,
statType,
line,
direction,
book,
sport,
teamAbbr,
playerId,
opponentAbbr: game?.opponentAbbr ?? null,
gameId: game?.gameId ?? null,
isHome: game?.isHome ?? null,
gameLogs,
errors,
},
};
}
module.exports = {
computeFeaturesForProp,
__internals: {
lookupPlayer,
lookupGameOnDate,
safeGetFeatures,
safeGetTrap,
safeGetConsistency,
},
};