Files
vyndr/src/services/model/calibrationService.js
T
builtbykev 4d1803f6d7 Calibrate hits point-in-time: partial pass, and an honest ceiling of 0.667
Fitted the isotonic map on game_date < 2026-08-02 (n=589) and evaluated it on
everything from that date forward (n=383). The map never saw the evaluation
rows, which is the only thing that makes the result mean anything -- fitting
and evaluating on the same rows always looks perfectly calibrated, because the
map is reciting the answers it was built from.

It works, on most of the distribution. Held-out after correction: 0.477 comes
back 0.506, 0.587 comes back 0.580, 0.667 comes back 0.603 -- against raw
errors of +0.191, +0.279 and +0.246 in the same bins. Ordering survived, and
that was verified pairwise rather than assumed, because a broken map would
silently destroy the one thing this model does well.

Two findings matter more than the pass.

First, the honest ceiling is 0.667. Once the numbers are truthful this model
has no 80%-plus hit reads at all -- the top of its range was miscalibration,
not confidence. A four-leg ticket at the ceiling is 0.198, where the raw
numbers implied 0.686. The high-floor parlay is a two-thirds-per-leg
proposition, and that is the number to say out loud.

Second, calibration is certified BY BAND rather than by a blanket flag.
Held-out error was -0.029 and +0.007 through the middle but -0.167 at the
bottom and +0.063 at the top: the model is trustworthy over most of its mass
and untrustworthy at both edges. A single true/false would either throw away
the 72% that works or ship the edges that do not. Only a probability inside a
certified band is marked stackable, and that flag is what chainAcross requires
before it will compound anything. The certified band is 0.40 to 0.60, n=276.

A methodological catch on the way: my first pass condition demanded honest bins
at 0.70 and above -- but honest calibration REMOVES those bins, since the
ceiling drops to 0.667. The gate would have failed the repair for succeeding.
It now tests the highest remaining band instead of a fixed threshold.

Wired forward with the same discipline: calibrationService fits strictly before
today, splits by time rather than at random, and returns null on thin history
so that "no calibrator" means nothing is stackable rather than "trust the raw
numbers". p_win is never mutated -- the calibrated value rides beside it as
p_win_calibrated, because a calibration map is a correction to a forecast, not
a different forecast, and the counter stays byte-identical.

4,275 tests green (339 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 17:51:15 -04:00

129 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* calibrationService — FIT ON HISTORY, APPLY TO TONIGHT.
*
* The backtest proved the repair works out-of-sample; this is the same
* discipline running forward in production. The map is fitted ONLY on outcomes
* that have already settled, and applied to props that have not been played. If
* that cut is ever relaxed, the map has seen the answer and every number it
* produces is fiction that will look excellent in review.
*
* ── WHAT THE BACKTEST ESTABLISHED (2026-08-04, hits) ─────────────────────
* Fitted on game_date < 2026-08-02 (n=589), evaluated on >= (n=383):
*
* raw 0.746 -> 0.556 0.843 -> 0.564 0.913 -> 0.667
* calibrated 0.477 -> 0.506 0.587 -> 0.580 0.667 -> 0.603
*
* Ordering survived (isotonic is monotone — verified, not assumed). Errors at
* the top fell from +0.28 to +0.06. And the honest CEILING dropped to 0.667:
* once the numbers are truthful, this model has no 80%+ hit reads at all. The
* "high-floor parlay" is a 0.667-per-leg proposition, not a 0.9 one.
*
* ── CERTIFICATION IS BY BAND, NOT A BLANKET FLAG ─────────────────────────
* Held-out errors were 0.029 and +0.007 through the middle, but 0.167 at the
* bottom and +0.063 at the top. The model is trustworthy over most of its mass
* and untrustworthy at both edges, so a single true/false would either throw
* away the 72% that works or ship the edges that do not. Only a probability
* inside a certified band is marked stackable.
*/
const cal = require('./calibration');
const { knownNumber } = require('../../utils/known');
/** Default: hold out the most recent quarter of history to certify on. */
const HOLDOUT_FRACTION = 0.35;
const MIN_FIT = 200;
/**
* Build a calibrator from settled rows.
*
* @param {Array<{p, won, date}>} settled rows STRICTLY BEFORE the props being graded
* @returns {object|null} null when there is not enough history — the caller must
* then treat every atom as uncalibrated rather than pass it through raw.
*/
function build(settled, opts = {}) {
const rows = (settled || [])
.map((r) => ({ p: knownNumber(r && r.p), won: knownNumber(r && r.won), d: String((r && r.date) || '') }))
.filter((r) => r.p !== null && r.won !== null)
.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : 0));
if (rows.length < (opts.minFit ?? MIN_FIT)) return null;
// Split by TIME, not at random: certifying on rows the map was fitted on
// always looks perfect, and a random split leaks the future into the fit.
const cut = Math.floor(rows.length * (1 - (opts.holdoutFraction ?? HOLDOUT_FRACTION)));
const fitRows = rows.slice(0, cut);
const certRows = rows.slice(cut);
if (fitRows.length < (opts.minFit ?? MIN_FIT) || certRows.length < 60) return null;
const map = cal.fitIsotonic(fitRows, { minTotal: opts.minFit ?? MIN_FIT });
if (!map) return null;
const corrected = certRows.map((r) => ({ ...r, p: cal.applyIsotonic(map, r.p) }));
const bands = cal.certifyBands(corrected, {
tolerance: opts.tolerance ?? 0.05,
minBin: opts.minBin ?? 40,
});
return {
map,
bands,
fit_n: fitRows.length,
certify_n: certRows.length,
fitted_through: fitRows[fitRows.length - 1].d,
certified_through: certRows[certRows.length - 1].d,
/**
* Calibrate one probability.
* `calibrated` is TRUE only inside a certified band — that flag is what
* `chain.chainAcross` requires before it will compound anything.
*/
calibrate(p) {
const raw = knownNumber(p);
if (raw === null) return { p_raw: null, p_calibrated: null, calibrated: false, reason: 'absent' };
const c = cal.applyIsotonic(map, raw);
if (c === null) return { p_raw: raw, p_calibrated: null, calibrated: false, reason: 'no_map_value' };
const inBand = cal.inCertifiedBand(bands, c);
return {
p_raw: raw,
p_calibrated: Math.round(c * 1000) / 1000,
calibrated: inBand,
reason: inBand ? null : 'outside_certified_band',
};
},
};
}
/**
* Load settled history from the ledger and build a calibrator, POINT-IN-TIME.
*
* `before` defaults to today, so the fit can only ever use games that are over.
* Injectable for tests; returns null rather than a permissive fallback, because
* "no calibrator" must mean "nothing is stackable", not "pass the raw numbers
* through".
*/
async function fromLedger(sb, { sport = 'mlb', stat = 'hits', before = null, ...opts } = {}) {
if (!sb) return null;
const cutoff = before || new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
const rows = [];
for (let from = 0; ; from += 1000) {
const { data, error } = await sb.from('ledger_entries')
.select('p_win, outcome, game_date, quarantine_reason')
.eq('sport', sport).is('user_id', null).eq('stat', stat)
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null)
.lt('game_date', cutoff) // STRICTLY before — the whole point
.range(from, from + 999);
if (error || !data || data.length === 0) break;
rows.push(...data);
if (data.length < 1000) break;
}
const clean = rows
.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'))
.map((r) => ({ p: Number(r.p_win), won: r.outcome === 'hit' ? 1 : 0, date: String(r.game_date) }));
const built = build(clean, opts);
return built ? { ...built, cutoff } : null;
}
module.exports = { build, fromLedger, HOLDOUT_FRACTION, MIN_FIT };