'use strict'; /** * gradeRanking (2026-07-29, specs/top-graded-selector.md) — THE ONE definition of * how a board of graded props is ordered. Extracted so the hero, the server * selector, and the client board cannot drift apart. * * WHO USES THIS: * - `services/heroPropService` — imports `takeablePWin` (its "top READ" rule * is p_win-FIRST, so it uses the primitive, not `rankGrades`). * - `services/topGradedService` — imports `rankGrades` (its "top GRADES" board * is grade-FIRST). Those two leading picks may legitimately differ; they * agree WITHIN the leading grade tier. * - `web/src/lib/slateAdapter.selectTopGrades` — a MIRROR, because the browser * cannot import `src/` (the Session-25 rule). `tests/unit/gradeBoardSort` * cross-checks the two on identical fixtures — the `playerName.js` precedent. * If you change the order here, change it there IN THE SAME COMMIT. * * WHY p_win AND NOT ev_pct/edge_pct: ev_pct is NULL on served grades and * `Number(null) === 0` made every prop tie at 0 (the hero bug); edge_pct is a * price-free (proj−line)/line artifact whose scale is a function of line size. * p_win is the only signal whose takeable-MLB-over CLV survived the skew audit. */ const { isTakeable } = require('../config/valueEngine'); const GRADE_RANK = Object.freeze({ 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10, }); /** Grade letter → sortable tier rank (lower = better). Unknown → 99. */ function gradeRankOf(g) { const k = String(g == null ? '' : g).trim().toUpperCase(); return GRADE_RANK[k] !== undefined ? GRADE_RANK[k] : 99; } /** Strict numeric read — `Number(null) === 0` is the recurring fabrication bug. */ function strictNum(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** * The takeable-gated champion probability for one grade row, or null. * * The takeable filter is MANDATORY: raw p_win crowns −300 chalk, which is not * the product. Band = `config/valueEngine.isTakeable` (−160..+200), the same * definition the takeable-edge proof and the over-side skew audit used. * Price falls back to the LOCKED odds (`gradedAt.odds`) when `book_odds` is absent. */ function takeablePWin(g) { const p = strictNum(g && g.p_win); if (p == null) return null; const price = strictNum(g && g.book_odds) ?? strictNum(g && g.gradedAt && g.gradedAt.odds); if (price == null || !isTakeable(price)) return null; return p; } /** Descending comparator that always sorts a null signal LAST (never first). */ function descNullsLast(a, b) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return b - a; } /** * rankGrades — "top GRADES" order: grade tier → confidence → takeable-gated * p_win → stable input order. * * EDGE KEY REMOVED 2026-08-01. It used to be the 4th key. Measured on n=200 * settled MLB rows, corr(edge, outcome) = -0.010 under the incumbent ruler and * -0.022 under the consensus ruler — it does not predict, so it must not break * ties either. Removing it is safe for EVERY sport: it takes a non-predictive * signal out, it does not put p_win in front (that is `rankByForecast`, gated * to sports whose own model has passed). * * Rows without a grade are dropped (a board of ungraded rows is not a board). * `limit` omitted → the whole ranked list (callers slice). */ function rankGrades(grades, limit) { const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade); const scored = arr.map((g, idx) => ({ g, idx, rank: gradeRankOf(g.grade), conf: strictNum(g.confidence) == null ? -1 : strictNum(g.confidence), pWin: takeablePWin(g), })); scored.sort((a, b) => a.rank - b.rank || b.conf - a.conf || descNullsLast(a.pWin, b.pWin) || a.idx - b.idx); const out = scored.map((s) => s.g); return limit == null ? out : out.slice(0, Math.max(0, limit)); } /** * rankByForecast — THE CHALLENGER instrument (2026-08-01). * * WHY THIS EXISTS, measured on n=200 settled MLB rows: * * corr(p_win, outcome) = +0.26 * corr(p_win - fair_prob_v1, outcome) = -0.010 * corr(p_win - fair_prob_v2, outcome) = -0.022 * * Subtracting the market price DESTROYS the signal, under BOTH rulers. So the * product must rank on the thing that predicts (p_win) and must not rank on * market-relative edge at all. `rankGrades` (the incumbent) keeps edge as its * 4th key; this one has no edge term anywhere. * * ORDER: takeable-gated p_win → grade tier → confidence → stable input order. * * p_win LEADS, grade follows. That inverts the incumbent, and deliberately: the * grade letter measured r ~ 0.005 against outcomes and is INVERTED (B 52.4% < * C 56.9%), while p_win measures +0.26. Leading with the letter would sort the * board by the weaker signal and use the stronger one only to break ties. * * The takeable gate is mandatory and unchanged: raw p_win crowns -300 chalk, * which is not the product. * * ON CALIBRATION: isotonic is a MONOTONE transform, so ranking on raw p_win and * ranking on isotonic-calibrated p_win produce the SAME ORDER. Calibration * matters when p_win is displayed or thresholded — it cannot change a ranking. * Nothing here needs the calibrated value. */ function rankByForecast(grades, limit) { const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade); const scored = arr.map((g, idx) => ({ g, idx, pWin: takeablePWin(g), rank: gradeRankOf(g.grade), conf: strictNum(g.confidence) == null ? -1 : strictNum(g.confidence), })); scored.sort((a, b) => descNullsLast(a.pWin, b.pWin) || a.rank - b.rank || b.conf - a.conf || a.idx - b.idx); const out = scored.map((s) => s.g); return limit == null ? out : out.slice(0, Math.max(0, limit)); } /** * WHICH SPORTS MAY RANK ON THE FORECAST — the per-sport doctrine, as a GATE. * * A sport ranks on p_win ONLY once its OWN model is built and shown to * predict — calibration AND resolution holding on its own holdout. MLB is the * only sport that has passed. Every other sport is held out as NOT-BUILT, * never as FAILED. * * WNBA IS NOT "ANTI-PREDICTIVE" AND DOES NOT "ABSTAIN" (corrected 2026-08-01). * The -0.12 result that produced those words was NBA-template machinery run on * WNBA data. WNBA has never had its own archetypes, variables, conditions or * calibration — it is precisely the "sport stubbed in on another sport's * template" that CLAUDE.md forbids. So -0.12 is the EXPECTED FAILURE OF AN * UNBUILT MODEL, not a verdict on the sport. Reading it as a verdict would * quietly retire a sport we never actually attempted. * * WNBA's own model-build is QUEUED as its own sport, after MLB is finished. * * The live consequence is identical either way — an unbuilt sport must not rank * on a signal that has not been shown to hold for it — which is why this set is * UNCHANGED. Only its meaning is corrected. A comment would not have stopped a * future flip from going global; this does. */ const FORECAST_RANKED_SPORTS = Object.freeze(new Set(['mlb'])); const ranksOnForecast = (sport) => FORECAST_RANKED_SPORTS.has(String(sport || '').toLowerCase()); /** Stable identity for a grade row, for comparing two orderings. */ function gradeKey(g) { if (!g) return ''; const player = g.player_name || g.player || ''; const stat = g.stat_type || g.stat || ''; return `${String(player).toLowerCase()}|${String(stat).toLowerCase()}|${g.line}|${g.direction || ''}`; } /** * rankingDelta — the CHALLENGER-FIRST measurement. How far does the board move * if the instrument changes from `rankGrades` (grade-then-edge) to * `rankByForecast` (p_win-first, no edge)? Pure; changes nothing. */ function rankingDelta(grades, topN = 10) { const incumbent = rankGrades(grades); const challenger = rankByForecast(grades); const posOf = (list) => { const m = new Map(); list.forEach((g, i) => m.set(gradeKey(g), i)); return m; }; const a = posOf(incumbent); const b = posOf(challenger); let moved = 0; let sumAbs = 0; let maxMove = 0; const moves = []; for (const [key, i] of a.entries()) { const j = b.get(key); if (j == null) continue; const d = j - i; if (d !== 0) moved += 1; sumAbs += Math.abs(d); if (Math.abs(d) > Math.abs(maxMove)) maxMove = d; moves.push({ key, from: i + 1, to: j + 1, delta: d }); } const n = a.size; const topA = new Set(incumbent.slice(0, topN).map(gradeKey)); const topB = new Set(challenger.slice(0, topN).map(gradeKey)); let overlap = 0; for (const k of topA) if (topB.has(k)) overlap += 1; return { n, moved, moved_pct: n ? Math.round((1000 * moved) / n) / 10 : null, mean_abs_move: n ? Math.round((10 * sumAbs) / n) / 10 : null, max_move: maxMove, top_n: topN, top_n_overlap: overlap, top_n_overlap_pct: topN ? Math.round((1000 * overlap) / topN) / 10 : null, // The headline for a board: does the #1 read change? incumbent_top: incumbent[0] ? gradeKey(incumbent[0]) : null, challenger_top: challenger[0] ? gradeKey(challenger[0]) : null, top_changed: incumbent[0] && challenger[0] ? gradeKey(incumbent[0]) !== gradeKey(challenger[0]) : null, biggest_movers: moves.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 10), }; } module.exports = { GRADE_RANK, gradeRankOf, strictNum, takeablePWin, descNullsLast, rankGrades, rankByForecast, rankingDelta, gradeKey, FORECAST_RANKED_SPORTS, ranksOnForecast, };