8c764c22a4
Both guards are ADDITIVE. The full suite (4,111 -> 4,126 tests, 331 suites) passes unchanged through the migration, which is the evidence that no currently-correct output moved: served path, champion, reference ruler and the four accruing challengers are byte-identical. GUARD 1 -- src/utils/known.js. Number(null)===0 has produced at least SIX separate defects here, including one in a module written the same week its author documented the trap. Per-module vigilance has demonstrably failed, so the rule lives in one place and SEVEN sites now delegate: platoonSplits, projectionChallenger, challengerProjection, contactChallenger, statcastAggregateService, consensusRuler, gradeRanking -- plus compoundTotalBases moved onto knownRate. Two functions, deliberately: knownNumber (any finite number -- a REAL 0 is a fact and must survive) and knownRate (non-negative, rejects booleans -- for counts/rates where `true` or -1 is broken, not thin). Collapsing them is how the next variant gets in. firstKnown() exists because `a || b` discards a measured 0 and `a ?? b` does not. MY OWN GUARD HAD THE BUG IT EXISTS TO PREVENT, and its own test caught it: Number([]) === 0, so an empty array coerced to a measured ZERO. Same trap wearing a different type. Both helpers now reject objects outright. GUARD 2 -- src/config/takeability.js. Takeability is BOOK IDENTITY and never price shape. Baseball prop markets are genuinely thin, juiced and one-sided, and all three are NORMAL structure: betrivers and hardrockbet legitimately quote one side only (5 such rows surfaced in yesterday's re-stamp), and a hits-over at -300 is a real placeable bet. A rule that inferred un-takeability from price extremity or one-sidedness would throw those away while still admitting a DFS book at an ordinary -119 -- exactly backwards, because the -119 is the fake one. THE DISTINCTION THAT MUST NOT COLLAPSE, now enforced by test: isTakeableMarket(book) -- CAN it be bet? (identity) isWithinPriceBand(odds) -- SHOULD we promote? (policy band, floor -160) A -300 DraftKings prop is takeable AND out of band; a PrizePicks -119 is in band AND not takeable. Independent axes. FLAGGED, NOT SILENTLY CHANGED: the ledger's `takeable` column is the PRICE-BAND answer, and its name predates this distinction. Four challengers and the ranking gate read it, so renaming or redefining it is its own order -- doing it here would have changed correct current behaviour under cover of a hardening change. Fixtures are REAL prod rows from the 2026-08-02 re-stamp, not invented. Gates: 4,126 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
239 lines
9.8 KiB
JavaScript
239 lines
9.8 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.
|
||
*/
|
||
|
||
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. */
|
||
// 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 || !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,
|
||
};
|