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
+45
View File
@@ -556,4 +556,49 @@ router.get('/propline-verify', async (req, res) => {
}
});
/**
* GET /api/internal/ranking-delta (Order: rank on p_win — CHALLENGER-FIRST)
*
* Reads the live snapshot and reports how far the board WOULD move if the
* ranking instrument changed from `rankGrades` (grade-first, edge as 4th key)
* to `rankByForecast` (p_win-first, no edge term). Changes nothing — the live
* ordering is untouched until this delta is reviewed.
*
* ?sports=mlb,wnba ?top=10
*/
router.get('/ranking-delta', async (req, res) => {
try {
const { cacheGet } = require('../utils/redis');
const { rankingDelta } = require('../utils/gradeRanking');
const sports = String(req.query.sports || 'mlb,wnba')
.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean).slice(0, 6);
const topN = Math.max(1, Math.min(50, parseInt(req.query.top, 10) || 10));
const out = {};
for (const sport of sports) {
let grades = null;
const snap = await cacheGet(`snapshot:${sport}:latest`);
if (snap && Array.isArray(snap.grades)) grades = snap.grades;
else {
const env = await cacheGet(`grades:${sport}`);
if (env && Array.isArray(env.grades)) grades = env.grades;
}
if (!grades || grades.length === 0) { out[sport] = { note: 'no cached grades' }; continue; }
const withPWin = grades.filter((g) => g && g.p_win != null).length;
out[sport] = {
graded: grades.length,
with_p_win: withPWin,
// Honest: if p_win is absent the challenger degrades to grade order and
// the delta understates. Say so rather than reporting a clean zero.
p_win_coverage_pct: grades.length ? Math.round((1000 * withPWin) / grades.length) / 10 : null,
...rankingDelta(grades, topN),
};
}
res.set('Cache-Control', 'no-store');
return res.json({ ok: true, live_ordering_unchanged: true, per_sport: out });
} catch (err) {
return res.status(500).json({ ok: false, error: err && err.message });
}
});
module.exports = router;
+22 -1
View File
@@ -20,6 +20,7 @@ const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
// being bypassed here on every graded row. Same layer as the CLV gate.
const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating');
const { rankByForecast, gradeKey } = require('../utils/gradeRanking');
const { resolveTierFromRequest } = require('../utils/requestTier');
const router = express.Router();
@@ -108,7 +109,27 @@ router.get('/:sport', async (req, res) => {
// The earlier resolution-flip freed settled grades, which made the free tier a
// ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model
// PRICE first (S67), then withhold judgment on EVERY itemized grade.
const gate = (grades) => gateItemizedGrades(stripModelPrice(grades, tier), tier);
// FORECAST RANK (Order: rank on p_win, 2026-08-01) — stamped BEFORE the
// model-price strip, so every tier receives the CORRECT ORDER without the
// paid values. Same precedent as topGradedService: `p_win` is stripped for
// unentitled callers, so a client cannot rank on it; an ordinal can travel
// where the magnitude cannot.
//
// ADDITIVE ONLY IN THIS ORDER. Nothing sorts by it yet — the live ordering
// is byte-identical until the flip is reviewed against the recorded delta.
// It leaks ordering, not magnitude, which is the same trade already made
// and accepted for the top-graded board.
const stampForecastRank = (grades) => {
if (!Array.isArray(grades) || grades.length === 0) return grades;
const ranked = rankByForecast(grades);
const pos = new Map();
ranked.forEach((g, i) => pos.set(gradeKey(g), i + 1));
return grades.map((g) => {
const r = pos.get(gradeKey(g));
return r == null ? g : { ...g, forecast_rank: r };
});
};
const gate = (grades) => gateItemizedGrades(stripModelPrice(stampForecastRank(grades), tier), tier);
// Free proof, none of it itemizing the nightly slate:
// - the tease: AGGREGATE count + tier shape, computed from the ungated rows and
// never joined back to one, so nobody can tell WHICH prop is the A