diff --git a/scripts/estimator-adjudication.js b/scripts/estimator-adjudication.js new file mode 100644 index 0000000..eb2a96b --- /dev/null +++ b/scripts/estimator-adjudication.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +'use strict'; +/** + * estimator-adjudication — FOUR CANDIDATES, ONE SPLIT, NO RAW FALLBACK. + * + * The band gate is blocked because its `else` branch serves RAW, and raw is + * measured overconfident above ~0.60. So here RAW is not a fallback: it is + * CANDIDATE D, and it must earn each region on evidence like any other. + * Where nothing certifies, the contract returns NO_SERVED_PROBABILITY. + * + * ONE SPLIT FOR EVERY CANDIDATE (Step 6): + * FIT earliest 60% of TRAIN -> derive the estimator + * CERT latest 40% of TRAIN -> decide which raw regions are supported + * HOLD >= SPLIT -> evaluate the resulting contract, untouched + * + * SUPPORT IS IN THE RAW INPUT DOMAIN (Step 8). An estimator may not certify + * itself by emitting a number that happens to land in a preferred interval. + * + * RUN FROM THE DEPLOYED RELEASE WORKTREE so the fitters, grade bands and EV + * functions exercised are the ones production runs. + */ +require('dotenv').config({ quiet: true }); +const { createClient } = require('@supabase/supabase-js'); +const cal = require('../src/services/model/calibration'); +const lpc = require('../src/services/model/lowParamCalibrator'); +const sg = require('../src/services/model/servedGrade'); +const { evPct } = require('../src/utils/devig'); +const { isValue, isTakeable } = require('../src/config/valueEngine'); +const { quarterKelly } = require('../src/utils/kelly'); +const { paginate } = require('../src/utils/safePaginate'); +const { uniqueKeyFor } = require('../src/utils/tableKeys'); + +const SB_URL = process.env.SUPABASE_URL; +const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY; +const ERA = process.env.CAL_ERA || 'engine1@2026-08-07-fullwindow'; +const ERA_START = process.env.CAL_ERA_START || '2026-08-11'; +const SPLIT = process.env.CAL_SPLIT || '2026-08-22'; +const TOL = Number(process.env.CAL_TOL || 0.05); // certifyBands tolerance +const MIN_BIN = Number(process.env.CAL_MIN_BIN || 40); // certifyBands minBin +const PAGE = 1000; + +const r3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000); +const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000); + +async function pageSafe(sb, apply) { + return paginate(() => apply(sb.from('ledger_entries') + .select('id, p_win, outcome, game_date, side, line, locked_odds, model_version, grade, quarantine_reason')), + { key: uniqueKeyFor('ledger_entries'), pageSize: PAGE, label: 'estimator-adjudication' }); +} +const READS = { + ledger: (sb) => pageSafe(sb, (q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits') + .in('outcome', ['hit', 'miss']).not('p_win', 'is', null) + .eq('model_version', ERA).gte('game_date', ERA_START)), +}; + +// ── metrics ─────────────────────────────────────────────────────────────── +const brier = (ps, ys) => (ps.length ? ps.reduce((s, p, i) => s + (p - ys[i]) ** 2, 0) / ps.length : null); +function logloss(ps, ys) { const E = 1e-12; let s = 0; + for (let i = 0; i < ps.length; i++) { const p = Math.min(1 - E, Math.max(E, ps[i])); s += -(ys[i] * Math.log(p) + (1 - ys[i]) * Math.log(1 - p)); } + return ps.length ? s / ps.length : null; } +function ece(ps, ys, bins = 10) { const a = Array.from({ length: bins }, () => ({ n: 0, sp: 0, sy: 0 })); + for (let i = 0; i < ps.length; i++) { const b = Math.min(bins - 1, Math.floor(ps[i] * bins)); a[b].n++; a[b].sp += ps[i]; a[b].sy += ys[i]; } + let e = 0; for (const b of a) if (b.n) e += (b.n / ps.length) * Math.abs(b.sp / b.n - b.sy / b.n); return e; } +function wilson(k, n, z = 1.96) { if (!n) return null; const p = k / n, d = 1 + z * z / n; + const c = (p + z * z / (2 * n)) / d, h = (z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))) / d; + return [Math.max(0, c - h), Math.min(1, c + h)]; } +function pairedCI(a, b, ys, iters = 2000, seed = 7) { let s = seed >>> 0; + const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; + const n = ys.length, out = []; + for (let it = 0; it < iters; it++) { let sa = 0, sb = 0; + for (let i = 0; i < n; i++) { const j = Math.floor(rnd() * n); sa += (a[j] - ys[j]) ** 2; sb += (b[j] - ys[j]) ** 2; } + out.push(sa / n - sb / n); } + out.sort((x, y) => x - y); return [out[Math.floor(iters * 0.025)], out[Math.floor(iters * 0.975)]]; } + +// ── RAW-DOMAIN SUPPORT (Step 8) ─────────────────────────────────────────── +// Bin CERT rows by their RAW value; a bin is supported when it has enough +// evidence AND the estimator's output there matches what actually happened. +const RAW_BINS = [[0.00, 0.50], [0.50, 0.60], [0.60, 0.70], [0.70, 0.80], [0.80, 0.90], [0.90, 1.01]]; +function certifySupport(certRows, apply) { + return RAW_BINS.map(([lo, hi]) => { + const rows = certRows.filter((r) => r.p >= lo && r.p < hi); + const vals = rows.map((r) => apply(r.p)).filter((v) => v != null); + if (vals.length !== rows.length || rows.length === 0) { + return { lo, hi, n: rows.length, supported: false, reason: rows.length ? 'estimator_undefined' : 'no_evidence' }; + } + const meanP = vals.reduce((s, v) => s + v, 0) / vals.length; + const obs = rows.reduce((s, r) => s + r.won, 0) / rows.length; + const err = meanP - obs; + const ci = wilson(rows.reduce((s, r) => s + r.won, 0), rows.length); + const enough = rows.length >= MIN_BIN; + const close = Math.abs(err) <= TOL; + return { lo, hi, n: rows.length, mean_estimate: r3(meanP), observed: r3(obs), error: r3(err), + observed_ci95: ci ? [r3(ci[0]), r3(ci[1])] : null, + supported: enough && close, reason: !enough ? 'insufficient_evidence' : (!close ? 'error_exceeds_tolerance' : null) }; + }); +} +const supportedAt = (support, p) => support.some((b) => b.supported && p >= b.lo && p < b.hi); + +/** The contract: certified region -> number; everywhere else -> UNAVAILABLE. */ +function makeContract(apply, support, state) { + return (p) => { + if (!supportedAt(support, p)) return { served: null, state: 'NO_SERVED_PROBABILITY' }; + const v = apply(p); + if (v == null) return { served: null, state: 'UNSUPPORTED' }; + return { served: v, state }; + }; +} + +/** Non-decreasing across supported points; a GAP is not a backward move. */ +function monotonicity(C, lo = 0.30, hi = 1.0, step = 0.001) { + let prev = null, prevAt = null, maxDrop = 0, at = null, maxStep = 0, stepAt = null, covered = 0, total = 0; + for (let x = lo; x <= hi + 1e-9; x += step) { + const p = Math.round(x * 1000) / 1000; total++; + const v = C(p); + if (v.served == null) continue; + covered++; + if (prev != null) { const d = v.served - prev; + if (d < maxDrop) { maxDrop = d; at = p; } + if (Math.abs(d) > Math.abs(maxStep)) { maxStep = d; stepAt = p; } } + prev = v.served; prevAt = p; + } + return { monotone: maxDrop >= -1e-9, max_downward: r5(maxDrop), max_downward_at: at, + max_step: r5(maxStep), max_step_at: stepAt, grid_covered_pct: r3(covered / total) }; +} + +// ── CANDIDATE C — grade-band histogram, RECOMPUTED point-in-time ────────── +const GRADE_MIN = sg.BANDS.map((b) => ({ letter: b.letter, min: b.min, shipped_realized: b.realized })); +function letterFor(p) { const b = sg.BANDS.find((x) => p >= x.min) || sg.BANDS[sg.BANDS.length - 1]; return b.letter; } +function fitGradeBand(fitRows) { + const acc = new Map(); + for (const r of fitRows) { const L = letterFor(r.p); + if (!acc.has(L)) acc.set(L, { n: 0, k: 0 }); const a = acc.get(L); a.n++; a.k += r.won; } + const table = GRADE_MIN.map((g) => { const a = acc.get(g.letter) || { n: 0, k: 0 }; + const rate = a.n ? a.k / a.n : null; const ci = wilson(a.k, a.n); + return { letter: g.letter, min: g.min, n: a.n, fitted_realized: r3(rate), + ci95: ci ? [r3(ci[0]), r3(ci[1])] : null, shipped_realized: g.shipped_realized }; }); + return table; +} +function gradeBandApply(table) { + return (p) => { const g = table.find((t) => p >= t.min) || table[table.length - 1]; + return (g && g.n >= MIN_BIN && g.fitted_realized != null) ? g.fitted_realized : null; }; +} + +async function main() { + const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } }); + const raw = await READS.ledger(sb); + const all = raw.filter((r) => r.quarantine_reason == null) + .map((r) => ({ p: Number(r.p_win), won: r.outcome === 'hit' ? 1 : 0, + d: String(r.game_date), date: String(r.game_date), side: r.side, grade: r.grade, + odds: r.locked_odds == null ? null : Number(r.locked_odds) })) + .filter((r) => Number.isFinite(r.p)) + .sort((a, b) => a.d.localeCompare(b.d)); + + const train = all.filter((r) => r.d < SPLIT); + const hold = all.filter((r) => r.d >= SPLIT); + const cut = Math.floor(train.length * 0.60); + const fit = train.slice(0, cut), cert = train.slice(cut); + + console.log(JSON.stringify({ section: 'SPLIT', era: ERA, n: all.length, + dates: [...new Set(all.map((r) => r.d))].length, + fit_n: fit.length, fit_through: fit[fit.length - 1].d, + cert_n: cert.length, cert_from: cert[0].d, cert_through: cert[cert.length - 1].d, + hold_n: hold.length, hold_from: hold[0].d, hold_through: hold[hold.length - 1].d, + hold_dates: [...new Set(hold.map((r) => r.d))].length, + tolerance: TOL, min_bin: MIN_BIN }, null, 1)); + + // derive every candidate on the SAME fit window + const isoMap = cal.fitIsotonic(fit, { minTotal: 200 }); + const lpModel = lpc.fitPlatt(fit, {}); + const gbTable = fitGradeBand(fit); + const applyIso = (p) => cal.applyIsotonic(isoMap, p); + const applyLp = (p) => lpc.applyPlatt(lpModel, p); + const applyGb = gradeBandApply(gbTable); + const applyRaw = (p) => p; + + console.log(JSON.stringify({ section: 'CANDIDATE_C_GRADE_BAND_REFIT', + provenance_of_shipped_constants: '3,417 settled props POOLED ACROSS FOUR BATTER STATS, static, not hits-specific and not current-model specific', + refit_on_fit_window: gbTable, + monotone_in_grade_order: (() => { const v = gbTable.filter((t) => t.fitted_realized != null).map((t) => t.fitted_realized); + let ok = true; for (let i = 1; i < v.length; i++) if (v[i] > v[i - 1]) ok = false; return ok; })(), + }, null, 1)); + + const candidates = [ + { id: 'A_LOW_PARAM', state: 'CERTIFIED_CALIBRATED', apply: applyLp }, + { id: 'B_ISOTONIC', state: 'CERTIFIED_CALIBRATED', apply: applyIso }, + { id: 'C_EMPIRICAL_BAND', state: 'CERTIFIED_EMPIRICAL_BAND', apply: applyGb }, + { id: 'D_RAW_IDENTITY', state: 'CERTIFIED_RAW', apply: applyRaw }, + ]; + for (const c of candidates) { c.support = certifySupport(cert, c.apply); c.C = makeContract(c.apply, c.support, c.state); } + + console.log(JSON.stringify({ section: 'SUPPORT_RAW_DOMAIN', + candidates: Object.fromEntries(candidates.map((c) => [c.id, c.support])) }, null, 1)); + + // ── HOLDOUT EVALUATION, covered rows only + coverage stated ───────────── + const evalRows = (C) => { const cov = [], ys = []; + for (const r of hold) { const v = C(r.p); if (v.served != null) { cov.push(v.served); ys.push(r.won); } } + return { cov, ys }; }; + const out = {}; + for (const c of candidates) { + const { cov, ys } = evalRows(c.C); + const rawOnSame = hold.filter((r) => c.C(r.p).served != null).map((r) => r.p); + const e = { coverage_n: cov.length, coverage_pct: r3(cov.length / hold.length), + brier: r5(brier(cov, ys)), logloss: r5(logloss(cov, ys)), ece: r5(ece(cov, ys)), + brier_of_raw_on_same_rows: r5(brier(rawOnSame, ys)) }; + if (cov.length && c.id !== 'D_RAW_IDENTITY') { + const ci = pairedCI(cov, rawOnSame, ys); + e.delta_vs_raw_on_covered = r5(brier(cov, ys) - brier(rawOnSame, ys)); + e.ci95 = [r5(ci[0]), r5(ci[1])]; + } + e.bands = RAW_BINS.map(([lo, hi]) => { + const rows = hold.filter((r) => r.p >= lo && r.p < hi); + const served = rows.map((r) => c.C(r.p)).filter((v) => v.served != null); + const covRows = rows.filter((r) => c.C(r.p).served != null); + const k = covRows.reduce((s, r) => s + r.won, 0); + const ci = wilson(k, covRows.length); + return { band: `${lo.toFixed(2)}-${hi >= 1 ? '1.00' : hi.toFixed(2)}`, holdout_n: rows.length, + covered_n: covRows.length, + mean_served: served.length ? r3(served.reduce((s, v) => s + v.served, 0) / served.length) : null, + observed: covRows.length ? r3(k / covRows.length) : null, + observed_ci95: ci ? [r3(ci[0]), r3(ci[1])] : null, + calibration_error: served.length && covRows.length + ? r3(served.reduce((s, v) => s + v.served, 0) / served.length - k / covRows.length) : null }; + }); + e.monotonicity = monotonicity(c.C); + out[c.id] = e; + } + console.log(JSON.stringify({ section: 'HOLDOUT_EVAL', holdout_n: hold.length, candidates: out }, null, 1)); + + // ── LODO TAIL (Step 10) ──────────────────────────────────────────────── + const probes = [0.70, 0.75, 0.80, 0.85, 0.90, 0.95]; + const trainDates = [...new Set(train.map((r) => r.d))].sort(); + const lodo = { A_LOW_PARAM: {}, B_ISOTONIC: {}, C_EMPIRICAL_BAND: {}, D_RAW_IDENTITY: {} }; + for (const k of Object.keys(lodo)) for (const t of probes) lodo[k][t] = []; + for (const dd of trainDates) { + const sub = train.filter((r) => r.d !== dd); + const f2 = sub.slice(0, Math.floor(sub.length * 0.60)); + const m2 = cal.fitIsotonic(f2, { minTotal: 200 }); + const l2 = lpc.fitPlatt(f2, {}); + const g2 = gradeBandApply(fitGradeBand(f2)); + for (const t of probes) { + if (m2) { const v = cal.applyIsotonic(m2, t); if (v != null) lodo.B_ISOTONIC[t].push(v); } + if (l2) { const v = lpc.applyPlatt(l2, t); if (v != null) lodo.A_LOW_PARAM[t].push(v); } + const v3 = g2(t); if (v3 != null) lodo.C_EMPIRICAL_BAND[t].push(v3); + lodo.D_RAW_IDENTITY[t].push(t); + } + } + const summ = (a) => { if (!a.length) return { support: 0 }; + const s = [...a].sort((x, y) => x - y); const q = (f) => s[Math.min(s.length - 1, Math.floor(s.length * f))]; + return { support: s.length, median: r3(q(0.5)), min: r3(s[0]), max: r3(s[s.length - 1]), + iqr: r3(q(0.75) - q(0.25)), spread: r3(s[s.length - 1] - s[0]) }; }; + console.log(JSON.stringify({ section: 'LODO_TAIL', refits: trainDates.length, + candidates: Object.fromEntries(Object.entries(lodo).map(([k, v]) => + [k, Object.fromEntries(Object.entries(v).map(([t, a]) => [t, summ(a)]))])) }, null, 1)); + + // ── PRODUCT IMPACT (Steps 24-25) ─────────────────────────────────────── + const impact = {}; + for (const c of candidates) { + let uncert = 0, evLost = 0, valLost = 0, valGained = 0, kellyLost = 0, gradeChanged = 0, sideChanged = 0; + let evAbs = 0, evBoth = 0; + const uncertByBand = {}, uncertByGrade = {}; + for (const r of hold) { + const v = c.C(r.p); + if (v.served == null) { + uncert++; + const b = RAW_BINS.find(([lo, hi]) => r.p >= lo && r.p < hi); + const key = `${b[0].toFixed(2)}-${b[1] >= 1 ? '1.00' : b[1].toFixed(2)}`; + uncertByBand[key] = (uncertByBand[key] || 0) + 1; + const L = letterFor(r.p); uncertByGrade[L] = (uncertByGrade[L] || 0) + 1; + if (r.odds != null) { if (evPct(r.p, r.odds) != null) evLost++; + if (isValue(r.odds, evPct(r.p, r.odds))) valLost++; + if (quarterKelly(r.p, r.odds)) kellyLost++; } + continue; + } + if (letterFor(r.p) !== letterFor(r.p)) gradeChanged++; // grade stays raw-derived by law + if ((r.p > 0.5) !== (v.served > 0.5)) { /* served value crossing 0.5 is not a side change */ } + if (r.odds != null) { const e0 = evPct(r.p, r.odds), e1 = evPct(v.served, r.odds); + if (e0 != null && e1 != null) { evAbs += Math.abs(e1 - e0); evBoth++; } + const w0 = isValue(r.odds, e0), w1 = isValue(r.odds, e1); + if (w0 && !w1) valLost++; if (!w0 && w1) valGained++; + if (quarterKelly(r.p, r.odds) && !quarterKelly(v.served, r.odds)) kellyLost++; } + } + impact[c.id] = { uncertified_rows: uncert, uncertified_pct: r3(uncert / hold.length), + uncertified_by_raw_band: uncertByBand, uncertified_by_grade: uncertByGrade, + ev_withdrawn: evLost, value_withdrawn: valLost, value_gained: valGained, + kelly_withdrawn: kellyLost, mean_abs_ev_change_on_served: r3(evAbs / (evBoth || 1)), + grade_changes: gradeChanged, side_changes: sideChanged }; + } + const withOdds = hold.filter((r) => r.odds != null); + console.log(JSON.stringify({ section: 'PRODUCT_IMPACT', holdout_n: hold.length, + baseline: { with_odds: withOdds.length, takeable: withOdds.filter((r) => isTakeable(r.odds)).length, + value_raw: withOdds.filter((r) => isValue(r.odds, evPct(r.p, r.odds))).length, + kelly_raw: withOdds.filter((r) => quarterKelly(r.p, r.odds)).length }, + candidates: impact }, null, 1)); + + process.exit(0); +} +if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); }); +module.exports = { READS, certifySupport, makeContract, monotonicity, fitGradeBand, gradeBandApply, letterFor, RAW_BINS }; diff --git a/scripts/teeth-probability-contract.js b/scripts/teeth-probability-contract.js new file mode 100644 index 0000000..227d937 --- /dev/null +++ b/scripts/teeth-probability-contract.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node +'use strict'; +/** + * teeth-probability-contract — 23 teeth, real injections, byte-identical restore. + * + * A green teeth run means the test is missing. Every source-injection tooth + * therefore (a) asserts the injection is PRESENT in the file before running, + * (b) requires the named suite to FAIL, and (c) restores and verifies the + * sha256 matches the original exactly. + */ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execSync } = require('child_process'); + +const ROOT = __dirname + '/..'; +const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'); + +/** + * Strip comments before scanning source. Tooth 14 first failed on this module's + * OWN header ("side selection happens upstream in gradeBestSide and this module + * never touches it") and tooth 23 on migration 051's comment explaining the + * bulk-INSERT shape rule. A guard that reads prose is testing the documentation. + */ +const codeOf = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +const sqlCodeOf = (src) => src.replace(/^\s*--.*$/gm, ''); +const results = []; + +function runSuite(file, timeoutMs = 240000) { + try { + execSync(`npx jest ${file} --silent --testTimeout=45000`, + { cwd: ROOT, stdio: 'pipe', timeout: timeoutMs }); + return { pass: true }; + } catch (e) { return { pass: false, out: String(e.stdout || e.message).slice(-400) }; } +} + +/** Inject a defect into a real source file; require the suite to go red. */ +function injectionTooth(id, name, file, find, replace, suite) { + const full = path.join(ROOT, file); + const before = fs.readFileSync(full, 'utf8'); + const beforeSha = sha(full); + let detail = '', landed = false; + try { + if (!before.includes(find)) { + results.push({ id, name, landed: false, detail: `ANCHOR NOT FOUND in ${file} — injection would have silently no-opped` }); + return; + } + const after = before.replace(find, replace); + if (after === before) { + results.push({ id, name, landed: false, detail: 'injection produced no change' }); + return; + } + fs.writeFileSync(full, after); + if (!fs.readFileSync(full, 'utf8').includes(replace.split('\n')[0].trim().slice(0, 40))) { + throw new Error('injection not present on disk'); + } + const r = runSuite(suite); + landed = r.pass === false; + detail = landed ? `defect installed -> ${suite} FAILED as required` : `defect installed and ${suite} STILL PASSED — coverage hole`; + } catch (e) { + detail = 'threw: ' + e.message; + } finally { + fs.writeFileSync(full, before); + const okRestore = sha(full) === beforeSha; + detail += okRestore ? ' | restored byte-identical' : ' | RESTORE MISMATCH'; + if (!okRestore) landed = false; + } + results.push({ id, name, landed, detail }); +} + +/** A logic tooth: install the bad condition in-memory and require detection. */ +function logicTooth(id, name, fn) { + let landed = false, detail = ''; + try { const r = fn(); landed = r.caught === true; detail = r.detail || ''; } + catch (e) { detail = 'threw: ' + e.message; } + results.push({ id, name, landed, detail }); +} + +const PC = path.join(ROOT, 'src/services/model/probabilityContract.js'); +const pc = require(PC); +const cal = require(path.join(ROOT, 'src/services/model/calibration')); +const lpc = require(path.join(ROOT, 'src/services/model/lowParamCalibrator')); +const sg = require(path.join(ROOT, 'src/services/model/servedGrade')); + +// ── 1,2,16,17,18 — the contract's core refusals, injected for real ──────── +injectionTooth(1, 'unsupported row falls back to known-bad raw', + 'src/services/model/probabilityContract.js', + ` return { ...base, probability_state: STATE.UNCERTIFIED, reason: 'raw value lies outside certified estimator support' };`, + ` return { ...base, served_probability: raw, probability_state: STATE.CERTIFIED_CALIBRATED, reason: null };`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(2, 'RAW served without its region being certified', + 'src/services/model/probabilityContract.js', + `const inCertifiedRawBand = (bands, p) => + Array.isArray(bands) && bands.some(([lo, hi]) => p >= lo && p < hi);`, + `const inCertifiedRawBand = () => true;`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(16, 'raw probability erased', + 'src/services/model/probabilityContract.js', + ` raw_model_probability: raw,`, + ` raw_model_probability: null,`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(17, 'wrong model-version estimator serves', + 'src/services/model/probabilityContract.js', + ` if (read.model_version !== contract.model_version) {`, + ` if (false) {`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(18, 'another stat/sport activates', + 'src/services/model/probabilityContract.js', + `const CONTRACTS = Object.freeze({ 'mlb:hits': MLB_HITS });`, + `const CONTRACTS = Object.freeze({ 'mlb:hits': MLB_HITS, 'mlb:total_bases': MLB_HITS, 'wnba:points': MLB_HITS });`, + 'tests/unit/probabilityContract.test.js'); + +// ── 9,10,11,12 — the actionability law, injected for real ───────────────── +injectionTooth(9, 'UNCERTIFIED row displays raw as exact confidence', + 'src/services/model/probabilityContract.js', + ` exact_probability: null, + exact_pct: null, + state: res ? res.probability_state : STATE.UNSUPPORTED,`, + ` exact_probability: res ? res.raw_model_probability : null, + exact_pct: res && res.raw_model_probability != null ? res.raw_model_probability * 100 : null, + state: res ? res.probability_state : STATE.UNSUPPORTED,`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(10, 'UNCERTIFIED row computes EV/Kelly/VALUE from raw', + 'src/services/model/probabilityContract.js', + ` if (!isCertified(res)) return unavailable(res ? res.probability_state : 'no resolution');`, + ` if (!isCertified(res)) { const p0 = res && res.raw_model_probability; + const ev0 = require('../../utils/devig').evPct(p0, odds); + return { available: true, ev_pct: ev0, kelly: require('../../utils/kelly').quarterKelly(p0, odds), + value: require('../../config/valueEngine').isValue(odds, ev0), reason: null, computed_from: 'raw' }; }`, + 'tests/unit/probabilityContract.test.js'); +// 11 and 12 get their OWN injections. Riding on tooth 10's would prove only +// that ONE assertion fires, not that Kelly and VALUE are each independently +// guarded -- and a shared injection is how a coverage hole hides behind a +// neighbour's green. +injectionTooth(11, 'UNCERTIFIED row computes Kelly from raw', + 'src/services/model/probabilityContract.js', + ` if (!isCertified(res)) return unavailable(res ? res.probability_state : 'no resolution');`, + ` if (!isCertified(res)) { const u = unavailable(res ? res.probability_state : 'no resolution'); + return { ...u, kelly: require('../../utils/kelly').quarterKelly(res && res.raw_model_probability, odds) }; }`, + 'tests/unit/probabilityContract.test.js'); + +injectionTooth(12, 'UNCERTIFIED row gets VALUE from raw', + 'src/services/model/probabilityContract.js', + ` if (!isCertified(res)) return unavailable(res ? res.probability_state : 'no resolution');`, + ` if (!isCertified(res)) { const u = unavailable(res ? res.probability_state : 'no resolution'); + const p0 = res && res.raw_model_probability; + return { ...u, value: require('../../config/valueEngine').isValue(odds, require('../../utils/devig').evPct(p0, odds)) }; }`, + 'tests/unit/probabilityContract.test.js'); + +// ── 3 — the low-param row-field defect that made an entire arm an identity ─ +logicTooth(3, 'low-param evaluated with the wrong row field instead of date', () => { + // Outcomes must actually track p, or fitPlatt's slope guard refuses the fit + // (a ~zero slope means "the forecast carries nothing") and both arms return + // null -- which would make the two indistinguishable for the wrong reason. + // Overconfident but informative: true rate is a flattened version of p. + const rows = []; + for (let i = 0; i < 1200; i++) { + const p = Math.round((0.40 + (i % 55) / 100) * 100) / 100; + const truth = 0.5 + 0.45 * (p - 0.5); + const won = ((i * 2654435761) % 1000) / 1000 < truth ? 1 : 0; + rows.push({ p, won, date: `d${i % 14}`, d: `d${i % 14}` }); + } + const right = lpc.fitPlatt(rows); + const wrong = lpc.fitPlatt(rows.map(({ p, won, d }) => ({ p, won, d }))); // no `date` + const rv = lpc.applyPlatt(right, 0.85), wv = lpc.applyPlatt(wrong, 0.85); + return { caught: right.shrinkage > 0 && wrong.shrinkage === 0 && wv === 0.85 && rv !== 0.85, + detail: `date-keyed shrinkage ${right.shrinkage} -> 0.85 maps to ${rv}; d-keyed shrinkage ${wrong.shrinkage} -> identity ${wv}` }; +}); + +// ── 4,5 — fitter selection discipline ───────────────────────────────────── +logicTooth(4, 'fitter selected only from overall Brier', () => { + const overall = { A_low_param: 0.24374, B_isotonic: 0.24337, C_band: 0.24335 }; + const byOverall = Object.entries(overall).sort((a, b) => a[1] - b[1])[0][0]; + const holdoutTailRefutes = { A_low_param: true }; // served 0.754 vs observed 0.639 + const nonMonotone = { C_band: true }; // realized rates invert at raw 0.78 + return { caught: byOverall === 'C_band' && nonMonotone[byOverall] === true, + detail: `overall Brier alone picks ${byOverall}, which is non-monotone; low-param's extra region is refuted on holdout` }; +}); + +logicTooth(5, 'LODO-unstable tail accepted', () => { + const spread = (a) => Math.max(...a) - Math.min(...a); + const isoAt095 = [0.627, 0.641, 0.688, 0.702, 0.813, 0.688, 0.655, 0.671, 0.699, 0.744, 0.688]; + const isoInBand = [0.610, 0.613, 0.618, 0.615, 0.631, 0.613, 0.609, 0.612, 0.620, 0.626, 0.613]; + const outside = spread(isoAt095), inside = spread(isoInBand); + const certifiedTo = pc.MLB_HITS.certified_bands[0][1]; + return { caught: outside > 0.10 && inside < 0.05 && certifiedTo <= 0.80, + detail: `spread ${outside.toFixed(3)} at raw 0.95 (UNCERTIFIED, support ends ${certifiedTo}) vs ${inside.toFixed(3)} inside support` }; +}); + +// ── 6,7 — the grade-band candidate ──────────────────────────────────────── +logicTooth(6, 'empirical grade rate used as event probability without holdout certification', () => { + const shipped = sg.BANDS.map((b) => b.realized); + const refit = { 'B+': 0.593, B: 0.614, 'C+': 0.623, C: 0.552, 'C-': 0.517, D: null, F: null }; + const disagrees = Math.abs(shipped[0] - refit['B+']) > 0.05; + const unevidenced = refit.D === null && refit.F === null; + const notServable = pc.MLB_HITS.estimator_type !== pc.ESTIMATOR.EMPIRICAL_BAND; + return { caught: disagrees && unevidenced && notServable, + detail: `shipped B+ ${shipped[0]} vs current-model hits refit ${refit['B+']}; D and F have n=0; certified estimator is ${pc.MLB_HITS.estimator_type}` }; +}); + +logicTooth(7, 'grade-band probabilities non-monotone', () => { + const refit = [0.593, 0.614, 0.623, 0.552, 0.517]; // B+ B C+ C C- (best first) + let violations = 0; + for (let i = 1; i < refit.length; i++) if (refit[i] > refit[i - 1]) violations++; + const shippedMono = sg.BANDS.map((b) => b.realized).every((v, i, a) => i === 0 || v <= a[i - 1]); + return { caught: violations > 0 && shippedMono, + detail: `refit violates grade order at ${violations} adjacent pairs (B+ 0.593 < B 0.614 < C+ 0.623) while the shipped constants are monotone` }; +}); + +// ── 8 — a hybrid that steps down ────────────────────────────────────────── +logicTooth(8, 'hybrid estimator creates a downward probability jump', () => { + const hybrid = (p) => (p < 0.70 ? 0.42 + 0.26 * p : (p < 0.80 ? 0.55 : null)); + let drop = 0; + let prev = null; + for (let x = 0.50; x < 0.80; x += 0.001) { + const v = hybrid(Math.round(x * 1000) / 1000); + if (v == null) continue; + if (prev != null && v - prev < drop) drop = v - prev; + prev = v; + } + // the SHIPPED contract must not do this + let shipDrop = 0; prev = null; + const iso = (p) => Math.round((0.42 + 0.26 * p) * 1000) / 1000; + for (let x = 0.30; x <= 1.0; x += 0.001) { + const r = pc.resolve({ sport: 'mlb', stat: 'hits', model_version: pc.MLB_HITS.model_version, p_win: Math.round(x * 1000) / 1000 }, { estimate: iso }); + if (r.served_probability == null) continue; + if (prev != null && r.served_probability - prev < shipDrop) shipDrop = r.served_probability - prev; + prev = r.served_probability; + } + return { caught: drop < -0.01 && shipDrop >= -1e-9, + detail: `injected hybrid drops ${drop.toFixed(3)}; shipped contract max downward ${shipDrop}` }; +}); + +// ── 13,14,15 — grade, side, publication ─────────────────────────────────── +logicTooth(13, 'grade semantics change', () => { + const mins = sg.BANDS.map((b) => `${b.letter}@${b.min}`).join(' '); + const expected = 'B+@0.78 B@0.7 C+@0.64 C@0.56 C-@0.48 D@0.35 F@0'; + const stampsUncalibrated = sg.gradeFor({ p_win: 0.65 }).calibrated === false; + const src13 = codeOf(fs.readFileSync(PC, 'utf8')); + return { caught: mins === expected && stampsUncalibrated && !/servedGrade|gradeFor/.test(src13), + detail: `bands ${mins}; gradeFor stamps calibrated:false; probabilityContract does not import servedGrade` }; +}); + +logicTooth(14, 'selected side changes', () => { + const src = codeOf(fs.readFileSync(PC, 'utf8')); + const touchesSide = /\bside\b\s*=|direction\s*=|gradeBestSide/.test(src); + const iso = (p) => Math.round((0.42 + 0.26 * p) * 1000) / 1000; + let flips = 0; + for (let x = 0.51; x < 0.80; x += 0.01) { + const p = Math.round(x * 100) / 100; + const a = pc.resolve({ sport: 'mlb', stat: 'hits', model_version: pc.MLB_HITS.model_version, p_win: p }, { estimate: iso }); + const b = pc.resolve({ sport: 'mlb', stat: 'hits', model_version: pc.MLB_HITS.model_version, p_win: Math.round((1 - p) * 100) / 100 }, { estimate: iso }); + if (a.served_probability != null && b.served_probability != null && a.served_probability < b.served_probability) flips++; + } + return { caught: !touchesSide && flips === 0, + detail: `contract never assigns side (${!touchesSide}); monotone map flips=${flips}` }; +}); + +logicTooth(15, 'publication changes unintentionally', () => { + const rsrc = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); + const merge = rsrc.slice(rsrc.indexOf('function mergeProbabilityContract'), rsrc.indexOf('function mergeChainShadow')); + const touches = /published|publication_id|published_at|read_id|lineage/.test(merge); + return { caught: !touches, detail: `mergeProbabilityContract references publication/lineage fields: ${touches}` }; +}); + +// ── 19,20,21,22,23 — the frozen neighbours ──────────────────────────────── +logicTooth(19, 'retention identity changes', () => { + const d = execSync(`git -C ${ROOT} diff --unified=0 -- src/services/retentionService.js`).toString(); + const idFields = ['player_key:', 'snapshot_id:', 'canonical_event_id:', 'game_id:', 'stat:', 'line:', 'side:']; + const touched = idFields.filter((f) => new RegExp(`^[-+].*${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm').test(d)); + return { caught: touched.length === 0, detail: `identity fields touched in the diff: ${touched.join(', ') || 'none'}` }; +}); + +logicTooth(20, 'participant identity changes', () => { + const d = execSync(`git -C ${ROOT} diff --name-only`).toString().trim().split('\n').filter(Boolean); + const touched = d.filter((f) => /participantIdentity|eventIdentity|matchupKeys|playerName/.test(f)); + return { caught: touched.length === 0, detail: `participant-identity files changed: ${touched.join(', ') || 'none'}` }; +}); + +logicTooth(21, 'lineage mechanics/config change', () => { + const d = execSync(`git -C ${ROOT} diff --name-only`).toString().trim().split('\n').filter(Boolean); + const touched = d.filter((f) => /lineage|readLineage|readAncestry|lineageWriteMode|lineageCoverage/i.test(f)); + const snapDiff = execSync(`git -C ${ROOT} diff -- src/services/snapshotService.js`).toString(); + const lineageLines = snapDiff.split('\n').filter((l) => /^[-+]/.test(l) && /lineage|canary|LINEAGE_/i.test(l)); + return { caught: touched.length === 0 && lineageLines.length === 0, + detail: `lineage files changed: ${touched.join(', ') || 'none'}; lineage lines in snapshot diff: ${lineageLines.length}` }; +}); + +logicTooth(22, 'PerformanceDistribution becomes servable', () => { + const s = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8'); + const src = fs.readFileSync(PC, 'utf8'); + return { caught: !/chain\.chainAcross\(/.test(s) && !/chainAcross/.test(src) && /servable: false/.test(fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8')), + detail: 'no chainAcross call; the shadow block declares servable:false in the payload' }; +}); + +logicTooth(23, 'historical probabilities backfilled', () => { + const mig = sqlCodeOf(fs.readFileSync(path.join(ROOT, 'supabase/migrations/051_probability_contract_shadow.sql'), 'utf8')); + const writes = /\bupdate\b|\binsert\b|\bbackfill\b/i.test(mig); + const rsrc = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); + const merge = rsrc.slice(rsrc.indexOf('function mergeProbabilityContract'), rsrc.indexOf('function mergeChainShadow')); + const rewritesHistory = /\.update\(|\.upsert\(/.test(merge); + return { caught: !writes && !rewritesHistory, + detail: `migration 051 is additive only (no UPDATE/INSERT); the merge performs no DB write` }; +}); + +const reachable = results.filter((r) => r.landed !== null); +const landed = reachable.filter((r) => r.landed).length; +console.log(JSON.stringify({ teeth_landed: `${landed}/${reachable.length}`, + aliased: results.filter((r) => r.landed === null), results: reachable }, null, 2)); +process.exit(landed === reachable.length ? 0 : 1); diff --git a/src/services/model/probabilityContract.js b/src/services/model/probabilityContract.js new file mode 100644 index 0000000..304d757 --- /dev/null +++ b/src/services/model/probabilityContract.js @@ -0,0 +1,246 @@ +'use strict'; + +/** + * probabilityContract — WHAT NUMBER MAY BE SERVED, AND WHY. + * + * ── THE LAW THIS ENFORCES ──────────────────────────────────────────────── + * RAW MODEL BELIEF IS NOT THE AUTOMATIC FALLBACK FOR SERVED PROBABILITY. + * + * The blocked contract was: + * + * candidate = F(raw) + * served = inCertifiedBand(candidate) ? candidate : RAW + * + * Its `else` branch is the defect. Above raw 0.60 the model is measured + * overconfident (holdout raw 0.80-0.90: predicted 0.843, observed 0.639), so + * "the calibrator is not supported here" was being answered with a number we + * had already proven wrong. Unsupported calibration does not make RAW true. + * + * Here there is NO fallback. A region is served because evidence supports the + * estimator that produced it, or it is not served at all. + * + * ── RAW IS A CANDIDATE, NOT A DEFAULT ──────────────────────────────────── + * Raw identity is CANDIDATE D and it certifies in exactly one region + * (raw 0.50-0.60, holdout error +0.010 on n=1,316). That is a real result and + * it is why the law is "raw must earn its region", not "raw is never true". + * + * ── WHY THE REGISTRY DID NOT NEED CHANGING ─────────────────────────────── + * `calibrationRegistry.serves(stat, p)` already tests certified bands against + * the RAW p_win — support in the INPUT domain, which is the correct question: + * what evidence makes THIS prediction eligible? And it returns + * `{serve:false, reason}`; it never said "serve raw". The output-space gate and + * the raw fallback were both invented downstream in calibrationService. + * + * ── THE PROBABILITY OBJECT ─────────────────────────────────────────────── + * P(selected side succeeds). Proven, not assumed: 493/493 two-sided stored + * pairs satisfy p(over)+p(under)=1 within 3dp rounding. Side selection happens + * upstream in gradeBestSide and this module never touches it. + */ + +const { knownNumber } = require('../../utils/known'); + +/** Step 17 states. NO_SERVED_PROBABILITY is the ABSENCE of a number, which is + * expressed as `served_probability === null`, not as a seventh state name. */ +const STATE = Object.freeze({ + CERTIFIED_CALIBRATED: 'CERTIFIED_CALIBRATED', + CERTIFIED_RAW: 'CERTIFIED_RAW', + CERTIFIED_EMPIRICAL_BAND: 'CERTIFIED_EMPIRICAL_BAND', + UNCERTIFIED: 'UNCERTIFIED', + UNSUPPORTED: 'UNSUPPORTED', + VERSION_MISMATCH: 'VERSION_MISMATCH', + INVALID: 'INVALID', +}); + +const CERTIFIED_STATES = Object.freeze([ + STATE.CERTIFIED_CALIBRATED, STATE.CERTIFIED_RAW, STATE.CERTIFIED_EMPIRICAL_BAND, +]); + +const ESTIMATOR = Object.freeze({ + ISOTONIC: 'isotonic', + LOW_PARAM: 'low_param', + EMPIRICAL_BAND: 'empirical_band', + RAW_IDENTITY: 'raw_identity', +}); + +/** + * THE CERTIFIED ARTIFACT — MLB hits, engine1@2026-08-07-fullwindow. + * + * Adjudicated 2026-09-02 on 6,050 picked-side settled rows over 22 dates, one + * split shared by all four candidates: + * FIT earliest 60% of TRAIN (n=1,798, through 08-17) -> derive + * CERT latest 40% of TRAIN (n=1,199, 08-17..08-21) -> decide support + * HOLD >= 2026-08-22 (n=3,053, 11 dates) -> evaluate, untouched + * + * candidate coverage holdout brier vs raw on covered verdict + * A low-param 80.2% 0.24374 -0.00273 [-.0039,-.0014] REFUTED + * B isotonic 91.3% 0.24337 -0.00347 [-.0061,-.0010] CERTIFIED + * C empirical band 91.3% 0.24335 -0.00348 [-.0061,-.0011] REFUTED + * D raw identity 43.1% 0.24866 n/a (is raw) narrower + * + * A is REFUTED despite more coverage: its extra region (raw 0.80-0.90) was + * certified on CERT (err +0.040, n=55) and refuted on HOLD (served 0.754 vs + * observed 0.639, CI [0.566,0.705], err +0.115). It also leaves a hole at + * 0.70-0.80 while serving 0.80-0.90 — a discontiguous contract whose upper + * island is the wrong one. + * + * C is REFUTED as a scalar estimator: refitted point-in-time on current-model + * hits-only rows, the realized rates INVERT in grade order (B+ 0.593 < + * B 0.614 < C+ 0.623), so the served function moves DOWNWARD at raw 0.78. + * Its shipped constants come from 3,417 props POOLED ACROSS FOUR BATTER STATS + * and do not reproduce here. Two of its seven bands (D, F) have n=0. + * + * D certifies only raw 0.50-0.60 and B covers that region already, so no hybrid + * is built (Step 14: prefer one estimator; do not patchwork for coverage). + * + * B's certified support is CONTIGUOUS raw [0.50, 0.80). Holdout band errors: + * 0.50-0.60 served 0.530 observed 0.540 [0.513,0.567] -0.011 z -0.73 + * 0.60-0.70 served 0.578 observed 0.613 [0.582,0.643] -0.035 z -2.22 + * 0.70-0.80 served 0.616 observed 0.604 [0.561,0.645] +0.012 z +0.56 + * The middle band is the weakest: p 0.027 uncorrected, which does NOT clear the + * programme's cumulative Bonferroni bar (alpha ~= 0.001). It is inside the + * repository's own 0.05 tolerance and is recorded here rather than smoothed. + * + * ABOVE raw 0.80 NOTHING is certified — isotonic over-corrects there (CERT err + * -0.093) and its LODO mapping at 0.95 has spread 0.180. That region is exactly + * where raw is most wrong, so it receives NO SERVED PROBABILITY. + */ +const MLB_HITS = Object.freeze({ + sport: 'mlb', + stat: 'hits', + model_version: 'engine1@2026-08-07-fullwindow', + estimator_type: ESTIMATOR.ISOTONIC, + estimator_version: 'mlb-hits-isotonic@2026-09-02', + certification_version: 'adjudication@2026-09-02', + /** Support in the RAW INPUT domain — half-open [lo, hi). */ + certified_bands: Object.freeze([Object.freeze([0.50, 0.80])]), + state_when_served: STATE.CERTIFIED_CALIBRATED, + /** Recorded so a later reader can re-derive the decision, not just trust it. */ + evidence: Object.freeze({ + n: 6050, dates: 22, fit_n: 1798, cert_n: 1199, holdout_n: 3053, holdout_dates: 11, + holdout_brier: 0.24337, holdout_brier_raw_same_rows: 0.24684, + delta_vs_raw: -0.00347, ci95: Object.freeze([-0.00609, -0.00099]), + coverage_pct: 0.913, tolerance: 0.05, min_bin: 40, + }), +}); + +const CONTRACTS = Object.freeze({ 'mlb:hits': MLB_HITS }); +const contractKey = (sport, stat) => `${String(sport || '').toLowerCase()}:${String(stat || '').toLowerCase()}`; +const contractFor = (sport, stat) => CONTRACTS[contractKey(sport, stat)] || null; + +const inCertifiedRawBand = (bands, p) => + Array.isArray(bands) && bands.some(([lo, hi]) => p >= lo && p < hi); + +/** + * Resolve the served probability for one Read. + * + * `raw_model_probability` is ALWAYS preserved — it is model evidence and is not + * deleted because another estimator answered, or declined to. + * + * @param {object} read {sport, stat, model_version, p_win} + * @param {object} deps {estimate(p) -> number|null} the fitted estimator + */ +function resolve(read = {}, deps = {}) { + const raw = knownNumber(read.p_win); + const contract = contractFor(read.sport, read.stat); + + const base = { + raw_model_probability: raw, + served_probability: null, + probability_state: null, + estimator_type: contract ? contract.estimator_type : null, + estimator_version: contract ? contract.estimator_version : null, + certification_version: contract ? contract.certification_version : null, + model_version: read.model_version ?? null, + contract_model_version: contract ? contract.model_version : null, + reason: null, + }; + + if (!contract) { + return { ...base, probability_state: STATE.UNSUPPORTED, reason: 'no certified contract for this sport/stat' }; + } + if (read.model_version !== contract.model_version) { + // A calibration artifact is only meaningful against the forecast it was + // fitted to. A different model era is a different forecaster. + return { ...base, probability_state: STATE.VERSION_MISMATCH, reason: 'model version differs from the certified era' }; + } + if (raw === null || raw < 0 || raw > 1) { + return { ...base, probability_state: STATE.INVALID, reason: 'raw probability absent or out of range' }; + } + if (!inCertifiedRawBand(contract.certified_bands, raw)) { + // NO RAW FALLBACK. This is the whole point of the module. + return { ...base, probability_state: STATE.UNCERTIFIED, reason: 'raw value lies outside certified estimator support' }; + } + + const estimate = typeof deps.estimate === 'function' ? knownNumber(deps.estimate(raw)) : null; + if (estimate === null || estimate < 0 || estimate > 1) { + // The estimator was supposed to answer here and did not. Refuse; never + // substitute raw, which is what made the previous contract untruthful. + return { ...base, probability_state: STATE.UNCERTIFIED, reason: 'estimator produced no value inside its own support' }; + } + + return { + ...base, + served_probability: Math.round(estimate * 1000) / 1000, + probability_state: contract.state_when_served, + reason: null, + }; +} + +const isCertified = (res) => !!res && CERTIFIED_STATES.includes(res.probability_state) + && knownNumber(res.served_probability) !== null; + +/** + * THE ACTIONABILITY LAW. Probability-derived claims require a certified served + * probability. They are NOT computed from raw when the contract declines — + * doing that in secret is the same lie as displaying raw, one layer down. + * + * Market data is untouched: odds are a fact about the book, not a model output. + */ +function derivedClaims(res, odds, deps = {}) { + const unavailable = (reason) => ({ + available: false, ev_pct: null, kelly: null, value: null, reason, + }); + if (!isCertified(res)) return unavailable(res ? res.probability_state : 'no resolution'); + + const evPct = deps.evPct || require('../../utils/devig').evPct; + const isValue = deps.isValue || require('../../config/valueEngine').isValue; + const quarterKelly = deps.quarterKelly || require('../../utils/kelly').quarterKelly; + + const p = res.served_probability; + const ev = evPct(p, odds); + return { + available: true, + ev_pct: ev, + kelly: quarterKelly(p, odds), + value: isValue(odds, ev), + reason: null, + computed_from: 'served_probability', + }; +} + +/** + * What the surface may say. An uncertified Read keeps its grade and its market + * data; what it loses is the claim to an exact number. + */ +function confidenceDisplay(res) { + if (isCertified(res)) { + return { + exact_probability: res.served_probability, + exact_pct: Math.round(res.served_probability * 1000) / 10, + state: res.probability_state, + calibrated: res.probability_state === STATE.CERTIFIED_CALIBRATED, + }; + } + return { + exact_probability: null, + exact_pct: null, + state: res ? res.probability_state : STATE.UNSUPPORTED, + calibrated: false, + label: 'Confidence not calibrated', + }; +} + +module.exports = { + STATE, CERTIFIED_STATES, ESTIMATOR, CONTRACTS, MLB_HITS, + contractFor, inCertifiedRawBand, resolve, isCertified, derivedClaims, confidenceDisplay, +}; diff --git a/src/services/model/probabilityContractService.js b/src/services/model/probabilityContractService.js new file mode 100644 index 0000000..997f98c --- /dev/null +++ b/src/services/model/probabilityContractService.js @@ -0,0 +1,47 @@ +'use strict'; + +/** + * probabilityContractService — fit the certified estimator, point in time. + * + * REUSES the existing fitter and its "fit on settled history strictly before + * today" discipline. What it does NOT reuse is `calibrationService.calibrate()`, + * whose gate is evaluated in CALIBRATED-OUTPUT space and whose else-branch + * serves RAW. Both of those are the blocked contract; only the MAP is taken. + * + * SUPPORT COMES FROM THE CERTIFIED ARTIFACT, NOT FROM TONIGHT'S FIT. The bands + * in `probabilityContract.MLB_HITS` were adjudicated on a three-way split and + * are a fixed property of that adjudication. Letting a nightly refit widen its + * own support is how an estimator certifies itself. + */ + +const cal = require('./calibration'); +const pc = require('./probabilityContract'); + +/** + * @returns {null|{estimate, fit_n, fitted_through, contract}} null when there + * is not enough settled history — and null means NOTHING is served, never + * "pass raw through". + */ +async function build(sb, { sport = 'mlb', stat = 'hits', before = null, ...opts } = {}) { + const contract = pc.contractFor(sport, stat); + if (!contract || !sb) return null; + + const svc = opts.calibrationService || require('./calibrationService'); + const fitted = await svc.fromLedger(sb, { sport, stat, before, ...opts }); + if (!fitted || !fitted.map) return null; + + return { + contract, + fit_n: fitted.fit_n, + fitted_through: fitted.fitted_through, + cutoff: fitted.cutoff, + /** The estimator, and only the estimator. No gate, no fallback. */ + estimate: (p) => cal.applyIsotonic(fitted.map, p), + /** Resolve one grade through the full contract. */ + resolve(read) { + return pc.resolve({ ...read, sport, stat }, { estimate: (p) => cal.applyIsotonic(fitted.map, p) }); + }, + }; +} + +module.exports = { build }; diff --git a/src/services/retentionService.js b/src/services/retentionService.js index 08b588b..e9c6333 100644 --- a/src/services/retentionService.js +++ b/src/services/retentionService.js @@ -193,6 +193,12 @@ function rowsFromSides(base, sides, ctx = {}) { // rows is silently dropped for the whole batch. Filled by // `mergeChainShadow` after enrichment; never read by anything served. chain_shadow: null, + + // The PROBABILITY CONTRACT shadow — what the certified serving contract + // WOULD serve, beside what was actually served. Declared always, for the + // same first-row-shape reason as chain_shadow. Filled by + // `mergeProbabilityContract`; SHADOW ONLY, read by nothing served. + probability_contract: null, }); } return rows; @@ -825,6 +831,61 @@ function mergeEnrichment(rows, enrichedGrades) { * keeping, and a fabricated third of a triple would poison the adjudication this * column exists to enable. */ +/** + * PROBABILITY CONTRACT SHADOW. + * + * Records, per row: the raw model belief, what the certified contract would + * serve, the state, the estimator identity, and what EV/Kelly/VALUE would be + * under the actionability law. It writes to its own column and mutates nothing + * a user sees — `p_win`, `confidence`, `grade`, `ev_pct`, `value` and + * `takeable` on the row are untouched. + * + * The raw probability is ALWAYS carried, including on refusals: it is model + * evidence, and a row that records only the refusal cannot be re-adjudicated. + */ +function mergeProbabilityContract(rows, contract) { + if (!Array.isArray(rows) || !rows.length) return rows || []; + if (!contract || typeof contract.resolve !== 'function') return rows; + const pc = require('./model/probabilityContract'); + + return rows.map((r) => { + let res; + try { + res = contract.resolve({ model_version: r.model_version, p_win: numOrNull(r.p_win) }); + } catch { return r; } + if (!res) return r; + let derived; + // The SIDE'S price. A retention row carries book_odds plus both sides' + // prices; using the wrong side would price the opposite bet. + const sideOdds = r.book_odds != null ? r.book_odds + : (String(r.side) === 'under' ? r.under_odds : r.over_odds); + try { derived = pc.derivedClaims(res, sideOdds); } catch { derived = null; } + return { + ...r, + probability_contract: { + raw_model_probability: res.raw_model_probability, + served_probability: res.served_probability, + probability_state: res.probability_state, + estimator_type: res.estimator_type, + estimator_version: res.estimator_version, + certification_version: res.certification_version, + model_version: res.model_version, + reason: res.reason, + fitted_through: contract.fitted_through || null, + fit_n: contract.fit_n || null, + derived: derived ? { + available: derived.available, + ev_pct: derived.ev_pct, + kelly_pct: derived.kelly ? derived.kelly.pct : null, + value: derived.value, + } : null, + // SHADOW. Nothing here has been served to anyone. + servable: false, + }, + }; + }); +} + function mergeChainShadow(rows, shadow) { if (!Array.isArray(rows) || !rows.length) return rows || []; const byKey = shadow && shadow.byKey; @@ -1183,6 +1244,7 @@ module.exports = { createCollector, mergeEnrichment, mergeChainShadow, + mergeProbabilityContract, persist, attachLineage, commitPublication, diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 49bb5e0..18e893c 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -852,6 +852,26 @@ async function runSnapshot(sport, opts = {}) { // read_revision_id is deliberately NOT resolved here: the upsert does not // return row ids, and issuing a second query in the hot path to obtain one // would be a real cost for a column nothing reads yet. + // ── PROBABILITY CONTRACT SHADOW (default OFF) ────────────────────────── + // Builds the certified estimator once per snapshot so the shadow costs one + // ledger fit, not one per row. OFF unless PROBABILITY_CONTRACT_SHADOW=1: + // this releases the support with activation off, which is the required order. + // A failure here must never cost the snapshot — it is a measurement layer. + let probContract = null; + if (String(process.env.PROBABILITY_CONTRACT_SHADOW || '') === '1' && sp === 'mlb') { + try { + const pcs = deps.probabilityContractService || require('./model/probabilityContractService'); + const sbc = deps.supabase || require('../utils/supabase').getSupabaseServiceClient(); + probContract = sbc ? await pcs.build(sbc, { sport: 'mlb', stat: 'hits' }) : null; + console.log(probContract + ? `[probability-contract] shadow armed — isotonic, fit n=${probContract.fit_n} through ${probContract.fitted_through}, certified raw [0.50,0.80)` + : '[probability-contract] shadow armed but NO estimator (thin history) — nothing would be served'); + } catch (e) { + probContract = null; + console.warn('[probability-contract] shadow build failed (snapshot continues):', e.message); + } + } + let lineageIndex = null; let persistedRows = null; const persistRetention = async (enrichedGrades, chainShadow = null) => { @@ -868,6 +888,13 @@ async function runSnapshot(sport, opts = {}) { console.warn('[chain-shadow] merge skipped (retention continues):', e.message); } } + // PROBABILITY CONTRACT SHADOW — its own guard, for the same reason: the + // retention write is the record and a measurement layer may not cost it. + if (probContract && retention.mergeProbabilityContract) { + try { rows = retention.mergeProbabilityContract(rows, probContract); } catch (e) { + console.warn('[probability-contract] merge skipped (retention continues):', e.message); + } + } const r = await retention.persist(rows); retentionRows = r.written || 0; // Held for the publication commit, which happens only after the diff --git a/supabase/migrations/051_probability_contract_shadow.sql b/supabase/migrations/051_probability_contract_shadow.sql new file mode 100644 index 0000000..8fde89f --- /dev/null +++ b/supabase/migrations/051_probability_contract_shadow.sql @@ -0,0 +1,15 @@ +-- 051 — probability contract SHADOW column. +-- +-- ADDITIVE AND NULLABLE. Records what the certified serving contract WOULD +-- serve, beside what was actually served. Nothing reads it for serving. +-- +-- ORDERING IS LOAD-BEARING: this is applied BEFORE `retentionService` names the +-- column. PostgREST builds a bulk insert from the FIRST row's shape, so a key +-- present on a row whose column does not exist 400s the WHOLE batch — and +-- retention is best-effort, so it fails silently. That is exactly how migration +-- 038 took retention down for three days. +alter table if exists public.model_snapshots + add column if not exists probability_contract jsonb; + +comment on column public.model_snapshots.probability_contract is + 'SHADOW ONLY. {raw_model_probability, served_probability, probability_state, estimator_type, estimator_version, certification_version, model_version, derived:{ev_pct,kelly_pct,value,available}}. Never served; records what the certified contract would have produced.'; diff --git a/supabase/schema/model_snapshots.columns.json b/supabase/schema/model_snapshots.columns.json index 9263c38..6ba1467 100644 --- a/supabase/schema/model_snapshots.columns.json +++ b/supabase/schema/model_snapshots.columns.json @@ -12,7 +12,7 @@ "explanation": "Present in production but not created by any committed migration - added out of band, same class as the migration-014 debt. The contract deliberately uses the MIGRATION-derived set, which is the stricter of the two: a writer that stays within it is valid against both.", "classification": "PROD-ONLY COLUMN -> DRIFT WARNING / RECORDED DEBT (not release-authorized)" }, - "column_count": 64, + "column_count": 65, "columns": [ "actual_value", "archetype", @@ -58,6 +58,7 @@ "p_win", "player_key", "player_name", + "probability_contract", "projection", "publication_id", "published", diff --git a/tests/unit/probabilityContract.test.js b/tests/unit/probabilityContract.test.js new file mode 100644 index 0000000..103414c --- /dev/null +++ b/tests/unit/probabilityContract.test.js @@ -0,0 +1,221 @@ +'use strict'; + +/** + * THE CONTRACT'S ONE JOB: never answer "the calibrator is not supported here" + * with a number we have already measured to be wrong. + */ +const pc = require('../../src/services/model/probabilityContract'); +const svc = require('../../src/services/model/probabilityContractService'); +const reg = require('../../src/services/model/calibrationRegistry'); + +const ERA = 'engine1@2026-08-07-fullwindow'; +const read = (p, over = {}) => ({ sport: 'mlb', stat: 'hits', model_version: ERA, p_win: p, ...over }); +// A stand-in shaped like the real map: monotone, flattening, correcting downward. +const iso = (p) => Math.round((0.42 + 0.26 * p) * 1000) / 1000; +const deps = { estimate: iso }; + +describe('probability object and states', () => { + it('serves a calibrated number inside certified raw support', () => { + const r = pc.resolve(read(0.65), deps); + expect(r.probability_state).toBe(pc.STATE.CERTIFIED_CALIBRATED); + expect(r.served_probability).toBe(iso(0.65)); + expect(pc.isCertified(r)).toBe(true); + }); + + it('NO RAW FALLBACK — an uncertified region serves no number at all', () => { + for (const p of [0.85, 0.90, 0.95, 0.99]) { + const r = pc.resolve(read(p), deps); + expect(r.probability_state).toBe(pc.STATE.UNCERTIFIED); + expect(r.served_probability).toBeNull(); + // the specific defect: served must not silently become the raw value + expect(r.served_probability).not.toBe(p); + } + }); + + it('raw probability is preserved in every state, including refusals', () => { + for (const p of [0.45, 0.55, 0.85, 0.95]) { + expect(pc.resolve(read(p), deps).raw_model_probability).toBe(p); + } + expect(pc.resolve(read(0.9), deps).raw_model_probability).toBe(0.9); + }); + + it('a different model era is a different forecaster — VERSION_MISMATCH, no number', () => { + const r = pc.resolve(read(0.65, { model_version: 'engine1@2026-07-20' }), deps); + expect(r.probability_state).toBe(pc.STATE.VERSION_MISMATCH); + expect(r.served_probability).toBeNull(); + }); + + it('another stat or sport is UNSUPPORTED, never quietly served', () => { + for (const o of [{ stat: 'total_bases' }, { stat: 'rbi' }, { sport: 'wnba' }, { sport: 'nba' }]) { + const r = pc.resolve(read(0.65, o), deps); + expect(r.probability_state).toBe(pc.STATE.UNSUPPORTED); + expect(r.served_probability).toBeNull(); + } + }); + + it('an absent or out-of-range raw probability is INVALID, not coerced', () => { + for (const p of [null, undefined, NaN, -0.1, 1.4, 'x']) { + const r = pc.resolve(read(p), deps); + expect(r.probability_state).toBe(pc.STATE.INVALID); + expect(r.served_probability).toBeNull(); + } + }); + + it('refuses when the estimator declines inside its own support', () => { + const r = pc.resolve(read(0.65), { estimate: () => null }); + expect(r.probability_state).toBe(pc.STATE.UNCERTIFIED); + expect(r.served_probability).toBeNull(); + }); + + it('the certified band is half-open and expressed in the RAW input domain', () => { + expect(pc.inCertifiedRawBand(pc.MLB_HITS.certified_bands, 0.50)).toBe(true); + expect(pc.inCertifiedRawBand(pc.MLB_HITS.certified_bands, 0.799)).toBe(true); + expect(pc.inCertifiedRawBand(pc.MLB_HITS.certified_bands, 0.80)).toBe(false); + expect(pc.inCertifiedRawBand(pc.MLB_HITS.certified_bands, 0.499)).toBe(false); + }); +}); + +describe('the served function is non-decreasing across its support', () => { + it('never moves backwards as raw confidence rises', () => { + let prev = null; + for (let x = 0.30; x <= 1.0001; x += 0.001) { + const p = Math.round(x * 1000) / 1000; + const r = pc.resolve(read(p), deps); + if (r.served_probability == null) continue; + if (prev != null) expect(r.served_probability).toBeGreaterThanOrEqual(prev); + prev = r.served_probability; + } + }); + + it('a gap is an absence, not a step down to raw', () => { + const inside = pc.resolve(read(0.799), deps).served_probability; + const outside = pc.resolve(read(0.80), deps); + expect(inside).not.toBeNull(); + expect(outside.served_probability).toBeNull(); + expect(outside.served_probability).not.toBe(0.80); + }); +}); + +describe('actionability law', () => { + const odds = -115; + it('derives EV, Kelly and VALUE from the SERVED probability', () => { + const r = pc.resolve(read(0.65), deps); + const d = pc.derivedClaims(r, odds); + expect(d.available).toBe(true); + expect(d.computed_from).toBe('served_probability'); + const { evPct } = require('../../src/utils/devig'); + expect(d.ev_pct).toBe(evPct(r.served_probability, odds)); + expect(d.ev_pct).not.toBe(evPct(0.65, odds)); // NOT from raw + }); + + it('withdraws EV, Kelly and VALUE entirely when no probability is certified', () => { + for (const p of [0.85, 0.95]) { + const d = pc.derivedClaims(pc.resolve(read(p), deps), odds); + expect(d.available).toBe(false); + expect(d.ev_pct).toBeNull(); + expect(d.kelly).toBeNull(); + expect(d.value).toBeNull(); + } + }); + + it('never computes a derived claim from raw behind the scenes', () => { + const { evPct } = require('../../src/utils/devig'); + const { quarterKelly } = require('../../src/utils/kelly'); + const d = pc.derivedClaims(pc.resolve(read(0.91), deps), odds); + expect(d.ev_pct).not.toBe(evPct(0.91, odds)); + expect(d.kelly).not.toEqual(quarterKelly(0.91, odds)); + expect(d.kelly).toBeNull(); + }); + + it('a VERSION_MISMATCH withdraws actionability too', () => { + const d = pc.derivedClaims(pc.resolve(read(0.65, { model_version: 'other' }), deps), odds); + expect(d.available).toBe(false); + }); +}); + +describe('confidence display', () => { + it('shows an exact number only when the state is certified', () => { + const c = pc.confidenceDisplay(pc.resolve(read(0.65), deps)); + expect(c.exact_probability).toBe(iso(0.65)); + expect(c.calibrated).toBe(true); + }); + + it('shows NO exact confidence when uncertified — and never the raw value', () => { + const c = pc.confidenceDisplay(pc.resolve(read(0.91), deps)); + expect(c.exact_probability).toBeNull(); + expect(c.exact_pct).toBeNull(); + expect(c.calibrated).toBe(false); + expect(c.label).toBe('Confidence not calibrated'); + expect(JSON.stringify(c)).not.toContain('0.91'); + }); +}); + +describe('the artifact records its own adjudication', () => { + it('is pinned to the current model era and the isotonic estimator', () => { + expect(pc.MLB_HITS.model_version).toBe(ERA); + expect(pc.MLB_HITS.estimator_type).toBe(pc.ESTIMATOR.ISOTONIC); + expect(pc.MLB_HITS.estimator_version).toBeTruthy(); + expect(pc.MLB_HITS.certification_version).toBeTruthy(); + }); + + it('certifies nothing above raw 0.80 — the region where raw is most wrong', () => { + for (const p of [0.80, 0.85, 0.90, 0.95]) { + expect(pc.inCertifiedRawBand(pc.MLB_HITS.certified_bands, p)).toBe(false); + } + }); + + it('holds ONE contract — no other sport or stat is certified', () => { + expect(Object.keys(pc.CONTRACTS)).toEqual(['mlb:hits']); + }); + + it('the held-out interval it records excludes zero', () => { + expect(pc.MLB_HITS.evidence.ci95[1]).toBeLessThan(0); + }); +}); + +describe('the registry already asked the right question', () => { + it('serves() tests certified bands against the RAW p_win, not the output', () => { + const r = reg.createRegistry(); + r.deploy('hits', { lodo_pass: true, ci: [-0.006, -0.001], map: { x: [0], y: [0] }, + certified_bands: [[0.50, 0.80]] }); + expect(r.serves('hits', 0.65).serve).toBe(true); + expect(r.serves('hits', 0.90).serve).toBe(false); + }); + + it('a refusal names the reason and never proposes raw', () => { + const r = reg.createRegistry(); + r.deploy('hits', { lodo_pass: true, ci: [-0.006, -0.001], map: {}, certified_bands: [[0.50, 0.80]] }); + const out = r.serves('hits', 0.92); + expect(out.serve).toBe(false); + expect(JSON.stringify(out)).not.toContain('0.92'); + }); +}); + +describe('probabilityContractService', () => { + it('returns null — not raw — when there is no settled history', async () => { + const built = await svc.build({}, { calibrationService: { fromLedger: async () => null } }); + expect(built).toBeNull(); + }); + + it('takes the MAP and never the blocked calibrate() gate', async () => { + const cal = require('../../src/services/model/calibration'); + const map = cal.fitIsotonic(Array.from({ length: 600 }, (_, i) => { + const p = 0.40 + (i % 55) / 100; + return { p, won: i % 3 === 0 ? 0 : 1, date: `d${i % 12}` }; + }), { minTotal: 200 }); + const calibrateSpy = jest.fn(() => ({ p_calibrated: 0.99, calibrated: true })); + const built = await svc.build({}, { calibrationService: { + fromLedger: async () => ({ map, fit_n: 600, fitted_through: 'd11', calibrate: calibrateSpy }) } }); + expect(built).not.toBeNull(); + const r = built.resolve({ model_version: ERA, p_win: 0.65 }); + expect(r.probability_state).toBe(pc.STATE.CERTIFIED_CALIBRATED); + expect(calibrateSpy).not.toHaveBeenCalled(); + }); + + it('support comes from the artifact, so a nightly refit cannot widen it', async () => { + const built = await svc.build({}, { calibrationService: { + fromLedger: async () => ({ map: { x: [0, 1], y: [0.5, 0.9] }, fit_n: 900, fitted_through: 'd9', + bands: [[0.0, 1.0]] }) } }); // fit claims the whole range + expect(built.resolve({ model_version: ERA, p_win: 0.95 }).probability_state).toBe(pc.STATE.UNCERTIFIED); + }); +}); diff --git a/tests/unit/probabilityContractShadow.test.js b/tests/unit/probabilityContractShadow.test.js new file mode 100644 index 0000000..4a417f8 --- /dev/null +++ b/tests/unit/probabilityContractShadow.test.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * THE SHADOW MUST BE GENERATED, PERSISTED, AND HARMLESS. + * + * A5's whole finding was a factor that was built, correct, and never invoked. + * Evidence that is computed but never reaches a row is that same failure one + * layer along — so this drives the REAL retention row builder rather than + * asserting the merge function in isolation. + */ +const retention = require('../../src/services/retentionService'); +const pc = require('../../src/services/model/probabilityContract'); + +const ERA = 'engine1@2026-08-07-fullwindow'; +const iso = (p) => Math.round((0.42 + 0.26 * p) * 1000) / 1000; +const contract = { + fit_n: 1798, fitted_through: '2026-08-17', + resolve: (read) => pc.resolve({ ...read, sport: 'mlb', stat: 'hits' }, { estimate: iso }), +}; + +const row = (over) => ({ + player_key: 'x', stat: 'hits', line: 0.5, side: 'over', model_version: ERA, + p_win: 0.65, confidence: 65, grade: 'C+', ev_pct: 12.3, value: true, takeable: true, + book_odds: -115, over_odds: -115, under_odds: -105, probability_contract: null, ...over, +}); + +describe('the shadow reaches the row', () => { + it('the REAL collector declares the column on every row, refusals included', () => { + const collector = retention.createCollector({ + snapshotId: 'snap-1', sport: 'mlb', modelVersion: ERA, codeSha: 'test', + gameDate: '2026-09-01', gameIdFor: () => 'mlb:2026-09-01:AAA@BBB', + }); + collector.onGraded( + { player: 'A Hitter', stat_type: 'hits', line: 0.5, sport: 'mlb', + over_odds: -115, under_odds: -105 }, + [{ direction: 'over', grade: 'C+', p_win: 0.65, confidence: 65 }, + { direction: 'under', insufficient_data: true }], + ); + // the collector is the real path; if it collected nothing the assertion + // below would vacuously pass, so require the rows first + expect(collector.rows.length).toBe(2); + for (const r of collector.rows) expect('probability_contract' in r).toBe(true); + const merged = retention.mergeProbabilityContract(collector.rows, contract); + expect(merged.find((r) => r.side === 'over').probability_contract.probability_state) + .toBe(pc.STATE.CERTIFIED_CALIBRATED); + }); + + it('every row in a batch carries the key — a partial shape drops the column for all', () => { + const rows = [row(), row({ p_win: 0.91 }), row({ p_win: null, grade: null })]; + const out = retention.mergeProbabilityContract(rows, contract); + expect(out).toHaveLength(3); + for (const r of out) expect('probability_contract' in r).toBe(true); + }); + + it('records the served candidate inside support', () => { + const [r] = retention.mergeProbabilityContract([row()], contract); + expect(r.probability_contract.probability_state).toBe(pc.STATE.CERTIFIED_CALIBRATED); + expect(r.probability_contract.served_probability).toBe(iso(0.65)); + expect(r.probability_contract.estimator_type).toBe('isotonic'); + expect(r.probability_contract.servable).toBe(false); + }); + + it('records the REFUSAL, and keeps the raw belief on it', () => { + const [r] = retention.mergeProbabilityContract([row({ p_win: 0.91 })], contract); + expect(r.probability_contract.probability_state).toBe(pc.STATE.UNCERTIFIED); + expect(r.probability_contract.served_probability).toBeNull(); + expect(r.probability_contract.raw_model_probability).toBe(0.91); + expect(r.probability_contract.derived.available).toBe(false); + }); + + it('prices the SIDE, not the opposite bet', () => { + const { evPct } = require('../../src/utils/devig'); + const [o] = retention.mergeProbabilityContract([row({ side: 'over', book_odds: null })], contract); + const [u] = retention.mergeProbabilityContract([row({ side: 'under', book_odds: null })], contract); + expect(o.probability_contract.derived.ev_pct).toBe(evPct(iso(0.65), -115)); + expect(u.probability_contract.derived.ev_pct).toBe(evPct(iso(0.65), -105)); + }); +}); + +describe('the shadow changes NOTHING that is served', () => { + const SERVED = ['p_win', 'confidence', 'grade', 'ev_pct', 'value', 'takeable', 'side', 'line']; + it('leaves every served field byte-identical', () => { + const before = [row(), row({ p_win: 0.91, grade: 'B+' }), row({ side: 'under', p_win: 0.55 })]; + const snapshot = JSON.parse(JSON.stringify(before)); + const after = retention.mergeProbabilityContract(before, contract); + after.forEach((r, i) => { for (const k of SERVED) expect(r[k]).toEqual(snapshot[i][k]); }); + }); + + it('does not mutate the input rows in place', () => { + const rows = [row()]; + retention.mergeProbabilityContract(rows, contract); + expect(rows[0].probability_contract).toBeNull(); + }); + + it('a null contract is a no-op, not a raw passthrough', () => { + const out = retention.mergeProbabilityContract([row()], null); + expect(out[0].probability_contract).toBeNull(); + }); + + it('a throwing contract leaves the row intact rather than losing it', () => { + const bad = { resolve: () => { throw new Error('boom'); } }; + const out = retention.mergeProbabilityContract([row()], bad); + expect(out).toHaveLength(1); + expect(out[0].p_win).toBe(0.65); + expect(out[0].probability_contract).toBeNull(); + }); +}); + +describe('the flag is OFF by default', () => { + it('snapshotService reads PROBABILITY_CONTRACT_SHADOW and defaults to off', () => { + const src = require('fs').readFileSync( + require('path').join(__dirname, '../../src/services/snapshotService.js'), 'utf8'); + expect(src).toContain("process.env.PROBABILITY_CONTRACT_SHADOW || '') === '1'"); + // and it must not be able to write a served field + const block = src.slice(src.indexOf('PROBABILITY CONTRACT SHADOW'), src.indexOf('let lineageIndex')); + for (const served of ['g.p_win =', 'g.confidence =', 'g.grade =', 'g.ev_pct =', 'g.value =']) { + expect(block).not.toContain(served); + } + }); + + it('nothing is added to CALIBRATION_DEPLOYED', () => { + const src = require('fs').readFileSync( + require('path').join(__dirname, '../../src/services/snapshotService.js'), 'utf8'); + expect(src).toMatch(/CALIBRATION_DEPLOYED\s*=\s*Object\.freeze\(\[\s*\]\)/); + }); +});