Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26; corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler. Subtracting the market destroys the signal under BOTH rulers, so a quantity that does not predict must not rank, gate or decide. CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the incumbent, grade-first with edge as its 4th key) is untouched and tested as untouched. NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable order, with NO edge term anywhere. p_win LEADS and the letter follows, deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% < C 56.9%) while p_win measures +0.26, so leading with the letter would sort by the weaker signal and use the stronger one only to break ties. Recorded in the code: isotonic calibration is a MONOTONE transform, so ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration matters when p_win is displayed or thresholded; it cannot change a ranking. Nothing here needs the calibrated value. rankingDelta + GET /api/internal/ranking-delta measure how far the board would move before any flip. The endpoint reports p_win coverage alongside the delta -- if p_win is absent the challenger degrades to grade order and the delta UNDERSTATES, which is worth saying rather than reporting a clean zero. forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so every tier gets the correct order without the paid values (the topGradedService precedent -- an ordinal can travel where the magnitude cannot). Additive only: nothing sorts by it yet. RETIRED AS DECISIONS (not rankings, so done now): - altLineScanner.compareToBookImplied no longer returns value_detected: edge > 0. Edge is still COMPUTED and returned -- losing the record would be worse than mis-using it -- but the verdict is an honest null with value_basis: 'retired:edge_does_not_predict'. - scanAltLines no longer filters to edge>0 or calls the survivor "optimal". The whole ladder is returned ranked and labelled 'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified) -- unwired like mlbGrader.js, left in place and made honest. An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must not use edge, but choosing between RUNGS OF THE SAME PROP is inherently price-relative -- ranking rungs by model probability alone would always pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So the gap stays the rung key, explicitly labelled unvalidated. Two superseded tests updated to stronger properties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -94,6 +94,114 @@ function rankGrades(grades, limit) {
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user