6c34af3414
Backup commit of uncommitted working-tree state found during Legion recon (Tony resurrection, STEP 0). This work existed only on the laptop disk. - chain shadow accrual + probe script (038_chain_shadow.sql) - WNBA possession feed: ESPN adapter, usage service, verify script (039_wnba_player_game.sql) - baseball chain - retention/snapshot service updates, tableKeys, matchupKeys - specs: chain-v1, wnba-possession-feed, wnba-source-survey - unit tests for the above Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QnvJAkC3h5QGmb6dipoiWn
402 lines
18 KiB
JavaScript
402 lines
18 KiB
JavaScript
'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('THE chainFn SLOT — the stage the header described and the code lacked', () => {
|
|
it('defaults to identity-on-p, so every pre-existing caller is unchanged', () => {
|
|
const out = chain.chainAcross([atom('a', 0.8), atom('b', 0.5)]);
|
|
expect(out.independent_probability).toBeCloseTo(0.4, 6);
|
|
expect(out.chain_fn_applied).toBe(false);
|
|
});
|
|
|
|
it('computes the per-entity probability from the atom + context when supplied', () => {
|
|
// The sport-specific work is an INPUT, not a code path: rate x opportunity.
|
|
const atoms = [
|
|
{ id: 'a', rate: 0.25, pa: 4, calibrated: true, gameId: 'G1' },
|
|
{ id: 'b', rate: 0.30, pa: 4, calibrated: true, gameId: 'G2' },
|
|
];
|
|
const chainFn = (at) => 1 - (1 - at.rate) ** at.pa;
|
|
const out = chain.chainAcross(atoms, { chainFn });
|
|
expect(out.chain_fn_applied).toBe(true);
|
|
const pa = 1 - 0.75 ** 4;
|
|
const pb = 1 - 0.70 ** 4;
|
|
expect(out.independent_probability).toBeCloseTo(pa * pb, 4);
|
|
});
|
|
|
|
it('reads context, so slate-level modifiers reach the atom', () => {
|
|
const chainFn = (at, ctx) => at.rate * (ctx.parkBoost || 1);
|
|
const legs = [{ id: 'a', rate: 0.4, calibrated: true }];
|
|
const plain = chain.chainUp(legs, { chainFn });
|
|
const boosted = chain.chainUp(legs, { chainFn, context: { parkBoost: 1.5 } });
|
|
expect(plain.expected_value).toBeCloseTo(0.4, 6);
|
|
expect(boosted.expected_value).toBeCloseTo(0.6, 6);
|
|
});
|
|
|
|
it('an atom the chainFn cannot read is DROPPED and COUNTED, never p=0', () => {
|
|
// A zero leg would zero an entire ticket, and "we could not read him" is not
|
|
// "he cannot do it". The count is what stops a silently-failing chainFn from
|
|
// looking like a thin slate.
|
|
const chainFn = (at) => (at.id === 'b' ? null : 0.8);
|
|
const out = chain.chainAcross([atom('a', 0), atom('b', 0)], { chainFn });
|
|
expect(out.legs).toBe(1);
|
|
expect(out.chain_fn_refused).toBe(1);
|
|
expect(out.compound_probability).toBeCloseTo(0.8, 6);
|
|
});
|
|
|
|
it('a THROWING chainFn refuses that atom rather than breaking the read', () => {
|
|
const chainFn = (at) => { if (at.id === 'b') throw new Error('no profile'); return 0.5; };
|
|
const out = chain.chainAcross([atom('a', 0), atom('b', 0)], { chainFn });
|
|
expect(out.ok).toBe(true);
|
|
expect(out.chain_fn_refused).toBe(1);
|
|
});
|
|
|
|
it('a chainFn returning an object merges its metadata onto the atom', () => {
|
|
const out = chain.chainUp([{ id: 'a', calibrated: true }], {
|
|
chainFn: () => ({ p: 0.3, weight: 2 }),
|
|
});
|
|
expect(out.expected_value).toBeCloseTo(0.6, 6);
|
|
});
|
|
|
|
it('refusing EVERY atom is an explicit refusal carrying the count', () => {
|
|
const out = chain.chainAcross([atom('a', 0.5), atom('b', 0.5)], { chainFn: () => null });
|
|
expect(out.ok).toBe(false);
|
|
expect(out.reason).toBe('no_usable_atoms');
|
|
expect(out.chain_fn_refused).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('CORRELATION IS SIGNED — basketball is not baseball with a hook', () => {
|
|
const pair = (p) => [atom('a', p, { gameId: 'G1' }), atom('b', p, { gameId: 'G1' })];
|
|
|
|
it('zero correlation is exactly the independent product', () => {
|
|
const out = chain.chainAcross(pair(0.7), { correlation: () => 0 });
|
|
expect(out.compound_probability).toBeCloseTo(0.49, 4);
|
|
expect(out.correlation_direction).toBe('independent');
|
|
});
|
|
|
|
it('POSITIVE moves the joint toward the weakest leg — unchanged from before', () => {
|
|
const out = chain.chainAcross(pair(0.7), { correlation: () => 0.6 });
|
|
expect(out.compound_probability).toBeGreaterThan(0.49);
|
|
// Fréchet upper bound: two co-monotone 0.7s land together at most 0.7.
|
|
expect(out.compound_probability).toBeLessThanOrEqual(0.7);
|
|
expect(out.correlation_direction).toBe('toward_joint');
|
|
});
|
|
|
|
it('NEGATIVE moves the joint APART — the case the old clamp made inexpressible', () => {
|
|
// Teammates competing for finite possessions: one player's shot is another
|
|
// player's non-shot, so they land together LESS often than independence says.
|
|
const out = chain.chainAcross(pair(0.7), { correlation: () => -0.6 });
|
|
expect(out.compound_probability).toBeLessThan(0.49);
|
|
expect(out.correlation_direction).toBe('apart');
|
|
});
|
|
|
|
it('the extremes are the FRÉCHET BOUNDS, which is why the direction is principled', () => {
|
|
const hi = chain.chainAcross(pair(0.7), { correlation: () => 1 });
|
|
const lo = chain.chainAcross(pair(0.7), { correlation: () => -1 });
|
|
expect(hi.compound_probability).toBeCloseTo(0.7, 4); // min(p_i)
|
|
expect(lo.compound_probability).toBeCloseTo(0.4, 4); // max(0, Sum p - (n-1))
|
|
});
|
|
|
|
it('the lower bound never goes below zero', () => {
|
|
const out = chain.chainAcross(
|
|
[atom('a', 0.2, { gameId: 'G1' }), atom('b', 0.3, { gameId: 'G1' })],
|
|
{ correlation: () => -1 },
|
|
);
|
|
expect(out.compound_probability).toBe(0);
|
|
});
|
|
|
|
it('a correlation outside [-1,1] is clamped, not trusted', () => {
|
|
const wild = chain.chainAcross(pair(0.7), { correlation: () => -50 });
|
|
const unit = chain.chainAcross(pair(0.7), { correlation: () => -1 });
|
|
expect(wild.compound_probability).toBeCloseTo(unit.compound_probability, 6);
|
|
});
|
|
});
|
|
|
|
describe('REDISTRIBUTE REACHES BOTH READINGS — or selfCheck lies about itself', () => {
|
|
const blowout = (as, ctx) => (ctx.blowout
|
|
? as.map((a) => (a.id === 'star' ? { ...a, p: a.p * 0.6 } : { ...a, p: a.p * 2 }))
|
|
: as);
|
|
|
|
it('chainAcross now takes the hook chainUp always had', () => {
|
|
const legs = [atom('star', 0.6, { gameId: 'G1' }), atom('bench', 0.1, { gameId: 'G1' })];
|
|
const out = chain.chainAcross(legs, { redistribute: blowout, context: { blowout: true } });
|
|
expect(out.redistributed).toBe(true);
|
|
expect(out.independent_probability).toBeCloseTo(0.36 * 0.2, 6);
|
|
});
|
|
|
|
it('and stays dormant unless a sport supplies it', () => {
|
|
const legs = [atom('star', 0.6, { gameId: 'G1' }), atom('bench', 0.1, { gameId: 'G1' })];
|
|
expect(chain.chainAcross(legs).redistributed).toBe(false);
|
|
});
|
|
|
|
it('THE POINT: a redistribution applied to both readings does not false-flag', () => {
|
|
// Before, redistribute reached only the team read. The across-read would
|
|
// then be built from pre-redistribution atoms, and selfCheck would report an
|
|
// INTERNAL_INCONSISTENCY the model had itself just manufactured.
|
|
const legs = [atom('star', 0.6, { gameId: 'G1' }), atom('bench', 0.1, { gameId: 'G1' })];
|
|
const opts = { redistribute: blowout, context: { blowout: true } };
|
|
|
|
const prep = chain.prepareAtoms(legs, opts);
|
|
const up = chain.chainUp(legs, opts);
|
|
const check = chain.selfCheck({ perEntity: prep.legs, teamRead: up.expected_value });
|
|
|
|
expect(prep.redistributed).toBe(true);
|
|
expect(check.confidence).toBe('NORMAL');
|
|
expect(check.flags).toEqual([]);
|
|
});
|
|
|
|
it('prepareAtoms is the shared preparation — same legs, both readings', () => {
|
|
const legs = [atom('star', 0.6), atom('bench', 0.1)];
|
|
const opts = { redistribute: blowout, context: { blowout: true } };
|
|
const prep = chain.prepareAtoms(legs, opts);
|
|
expect(prep.legs.map((l) => l.p)).toEqual([0.36, 0.2]);
|
|
// chainAcross and chainUp must see EXACTLY these.
|
|
expect(chain.chainUp(legs, opts).expected_value).toBeCloseTo(0.56, 6);
|
|
expect(chain.chainAcross(legs, opts).independent_probability).toBeCloseTo(0.072, 6);
|
|
});
|
|
|
|
it('a redistribute that returns nothing usable leaves the legs alone', () => {
|
|
const legs = [atom('a', 0.5)];
|
|
const out = chain.chainUp(legs, { redistribute: () => [] });
|
|
expect(out.redistributed).toBe(false);
|
|
expect(out.expected_value).toBeCloseTo(0.5, 6);
|
|
});
|
|
});
|
|
|
|
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 SERVICE — fit past, apply forward, certify by band', () => {
|
|
const svc = require('../../src/services/model/calibrationService');
|
|
|
|
/** Rows dated so the time-split is meaningful. */
|
|
const hist = (specs) => {
|
|
const out = [];
|
|
let day = 1;
|
|
for (const [p, n, rate] of specs) {
|
|
for (let i = 0; i < n; i += 1) {
|
|
// Wins are INTERLEAVED, not front-loaded. Front-loading makes the
|
|
// outcome correlate with the date, so a time-split would train on the
|
|
// wins and certify on the losses — the generator would be creating the
|
|
// very leakage the split exists to prevent.
|
|
const won = Math.floor((i + 1) * rate) > Math.floor(i * rate) ? 1 : 0;
|
|
out.push({ p, won, date: `2026-07-${String(day).padStart(2, '0')}` });
|
|
if (out.length % 40 === 0) day = Math.min(28, day + 1);
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
it('refuses to build on thin history rather than passing raw numbers through', () => {
|
|
// "No calibrator" must mean nothing is stackable, never "trust the model".
|
|
expect(svc.build(hist([[0.6, 50, 0.5]]))).toBeNull();
|
|
expect(svc.build([])).toBeNull();
|
|
});
|
|
|
|
it('corrects an over-confident model and marks the corrected value calibrated', () => {
|
|
// Claims 0.9, realises 0.6 — the shape measured on real hits.
|
|
const c = svc.build(hist([[0.5, 300, 0.5], [0.9, 300, 0.6]]), { minBin: 30 });
|
|
expect(c).not.toBeNull();
|
|
const out = c.calibrate(0.9);
|
|
expect(out.p_calibrated).toBeLessThan(0.75); // the 0.9 claim is corrected down
|
|
expect(out.p_raw).toBe(0.9);
|
|
});
|
|
|
|
it('a probability OUTSIDE a certified band is not stackable', () => {
|
|
const c = svc.build(hist([[0.5, 300, 0.5], [0.9, 300, 0.6]]), { minBin: 30 });
|
|
const far = c.calibrate(0.02);
|
|
// Whatever it maps to, if the band was never certified it cannot compound.
|
|
if (!far.calibrated) expect(far.reason).toBe('outside_certified_band');
|
|
});
|
|
|
|
it('an absent probability is absent, never 0', () => {
|
|
const c = svc.build(hist([[0.5, 300, 0.5], [0.9, 300, 0.6]]), { minBin: 30 });
|
|
const out = c.calibrate(null);
|
|
expect(out.p_calibrated).toBeNull();
|
|
expect(out.calibrated).toBe(false);
|
|
});
|
|
|
|
it('splits by TIME — the certification window is later than the fit window', () => {
|
|
const c = svc.build(hist([[0.5, 300, 0.5], [0.9, 300, 0.6]]), { minBin: 30 });
|
|
expect(c.certified_through >= c.fitted_through).toBe(true);
|
|
expect(c.fit_n).toBeGreaterThan(0);
|
|
expect(c.certify_n).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('END TO END: uncalibrated legs are refused; calibrated ones compound', () => {
|
|
const c = svc.build(hist([[0.5, 300, 0.5], [0.9, 300, 0.6]]), { minBin: 30 });
|
|
const legs = ['a', 'b'].map((id) => {
|
|
const v = c.calibrate(0.5);
|
|
return { id, p: v.p_calibrated, calibrated: v.calibrated, gameId: `g${id}` };
|
|
});
|
|
const out = chain.chainAcross(legs);
|
|
if (legs.every((l) => l.calibrated)) {
|
|
expect(out.ok).toBe(true);
|
|
expect(out.compound_probability).toBeCloseTo(legs[0].p * legs[1].p, 3);
|
|
} else {
|
|
expect(out.reason).toBe('uncalibrated_atoms');
|
|
}
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|