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:
Kev
2026-08-01 01:24:55 -04:00
parent 7140e62b65
commit 86d123945c
6 changed files with 341 additions and 27 deletions
+108
View File
@@ -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,
};