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
+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.
*
* States:
+3 -21
View File
@@ -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.
+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.
*
* 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 };