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