Files
vyndr/src/utils/gradeAdapter.js
T
builtbykev 1a94ef5fcf Revive the dead probability layer + restore grade range ON MERIT
Folds re-sequenced steps 1+2 into one change (Kev's call): same bug
family — features wired to sources that return null.

THE PROBABILITY LAYER WAS DEAD IN PRODUCTION. p_win/ev_pct/kelly/
model_odds/value were absent on 0/8 live grades because
gameLogService.getGameLogs returns null for MLB by construction and
depends on the offline Python service for NBA/WNBA, so meta.gameLogs was
[] for every sport. This was the S46 bug in a second location — that fix
gave featureCache an MLB branch (why grades still worked) but never the
estimator. featureCache.getStatRows now supplies normalized rows
([{date,[statType]:v}], most-recent-first) for every sport, feeding the
estimator AND consistency AND game_count_in_7d from one fetch.
VERIFIED on real props: p_win 25/25 WNBA, 8/8 MLB (was 0).

GRADE RANGE, ON MERIT — never by rescaling (permanent founder ruling:
minting A's without new information is a relabelled B sold as an A and
corrupts an append-only ledger).
- refreshTeamStats wired into runSnapshot — it had ZERO production
  callers, so opp_rank_stat was permanently null and a +/-1.0 factor
  could never fire. Test-env no-op (opsNotify precedent).
- L20 made SYMMETRIC: both branches were delta +1.0, so the season
  baseline could only ever ADD. No negative path was a structural reason
  D was unreachable. New l20_contradicts_* carries -1.0.
- game_count_in_7d derived from real logged dates (heavy_workload_7d).
- NOT wired, deliberately, with reasons inline: teamId (no team_id
  column; getFeatures reads it top-level; factor also needs a starter-id
  list) and season_type (ESPN 2 = REGULAR season; threading it raw would
  fire veteran_in_playoffs in July). Dead code dressed as a fix is the
  thing we are removing, not adding.

CALIBRATION GUARD (found by verifying, not assuming): consistency CV is
NBA-tuned; for a Poisson-ish stat cv ~ 1/sqrt(mean), so any stat with
mean < 4 auto-classifies boom_bust. First verification run showed 8/8 MLB
props boom_bust — a blanket -1.0 that dropped the board to all-C. Floored
at CONSISTENCY_MIN_MEAN=4 -> 'unknown' below. Absent beats wrong. MLB
low-count stats therefore still get no consistency factor: honest, not
fixed. Scale-free index-of-dispersion classifier is the open follow-up.

CONFIDENCE IS NOT A PROBABILITY: payloads carry confidence_basis:
'grade_band'. Corrected mlb-grade-degradation.md — its "25/25
grade<->confidence agreement" is a TAUTOLOGY (confidence is derived FROM
the letter, so it would report 25/25 even if every grade were wrong), not
a validation. Removed dead mlbGrader.js (referenced only by its own test)
and the stale computeFeatures comment claiming a penalty that never ran.

VERIFICATION (scripts/verify-grade-range.js, real props/logs/engine):
WNBA 25 props B 68%->32%, C 32%->64%, D 0->1 (4%); 11-step spread went
from 2 steps to 5 (C/C+/B-/D). The D is earned: Angel Reese assists o2.5,
p_win 0.365. Nothing flooded — grades got HARDER. A did not emit locally
because opp_rank_stat needs the Redis cache only prod populates (local
ceiling +3.0 vs the +4.5 A needs); reachability is proven arithmetically
and locked in tests. Prod A-emission is the outstanding fingerprint.

MARKETING HOLD: "A-RATED" (AccuracyBadge, TopSignals) is unsupported
until that fingerprint. Confirmed honest fallbacks render today —
/api/ledger/accuracy has B and C buckets only, so the badge shows
"MODEL · 63% HIT" and TopSignals self-hides. Nothing fabricated ships.

Suite 276/3286 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 18:54:51 -04:00

176 lines
7.6 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,
// 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,
},
};