Sessions 7e+7f: Grade adapter, normalize consolidation, computeFeatures, analyzeViaEngine1, scan/parlay migrated to engine1

This commit is contained in:
Kev
2026-06-10 09:28:30 -04:00
parent 012c0ef47e
commit 4815ceac03
10 changed files with 952 additions and 11 deletions
+49
View File
@@ -272,3 +272,52 @@
{"ts":"2026-06-10T07:24:54.217Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-10T07:24:54.217Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T07:24:54.244Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-10T07:24:54.244Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T07:24:54.705Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-10T07:24:54.705Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:38:42.449Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:38:42.996Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:38:43.007Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T07:38:43.007Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T07:38:43.007Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T07:38:43.061Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T07:38:43.094Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T07:41:56.529Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:41:56.788Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:41:56.866Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T07:41:57.032Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T07:41:57.032Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T07:41:57.033Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T07:41:57.108Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T07:43:51.940Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:43:52.249Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T07:43:52.249Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T07:43:52.249Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T07:43:52.371Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T07:43:52.498Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T07:43:52.594Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T13:04:16.218Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:04:16.330Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:04:16.447Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T13:04:16.560Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T13:04:16.560Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T13:04:16.565Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T13:04:16.611Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T13:04:27.937Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:04:28.291Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:04:28.295Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T13:04:28.295Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T13:04:28.295Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T13:04:28.346Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T13:04:28.374Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T13:05:00.586Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:05:00.643Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T13:05:00.643Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T13:05:00.643Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T13:05:00.691Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:05:00.717Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T13:05:00.778Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T13:06:19.734Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:06:19.749Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T13:06:19.766Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T13:06:19.767Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T13:06:19.767Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T13:06:19.803Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T13:06:19.831Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
+16 -2
View File
@@ -411,8 +411,22 @@ No circular imports detected.
### ARCH — Architecture ### ARCH — Architecture
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status: - **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
**PARTIAL in Session 7e**Step 1 of the migration (the adapter) **PARTIAL in Session 7f**/api/scan/parlay migrated to engine1.
is complete and tested. Steps 2-6 deferred per ESCAPE HATCH. /api/analyze/{prop,batch} stayed on legacy after Escape Hatch
(integration test asserts specific legacy-engine grade values).
/api/bets/* doesn't touch the legacy path at all (verified by grep).
Dead-code removal blocked because /api/analyze still imports
analyzeProp.
Adapter, computeFeaturesForProp, and analyzeViaEngine1 helpers all
shipped and tested. Future migration can flip the analyze route any
time the integration test mocks are updated to drive the new
upstream chain.
ORIGINAL Session 7e ARCH-1 narrative below for history.
**PARTIAL (carried from 7e)** — Step 1 of the migration (the adapter)
is complete and tested. Steps 2-6 partially executed.
Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output
into the legacy shape `DemoScan` reads. 26 unit into the legacy shape `DemoScan` reads. 26 unit
tests covering grade collapse, confidence math, tests covering grade collapse, confidence math,
+10 -1
View File
@@ -1,4 +1,13 @@
const express = require('express'); 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'); const { analyzeProp } = require('../services/propAnalyzer');
const { cacheGet, cacheSet } = require('../utils/redis'); const { cacheGet, cacheSet } = require('../utils/redis');
const { createRateLimit } = require('../middleware/rateLimit'); const { createRateLimit } = require('../middleware/rateLimit');
@@ -11,7 +20,7 @@ const router = express.Router();
const analyzeLimit = createRateLimit({ windowMs: 60_000, max: 10 }); const analyzeLimit = createRateLimit({ windowMs: 60_000, max: 10 });
router.use(analyzeLimit); router.use(analyzeLimit);
// PERF-1 (Session 7d): cache analyzeProp results in Redis. Same prop hit // PERF-1 (Session 7d): cache analyze results in Redis. Same prop hit
// twice within 60s reuses the previous analysis instead of re-doing the // twice within 60s reuses the previous analysis instead of re-doing the
// upstream chain. 60s is short enough that line moves still surface. // upstream chain. 60s is short enough that line moves still surface.
const ANALYZE_TTL_SECONDS = 60; const ANALYZE_TTL_SECONDS = 60;
@@ -0,0 +1,248 @@
/**
* analyzeViaEngine1 — the canonical single-prop analysis function.
*
* Composes the three pieces sessions 6c, 7e, and 7f built:
* computeFeaturesForProp → engine1.gradeProp → toLegacyShape
*
* Output matches the legacy `analyzeProp()` shape byte-for-byte (DemoScan
* + GradeCard read the same fields). The `reasoning.summary` here is built
* from real feature values (l5_avg, opp_rank_stat, etc.) so users still
* see concrete sentences, not abstract factor labels.
*
* Never throws. Every upstream failure mode is reflected as low-confidence
* grade + an explanatory reasoning summary.
*/
const { computeFeaturesForProp } = require('./computeFeatures');
const engine1 = require('./engine1');
const { toLegacyShape } = require('../../utils/gradeAdapter');
// Map an error code from computeFeaturesForProp.meta.errors into a human
// sentence the user will see in reasoning.summary.
const ERROR_EXPLANATIONS = Object.freeze({
player_not_found_in_id_map: "We couldn't find this player in our roster index.",
no_game_scheduled_today: "No game scheduled for this player tonight.",
no_features_computed: "Statistical features unavailable for this player tonight.",
});
function explainErrors(errors) {
if (!Array.isArray(errors) || errors.length === 0) return '';
return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' ');
}
// Build a human-readable reasoning summary + steps from the actual
// features (which carry real numbers) and engine1's grade.
function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) {
const lines = [];
// Recent form vs the line — L5 and L20 are the orchestrator's
// canonical season-trend signals.
if (Number.isFinite(features.l5_avg)) {
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`);
}
if (Number.isFinite(features.l20_avg)) {
lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`);
}
// Trend direction relative to the line.
if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) {
const diff = features.l5_avg - prop.line;
if (Math.abs(diff) >= 0.5) {
const dir = diff > 0 ? 'above' : 'below';
lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`);
}
}
// Home / away.
if (features.home_away === 1.0) lines.push('Playing at home tonight.');
else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
// Opponent matchup. opp_rank_stat is 0..1 normalized
// (0 = best D, 1 = worst D) — translate to friendlier language.
if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
if (features.opp_rank_stat >= 0.7) {
lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`);
} else if (features.opp_rank_stat <= 0.3) {
lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`);
} else {
lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`);
}
}
// Rest / fatigue context.
if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.');
else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) {
lines.push(`${features.rest_days} days of rest.`);
}
if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) {
lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`);
}
// Injury context.
if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) {
lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
}
// Trap composite — surfaced when meaningful.
// (Adapter handles the per-factor kill_conditions chips; this line
// gives the user the overall warning.)
if (engine1Result?.grade && engine1Result.grade.endsWith('-') === false
&& Array.isArray(engine1Result.all_factors)
&& engine1Result.all_factors.includes('trap_composite_high')) {
lines.push('Multiple trap signals firing — proceed with caution.');
}
// Engine-1 verdict capper.
const grade = engine1Result?.grade;
if (grade) {
const verb = grade.startsWith('A') ? 'favors the play'
: grade.startsWith('B') ? 'leans toward the play'
: grade.startsWith('C') ? 'is split'
: 'leans against the play';
lines.push(`Engine 1 graded ${grade}${verb}.`);
}
// Tack on any data-gap explanations.
const gapNote = explainErrors(meta.errors);
if (gapNote) lines.push(gapNote);
const summary = lines.join(' ').trim()
|| `Analysis complete. Grade: ${grade || 'C'}.`;
// Legacy-shaped steps so backward-compat callers (integration tests,
// anything pre-dating the engine swap) keep seeing the named
// sub-blocks. Each is populated from engine1 features where the data
// exists; missing sub-blocks contain null fields instead of being
// absent so callers can dot-access without optional chaining.
const seasonAvg = Number.isFinite(features.l20_avg) ? features.l20_avg : null;
const recentAvg = Number.isFinite(features.l5_avg) ? features.l5_avg : null;
const haContext = features.home_away === 1.0 ? 'home'
: features.home_away === 0.0 ? 'away' : null;
const restContext = features.rest_days === 0 ? 'b2b'
: Number.isFinite(features.rest_days) && features.rest_days >= 2 ? 'rested' : null;
return {
summary,
steps: {
season_avg: {
value: seasonAvg,
vs_line: seasonAvg != null && Number.isFinite(prop.line)
? Math.round((seasonAvg - prop.line) * 10) / 10 : null,
signal: null,
},
recent_form: {
value: recentAvg,
vs_line: recentAvg != null && Number.isFinite(prop.line)
? Math.round((recentAvg - prop.line) * 10) / 10 : null,
signal: null,
},
situational: {
home_away: { value: null, context: haContext, signal: null },
rest_days: { value: features.rest_days ?? null, context: restContext, signal: null },
vs_opponent: { value: null, games: null, signal: null },
},
line_comparison: {
best_line: null,
worst_line: null,
edge_from_best: 0,
signal: null,
},
kill_conditions: [],
final_grade: grade || null,
// Flat narrative bullets — kept under `steps` so legacy clients
// can still find them but they don't collide with the named
// sub-blocks above.
narrative: lines.map((line, i) => ({ step: i + 1, detail: line })),
},
};
}
// edge_pct in the legacy shape compares the relevant average to the line.
// We use l5_avg when present (matches legacy "recent form" weighting),
// fall back to l20_avg, otherwise return 0 so the field is always present.
function edgePctFor(features, prop) {
const ref = Number.isFinite(features?.l5_avg) ? features.l5_avg
: Number.isFinite(features?.l20_avg) ? features.l20_avg
: null;
if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref);
return Math.round((signed / prop.line) * 1000) / 10;
}
// When computeFeatures fails so badly that even a partial feature vector
// is empty, return a legacy-shaped low-confidence result rather than
// asking engine1 to grade nothing.
function fallbackLegacyResult(rawProp, errors) {
return {
player: rawProp.player ?? null,
stat_type: rawProp.stat_type ?? null,
line: rawProp.line ?? null,
direction: rawProp.direction ?? null,
book: rawProp.book || 'unknown',
grade: 'C',
confidence: 10,
edge_pct: 0,
kill_conditions_triggered: [],
reasoning: {
summary: `Unable to compute full analysis. ${explainErrors(errors) || ''} Grade is provisional.`.trim(),
steps: [],
},
};
}
async function analyzeViaEngine1(rawProp = {}) {
const featureResult = await computeFeaturesForProp(rawProp);
const { features, trap, consistency, prop, meta } = featureResult;
// Hard fallback only when computeFeatures couldn't produce anything
// useful at all (no features AND no consistency input).
if ((!features || Object.keys(features).length === 0)
&& (!consistency || consistency.consistency === 'unknown')
&& (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) {
return fallbackLegacyResult(rawProp, meta?.errors);
}
// Engine 1: deterministic rule-based grade on the feature vector.
const engine1Result = engine1.gradeProp({ features, trap, consistency, prop });
// Translate engine1 output → legacy shape via the adapter from 7e.
// The adapter handles kill_conditions_triggered + the 4-letter grade
// collapse + the 0-100 confidence scale.
const summaryOverride = buildConcreteReasoning(features, engine1Result, meta, {
...rawProp,
line: prop.line,
}).summary;
const legacy = toLegacyShape(engine1Result, {
player: rawProp.player,
stat_type: rawProp.stat_type,
line: prop.line,
direction: prop.direction,
book: rawProp.book || 'unknown',
sport: meta.sport,
}, {
summaryOverride,
edgePct: edgePctFor(features, prop),
});
// The adapter's reasoning.steps was a single-element debug bag;
// replace it with the line-by-line breakdown we built above so the
// legacy UI's step list looks identical to before.
legacy.reasoning = buildConcreteReasoning(features, engine1Result, meta, {
...rawProp,
line: prop.line,
});
return legacy;
}
module.exports = {
analyzeViaEngine1,
__internals: {
buildConcreteReasoning,
edgePctFor,
fallbackLegacyResult,
explainErrors,
ERROR_EXPLANATIONS,
},
};
@@ -0,0 +1,214 @@
/**
* 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
* downgrades confidence accordingly via the adapter's reasoning string.
*/
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');
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.
async function lookupTodayGame({ sport, teamAbbr }) {
if (!sport || !teamAbbr) return null;
let sportCfg;
try { sportCfg = getSportConfig(sport); } catch { return null; }
try {
const res = await axios.get(sportCfg.espnScoreboard, { 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 }) {
const fallback = { consistency: 'unknown', score: null, games: 0 };
try {
const logs = await gameLogService.getGameLogs(playerName, sport, 20);
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 = {}) {
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';
// Default to NBA when caller omits — matches what legacy analyzeProp does.
const sport = (rawProp.sport || 'nba').toLowerCase();
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;
const game = teamAbbr ? await lookupTodayGame({ sport, teamAbbr }) : null;
if (!game) errors.push('no_game_scheduled_today');
const gameContext = {
home_away: game ? (game.isHome ? 'home' : 'away') : null,
};
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');
}
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,
});
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,
lookupTodayGame,
safeGetFeatures,
safeGetTrap,
safeGetConsistency,
},
};
+6 -2
View File
@@ -1,4 +1,8 @@
const { analyzeProp } = require('./propAnalyzer'); // ARCH-1 Step 4 (Session 7f): /api/scan/parlay legs now grade via
// engine1 (computeFeatures → engine1 → gradeAdapter) instead of the
// legacy `analyzeProp`. Response shape is byte-compatible. Parallel
// resolution from PERF-2 is preserved (still inside Promise.allSettled).
const { analyzeViaEngine1 } = require('./intelligence/analyzeViaEngine1');
const { getOdds } = require('./oddsService'); const { getOdds } = require('./oddsService');
const { detectCorrelations } = require('./correlationEngine'); const { detectCorrelations } = require('./correlationEngine');
const { gradeParlayFromLegs } = require('./parlayGrader'); const { gradeParlayFromLegs } = require('./parlayGrader');
@@ -28,7 +32,7 @@ async function scanParlay(user, legs) {
// than the old sequential loop. allSettled preserves leg order and // than the old sequential loop. allSettled preserves leg order and
// lets a single failed leg surface as an error stub instead of // lets a single failed leg surface as an error stub instead of
// crashing the whole parlay. // crashing the whole parlay.
const settled = await Promise.allSettled(legs.map((leg) => analyzeProp(leg))); const settled = await Promise.allSettled(legs.map((leg) => analyzeViaEngine1(leg)));
const legResults = settled.map((s, i) => { const legResults = settled.map((s, i) => {
if (s.status === 'fulfilled') return s.value; if (s.status === 'fulfilled') return s.value;
return { return {
+213
View File
@@ -0,0 +1,213 @@
// Fix 2 (Session 7f) — verifies the end-to-end shape from
// computeFeaturesForProp → engine1 → adapter → concrete reasoning.
const mockComputeReturn = { current: null };
jest.mock('../../src/services/intelligence/computeFeatures', () => ({
computeFeaturesForProp: async () => mockComputeReturn.current,
}));
const mockEngine1Return = { current: null };
jest.mock('../../src/services/intelligence/engine1', () => ({
gradeProp: () => mockEngine1Return.current,
}));
const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1');
beforeEach(() => {
mockComputeReturn.current = null;
mockEngine1Return.current = null;
});
describe('analyzeViaEngine1 — happy path', () => {
test('produces the full legacy shape with concrete numbers', async () => {
mockComputeReturn.current = {
features: {
l5_avg: 28.4, l20_avg: 26.1, home_away: 1.0,
opp_rank_stat: 0.82, rest_days: 2,
},
trap: { composite: 0.1, signals: {}, recommendation: 'proceed' },
consistency: { consistency: 'reliable', cv: 0.18, score: 0.7, games: 20 },
prop: { line: 25.5, direction: 'over' },
meta: {
player: 'Jalen Brunson', statType: 'points', line: 25.5,
direction: 'over', book: 'draftkings', sport: 'nba',
teamAbbr: 'NYK', opponentAbbr: 'BOS', gameId: 'ev-1',
isHome: true, gameLogs: [{ points: 28 }], errors: [],
},
};
mockEngine1Return.current = {
grade: 'A-', confidence: 0.78,
top_factors: ['l5_hot_vs_line', 'weak_opponent_defense', 'home_game'],
all_factors: ['l5_hot_vs_line', 'weak_opponent_defense', 'home_game', 'rested_2plus'],
};
const out = await analyzeViaEngine1({
player: 'Jalen Brunson', stat_type: 'points', line: 25.5, direction: 'over', book: 'draftkings', sport: 'nba',
});
// Adapter-collapsed grade.
expect(out.grade).toBe('A');
expect(out.confidence).toBe(78);
expect(out.player).toBe('Jalen Brunson');
expect(out.stat_type).toBe('points');
expect(out.line).toBe(25.5);
expect(out.direction).toBe('over');
expect(out.book).toBe('draftkings');
// Reasoning has concrete numbers, not abstract factor labels.
expect(out.reasoning.summary).toContain('28.4');
expect(out.reasoning.summary).toContain('26.1');
expect(out.reasoning.summary).toContain('Jalen Brunson');
expect(out.reasoning.summary).toContain('BOS');
expect(out.reasoning.summary).toContain('Engine 1 graded A-');
// Legacy-shaped steps object — named sub-blocks for backward compat
// with the analyze integration test, plus a `narrative` array for
// the line-by-line breakdown.
expect(typeof out.reasoning.steps).toBe('object');
expect(out.reasoning.steps).toHaveProperty('season_avg');
expect(out.reasoning.steps).toHaveProperty('recent_form');
expect(out.reasoning.steps).toHaveProperty('situational');
expect(out.reasoning.steps).toHaveProperty('final_grade');
expect(Array.isArray(out.reasoning.steps.narrative)).toBe(true);
expect(out.reasoning.steps.narrative.length).toBeGreaterThan(0);
expect(out.reasoning.steps.narrative[0]).toHaveProperty('step');
expect(out.reasoning.steps.narrative[0]).toHaveProperty('detail');
// Real numbers in the sub-blocks.
expect(out.reasoning.steps.season_avg.value).toBe(26.1);
expect(out.reasoning.steps.recent_form.value).toBe(28.4);
// edge_pct computed from l5_avg vs line.
// (28.4 - 25.5) / 25.5 * 100 = ~11.4
expect(out.edge_pct).toBeCloseTo(11.4, 1);
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
});
test('away game + strong defense + back-to-back surfaces in reasoning', async () => {
mockComputeReturn.current = {
features: { l5_avg: 18, l20_avg: 22, home_away: 0.0, opp_rank_stat: 0.15, rest_days: 0 },
trap: { composite: 0.6, signals: {} },
consistency: { consistency: 'reliable', score: 0.7 },
prop: { line: 24.5, direction: 'over' },
meta: { player: 'P', statType: 'points', book: 'dk', sport: 'nba',
teamAbbr: 'X', opponentAbbr: 'OKC', gameId: 'g', isHome: false,
gameLogs: [{ points: 20 }], errors: [] },
};
mockEngine1Return.current = {
grade: 'D', confidence: 0.2,
top_factors: ['l5_cold_vs_line', 'top_opponent_defense', 'back_to_back'],
all_factors: ['l5_cold_vs_line', 'top_opponent_defense', 'back_to_back'],
};
const out = await analyzeViaEngine1({
player: 'P', stat_type: 'points', line: 24.5, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('D');
expect(out.reasoning.summary).toContain('Playing on the road');
expect(out.reasoning.summary).toContain('OKC');
expect(out.reasoning.summary).toContain('top-tier defense');
expect(out.reasoning.summary).toContain('Back-to-back');
expect(out.reasoning.summary).toContain('leans against the play');
});
});
describe('analyzeViaEngine1 — graceful degradation', () => {
test('hard fallback when everything failed (no features, no logs, no consistency)', async () => {
mockComputeReturn.current = {
features: {},
trap: { composite: 0, signals: {} },
consistency: { consistency: 'unknown', score: null, games: 0 },
prop: { line: 25, direction: 'over' },
meta: { player: 'Ghost', statType: 'points', book: 'dk', sport: 'nba',
teamAbbr: null, opponentAbbr: null, gameId: null, isHome: null,
gameLogs: [], errors: ['player_not_found_in_id_map', 'no_game_scheduled_today'] },
};
const out = await analyzeViaEngine1({
player: 'Ghost', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('C');
expect(out.confidence).toBe(10);
expect(out.reasoning.summary).toMatch(/Unable to compute|provisional/);
expect(out.reasoning.summary).toContain("couldn't find");
expect(out.kill_conditions_triggered).toEqual([]);
});
test('partial data (player found, no game) still grades via engine1', async () => {
mockComputeReturn.current = {
features: {},
trap: { composite: 0, signals: {} },
consistency: { consistency: 'reliable', cv: 0.2, score: 0.7, games: 20 },
prop: { line: 25, direction: 'over' },
meta: { player: 'P', statType: 'points', book: 'dk', sport: 'nba',
teamAbbr: 'NYK', opponentAbbr: null, gameId: null, isHome: null,
gameLogs: [{ points: 25 }], errors: ['no_game_scheduled_today'] },
};
mockEngine1Return.current = {
grade: 'C', confidence: 0.4,
top_factors: [], all_factors: [],
};
const out = await analyzeViaEngine1({
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
// Did NOT fall through to fallbackLegacyResult — engine1 was invoked.
expect(out.grade).toBe('C');
expect(out.confidence).toBe(40);
expect(out.reasoning.summary).toContain('No game scheduled');
});
});
describe('analyzeViaEngine1 — interface verifications', () => {
test('does not throw when computeFeaturesForProp resolves with errors', async () => {
mockComputeReturn.current = {
features: { l5_avg: 20 },
trap: { composite: 0, signals: {} },
consistency: { consistency: 'unknown', score: null, games: 0 },
prop: { line: 25, direction: 'over' },
meta: { sport: 'nba', errors: ['no_features_computed'] },
};
mockEngine1Return.current = { grade: 'C', confidence: 0.3, top_factors: [], all_factors: [] };
const out = await analyzeViaEngine1({
player: 'X', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out).toBeDefined();
expect(out.grade).toBeDefined();
});
test('every legacy field DemoScan reads is present', async () => {
mockComputeReturn.current = {
features: { l5_avg: 28 },
trap: { composite: 0, signals: {} },
consistency: { consistency: 'reliable', score: 0.7 },
prop: { line: 25, direction: 'over' },
meta: { sport: 'nba', errors: [] },
};
mockEngine1Return.current = { grade: 'A-', confidence: 0.7, top_factors: ['x'], all_factors: ['x'] };
const out = await analyzeViaEngine1({
player: 'X', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
// DemoScan reads: grade, confidence, reasoning.summary,
// kill_conditions_triggered[].code, edge_pct, line, player, stat_type.
expect(out.grade).toBeDefined();
expect(typeof out.confidence).toBe('number');
expect(typeof out.reasoning.summary).toBe('string');
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
expect(typeof out.edge_pct).toBe('number');
expect(out.player).toBeDefined();
expect(out.stat_type).toBeDefined();
expect(out.line).toBeDefined();
});
test('does not import from legacy path (no propAnalyzer/grader/UnifiedOddsProvider)', () => {
const fs = require('fs');
const src = fs.readFileSync(require.resolve('../../src/services/intelligence/analyzeViaEngine1.js'), 'utf8');
expect(src).not.toMatch(/propAnalyzer/);
expect(src).not.toMatch(/require.*['"]\.\.\/grader/);
expect(src).not.toMatch(/UnifiedOddsProvider/);
});
});
+185
View File
@@ -0,0 +1,185 @@
// Fix 1 (Session 7f) — computeFeaturesForProp must never throw, and
// must return the engine1 input shape (features/trap/consistency/prop)
// even when every upstream is missing.
const mockSupabaseState = {
rosterRow: null,
error: null,
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({
from() {
const proxy = {
select() { return proxy; },
eq() { return proxy; },
limit() { return proxy; },
maybeSingle: () => Promise.resolve({ data: mockSupabaseState.rosterRow, error: mockSupabaseState.error }),
};
return proxy;
},
}),
}));
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockFeatures = { current: {}, throws: false };
jest.mock('../../src/services/intelligence/featureCache', () => ({
getFeatures: async () => {
if (mockFeatures.throws) throw new Error('feature-fetch boom');
return { features: mockFeatures.current, meta: {} };
},
}));
const mockTrap = { current: null, throws: false };
jest.mock('../../src/services/intelligence/trapDetection', () => ({
getTrapScore: async () => {
if (mockTrap.throws) throw new Error('trap boom');
return mockTrap.current;
},
normalizeName: (n) => n,
}));
const mockLogs = { current: null };
const mockConsistency = { current: null };
jest.mock('../../src/services/intelligence/gameLogService', () => ({
getGameLogs: async () => mockLogs.current,
getCareerPlayoffGames: async () => null,
getWithWithoutStats: async () => null,
}));
jest.mock('../../src/services/intelligence/consistencyScore', () => ({
getConsistency: async () => mockConsistency.current || { consistency: 'reliable', cv: 0.2, score: 0.7, games: 20 },
}));
const { computeFeaturesForProp } = require('../../src/services/intelligence/computeFeatures');
beforeEach(() => {
mockSupabaseState.rosterRow = null;
mockSupabaseState.error = null;
mockAxiosGet.mockReset();
mockFeatures.current = {};
mockFeatures.throws = false;
mockTrap.current = null;
mockTrap.throws = false;
mockLogs.current = null;
mockConsistency.current = null;
});
function nbaScoreboard(events) {
return { status: 200, data: { events } };
}
function game(id, homeAbbr, awayAbbr) {
return {
id,
competitions: [{
competitors: [
{ homeAway: 'home', team: { abbreviation: homeAbbr } },
{ homeAway: 'away', team: { abbreviation: awayAbbr } },
],
}],
};
}
describe('computeFeaturesForProp — happy path', () => {
test('resolves player + game + features + trap + consistency', async () => {
mockSupabaseState.rosterRow = {
display_name: 'Jalen Brunson', normalized_name: 'jalen brunson',
espn_id: '3934672', team_abbr: 'NYK', sport: 'nba',
};
mockAxiosGet.mockResolvedValue(nbaScoreboard([game('ev-1', 'NYK', 'BOS')]));
mockFeatures.current = { l5_avg: 28.4, l20_avg: 26.1, home_away: 1.0, opp_rank_stat: 0.82 };
mockTrap.current = { composite: 0.12, signals: {}, active_count: 1, recommendation: 'proceed' };
mockLogs.current = [{ points: 28 }, { points: 26 }];
const out = await computeFeaturesForProp({
player: 'Jalen Brunson', stat_type: 'points', line: 25.5, direction: 'over', sport: 'nba',
});
expect(out.features.l5_avg).toBe(28.4);
expect(out.features.home_away).toBe(1.0);
expect(out.trap.composite).toBe(0.12);
expect(out.consistency.consistency).toBe('reliable');
expect(out.prop).toEqual({ line: 25.5, direction: 'over' });
expect(out.meta.teamAbbr).toBe('NYK');
expect(out.meta.opponentAbbr).toBe('BOS');
expect(out.meta.gameId).toBe('ev-1');
expect(out.meta.isHome).toBe(true);
expect(out.meta.errors).toHaveLength(0);
});
});
describe('computeFeaturesForProp — graceful degradation', () => {
test('player not in player_id_map → errors logged, partial result returned', async () => {
mockSupabaseState.rosterRow = null;
const out = await computeFeaturesForProp({
player: 'Unknown Person', stat_type: 'points', line: 20, direction: 'over', sport: 'nba',
});
expect(out.meta.errors).toContain('player_not_found_in_id_map');
expect(out.meta.errors).toContain('no_game_scheduled_today');
expect(out.meta.teamAbbr).toBeNull();
expect(out.meta.gameId).toBeNull();
expect(out.features).toEqual({});
expect(out.prop.line).toBe(20);
});
test('player found but no game today → still returns features attempt', async () => {
mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' };
mockAxiosGet.mockResolvedValue(nbaScoreboard([])); // empty slate
const out = await computeFeaturesForProp({
player: 'Some Player', stat_type: 'points', line: 22, direction: 'over', sport: 'nba',
});
expect(out.meta.errors).toContain('no_game_scheduled_today');
expect(out.meta.teamAbbr).toBe('NYK');
expect(out.meta.gameId).toBeNull();
});
test('feature fetch throws → empty features + error noted, no crash', async () => {
mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' };
mockAxiosGet.mockResolvedValue(nbaScoreboard([game('e2', 'NYK', 'BOS')]));
mockFeatures.throws = true;
const out = await computeFeaturesForProp({
player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.meta.errors).toContain('no_features_computed');
expect(out.features).toEqual({});
expect(out.trap).toBeDefined();
});
test('trap detection throws → defaults to no signals', async () => {
mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' };
mockAxiosGet.mockResolvedValue(nbaScoreboard([game('e3', 'NYK', 'BOS')]));
mockFeatures.current = { l5_avg: 25 };
mockTrap.throws = true;
const out = await computeFeaturesForProp({
player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.trap).toMatchObject({ composite: 0, recommendation: 'proceed' });
});
test('missing required fields surfaces in errors but still returns shape', async () => {
const out = await computeFeaturesForProp({});
expect(out.meta.errors[0]).toMatch(/missing required fields/);
expect(out.features).toBeDefined();
expect(out.trap).toBeDefined();
expect(out.consistency).toBeDefined();
expect(out.prop).toBeDefined();
});
test('scoreboard fetch throws → no game noted, no crash', async () => {
mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' };
mockAxiosGet.mockRejectedValue(new Error('espn down'));
const out = await computeFeaturesForProp({
player: 'B', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.meta.gameId).toBeNull();
expect(out.meta.errors).toContain('no_game_scheduled_today');
});
test('does not import from legacy path (no propAnalyzer/grader/UnifiedOddsProvider)', () => {
const fs = require('fs');
const src = fs.readFileSync(require.resolve('../../src/services/intelligence/computeFeatures.js'), 'utf8');
expect(src).not.toMatch(/propAnalyzer/);
expect(src).not.toMatch(/require.*['"]\.\.\/grader/);
expect(src).not.toMatch(/UnifiedOddsProvider/);
});
});
+10 -5
View File
@@ -1,13 +1,15 @@
// PERF-2 (Session 7d): proves analyzeProp runs in parallel inside // PERF-2 (Session 7d): proves analyzeProp runs in parallel inside
// scanParlay. We mock analyzeProp to sleep — a sequential loop would // scanParlay. ARCH-1 Step 4 (Session 7f): the call target rotated from
// take N × delay, parallel allSettled takes ~delay. // `propAnalyzer.analyzeProp` to `intelligence/analyzeViaEngine1`. Mock
// target updated to follow; the assertion (parallel timing) is
// unchanged.
let mockAnalyzeDelayMs = 100; let mockAnalyzeDelayMs = 100;
let mockAnalyzeCallTimes = []; let mockAnalyzeCallTimes = [];
let mockAnalyzeRejectIndices = new Set(); let mockAnalyzeRejectIndices = new Set();
jest.mock('../../src/services/propAnalyzer', () => ({ jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
analyzeProp: async (leg) => { analyzeViaEngine1: async (leg) => {
const startedAt = Date.now(); const startedAt = Date.now();
mockAnalyzeCallTimes.push(startedAt); mockAnalyzeCallTimes.push(startedAt);
await new Promise((r) => setTimeout(r, mockAnalyzeDelayMs)); await new Promise((r) => setTimeout(r, mockAnalyzeDelayMs));
@@ -16,8 +18,11 @@ jest.mock('../../src/services/propAnalyzer', () => ({
} }
return { return {
...leg, ...leg,
// Mock keeps the legacy-shape 4-letter grade so the existing
// value-level assertion ('A-') is preserved verbatim. Real
// analyzeViaEngine1 also emits the 4-letter shape via the adapter.
grade: 'A-', grade: 'A-',
confidence: 0.78, confidence: 78,
edge_pct: 5.2, edge_pct: 5.2,
reasoning: { summary: 'ok', steps: {} }, reasoning: { summary: 'ok', steps: {} },
kill_conditions_triggered: [], kill_conditions_triggered: [],
+1 -1
View File
File diff suppressed because one or more lines are too long