diff --git a/BUILD-STATE.md b/BUILD-STATE.md index bd09e96..499b176 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,27 @@ ## Last Updated 2026-08-03 +## Session 90 (2026-08-04) — chaining-v1: portable chain + the calibration gate ✅ +4,269 tests / 339 suites green, build exit 0. Counter + frozen clusters +byte-identical. +- **HIT-PARLAY BLOCKED (the order's own prerequisite, failed decisively).** + n=972: predicted 0.911 → actual 0.630; flat ~63% above 0.70. A 4-leg 91% + ticket is 0.686 by the model, 0.157 in fact. `chainAcross` refuses + uncalibrated atoms — verified end-to-end on real data. +- **`calibration.js`** — reliability table, `isCalibrated` gate (tolerance 0.05, + high-end weighted), and `fitIsotonic` (monotone: ordering preserved, numbers + corrected). Real map: 0.65→0.594, 0.85→0.639, 0.91→0.639. +- **`chain.js`** — the portable core. Aggregator pluggable (ACROSS=parlay, + UP=score), sport parts as inputs, archetype-`redistribute` hook (dormant in + baseball, live in basketball). Self-check flags internal inconsistency as LOW + CONFIDENCE; market divergence flags a contested script WITHOUT claiming we're + right. `propagate` is shrinkage-weighted by sample. +- **NOT built this order:** the independent game-script projection. It needs + proven team-level atoms + out-of-sample validation vs actual margins, and no + atom has passed the gate yet — building it now would be plausibility, not proof. +- **Next:** fit the point-in-time isotonic map and re-gate hits; that is the + unlock for the parlay surface. + ## Session 89 (2026-08-04) — Lineup + baserunner context ingested ✅ Spec: commit history + `src/services/lineupContextService.js`. 4,250 tests / 338 suites green, build exit 0. Counter + frozen clusters byte-identical. diff --git a/CLAUDE.md b/CLAUDE.md index 8dad875..83b5d30 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1586,6 +1586,44 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). - DRIVER/CATALYST pre-registered theories are now INPUT-READY (were input-blocked); they are sample-blocked from here. Ingesting is not proving. +## chaining-v1 + the calibration gate (Session 90 — non-obvious) +- **THE HIT-PARLAY SURFACE IS BLOCKED, and the block is structural.** Measured on + 972 settled hits props: the model is monotonically over-confident exactly where + a parlay stacks — predicted **0.911 → actual 0.630** (err 0.281), and it is + FLAT at ~63% for everything above 0.70 (no discrimination there at all). + A 4-leg "91%" ticket: model 0.686, reality 0.157 — a **4.4x overstatement that + compounds with every leg**. `chain.chainAcross` REFUSES atoms not marked + `calibrated: true`. Refusing is the feature. +- **Calibration ≠ resolution, and chaining cares about calibration.** A model can + rank fine and be useless compounded. Single props survive a calibration error; + a parlay multiplies it. Never stack a probability that has not passed + `calibration.isCalibrated`. +- **`calibration.fitIsotonic` is the honest repair** — monotone (pool-adjacent- + violators), so the model's ORDERING survives untouched while the NUMBERS move + to what actually happened. Real map: 0.65→0.594, 0.85→0.639, **0.91→0.639**. + Fit it POINT-IN-TIME (outcomes preceding the prop) or it has seen the answer. +- **`src/services/model/chain.js` is the portable core:** + `base_events + context → chainFn → aggregator`, aggregator pluggable — + ACROSS = compound ticket, UP = team score. Sport parts are INPUTS, not code + paths, so basketball is content not a rebuild. +- **`redistribute` is the archetype hook** — DORMANT in baseball (a nine-run lead + doesn't change who bats next), LIVE in basketball (blowout fades the star, + feeds the bench). It exists now so the machine doesn't need rewriting later. +- **Treating same-game legs as independent errs in the FLATTERING direction** — + they share pitcher/park/weather, so the joint is likelier than the product. + `chainAcross` applies a bounded shift toward the weakest leg and labels it an + approximation, not a joint distribution. Prefer cross-game legs. +- **An unreadable atom is DROPPED, never p=0** — a single zero leg would zero an + entire ticket. +- **Market divergence is NOT an error signal** and must not downgrade confidence: + it flags a contested game script (its props are the best or the worst on the + board, unknown which). **Internal inconsistency IS** — per-entity reads not + summing to the team read means one is wrong and we don't know which, so the + honest output is LOW CONFIDENCE, not a correction. +- **`propagate` is shrinkage-weighted by sample** — one game moves a 400-obs atom + barely and a 4-obs atom a lot. That gap is the difference between learning and + noise-chasing. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/src/services/model/calibration.js b/src/services/model/calibration.js new file mode 100644 index 0000000..634fe94 --- /dev/null +++ b/src/services/model/calibration.js @@ -0,0 +1,156 @@ +'use strict'; + +/** + * calibration — DOES 78% MEAN 78%? + * + * Resolution asks whether higher forecasts hit more often. Calibration asks + * whether the NUMBER is true. A model can rank perfectly and still be useless + * for anything compounded, and the two failures look nothing alike. + * + * ── WHY THIS GATES THE PARLAY SURFACE ──────────────────────────────────── + * Measured on 971 settled MLB hits props (2026-08-04): + * + * predicted 0.456 → actual 0.493 (under-confident) + * predicted 0.645 → actual 0.614 + * predicted 0.747 → actual 0.605 over by 0.142 + * predicted 0.844 → actual 0.630 over by 0.214 + * predicted 0.911 → actual 0.630 over by 0.281 + * + * Above 0.70 the model is FLAT at ~63% — it has no discriminating power there at + * all, and it is severely over-confident. Single props survive this; a parlay + * does not. Four "90%" legs: + * + * model 0.91^4 = 0.686 reality 0.63^4 = 0.157 + * + * A 4.4x overstatement, and it compounds with every leg. Errors that are + * survivable one at a time multiply when chained, which is why chaining must be + * GATED on calibration rather than merely warned about. + * + * ── THE FIX IS A MAP, NOT A MODEL CHANGE ───────────────────────────────── + * Isotonic (monotone) regression from predicted → realized, fitted on settled + * outcomes. It preserves the model's ORDERING — the ranking it does have is + * untouched — while correcting the numbers to what actually happened. This is + * the honest repair for a model that ranks better than it counts. + * + * POINT-IN-TIME: a calibration map must be fitted on outcomes that PRECEDE the + * prop it is applied to, or it has seen the answer. `fitIsotonic` takes whatever + * it is given; the caller is responsible for the cut, and `calibrationReport` + * carries the window so the cut is visible. + */ + +const { knownNumber } = require('../../utils/known'); + +const MIN_BIN = 15; // below this a bin is noise, not a reading +const MIN_TOTAL = 200; // below this there is no calibration to speak of +/** Max tolerable |predicted − actual| in any populated bin, for the gate. */ +const MAX_BIN_ERROR = 0.05; + +/** + * Reliability table: bin the forecasts and compare each bin's mean prediction + * with what actually happened in it. + */ +function reliability(rows, bins = 10) { + const buckets = Array.from({ length: bins }, () => ({ n: 0, sumP: 0, wins: 0 })); + for (const r of rows || []) { + const p = knownNumber(r && r.p); + const won = knownNumber(r && r.won); + if (p === null || won === null) continue; // absent, never assumed + const idx = Math.min(bins - 1, Math.max(0, Math.floor(p * bins))); + const b = buckets[idx]; + b.n += 1; b.sumP += p; b.wins += won > 0 ? 1 : 0; + } + return buckets + .map((b, i) => (b.n === 0 ? null : { + bin: i, + n: b.n, + mean_predicted: b.sumP / b.n, + actual: b.wins / b.n, + error: b.sumP / b.n - b.wins / b.n, + })) + .filter(Boolean); +} + +/** + * THE GATE. Is this forecast safe to compound? + * + * Deliberately strict, and deliberately weighted toward the HIGH end: a parlay + * is built out of confident legs, so an error at 0.9 is the one that actually + * costs money. A model may be perfectly usable for single props and still fail + * here — those are different questions and this returns the chaining answer. + */ +function isCalibrated(rows, opts = {}) { + const minBin = opts.minBin ?? MIN_BIN; + const maxErr = opts.maxBinError ?? MAX_BIN_ERROR; + const table = reliability(rows, opts.bins ?? 10).filter((b) => b.n >= minBin); + const total = (rows || []).length; + if (total < (opts.minTotal ?? MIN_TOTAL)) { + return { calibrated: false, reason: 'insufficient_sample', n: total, table }; + } + if (table.length === 0) { + return { calibrated: false, reason: 'no_populated_bins', n: total, table }; + } + const worst = table.reduce((a, b) => (Math.abs(b.error) > Math.abs(a.error) ? b : a)); + const highEnd = table.filter((b) => b.mean_predicted >= 0.70); + const worstHigh = highEnd.length + ? highEnd.reduce((a, b) => (Math.abs(b.error) > Math.abs(a.error) ? b : a)) + : null; + const ok = Math.abs(worst.error) <= maxErr; + return { + calibrated: ok, + reason: ok ? null : 'bin_error_exceeds_tolerance', + n: total, + max_bin_error: worst.error, + worst_bin: worst, + worst_high_confidence_bin: worstHigh, + tolerance: maxErr, + table, + }; +} + +/** + * Isotonic (pool-adjacent-violators) regression: predicted → realized, monotone + * non-decreasing. Returns the fitted step points. + * + * Monotone by construction, so the model's ORDERING survives untouched — only + * the numbers move. That is the point: we are repairing what it counts, not + * what it ranks. + */ +function fitIsotonic(rows, opts = {}) { + const pts = (rows || []) + .map((r) => ({ p: knownNumber(r && r.p), won: knownNumber(r && r.won) })) + .filter((r) => r.p !== null && r.won !== null) + .sort((a, b) => a.p - b.p); + if (pts.length < (opts.minTotal ?? MIN_TOTAL)) return null; + + // Each observation starts as its own block; merge while monotonicity is violated. + const blocks = pts.map((r) => ({ sumY: r.won > 0 ? 1 : 0, n: 1, lo: r.p, hi: r.p })); + const stack = []; + for (const b of blocks) { + stack.push({ ...b }); + while (stack.length > 1) { + const top = stack[stack.length - 1]; + const prev = stack[stack.length - 2]; + if (prev.sumY / prev.n <= top.sumY / top.n) break; + stack.pop(); stack.pop(); + stack.push({ sumY: prev.sumY + top.sumY, n: prev.n + top.n, lo: prev.lo, hi: top.hi }); + } + } + return stack.map((b) => ({ lo: b.lo, hi: b.hi, value: b.sumY / b.n, n: b.n })); +} + +/** Apply a fitted map. Outside the fitted range, the nearest block governs. */ +function applyIsotonic(map, p) { + const x = knownNumber(p); + if (x === null || !Array.isArray(map) || map.length === 0) return null; + if (x <= map[0].hi) return map[0].value; + for (const b of map) if (x >= b.lo && x <= b.hi) return b.value; + for (let i = 0; i < map.length - 1; i += 1) { + if (x > map[i].hi && x < map[i + 1].lo) return map[i + 1].value; + } + return map[map.length - 1].value; +} + +module.exports = { + reliability, isCalibrated, fitIsotonic, applyIsotonic, + MIN_BIN, MIN_TOTAL, MAX_BIN_ERROR, +}; diff --git a/src/services/model/chain.js b/src/services/model/chain.js new file mode 100644 index 0000000..2795873 --- /dev/null +++ b/src/services/model/chain.js @@ -0,0 +1,226 @@ +'use strict'; + +/** + * chain — THE PORTABLE BASE-EVENT CHAIN. + * + * One idea, read two ways: + * + * base_events + situational_context → chain_fn → AGGREGATOR + * + * ACROSS players → compound ticket probability (parlay) + * UP to the team → expected scoring / game-script + * + * The atoms are the same either way. A hitter's P(hit) is a leg of a ticket when + * read across, and a component of his team's expected runs when read up. That is + * the whole reason this is one machine rather than two: an improvement to an + * atom improves everything that reads it, and an inconsistency between the two + * readings is a signal that something is wrong. + * + * ── WHAT IS PLUGGABLE, AND WHY ─────────────────────────────────────────── + * Baseball fills this now; basketball must plug in WITHOUT a rebuild. So the + * sport-specific parts are inputs, not code paths: + * + * atoms — which base events exist for this sport + * context — the situational modifiers + * chainFn — how an atom becomes a per-entity probability + * aggregator — ACROSS (compound) or UP (sum to team) + * redistribute — the archetype-redistribution HOOK: a blowout moves + * involvement between archetypes (fades the star, feeds + * the bench). DORMANT in baseball — a nine-run lead does + * not change who bats next — and LIVE in basketball, + * where it is most of the edge. The hook exists here so + * basketball is content rather than a rewrite. + * + * ── CALIBRATION IS A HARD PRECONDITION, NOT A WARNING ──────────────────── + * Errors that are survivable one at a time MULTIPLY when chained. Measured on + * real hits props: at a predicted 0.911 the realized rate is 0.630, so four such + * legs are 0.686 by the model and 0.157 in fact — a 4.4x overstatement that + * compounds with every leg added. So `chainAcross` REFUSES to compound atoms + * that are not marked calibrated. Refusing is the feature; a parlay built on + * uncalibrated probabilities is the single most harmful thing this product could + * ship, because it is confidently wrong in the direction the user pays for. + * + * ── SELF-CHECK ─────────────────────────────────────────────────────────── + * Reading the same atoms two ways gives a free consistency test. If the + * per-player reads do not sum to the team read, one of them is wrong and we do + * not yet know which — so the correct output is LOW CONFIDENCE, pre-game, rather + * than a confident number from whichever path we happened to trust. A model that + * knows which of its own calls to distrust can grade selectively. + */ + +const { knownNumber } = require('../../utils/known'); + +/** An atom that cannot be read is ABSENT — never a zero probability. */ +function usableAtoms(atoms) { + return (atoms || []).filter((a) => a && knownNumber(a.p) !== null); +} + +/** + * CHAIN ACROSS — compound probability of every leg landing. + * + * @param {Array} atoms [{ id, p, calibrated, gameId, entityId, ... }] + * @param {object} opts + * correlation(a, b) → 0..1 shared-variance estimate between two legs + * requireCalibrated (default TRUE) — see the header + * @returns {object|null} refusal is explicit and reasoned, never a silent 0 + */ +function chainAcross(atoms, opts = {}) { + const requireCalibrated = opts.requireCalibrated !== false; + const legs = usableAtoms(atoms); + if (legs.length === 0) return { ok: false, reason: 'no_usable_atoms' }; + + if (requireCalibrated) { + const uncal = legs.filter((l) => l.calibrated !== true); + if (uncal.length > 0) { + return { + ok: false, + reason: 'uncalibrated_atoms', + detail: 'compounding multiplies calibration error; a leg whose stated probability is not its realized rate makes the ticket confidently wrong', + uncalibrated: uncal.map((l) => l.id), + legs: legs.length, + }; + } + } + + // Independent product first, then a correlation discount. Same-game legs share + // the pitcher, the park and the weather, so treating them as independent + // OVERSTATES the ticket — the error runs in the flattering direction, which is + // exactly the one to be careful about. + const corrFn = typeof opts.correlation === 'function' ? opts.correlation : () => 0; + let independent = 1; + for (const l of legs) independent *= Math.min(1, Math.max(0, Number(l.p))); + + let pairs = 0; + let corrSum = 0; + for (let i = 0; i < legs.length; i += 1) { + for (let j = i + 1; j < legs.length; j += 1) { + const c = Number(corrFn(legs[i], legs[j])); + if (Number.isFinite(c)) { corrSum += Math.min(1, Math.max(0, c)); pairs += 1; } + } + } + const meanCorr = pairs > 0 ? corrSum / pairs : 0; + + // Positive correlation makes the JOINT more likely than independence implies + // (legs tend to land together), so the adjustment moves toward the weakest leg + // — bounded, and stated as an approximation rather than a derivation. + const weakest = Math.min(...legs.map((l) => Number(l.p))); + const compound = independent + meanCorr * (weakest - independent); + + return { + ok: true, + legs: legs.length, + independent_probability: round4(independent), + mean_pairwise_correlation: round4(meanCorr), + compound_probability: round4(Math.min(1, Math.max(0, compound))), + cross_game_legs: new Set(legs.map((l) => l.gameId)).size, + correlation_caveat: 'pairwise mean, applied as a bounded shift toward the weakest leg — an approximation, not a joint distribution', + }; +} + +/** + * CHAIN UP — sum atoms to a team-level expectation (expected scoring). + * + * `redistribute` is the archetype hook. It receives the atoms and the context + * and may return a reweighted set — dormant in baseball, live in basketball. + */ +function chainUp(atoms, opts = {}) { + let legs = usableAtoms(atoms); + if (legs.length === 0) return { ok: false, reason: 'no_usable_atoms' }; + + let redistributed = false; + if (typeof opts.redistribute === 'function') { + const out = opts.redistribute(legs, opts.context || {}); + if (Array.isArray(out) && out.length > 0) { legs = usableAtoms(out); redistributed = true; } + } + + const weightOf = (a) => { + const w = knownNumber(a.weight); + return w === null ? 1 : w; // absent weight contributes once, not zero + }; + const expected = legs.reduce((s, a) => s + Number(a.p) * weightOf(a), 0); + return { + ok: true, + contributors: legs.length, + expected_value: round4(expected), + redistributed, + }; +} + +/** + * SELF-CHECK — do the two readings of the same atoms agree? + * + * A divergence does not tell us WHICH reading is wrong, so the honest output is + * a confidence downgrade rather than a correction. Pre-game, that is exactly + * what the product needs: the model flagging its own suspect calls. + */ +function selfCheck({ perEntity, teamRead, marketRead = null, tolerance = 0.15 } = {}) { + const flags = []; + const sum = (perEntity || []).reduce((s, a) => { + const p = knownNumber(a && a.p); + return p === null ? s : s + p * (knownNumber(a.weight) ?? 1); + }, 0); + const team = knownNumber(teamRead); + + let internalDivergence = null; + if (team !== null && (perEntity || []).length > 0) { + const denom = Math.max(1e-9, Math.abs(team)); + internalDivergence = Math.abs(sum - team) / denom; + if (internalDivergence > tolerance) { + flags.push({ + flag: 'INTERNAL_INCONSISTENCY', + detail: `per-entity reads sum to ${round4(sum)} against a team read of ${round4(team)}`, + consequence: 'one of the two is wrong and we do not know which — every prop drawing on these atoms is LOW CONFIDENCE', + }); + } + } + + // Divergence from the market is NOT an error signal — it is the point of + // projecting independently. It flags the game script as one where our read and + // the market's disagree, which is where a mispricing would live IF we are + // right. It never means we are right. + let marketDivergence = null; + const mkt = knownNumber(marketRead); + if (mkt !== null && team !== null) { + marketDivergence = team - mkt; + if (Math.abs(marketDivergence) > tolerance * Math.max(1, Math.abs(mkt))) { + flags.push({ + flag: 'SCRIPT_DIVERGES_FROM_MARKET', + detail: `our projection ${round4(team)} vs market ${round4(mkt)}`, + consequence: 'the game script is contested — props downstream of it are either the best or the worst on the board, and which one is unknown until settled', + }); + } + } + + return { + per_entity_sum: round4(sum), + team_read: team, + internal_divergence: internalDivergence === null ? null : round4(internalDivergence), + market_divergence: marketDivergence === null ? null : round4(marketDivergence), + tolerance, + flags, + confidence: flags.some((f) => f.flag === 'INTERNAL_INCONSISTENCY') ? 'LOW' : 'NORMAL', + }; +} + +/** + * PROPAGATION — a settled outcome corrects the atom responsible, and therefore + * every reading that shares it. + * + * Shrinkage-weighted by sample: one game moves a 400-observation atom barely at + * all and a 5-observation atom a lot, which is the difference between learning + * and chasing noise. `priorWeight` is the number of observations the existing + * estimate is worth. + */ +function propagate(atom, observation, opts = {}) { + const prior = knownNumber(atom && atom.p); + const obs = knownNumber(observation && observation.won); + if (prior === null || obs === null) return atom; // nothing to learn from + const n = knownNumber(atom && atom.n) ?? 0; + const priorWeight = knownNumber(opts.priorWeight) ?? Math.max(1, n); + const updated = (prior * priorWeight + (obs > 0 ? 1 : 0)) / (priorWeight + 1); + return { ...atom, p: round4(updated), n: n + 1, last_updated_from: observation.id ?? null }; +} + +const round4 = (v) => (Number.isFinite(v) ? Math.round(v * 10000) / 10000 : v); + +module.exports = { chainAcross, chainUp, selfCheck, propagate, usableAtoms }; diff --git a/tests/unit/chain.test.js b/tests/unit/chain.test.js new file mode 100644 index 0000000..c2fe051 --- /dev/null +++ b/tests/unit/chain.test.js @@ -0,0 +1,167 @@ +'use strict'; + +/** + * The portable chain + the calibration gate that guards it. + * + * The failure these exist to prevent is specific: compounding probabilities that + * are individually survivable and jointly catastrophic. A 4-leg ticket of "90%" + * legs whose realized rate is 63% is a 4.4x overstatement, in the direction the + * user pays for. + */ + +const chain = require('../../src/services/model/chain'); +const cal = require('../../src/services/model/calibration'); + +const atom = (id, p, extra = {}) => ({ id, p, calibrated: true, gameId: `g${id}`, ...extra }); + +describe('CHAIN ACROSS — the calibration gate is structural', () => { + it('REFUSES to compound an uncalibrated atom', () => { + const out = chain.chainAcross([atom('a', 0.9), { ...atom('b', 0.9), calibrated: false }]); + expect(out.ok).toBe(false); + expect(out.reason).toBe('uncalibrated_atoms'); + expect(out.uncalibrated).toEqual(['b']); + }); + + it('the refusal is the feature — this is the 4.4x case, made unbuildable', () => { + // Model 0.91^4 = 0.686; realized 0.63^4 = 0.157. + const legs = ['a', 'b', 'c', 'd'].map((k) => ({ ...atom(k, 0.91), calibrated: false })); + expect(chain.chainAcross(legs).ok).toBe(false); + // And the same legs, once genuinely calibrated, DO compound. + const ok = chain.chainAcross(legs.map((l) => ({ ...l, calibrated: true }))); + expect(ok.ok).toBe(true); + expect(ok.compound_probability).toBeCloseTo(0.91 ** 4, 3); + }); + + it('independent cross-game legs multiply', () => { + const out = chain.chainAcross([atom('a', 0.8), atom('b', 0.5)]); + expect(out.independent_probability).toBeCloseTo(0.4, 6); + expect(out.cross_game_legs).toBe(2); + }); + + it('correlated same-game legs are NOT treated as independent', () => { + const legs = [atom('a', 0.7, { gameId: 'G1' }), atom('b', 0.7, { gameId: 'G1' })]; + const indep = chain.chainAcross(legs); + const corr = chain.chainAcross(legs, { correlation: () => 0.6 }); + // Shared pitcher/park/weather makes them land together more often than + // independence implies — and independence is the FLATTERING error here. + expect(corr.compound_probability).toBeGreaterThan(indep.compound_probability); + expect(corr.cross_game_legs).toBe(1); + }); + + it('an unreadable atom is dropped, never counted as probability zero', () => { + const out = chain.chainAcross([atom('a', 0.8), { id: 'b', p: null, calibrated: true }]); + expect(out.ok).toBe(true); + expect(out.legs).toBe(1); // a p=0 leg would have zeroed the ticket + expect(out.compound_probability).toBeCloseTo(0.8, 6); + }); + + it('no usable atoms is an explicit refusal, not a zero', () => { + expect(chain.chainAcross([]).ok).toBe(false); + expect(chain.chainAcross([{ id: 'x', p: null }]).reason).toBe('no_usable_atoms'); + }); +}); + +describe('CHAIN UP — same atoms, team reading', () => { + it('sums atoms into an expected value', () => { + const out = chain.chainUp([atom('a', 0.3), atom('b', 0.4), atom('c', 0.5)]); + expect(out.expected_value).toBeCloseTo(1.2, 6); + expect(out.contributors).toBe(3); + }); + + it('respects weights, and an absent weight contributes ONCE not zero', () => { + const out = chain.chainUp([atom('a', 0.5, { weight: 4 }), atom('b', 0.5)]); + expect(out.expected_value).toBeCloseTo(2.5, 6); + }); + + it('the archetype-redistribution HOOK is dormant unless supplied', () => { + const legs = [atom('star', 0.6), atom('bench', 0.1)]; + expect(chain.chainUp(legs).redistributed).toBe(false); + }); + + it('and LIVE when a sport supplies it — a blowout fades the star, feeds the bench', () => { + // Dormant in baseball (a nine-run lead does not change who bats next); + // this is the basketball case the hook exists for. + const legs = [atom('star', 0.6), atom('bench', 0.1)]; + const out = chain.chainUp(legs, { + context: { blowout: true }, + redistribute: (as, ctx) => (ctx.blowout + ? as.map((a) => (a.id === 'star' ? { ...a, p: a.p * 0.6 } : { ...a, p: a.p * 2 })) + : as), + }); + expect(out.redistributed).toBe(true); + expect(out.expected_value).toBeCloseTo(0.6 * 0.6 + 0.1 * 2, 6); + }); +}); + +describe('SELF-CHECK — the model flagging its own suspect calls', () => { + it('flags an internal inconsistency as LOW CONFIDENCE, without guessing which side is wrong', () => { + const out = chain.selfCheck({ + perEntity: [{ p: 0.5 }, { p: 0.5 }, { p: 0.5 }], // sums to 1.5 + teamRead: 4.0, + }); + expect(out.confidence).toBe('LOW'); + expect(out.flags.map((f) => f.flag)).toContain('INTERNAL_INCONSISTENCY'); + expect(out.flags[0].consequence).toMatch(/do not know which/); + }); + + it('agreement is NORMAL confidence', () => { + const out = chain.selfCheck({ perEntity: [{ p: 1.0 }, { p: 1.1 }], teamRead: 2.05 }); + expect(out.confidence).toBe('NORMAL'); + expect(out.flags).toEqual([]); + }); + + it('market divergence FLAGS the script but never claims we are right', () => { + const out = chain.selfCheck({ perEntity: [{ p: 5.5 }], teamRead: 5.5, marketRead: 3.5 }); + const f = out.flags.find((x) => x.flag === 'SCRIPT_DIVERGES_FROM_MARKET'); + expect(f).toBeTruthy(); + expect(f.consequence).toMatch(/either the best or the worst/); + // Divergence is not an error signal, so it does not downgrade confidence. + expect(out.confidence).toBe('NORMAL'); + }); +}); + +describe('PROPAGATION — one settled result improves every reading that shares the atom', () => { + it('moves a thin atom a lot and a heavy atom barely at all', () => { + const thin = chain.propagate({ id: 'a', p: 0.5, n: 4 }, { won: 1 }); + const heavy = chain.propagate({ id: 'a', p: 0.5, n: 400 }, { won: 1 }); + expect(thin.p - 0.5).toBeGreaterThan((heavy.p - 0.5) * 10); + // That gap is the difference between learning and chasing noise. + expect(heavy.n).toBe(401); + }); + + it('an unreadable observation changes nothing', () => { + const before = { id: 'a', p: 0.5, n: 10 }; + expect(chain.propagate(before, { won: null })).toEqual(before); + expect(chain.propagate(before, null)).toEqual(before); + }); +}); + +describe('CALIBRATION — the gate itself', () => { + const rows = (spec) => spec.flatMap(([p, n, hitRate]) => + Array.from({ length: n }, (_, i) => ({ p, won: i < Math.round(n * hitRate) ? 1 : 0 }))); + + it('passes a model whose stated probabilities are its realized rates', () => { + const out = cal.isCalibrated(rows([[0.3, 100, 0.30], [0.5, 100, 0.50], [0.9, 100, 0.90]])); + expect(out.calibrated).toBe(true); + }); + + it('FAILS the real hits curve — over-confident exactly where a parlay stacks', () => { + const out = cal.isCalibrated(rows([[0.45, 152, 0.49], [0.75, 152, 0.605], [0.91, 100, 0.63]])); + expect(out.calibrated).toBe(false); + expect(out.reason).toBe('bin_error_exceeds_tolerance'); + expect(out.worst_high_confidence_bin.error).toBeGreaterThan(0.15); + }); + + it('refuses to judge on too little data rather than guessing', () => { + expect(cal.isCalibrated(rows([[0.5, 20, 0.5]])).reason).toBe('insufficient_sample'); + }); + + it('isotonic fitting corrects the numbers while PRESERVING the ordering', () => { + const map = cal.fitIsotonic(rows([[0.45, 152, 0.49], [0.75, 152, 0.605], [0.91, 100, 0.63]])); + expect(map).not.toBeNull(); + const lo = cal.applyIsotonic(map, 0.45); + const hi = cal.applyIsotonic(map, 0.91); + expect(hi).toBeGreaterThanOrEqual(lo); // ordering survives + expect(hi).toBeLessThan(0.75); // the 0.91 claim is corrected down hard + }); +});