Files
vyndr/src/services/intelligence/computeFeatures.js
T
builtbykev f7cc19772b Canonical MLB participant: prove the human, then dedupe
VYNDR had three definitions of "same player": raw p.player (dedupe),
utils/normalize.normalizeName (features, NO nicknames), utils/playerName.nameKey
(retention, WITH nicknames). Retention was the first layer to notice they
disagreed, and all it could do was discard the loser — 28 collisions.

The fix is not a better spelling. The event-scoped roster ALREADY carries the
StatsAPI personId; buildPlayerTeamIndex was fetching and discarding it.

SCOPE IS LOAD-BEARING, and this is the finding that shaped the design. Measured
on the real 2026-08-28 league rosters (1,583 rows, 30 teams): a bare name key is
NOT globally unique — `max muncy` (ATH 691777 / LAD 571970), `jose fermin`
(665877/820862) and `luis garcia` (472610/671277) each resolve to TWO different
humans. Team-scoped: 0 ambiguous. Event-scoped across 33 real events including
the verified doubleheader date: 0 ambiguous. So resolution is scoped to the two
teams actually playing, and FAILS CLOSED — no event, no game, no candidate, or
more than one candidate yields null and the prop keeps prior behaviour.

FEATURE IDENTITY PARITY, measured on both real cohorts before writing the patch
(504 participants):
  RAW_SUCCESS/CANONICAL_SUCCESS/SAME id      365
  RAW_SUCCESS/CANONICAL_SUCCESS/DIFFERENT id   0   <- required 0
  RAW_SUCCESS/CANONICAL_FAIL                   0   <- required 0
  CANONICAL_AMBIGUOUS                          0   <- required 0
  RAW_FAIL/CANONICAL_SUCCESS                   7   <- repair, not regression
The seven improvements are exactly the alias class: Michael->Mickey Gasper,
AJ->A.J. Ewing, JT->J.T. Realmuto, Mike->Michael Busch, Richard->Richie
Palacios. This is why raw spelling could not be trusted: player_id_map holds
`mickey gasper` and NOT `michael gasper`, so under the raw name the lookup
outcome depended on which alias happened to survive dedupe. Provider arrival
order was deciding model input availability.

ALL SEVEN COLLISION GROUPS PROVEN SAME_PLAYER_ALIAS against the authoritative
roster for the exact event date — one personId each (681715, 699625, 676356,
695491, 673357, 681508, 671739), zero false normalizations, zero unresolved —
and all seven collapse to one participant under the new key, so the 14 colliding
propositions merge upstream instead of being discarded downstream.

FALSE-MERGE SIMULATION on both cohorts: cross-event 0, different-stat 0,
different-line 0, false merges 0. Note honestly: the retained rows cannot
exhibit the merges themselves, because retention already discarded the losers —
so the merge half is proven directly on the alias groups, the safety half on the
cohorts.

RETENTION IS UNTOUCHED — schema, player_key, conflict identity and the collision
counter are all unchanged. collision_count must reach 0 by upstream repair, never
by making the counter lenient. A teeth proof injects raw spelling into the
retention identity and the suite rejects it.

Source provenance preserved: p.player is never overwritten; the participant
rides beside it as mlb_person_id + canonical_player_name.

Twelve teeth against a green baseline of 117 — dedupe back to raw name (3),
feature lookup back to the alias (2), resolver stops blocking ambiguity (1),
lookup degraded to raw (1), event scope dropped (1), event/line/stat dropped
from the key (2/2/2), provenance destroyed (1), raw spelling in retention
identity (2), collision_count accepted nonzero (2), and a guard proving the
frozen game-date repair cannot be reverted (10). Restored byte-identically.
Teeth #4 (canonical resolving to a DIFFERENT feature id) is covered by the
real-data parity measurement rather than an injected branch, because it is a
data comparison, not a code path — stated plainly rather than claimed as a test.

No hardcoded player names in production code — a test greps for all seven.

4 files, 86 insertions. retentionService, gameBinder, oddsService,
acquisitionTrace, probabilityEstimator, bookRoles, playerName, normalize and
snapshotScheduler: UNCHANGED. Admission rules, MODEL_BOOK filters and model
formulas: 0 changed lines. The game-date repair is intact.

391 suites / 5,339 tests pass. web tsc exit 0. Lineage stays OFF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
2026-08-28 12:54:35 -04:00

476 lines
21 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;
// FEATURE IDENTITY. Provider alias choice must not decide whether a proven
// athlete resolves. `player_id_map` holds `mickey gasper` and NOT
// `michael gasper`, so with the raw spelling the lookup depended on which
// alias happened to survive. When event-scoped roster resolution proved the
// participant, use the canonical roster name; otherwise the raw name, exactly
// as before. No formula changes — only which truthful representation is
// handed to the existing lookup.
const featurePlayer = rawProp.canonical_player_name || 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: featurePlayer, 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(featurePlayer, 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,
},
};