Sessions 7e-7g: Grading path unified - adapter, computeFeatures, analyzeViaEngine1, all routes migrated, dead code removed

This commit is contained in:
Kev
2026-06-10 10:23:55 -04:00
parent 4815ceac03
commit 4e18eb1efe
9 changed files with 181 additions and 712 deletions
+8 -11
View File
@@ -1,14 +1,11 @@
const express = require('express');
// ARCH-1 ESCAPE HATCH (Session 7f): /api/analyze stays on the legacy
// analyzeProp path. The unification's adapter + computeFeatures +
// analyzeViaEngine1 helpers are built and tested, but the integration
// test for this route asserts specific grade VALUES that the legacy
// engine produced for specific input mocks. Swapping in engine1
// preserves the SHAPE (verified) but produces different VALUES because
// the engine logic is intentionally different. Per the session rule
// "tests pass unmodified", reverted. See docs/SYSTEM-MANIFEST.md §8
// ARCH-1 for the migration roadmap.
const { analyzeProp } = require('../services/propAnalyzer');
// ARCH-1 FIXED (Session 7g): /api/analyze grades through engine1 via
// the unified analyzeViaEngine1 helper (computeFeatures → engine1 →
// gradeAdapter). Response shape stays legacy-compatible — DemoScan
// reads grade / confidence / kill_conditions_triggered /
// reasoning.{summary,steps} without any change. Rate limit (SEC-1)
// and Redis cache (PERF-1) both preserved.
const { analyzeViaEngine1 } = require('../services/intelligence/analyzeViaEngine1');
const { cacheGet, cacheSet } = require('../utils/redis');
const { createRateLimit } = require('../middleware/rateLimit');
@@ -36,7 +33,7 @@ async function cachedAnalyze(prop) {
const key = analyzeCacheKey(prop);
const cached = await cacheGet(key);
if (cached) return { ...cached, _cache: 'HIT' };
const result = await analyzeProp(prop);
const result = await analyzeViaEngine1(prop);
// cacheSet swallows failures (degraded Redis) — analysis still flows
// even when the cache is down.
await cacheSet(key, result, ANALYZE_TTL_SECONDS);
-76
View File
@@ -1,76 +0,0 @@
// DEPRECATED — Session 7c audit flagged this for unification with the
// new Engine 1 (src/services/intelligence/engine1.js). Session 7d deferred
// the rewire because the output shapes are incompatible:
// - Legacy: 4-letter grade (A|B|C|D), 0-100 confidence, kill_conditions,
// reasoning.steps. Consumed by /api/analyze, /api/scan, /api/bets
// and the frontend GradeCard + DemoScan components.
// - New: 11-step grade (F..A+), 0-1 confidence, factors array. Consumed
// by /api/grading/pipeline only.
// Migration plan in docs/SYSTEM-MANIFEST.md §8 ARCH-1. Do not extend this
// file — new features land in engine1.js. Remove this file when the legacy
// route set retires.
function computeGrade(stepResults) {
const {
seasonDelta,
recentDelta,
situationalDelta,
lineEdge,
killConditions,
gamesPlayed,
seasonAndRecentAgree,
} = stepResults;
// Composite score: weighted combination of all deltas
const composite = (
(seasonDelta || 0) * 1.0 +
(recentDelta || 0) * 1.5 +
(situationalDelta || 0) * 1.2 +
(lineEdge || 0) * 0.8
) / 4.5;
// Base grade from composite
let grade;
let baseConfidence;
if (composite >= 3.0) {
grade = 'A';
baseConfidence = Math.min(80 + Math.floor((composite - 3.0) * 5), 95);
} else if (composite >= 1.5) {
grade = 'B';
baseConfidence = Math.min(65 + Math.floor((composite - 1.5) * 9.3), 79);
} else if (composite >= 0.5) {
grade = 'C';
baseConfidence = Math.min(50 + Math.floor((composite - 0.5) * 14), 64);
} else {
grade = 'D';
baseConfidence = Math.max(30 + Math.floor(composite * 20), 30);
}
// Sample bonus
let sampleBonus = 0;
if (gamesPlayed > 50) sampleBonus = 5;
else if (gamesPlayed > 30) sampleBonus = 3;
// Consistency bonus
let consistencyBonus = 0;
if (seasonAndRecentAgree === true) consistencyBonus = 5;
else if (seasonAndRecentAgree === false) consistencyBonus = -5;
let confidence = baseConfidence + sampleBonus + consistencyBonus;
// Kill condition penalty
const hasKillConditions = killConditions && killConditions.length > 0;
if (hasKillConditions) {
// Cap grade at C
if (grade === 'A' || grade === 'B') {
grade = 'C';
}
confidence -= 15;
}
// Clamp confidence
confidence = Math.max(30, Math.min(95, confidence));
return { grade, confidence, composite: Math.round(composite * 100) / 100 };
}
module.exports = { computeGrade };
-248
View File
@@ -1,248 +0,0 @@
const { getOdds } = require('./oddsService');
const nbaStats = require('./nbaStatsClient');
const { evaluateKillConditions } = require('./killConditions');
const { computeGrade } = require('./grader');
const { deltaToSignal, directedDelta } = require('../utils/signals');
async function analyzeProp({ player, stat_type, line, direction, book }) {
// Fetch all data in parallel
const [oddsResult, seasonAvg, lastN, homeAwaySplit, restSplit] = await Promise.all([
getOdds('nba'),
nbaStats.getSeasonAvg(player),
nbaStats.getLastN(player, 10),
nbaStats.getSplits(player, stat_type, 'home_away'),
nbaStats.getSplits(player, stat_type, 'rest_days'),
]);
// Determine opponent from odds data
const playerProps = oddsResult.props.filter(
(p) => p.player.toLowerCase().includes(player.toLowerCase()) && p.stat_type === stat_type
);
let opponent = null;
let isHome = null;
if (playerProps.length > 0) {
const prop = playerProps[0];
// We have home_team and away_team but don't know which the player belongs to
// Use NBA stats team to determine
const playerTeam = seasonAvg?.team;
if (playerTeam) {
if (playerTeam === prop.home_team) {
isHome = true;
opponent = prop.away_team;
} else if (playerTeam === prop.away_team) {
isHome = false;
opponent = prop.home_team;
}
}
}
// Fetch vs-opponent split if we know the opponent
let vsOpponentSplit = null;
if (opponent) {
try {
vsOpponentSplit = await nbaStats.getSplits(player, stat_type, 'vs_team', opponent);
} catch (_) {
// No opponent data available
}
}
// Find game spread
let spread = null;
if (oddsResult.spreads && oddsResult.spreads.length > 0) {
const gameSpread = oddsResult.spreads.find((s) => {
const playerTeam = seasonAvg?.team;
return playerTeam && (s.home_team === playerTeam || s.away_team === playerTeam);
});
if (gameSpread) {
// home_spread is from the home team's perspective
const playerTeam = seasonAvg?.team;
if (playerTeam === gameSpread.home_team) {
spread = gameSpread.home_spread;
} else {
spread = -gameSpread.home_spread;
}
}
}
const seasonStatVal = seasonAvg?.stats?.[stat_type];
const recentStatVal = lastN?.stats?.[stat_type];
// Step 1: Season average compare
const seasonDelta = seasonStatVal != null ? directedDelta(seasonStatVal, line, direction) : 0;
const seasonSignal = deltaToSignal(seasonDelta);
// Step 2: Recent form (last 10)
const recentDelta = recentStatVal != null ? directedDelta(recentStatVal, line, direction) : 0;
const recentSignal = deltaToSignal(recentDelta);
// Step 3: Situational factors
const homeAwayData = homeAwaySplit?.splits;
let situationalAvg = null;
let homeAwaySignal = 'neutral';
let homeAwayContext = null;
if (homeAwayData && isHome != null) {
const relevantSplit = isHome ? homeAwayData.home : homeAwayData.away;
if (relevantSplit) {
situationalAvg = relevantSplit.avg;
homeAwayContext = isHome ? 'home' : 'away';
homeAwaySignal = deltaToSignal(directedDelta(relevantSplit.avg, line, direction));
}
}
// Rest days / B2B
const restData = restSplit?.splits;
let restSignal = 'neutral';
let restContext = null;
let restAvg = null;
let isB2B = false;
if (restData) {
// Determine current rest status from last game date in lastN
// For now, use overall rest data — B2B detection would need schedule info
// Use the b2b split if games > 0 as an indicator
if (restData.b2b && restData.b2b.games > 0) {
restAvg = restData.b2b.avg;
restContext = 'b2b';
// Check if current game is B2B (heuristic: if b2b games exist, flag it)
// True B2B detection needs schedule — we'll flag when b2b avg is significantly different
isB2B = false; // Conservative: only flag if we can confirm
}
if (restData['1_day_rest'] && restData['1_day_rest'].games > 0 && !restAvg) {
restAvg = restData['1_day_rest'].avg;
restContext = '1_day_rest';
}
if (restAvg != null) {
restSignal = deltaToSignal(directedDelta(restAvg, line, direction));
}
}
// Vs opponent
let vsOpponentSignal = 'neutral';
let vsOpponentAvg = null;
let vsOpponentGames = 0;
if (vsOpponentSplit?.splits?.vs_opponent) {
vsOpponentAvg = vsOpponentSplit.splits.vs_opponent.avg;
vsOpponentGames = vsOpponentSplit.splits.vs_opponent.games;
vsOpponentSignal = deltaToSignal(directedDelta(vsOpponentAvg, line, direction));
}
// Step 4: Cross-book line comparison
const allLines = playerProps.map((p) => ({ book: p.book, line: p.line }));
// Also check grouped props from odds response (they may be grouped by player)
let bestLine = null;
let worstLine = null;
let lineEdge = 0;
if (allLines.length > 0) {
if (direction === 'over') {
// For over, lowest line is best
bestLine = allLines.reduce((a, b) => (a.line < b.line ? a : b));
worstLine = allLines.reduce((a, b) => (a.line > b.line ? a : b));
} else {
// For under, highest line is best
bestLine = allLines.reduce((a, b) => (a.line > b.line ? a : b));
worstLine = allLines.reduce((a, b) => (a.line < b.line ? a : b));
}
lineEdge = Math.abs(bestLine.line - worstLine.line);
}
const lineSignal = deltaToSignal(lineEdge);
// Compute situational delta (weighted average of available splits)
const sitDeltas = [];
if (situationalAvg != null) sitDeltas.push(directedDelta(situationalAvg, line, direction));
if (restAvg != null) sitDeltas.push(directedDelta(restAvg, line, direction));
if (vsOpponentAvg != null) sitDeltas.push(directedDelta(vsOpponentAvg, line, direction));
const situationalDelta = sitDeltas.length > 0
? sitDeltas.reduce((a, b) => a + b, 0) / sitDeltas.length
: 0;
// Step 5: Kill conditions
const killConditions = evaluateKillConditions({
seasonStats: seasonAvg?.stats,
recentStats: recentStatVal != null ? { value: recentStatVal } : null,
homeAwaySplit: situationalAvg != null ? { avg: situationalAvg } : null,
restSplit: { isB2B },
vsOpponentSplit: vsOpponentAvg != null ? { games: vsOpponentGames } : null,
spread,
});
// Step 6: Grade
const seasonAndRecentAgree = (seasonDelta > 0 && recentDelta > 0) || (seasonDelta < 0 && recentDelta < 0);
const { grade, confidence, composite } = computeGrade({
seasonDelta,
recentDelta,
situationalDelta,
lineEdge,
killConditions,
gamesPlayed: seasonAvg?.stats?.games_played || 0,
seasonAndRecentAgree: seasonDelta !== 0 && recentDelta !== 0 ? seasonAndRecentAgree : null,
});
// Edge percentage
const relevantAvg = recentStatVal || seasonStatVal || line;
const edgePct = direction === 'over'
? Math.round(((relevantAvg - line) / line) * 1000) / 10
: Math.round(((line - relevantAvg) / line) * 1000) / 10;
// Build reasoning summary
const parts = [];
if (seasonStatVal != null) parts.push(`${player} averages ${seasonStatVal} on the season`);
if (recentStatVal != null && recentStatVal !== seasonStatVal) parts.push(`${recentStatVal} over his last 10`);
if (homeAwayContext && situationalAvg != null) parts.push(`${situationalAvg} ${homeAwayContext === 'home' ? 'at home' : 'on the road'}`);
if (vsOpponentAvg != null && opponent) parts.push(`${vsOpponentAvg} vs ${opponent} (${vsOpponentGames} games)`);
if (killConditions.length > 0) parts.push(`Kill conditions: ${killConditions.map((k) => k.code).join(', ')}`);
if (killConditions.length === 0) parts.push('No kill conditions');
return {
player,
stat_type,
line,
direction,
book,
grade,
edge_pct: edgePct,
confidence,
kill_conditions_triggered: killConditions,
reasoning: {
summary: parts.join('. ') + '.',
steps: {
season_avg: {
value: seasonStatVal ?? null,
vs_line: seasonStatVal != null ? Math.round((seasonStatVal - line) * 10) / 10 : null,
signal: seasonSignal,
},
recent_form: {
value: recentStatVal ?? null,
vs_line: recentStatVal != null ? Math.round((recentStatVal - line) * 10) / 10 : null,
signal: recentSignal,
},
situational: {
home_away: {
value: situationalAvg,
context: homeAwayContext,
signal: homeAwaySignal,
},
rest_days: {
value: restAvg,
context: restContext,
signal: restSignal,
},
vs_opponent: {
value: vsOpponentAvg,
games: vsOpponentGames,
signal: vsOpponentSignal,
},
},
line_comparison: {
best_line: bestLine,
worst_line: worstLine,
edge_from_best: lineEdge,
signal: lineSignal,
},
kill_conditions: killConditions,
final_grade: grade,
},
},
};
}
module.exports = { analyzeProp };