d103ecf4c3
BYTE-IDENTICAL. The audit found no consumer getting the wrong axis, so this is a disambiguation, not a bug fix. 4,131 tests / 331 suites green. STEP 1 AUDIT -- and the order's premise was wrong in a useful way: the four accruing challengers read the flag ZERO times (not four) the ranking gate wants PROMOTION, gets promotion [correct] the ledger column holds the LEDGER band, consumed as such the UI (LiveHeroProp) TYPES a `takeable` field it never renders THERE ARE THREE DEFINITIONS, NOT TWO -- and I only found the third by tracing the ranking gate: 1. IDENTITY can it be bet? book identity (takeability) 2. LEDGER BAND worth recording? odds >= -160, UNCAPPED plus 3. PROMOTION worth crowning? -160..+200, i.e. band PLUS a ceiling (2) and (3) genuinely disagree, and I measured it rather than asserting it: 439 rows -- 28.2% of all takeable=true ledger rows -- carry prices above +200, up to +1300. A +1300 longshot is a real bet worth RECORDING and not one worth CROWNING. Both are correct for their own purpose. THE DANGER WAS NEVER THE LOGIC. It was that three questions shared one word, so a reader could not tell which answer they held -- and hits, which must model thin/juiced/one-sided REAL markets, would have been the next reader to guess wrong. RESOLUTION: all three now have distinct names in config/takeability.js; gradeRanking calls isWithinPromotionBand so its intent is self-evident (a test pins it byte-identical to the old valueEngine call across the whole price range); the ledger dual-writes within_price_band with `takeable` kept as a documented DEPRECATED MIRROR so nothing breaks. Column comments in the database now say what each column actually holds. I did NOT redefine `takeable` in place. Four readers and a ranking gate sit on it, and silently changing its meaning under cover of a naming change is exactly the class of move this session keeps removing. Gates: 4,131 tests / 331 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
'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.
|
||
*/
|
||
|
||
// INTENT: this gate asks "should we CROWN this?", not "can it be bet?". It is
|
||
// the PROMOTION band (floor AND a +200 ceiling) -- a +1300 longshot is a real,
|
||
// placeable bet that we deliberately do not crown. Named explicitly since three
|
||
// different questions used to share the word "takeable".
|
||
const { isWithinPromotionBand } = require('../config/takeability');
|
||
|
||
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. */
|
||
// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced
|
||
// at least SIX separate defects in this codebase, including one in a module
|
||
// written the same week its author documented the trap — per-module vigilance
|
||
// has demonstrably failed. Semantics are byte-identical to the local copy this
|
||
// replaces, so no output changes; the point is that there is now ONE rule.
|
||
const { knownNumber } = require('./known');
|
||
const strictNum = knownNumber;
|
||
|
||
/**
|
||
* 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 || !isWithinPromotionBand(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,
|
||
};
|