/** * 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, // Session 63 — TRUTH LABEL. `confidence` is NOT a probability: engine1 // derives it by looking up the midpoint of the band belonging to the letter // it already chose, so it carries ZERO information beyond the letter and can // never disagree with it. (That is also why mlb-grade-degradation.md's // "25/25 grade<->confidence agreement" was a tautology, not a validation.) // The real, independent probability is `p_win` — the quantile estimate over // actual game logs — which is attached by analyzeViaEngine1 and is what any // surface showing a percentage should render. confidence_basis: 'grade_band', 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, }, };