1a94ef5fcf
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
176 lines
7.6 KiB
JavaScript
176 lines
7.6 KiB
JavaScript
/**
|
||
* 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,
|
||
},
|
||
};
|