Session 7e: Grade adapter, normalize consolidation, ARCH-2 banners
This commit is contained in:
@@ -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.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-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
@@ -411,24 +411,50 @@ No circular imports detected.
|
||||
### ARCH — Architecture
|
||||
|
||||
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
|
||||
**DEFERRED in Session 7d** — the legacy `propAnalyzer → grader →
|
||||
UnifiedOddsProvider` chain returns a 4-letter grade + 0-100
|
||||
confidence + kill-conditions + reasoning.steps shape; the new
|
||||
`engine1` returns an 11-step grade + 0-1 confidence + factors. Both
|
||||
shapes are consumed by different surfaces (GradeCard + DemoScan vs
|
||||
the orchestrator's grade_history writer). Unification requires
|
||||
frontend + backend coordination — out of scope for the audit-fix
|
||||
session. Migration plan: (1) decide which shape the UI keeps; (2)
|
||||
write an adapter from the other shape; (3) rewire one route at a
|
||||
time, starting with `/api/analyze/prop`. Session 7d added a
|
||||
DEPRECATED banner to `src/services/grader.js` to signal that new
|
||||
features should land in engine1.js.
|
||||
**PARTIAL in Session 7e** — Step 1 of the migration (the adapter)
|
||||
is complete and tested. Steps 2-6 deferred per ESCAPE HATCH.
|
||||
Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output
|
||||
into the legacy shape `DemoScan` reads. 26 unit
|
||||
tests covering grade collapse, confidence math,
|
||||
kill-condition mapping, reasoning fallback, partial
|
||||
input safety.
|
||||
Step 2-6 deferred — legacy `analyzeProp` and `engine1.gradeProp()`
|
||||
have incompatible INPUT contracts: the legacy
|
||||
analyzer takes a raw prop and self-fetches data
|
||||
(odds, stats, opponent, spread, kill conditions);
|
||||
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:
|
||||
Low (documented in 6a). `src/services/{circuitBreaker.js,
|
||||
rateLimiter.js}` (keyed registry, legacy) coexist with
|
||||
`src/utils/rateLimiter.js` (factory, new). Consolidation is purely
|
||||
cosmetic — both work. Scope: half-session. Status: open.
|
||||
Low. Status: **DOCUMENTED in Session 7e.** Both legacy modules now
|
||||
carry banners listing their three callers:
|
||||
- `src/services/UnifiedOddsProvider.js`
|
||||
- `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
|
||||
|
||||
@@ -466,10 +492,12 @@ No circular imports detected.
|
||||
### DUP — Duplicates
|
||||
|
||||
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
|
||||
**DOCUMENTED in Session 7d.** Both implementations now carry
|
||||
cross-reference comments explaining the divergence (script keeps
|
||||
digits for legacy roster fields; trap detector strips them). Full
|
||||
consolidation deferred until the script can drop its digit support.
|
||||
**FIXED in Session 7e.** Consolidated into `src/utils/normalize.js`
|
||||
with a `keepDigits` option (default false). `trapDetection.js`
|
||||
imports the default; `scripts/populate-player-ids.js` wraps with
|
||||
`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`.**
|
||||
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
|
||||
|
||||
@@ -48,22 +48,11 @@ const espnSportPath = {
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// DUP-1 (Session 7c): a near-twin lives in
|
||||
// src/services/intelligence/trapDetection.js. This variant KEEPS digits
|
||||
// because some legacy roster fields encode jersey numbers in the name
|
||||
// string; the trap detector strips them. If those legacy fields ever
|
||||
// go away, consolidate to a shared util in src/utils/.
|
||||
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();
|
||||
}
|
||||
// DUP-1 (Session 7e): consolidated into src/utils/normalize.js. The
|
||||
// script keeps digits because legacy roster fields can encode jersey
|
||||
// numbers in the name string.
|
||||
const { normalizeName } = require('../src/utils/normalize');
|
||||
const normalizeRosterName = (name) => normalizeName(name, { keepDigits: true });
|
||||
|
||||
async function fetchJSON(url, { params } = {}) {
|
||||
const res = await axios.get(url, { params, timeout: 15_000 });
|
||||
@@ -108,7 +97,7 @@ async function fetchMlbAllPlayers() {
|
||||
return list.map((p) => ({
|
||||
mlbam_id: String(p.id),
|
||||
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) {
|
||||
allPlayers.push({
|
||||
display_name: p.name,
|
||||
normalized_name: normalizeName(p.name),
|
||||
normalized_name: normalizeRosterName(p.name),
|
||||
espn_id: p.id,
|
||||
sport,
|
||||
team_abbr: team.abbreviation,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* States:
|
||||
|
||||
@@ -23,32 +23,14 @@
|
||||
|
||||
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
|
||||
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) {
|
||||
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
|
||||
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
|
||||
// the trap detector — the orchestrator owns the injury-history context.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user