Session 7e: Grade adapter, normalize consolidation, ARCH-2 banners

This commit is contained in:
Kev
2026-06-10 03:37:07 -04:00
parent 6f4a353de9
commit 012c0ef47e
11 changed files with 571 additions and 60 deletions
+35
View File
@@ -237,3 +237,38 @@
{"ts":"2026-06-10T06:54:15.253Z","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-10T06:54:15.253Z","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-10T06:54:15.298Z","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-10T06:54:15.298Z","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-10T06:54:15.578Z","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-10T06:54:15.578Z","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:14:59.767Z","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:14:59.824Z","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:14:59.825Z","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:14:59.825Z","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:14:59.888Z","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:14:59.939Z","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:15:00.455Z","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:17:22.653Z","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:17:22.670Z","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:17:22.671Z","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:17:22.671Z","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:17:22.712Z","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:17:22.758Z","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:17:23.302Z","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:19:34.473Z","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:19:34.521Z","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:19:34.521Z","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:19:34.521Z","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:19:34.556Z","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:19:34.569Z","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:19:34.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-10T07:23:11.063Z","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:23:11.063Z","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:23:11.063Z","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:23:11.109Z","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:23:11.152Z","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:23:11.255Z","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:23:11.616Z","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.137Z","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.159Z","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.160Z","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:24:54.160Z","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: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.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"}
+48 -20
View File
@@ -411,24 +411,50 @@ No circular imports detected.
### ARCH — Architecture ### ARCH — Architecture
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status: - **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
**DEFERRED in Session 7d**the legacy `propAnalyzer → grader → **PARTIAL in Session 7e**Step 1 of the migration (the adapter)
UnifiedOddsProvider` chain returns a 4-letter grade + 0-100 is complete and tested. Steps 2-6 deferred per ESCAPE HATCH.
confidence + kill-conditions + reasoning.steps shape; the new Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output
`engine1` returns an 11-step grade + 0-1 confidence + factors. Both into the legacy shape `DemoScan` reads. 26 unit
shapes are consumed by different surfaces (GradeCard + DemoScan vs tests covering grade collapse, confidence math,
the orchestrator's grade_history writer). Unification requires kill-condition mapping, reasoning fallback, partial
frontend + backend coordination — out of scope for the audit-fix input safety.
session. Migration plan: (1) decide which shape the UI keeps; (2) Step 2-6 deferred — legacy `analyzeProp` and `engine1.gradeProp()`
write an adapter from the other shape; (3) rewire one route at a have incompatible INPUT contracts: the legacy
time, starting with `/api/analyze/prop`. Session 7d added a analyzer takes a raw prop and self-fetches data
DEPRECATED banner to `src/services/grader.js` to signal that new (odds, stats, opponent, spread, kill conditions);
features should land in engine1.js. engine1 takes a pre-computed feature vector and is
a pure grading function. To rewire any route to
engine1 we'd first need to extract an
orchestrator-lite preprocessor — that's the kind
of architectural change 7c/7d's no-restructure
rule blocks. The reasoning.summary string-level
parity is also currently lost (concrete numbers
in legacy vs abstract factor labels in engine1).
Migration roadmap (now actionable for the next session):
(a) Extract `gradingOrchestrator.gradeProp()` into a standalone
`computeFeaturesForProp({player, stat_type, line, direction})`
that lifts the player-roster + feature-fetch + trap-detect +
consistency-score logic into a reusable callable.
(b) Wrap engine1 + gradeAdapter behind a thin
`analyzeViaEngine1(prop)` helper.
(c) Rewire one route at a time — `/api/analyze/prop` first
(lowest risk: single prop, public, well-tested), then
`/api/analyze/batch`, then `/api/scan/parlay`, then
`/api/bets/*`.
(d) Remove `grader.js` + `propAnalyzer.js` + the legacy book
adapters only after every consumer has migrated and a soak
period.
Session 7d's DEPRECATED banner on `src/services/grader.js` stays
put.
- **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity: - **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity:
Low (documented in 6a). `src/services/{circuitBreaker.js, Low. Status: **DOCUMENTED in Session 7e.** Both legacy modules now
rateLimiter.js}` (keyed registry, legacy) coexist with carry banners listing their three callers:
`src/utils/rateLimiter.js` (factory, new). Consolidation is purely - `src/services/UnifiedOddsProvider.js`
cosmetic — both work. Scope: half-session. Status: open. - `src/services/adapters/ESPNAdapter.js`
- `src/services/adapters/PinnacleAdapter.js`
Full removal blocks on ARCH-1 Step 6 — the legacy book adapters
retire together with the legacy grading path.
### SEC — Security ### SEC — Security
@@ -466,10 +492,12 @@ No circular imports detected.
### DUP — Duplicates ### DUP — Duplicates
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status: - **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
**DOCUMENTED in Session 7d.** Both implementations now carry **FIXED in Session 7e.** Consolidated into `src/utils/normalize.js`
cross-reference comments explaining the divergence (script keeps with a `keepDigits` option (default false). `trapDetection.js`
digits for legacy roster fields; trap detector strips them). Full imports the default; `scripts/populate-player-ids.js` wraps with
consolidation deferred until the script can drop its digit support. `keepDigits: true` via `normalizeRosterName`. 9 unit tests cover
accent strip, suffix removal, digit options, idempotency, null
input.
- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.** - **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor — Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
+7 -18
View File
@@ -48,22 +48,11 @@ const espnSportPath = {
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
// DUP-1 (Session 7c): a near-twin lives in // DUP-1 (Session 7e): consolidated into src/utils/normalize.js. The
// src/services/intelligence/trapDetection.js. This variant KEEPS digits // script keeps digits because legacy roster fields can encode jersey
// because some legacy roster fields encode jersey numbers in the name // numbers in the name string.
// string; the trap detector strips them. If those legacy fields ever const { normalizeName } = require('../src/utils/normalize');
// go away, consolidate to a shared util in src/utils/. const normalizeRosterName = (name) => normalizeName(name, { keepDigits: true });
function normalizeName(name) {
if (!name) return '';
return name
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // strip accents
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '') // suffixes
.replace(/[^a-z0-9\s]/g, ' ') // punctuation (keeps digits)
.replace(/\s+/g, ' ') // collapse spaces
.trim();
}
async function fetchJSON(url, { params } = {}) { async function fetchJSON(url, { params } = {}) {
const res = await axios.get(url, { params, timeout: 15_000 }); const res = await axios.get(url, { params, timeout: 15_000 });
@@ -108,7 +97,7 @@ async function fetchMlbAllPlayers() {
return list.map((p) => ({ return list.map((p) => ({
mlbam_id: String(p.id), mlbam_id: String(p.id),
fullName: p.fullName, fullName: p.fullName,
normalized: normalizeName(p.fullName), normalized: normalizeRosterName(p.fullName),
})); }));
} }
@@ -129,7 +118,7 @@ async function processSport(sport, { dryRun }) {
for (const p of roster) { for (const p of roster) {
allPlayers.push({ allPlayers.push({
display_name: p.name, display_name: p.name,
normalized_name: normalizeName(p.name), normalized_name: normalizeRosterName(p.name),
espn_id: p.id, espn_id: p.id,
sport, sport,
team_abbr: team.abbreviation, team_abbr: team.abbreviation,
+10
View File
@@ -1,4 +1,14 @@
/** /**
* ARCH-2 (Session 7e): LEGACY circuit breaker. The canonical implementation
* lives in src/utils/rateLimiter.js (createCircuitBreaker factory).
* New code should import from there.
*
* Current callers of this legacy module (remove this file when the list
* empties):
* - src/services/UnifiedOddsProvider.js
* - src/services/adapters/ESPNAdapter.js
* - src/services/adapters/PinnacleAdapter.js
*
* Per-upstream circuit breaker. * Per-upstream circuit breaker.
* *
* States: * States:
+3 -21
View File
@@ -23,32 +23,14 @@
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement'); const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
const { getSupabaseServiceClient } = require('../../utils/supabase'); const { getSupabaseServiceClient } = require('../../utils/supabase');
// DUP-1 (Session 7e): consolidated into src/utils/normalize.js.
// Trap matching strips digits — keepDigits stays at the default false.
const { normalizeName } = require('../../utils/normalize');
function inactive(reason) { function inactive(reason) {
return { score: 0, active: false, explanation: reason }; return { score: 0, active: false, explanation: reason };
} }
// Normalize player names for matching across data sources. ParlayAPI may
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
// punctuation, suffixes, and collapse whitespace so equivalence works.
//
// DUP-1 (Session 7c): a near-identical implementation lives in
// scripts/populate-player-ids.js. The script's variant keeps digits
// (some legacy roster fields encode jersey numbers); this one strips
// them because trap matches go by player name only. If the script
// stops needing digits, consolidate to a shared util.
function normalizeName(name) {
if (!name) return '';
return String(name)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
.replace(/[^a-z\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Detect whether a key teammate transitioned from OUT in the recent past // Detect whether a key teammate transitioned from OUT in the recent past
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking // to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
// the trap detector — the orchestrator owns the injury-history context. // the trap detector — the orchestrator owns the injury-history context.
+10
View File
@@ -1,4 +1,14 @@
/** /**
* ARCH-2 (Session 7e): LEGACY rate limiter. The canonical implementation
* lives in src/utils/rateLimiter.js (factory pattern, paired with
* createCircuitBreaker). New code should import from there.
*
* Current callers of this legacy module (remove this file when the list
* empties):
* - src/services/UnifiedOddsProvider.js
* - src/services/adapters/ESPNAdapter.js
* - src/services/adapters/PinnacleAdapter.js
*
* Token bucket rate limiter, per upstream key. * Token bucket rate limiter, per upstream key.
* *
* Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway * Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway
+166
View File
@@ -0,0 +1,166 @@
/**
* Engine 1 (new) → legacy grader shape adapter.
*
* Engine 1 emits an 11-step grade (F..A+) + 0-1 confidence + a labelled
* factors array. The frontend (`DemoScan.tsx`, `GradeCard.tsx`) and the
* `/api/analyze`, `/api/scan`, `/api/bets` routes were built against the
* legacy `grader.js` shape:
*
* {
* player, stat_type, line, direction, book,
* grade, // 'A' | 'B' | 'C' | 'D' (4-letter)
* confidence, // 0-100 integer
* edge_pct, // signed percentage
* kill_conditions_triggered: [{ code, ... }],
* reasoning: { summary: string, steps: {...} }
* }
*
* Future route rewires that swap `analyzeProp` for the orchestrator/
* engine1 path will pipe the result through `toLegacyShape()` so the
* frontend sees no change.
*
* NOTE — Session 7e ESCAPE HATCH: the adapter is built but not yet
* applied to a live route. Engine 1's input is a pre-computed feature
* vector; the legacy analyzer takes a raw prop and fetches its own
* data. Wiring those two together requires an orchestrator-lite
* preprocessor that doesn't exist yet. This file exists so the next
* session can drop it in once the preprocessor is in place. See
* docs/SYSTEM-MANIFEST.md §8 ARCH-1.
*/
const FOUR_LETTER_MAP = Object.freeze({
'A+': 'A', 'A': 'A', 'A-': 'A',
'B+': 'B', 'B': 'B', 'B-': 'B',
'C+': 'C', 'C': 'C', 'C-': 'C',
'D': 'D',
'F': 'F', // DemoScan's ACCURACY map yields '—' for F, which is fine.
});
// Factors that, when present, suggest a kill condition in the legacy
// sense. Each maps to a stable code + human reason so the UI keeps
// rendering the same chip set it always has. Unknown factors fall
// through to a generic "signal" entry rather than disappearing.
const FACTOR_TO_KILL_CONDITION = Object.freeze({
trap_composite_high: { code: 'TRAP', reason: 'Multiple trap signals firing.' },
l5_cold_vs_line: { code: 'COLD_L5', reason: 'Last-5 average significantly below the line.' },
l5_hot_vs_under: { code: 'COLD_L5', reason: 'Hot streak conflicts with UNDER side.' },
consistency_boom_bust: { code: 'BOOM_BUST', reason: 'Player is boom-or-bust on this stat.' },
top_opponent_defense: { code: 'TOP_DEFENSE', reason: 'Opponent ranks top-five defending this stat.' },
back_to_back: { code: 'B2B', reason: 'Back-to-back game.' },
heavy_workload_7d: { code: 'FATIGUE', reason: '4+ games in the last 7 days.' },
away_vs_top5_defense: { code: 'TOP_DEFENSE', reason: 'Away game vs a top-five defense.' },
rookie_in_playoffs: { code: 'NEW_CONTEXT', reason: 'No prior playoff experience.' },
});
function fourLetterGrade(elevenStep) {
if (typeof elevenStep !== 'string') return null;
return FOUR_LETTER_MAP[elevenStep.trim()] ?? null;
}
function legacyConfidence(unitProb) {
// Guard nullish first — Number(null) === 0 is finite, which would
// silently produce a 0% confidence instead of "unknown".
if (unitProb == null) return null;
const n = Number(unitProb);
if (!Number.isFinite(n)) return null;
return Math.max(0, Math.min(100, Math.round(n * 100)));
}
// Build kill_conditions_triggered from engine1 factors. Only factors
// that signal something the user should worry about become chips —
// positive factors (e.g. l5_hot_vs_line) don't.
function killConditionsFromFactors(factors) {
if (!Array.isArray(factors)) return [];
const out = [];
const seen = new Set();
for (const f of factors) {
const entry = FACTOR_TO_KILL_CONDITION[f];
if (entry && !seen.has(entry.code)) {
out.push({ ...entry });
seen.add(entry.code);
}
}
return out;
}
// Engine 1 factors are abstract labels. To produce a reasoning.summary
// the legacy UI renders, we string the top-three factors together with a
// human-readable verb. Callers that have richer context (e.g. the
// orchestrator with featurePayload) should pass their own sentence in
// `summaryOverride`.
function buildReasoningSummary(engine1Result, prop, summaryOverride) {
if (summaryOverride && typeof summaryOverride === 'string') return summaryOverride;
const top = Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors.slice(0, 3) : [];
if (top.length === 0) {
return `Grade ${engine1Result?.grade ?? '—'} from Engine 1 (no surfaced factors).`;
}
const verb = engine1Result?.grade?.startsWith('A') ? 'favoring the play'
: engine1Result?.grade?.startsWith('B') ? 'leaning the play'
: engine1Result?.grade?.startsWith('C') ? 'split'
: 'against the play';
return `Engine 1 graded ${engine1Result?.grade ?? '—'} ${verb} — top factors: ${top.join(', ')}.`;
}
// edge_pct lives in the legacy response. Engine 1 doesn't compute it.
// If the caller passes a computed value (e.g. l5_avg line), use it;
// otherwise return 0 so the field exists and the UI doesn't blow up
// on missing data.
function legacyEdgePct(edgePctOverride) {
const n = Number(edgePctOverride);
return Number.isFinite(n) ? n : 0;
}
/**
* toLegacyShape — transform an engine1 grading result into the shape
* `/api/analyze` and downstream callers historically returned.
*
* @param {Object} engine1Result — { grade, confidence, top_factors, all_factors }
* @param {Object} prop — { player, stat_type, line, direction, book, sport }
* @param {Object} [opts]
* @param {string} [opts.summaryOverride] — supply a fuller human sentence if available
* @param {number} [opts.edgePct] — pre-computed edge percentage
* @returns {Object} legacy-shaped result, including the `_cache: 'MISS'` field
* the PERF-1 wrapper expects callers to receive.
*/
function toLegacyShape(engine1Result, prop = {}, opts = {}) {
if (!engine1Result || typeof engine1Result !== 'object') return null;
const grade = fourLetterGrade(engine1Result.grade);
const confidence = legacyConfidence(engine1Result.confidence);
const kill = killConditionsFromFactors(engine1Result.all_factors || engine1Result.top_factors);
return {
player: prop.player ?? null,
stat_type: prop.stat_type ?? null,
line: prop.line ?? null,
direction: prop.direction ?? null,
book: prop.book ?? null,
grade,
confidence,
edge_pct: legacyEdgePct(opts.edgePct),
kill_conditions_triggered: kill,
reasoning: {
summary: buildReasoningSummary(engine1Result, prop, opts.summaryOverride),
// The legacy `steps` block carried season_avg / recent_form /
// situational / line_comparison / kill_conditions / final_grade.
// Engine 1 doesn't surface those individually, so the adapter
// emits an `engine1_factors` block as the modern equivalent.
// The frontend reads `.summary` only; this is documentation.
steps: {
engine1_factors: engine1Result.all_factors || engine1Result.top_factors || [],
final_grade: grade,
},
},
};
}
module.exports = {
toLegacyShape,
__internals: {
FOUR_LETTER_MAP,
FACTOR_TO_KILL_CONDITION,
fourLetterGrade,
legacyConfidence,
killConditionsFromFactors,
buildReasoningSummary,
legacyEdgePct,
},
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Player-name normalization for cross-source matching.
*
* ParlayAPI emits "Brunson, Jalen"; ESPN emits "Jalen Brunson"; nba_api
* sometimes attaches a jersey number. Normalize aggressively so equality
* comparisons just work.
*
* Pipeline:
* NFD unicode → strip accents → lowercase → drop suffixes (jr/sr/ii/iii/
* iv/v) → strip punctuation → collapse whitespace → trim.
*
* `keepDigits` (default false) controls whether digits survive the
* punctuation strip. The trap detector matches names only and wants
* digits gone; the player-ID population script keeps them because some
* legacy roster fields encode jersey numbers inline.
*/
function normalizeName(name, { keepDigits = false } = {}) {
if (!name) return '';
const punctClass = keepDigits ? '[^a-z0-9\\s]' : '[^a-z\\s]';
return String(name)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
.replace(new RegExp(punctClass, 'g'), ' ')
.replace(/\s+/g, ' ')
.trim();
}
module.exports = { normalizeName };
+204
View File
@@ -0,0 +1,204 @@
const {
toLegacyShape,
__internals: {
FOUR_LETTER_MAP,
fourLetterGrade,
legacyConfidence,
killConditionsFromFactors,
buildReasoningSummary,
legacyEdgePct,
},
} = require('../../src/utils/gradeAdapter');
describe('gradeAdapter — fourLetterGrade', () => {
test('A-tier (A+, A, A-) all map to "A"', () => {
expect(fourLetterGrade('A+')).toBe('A');
expect(fourLetterGrade('A')).toBe('A');
expect(fourLetterGrade('A-')).toBe('A');
});
test('B-tier maps to "B"', () => {
expect(fourLetterGrade('B+')).toBe('B');
expect(fourLetterGrade('B')).toBe('B');
expect(fourLetterGrade('B-')).toBe('B');
});
test('C-tier maps to "C"', () => {
expect(fourLetterGrade('C+')).toBe('C');
expect(fourLetterGrade('C')).toBe('C');
expect(fourLetterGrade('C-')).toBe('C');
});
test('D maps to "D", F maps to "F" (DemoScan handles F → "—")', () => {
expect(fourLetterGrade('D')).toBe('D');
expect(fourLetterGrade('F')).toBe('F');
});
test('unknown / null / non-string returns null', () => {
expect(fourLetterGrade('Z')).toBeNull();
expect(fourLetterGrade(null)).toBeNull();
expect(fourLetterGrade(undefined)).toBeNull();
expect(fourLetterGrade(42)).toBeNull();
});
test('FOUR_LETTER_MAP exposes every engine1 grade', () => {
const expected = ['A+','A','A-','B+','B','B-','C+','C','C-','D','F'];
for (const g of expected) expect(FOUR_LETTER_MAP[g]).toBeDefined();
});
});
describe('gradeAdapter — legacyConfidence', () => {
test('0.0 → 0, 0.5 → 50, 1.0 → 100', () => {
expect(legacyConfidence(0.0)).toBe(0);
expect(legacyConfidence(0.5)).toBe(50);
expect(legacyConfidence(1.0)).toBe(100);
});
test('0.873 → 87 (rounded)', () => {
expect(legacyConfidence(0.873)).toBe(87);
});
test('out-of-range clamps to [0, 100]', () => {
expect(legacyConfidence(-0.5)).toBe(0);
expect(legacyConfidence(2.0)).toBe(100);
});
test('non-numeric input returns null', () => {
expect(legacyConfidence(null)).toBeNull();
expect(legacyConfidence(undefined)).toBeNull();
expect(legacyConfidence('abc')).toBeNull();
});
});
describe('gradeAdapter — killConditionsFromFactors', () => {
test('translates known negative factors to kill condition entries', () => {
const out = killConditionsFromFactors(['trap_composite_high', 'l5_cold_vs_line']);
const codes = out.map((k) => k.code);
expect(codes).toContain('TRAP');
expect(codes).toContain('COLD_L5');
});
test('positive factors are ignored', () => {
const out = killConditionsFromFactors(['l5_hot_vs_line', 'home_game', 'rested_2plus']);
expect(out).toEqual([]);
});
test('deduplicates by code (l5_cold + l5_hot_under both → COLD_L5)', () => {
const out = killConditionsFromFactors(['l5_cold_vs_line', 'l5_hot_vs_under']);
expect(out).toHaveLength(1);
expect(out[0].code).toBe('COLD_L5');
});
test('empty / non-array → []', () => {
expect(killConditionsFromFactors([])).toEqual([]);
expect(killConditionsFromFactors(null)).toEqual([]);
expect(killConditionsFromFactors(undefined)).toEqual([]);
});
});
describe('gradeAdapter — buildReasoningSummary', () => {
test('uses summaryOverride when provided', () => {
const out = buildReasoningSummary({ grade: 'A' }, {}, 'fancy override');
expect(out).toBe('fancy override');
});
test('builds a sentence from top_factors', () => {
const out = buildReasoningSummary({
grade: 'A-',
top_factors: ['l5_hot_vs_line', 'home_game', 'rested_2plus'],
}, { player: 'X' });
expect(out).toContain('A-');
expect(out).toContain('l5_hot_vs_line');
expect(out).toContain('favoring the play');
});
test('falls back gracefully when no factors are surfaced', () => {
const out = buildReasoningSummary({ grade: 'B' });
expect(typeof out).toBe('string');
expect(out).toContain('B');
});
});
describe('gradeAdapter — legacyEdgePct', () => {
test('numeric override passes through', () => {
expect(legacyEdgePct(5.2)).toBe(5.2);
expect(legacyEdgePct(-3)).toBe(-3);
});
test('non-numeric returns 0 (legacy callers expect the field present)', () => {
expect(legacyEdgePct(null)).toBe(0);
expect(legacyEdgePct(undefined)).toBe(0);
expect(legacyEdgePct('abc')).toBe(0);
});
});
describe('gradeAdapter — toLegacyShape (round-trip)', () => {
const engine1Result = {
grade: 'A-',
confidence: 0.78,
top_factors: ['l5_hot_vs_line', 'opp_rank_stat', 'rested_2plus'],
all_factors: ['l5_hot_vs_line', 'opp_rank_stat', 'rested_2plus', 'home_game'],
};
const prop = {
player: 'Jalen Brunson',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
sport: 'nba',
};
test('produces every field DemoScan consumes', () => {
const out = toLegacyShape(engine1Result, prop);
expect(out).toHaveProperty('grade', 'A');
expect(out).toHaveProperty('confidence', 78);
expect(out).toHaveProperty('reasoning.summary');
expect(typeof out.reasoning.summary).toBe('string');
expect(out).toHaveProperty('kill_conditions_triggered');
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
expect(out).toHaveProperty('edge_pct');
expect(out).toHaveProperty('player', 'Jalen Brunson');
expect(out).toHaveProperty('stat_type', 'points');
expect(out).toHaveProperty('line', 26.5);
expect(out).toHaveProperty('direction', 'over');
expect(out).toHaveProperty('book', 'draftkings');
});
test('edgePct override flows through', () => {
const out = toLegacyShape(engine1Result, prop, { edgePct: 4.7 });
expect(out.edge_pct).toBe(4.7);
});
test('summaryOverride wins over the factor-based sentence', () => {
const out = toLegacyShape(engine1Result, prop, { summaryOverride: 'Hand-written take.' });
expect(out.reasoning.summary).toBe('Hand-written take.');
});
test('partial engine1 input does not crash', () => {
const partial = { grade: 'B+' };
const out = toLegacyShape(partial, prop);
expect(out.grade).toBe('B');
expect(out.confidence).toBeNull();
expect(out.kill_conditions_triggered).toEqual([]);
expect(typeof out.reasoning.summary).toBe('string');
});
test('null engine1 input returns null', () => {
expect(toLegacyShape(null, prop)).toBeNull();
expect(toLegacyShape(undefined, prop)).toBeNull();
});
test('engine1_factors travel through reasoning.steps for debugging', () => {
const out = toLegacyShape(engine1Result, prop);
expect(Array.isArray(out.reasoning.steps.engine1_factors)).toBe(true);
expect(out.reasoning.steps.engine1_factors).toEqual(engine1Result.all_factors);
expect(out.reasoning.steps.final_grade).toBe('A');
});
test('legacy-shape compatibility: every key DemoScan reads is present', () => {
const out = toLegacyShape(engine1Result, prop);
// DemoScan reads: grade, confidence, reasoning.summary,
// kill_conditions_triggered (with .code on each entry),
// implied_probability (optional — adapter doesn't add it but doesn't
// need to since the field is allowed to be undefined).
expect(out.grade).toBeDefined();
expect(out.confidence).toBeDefined();
expect(out.reasoning.summary).toBeDefined();
for (const kc of out.kill_conditions_triggered) {
expect(kc.code).toBeDefined();
expect(typeof kc.code).toBe('string');
}
});
});
+57
View File
@@ -0,0 +1,57 @@
const { normalizeName } = require('../../src/utils/normalize');
describe('normalizeName (DUP-1)', () => {
test('basic lowercase + trim', () => {
expect(normalizeName(' Jalen Brunson ')).toBe('jalen brunson');
});
test('strips accents via NFD', () => {
expect(normalizeName('Luka Dončić')).toBe('luka doncic');
});
test('drops suffixes jr/sr/ii/iii/iv/v', () => {
expect(normalizeName('Tim Hardaway Jr.')).toBe('tim hardaway');
expect(normalizeName('Wade Phillips Sr.')).toBe('wade phillips');
expect(normalizeName('Sammy Davis III')).toBe('sammy davis');
// VIII isn't in the suffix list (i, ii, iii, iv, v only) — it's
// realistically not a player-name suffix, so the regex leaves it.
expect(normalizeName('Henry VIII')).toBe('henry viii');
});
test('strips punctuation (default: keepDigits=false drops digits too)', () => {
expect(normalizeName('A.J. Brown')).toBe('a j brown');
expect(normalizeName("Shaq O'Neal #34")).toBe('shaq o neal');
});
test('keepDigits true preserves jersey numbers', () => {
expect(normalizeName("Shaq O'Neal #34", { keepDigits: true })).toBe('shaq o neal 34');
expect(normalizeName('Player 23', { keepDigits: true })).toBe('player 23');
expect(normalizeName('Player 23', { keepDigits: false })).toBe('player');
});
test('collapses runs of whitespace', () => {
expect(normalizeName('Spaced Out\tName')).toBe('spaced out name');
});
test('null / undefined / empty input returns empty string', () => {
expect(normalizeName(null)).toBe('');
expect(normalizeName(undefined)).toBe('');
expect(normalizeName('')).toBe('');
});
test('numeric input gets toStringed', () => {
expect(normalizeName(42, { keepDigits: true })).toBe('42');
});
test('idempotent — normalize(normalize(x)) === normalize(x)', () => {
const samples = ['Luka Dončić', 'Tim Hardaway Jr.', "A.J. Brown #11"];
for (const s of samples) {
const once = normalizeName(s);
expect(normalizeName(once)).toBe(once);
}
for (const s of samples) {
const once = normalizeName(s, { keepDigits: true });
expect(normalizeName(once, { keepDigits: true })).toBe(once);
}
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long