Files
vyndr/src/utils/gradeAdapter.js
T

167 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
},
};