556d186ff1
"Is the shadow effective?" was only answerable by waiting for a snapshot to write a row. That leaves a blind spot with real cost: a variable SET IN COOLIFY BUT NOT YET APPLIED to the running process is indistinguishable from an unset one, and the runtime probe already proves the distinction matters — code_sha22cf51cwith started_at 01:45:44Z means anything set after that is not in this process's environment. probabilityContract.shadowState() is now the single evaluator. snapshotService calls it and the protected status probe calls it, and a test asserts NEITHER reads process.env directly — the same rule that keeps lineage_write_mode honest. Reading the env in two places is how a status page and a gate come to disagree. Strict by construction: only the exact string '1' enables it. 'true', 'yes', 'on', '01', ' 1 ' and '' are all OFF, because a loose parse turns a typo into an activation. `configuration_source` separates an unset variable from one explicitly set to '0', and `live_serving` is reported as its own switch so the shadow can never be read as implying serving. No behaviour changes. The shadow still defaults OFF, CALIBRATION_DEPLOYED is still [], and served fields are untouched. Frontend byte-identical to the last green build (git reports zero changes under web/), so the build from22cf51cstands. Suite 401/401, 5,597 passed, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
'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', () => {
|
|
it('leaves EVERY key byte-identical except probability_contract', () => {
|
|
// Diffing a NAMED LIST of served fields only proves the fields someone
|
|
// thought to list. Diff every key, so a field added later is covered by
|
|
// this test on the day it appears.
|
|
const before = [row(), row({ p_win: 0.91, grade: 'B+' }), row({ side: 'under', p_win: 0.55 }),
|
|
row({ p_win: null, grade: null }), row({ model_version: 'engine1@2026-07-20' })];
|
|
const snapshot = JSON.parse(JSON.stringify(before));
|
|
const after = retention.mergeProbabilityContract(before, contract);
|
|
after.forEach((r, i) => {
|
|
const keys = new Set([...Object.keys(r), ...Object.keys(snapshot[i])]);
|
|
keys.delete('probability_contract');
|
|
expect(keys.size).toBeGreaterThan(8); // the diff must be non-vacuous
|
|
for (const k of keys) expect(r[k]).toEqual(snapshot[i][k]);
|
|
});
|
|
});
|
|
|
|
it('adds exactly ONE key and no others', () => {
|
|
const before = row();
|
|
const after = retention.mergeProbabilityContract([before], contract)[0];
|
|
const added = Object.keys(after).filter((k) => !(k in before));
|
|
const removed = Object.keys(before).filter((k) => !(k in after));
|
|
expect(added).toEqual([]); // declared null upfront
|
|
expect(removed).toEqual([]);
|
|
expect(after.probability_contract).not.toBeNull();
|
|
});
|
|
|
|
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("probabilityContract').shadowState().shadow === 'ON'");
|
|
// 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*\]\)/);
|
|
});
|
|
});
|
|
|
|
describe('the artifact must agree with the contract, not merely accompany it', () => {
|
|
const cal = require('../../src/services/model/calibration');
|
|
const svc = require('../../src/services/model/probabilityContractService');
|
|
const map = cal.fitIsotonic(Array.from({ length: 900 }, (_, i) => {
|
|
const p = Math.round((0.35 + (i % 60) / 100) * 1000) / 1000;
|
|
return { p, won: ((i * 2654435761) % 1000) / 1000 < (0.5 + 0.45 * (p - 0.5)) ? 1 : 0, date: `d${i % 14}` };
|
|
}), { minTotal: 200 });
|
|
const build = () => svc.build({}, { calibrationService: { fromLedger: async () => ({
|
|
map, fit_n: 900, fitted_through: '2026-08-21', cutoff: '2026-09-02' }) } });
|
|
|
|
it('the built artifact declares the CERTIFIED era and estimator', async () => {
|
|
const b = await build();
|
|
expect(b.artifact.model_version).toBe(pc.MLB_HITS.model_version);
|
|
expect(b.artifact.estimator_type).toBe(pc.MLB_HITS.estimator_type);
|
|
});
|
|
|
|
it('an artifact fitted for another era serves NOTHING, even on a matching read', () => {
|
|
const r = pc.resolve({ sport: 'mlb', stat: 'hits', model_version: ERA, p_win: 0.65 },
|
|
{ estimate: () => 0.6, artifact: { model_version: 'engine1@2026-07-20', estimator_type: 'isotonic' } });
|
|
expect(r.probability_state).toBe(pc.STATE.VERSION_MISMATCH);
|
|
expect(r.served_probability).toBeNull();
|
|
});
|
|
|
|
it('an artifact of the wrong estimator type serves NOTHING', () => {
|
|
const r = pc.resolve({ sport: 'mlb', stat: 'hits', model_version: ERA, p_win: 0.65 },
|
|
{ estimate: () => 0.6, artifact: { model_version: ERA, estimator_type: 'low_param' } });
|
|
expect(r.probability_state).toBe(pc.STATE.VERSION_MISMATCH);
|
|
expect(r.served_probability).toBeNull();
|
|
});
|
|
|
|
it('and the row records the mismatch rather than dropping it', () => {
|
|
const bad = { fit_n: 1, fitted_through: 'x',
|
|
resolve: (read) => pc.resolve({ ...read, sport: 'mlb', stat: 'hits' },
|
|
{ estimate: () => 0.6, artifact: { model_version: 'engine1@2026-07-20', estimator_type: 'isotonic' } }) };
|
|
const [r] = retention.mergeProbabilityContract([row()], bad);
|
|
expect(r.probability_contract.probability_state).toBe(pc.STATE.VERSION_MISMATCH);
|
|
expect(r.probability_contract.served_probability).toBeNull();
|
|
expect(r.probability_contract.raw_model_probability).toBe(0.65);
|
|
});
|
|
});
|
|
|
|
describe('one evaluator for the shadow mode', () => {
|
|
it('only the exact string 1 enables it — a loose parse turns a typo into an activation', () => {
|
|
expect(pc.shadowState({ PROBABILITY_CONTRACT_SHADOW: '1' }).shadow).toBe('ON');
|
|
// process.env values are always strings, so only string inputs are asserted;
|
|
// claiming a numeric 1 should be OFF would be asserting a case the parse
|
|
// never sees, and one where OFF is not obviously the right answer anyway.
|
|
for (const v of ['true', 'yes', 'on', '0', ' 1 ', '', '01', 'TRUE']) {
|
|
expect(pc.shadowState({ PROBABILITY_CONTRACT_SHADOW: v }).shadow).toBe('OFF');
|
|
}
|
|
expect(pc.shadowState({}).shadow).toBe('OFF');
|
|
});
|
|
|
|
it('distinguishes an unset variable from one explicitly set', () => {
|
|
expect(pc.shadowState({}).configuration_source).toBe('default');
|
|
expect(pc.shadowState({ PROBABILITY_CONTRACT_SHADOW: '0' }).configuration_source).toBe('environment');
|
|
});
|
|
|
|
it('the shadow never implies live serving', () => {
|
|
expect(pc.shadowState({ PROBABILITY_CONTRACT_SHADOW: '1' }).live_serving).toBe('OFF');
|
|
});
|
|
|
|
it('the pipeline and the status probe call the SAME evaluator, never a second parse', () => {
|
|
const fs = require('fs'); const path = require('path');
|
|
const snap = fs.readFileSync(path.join(__dirname, '../../src/services/snapshotService.js'), 'utf8');
|
|
const route = fs.readFileSync(path.join(__dirname, '../../src/routes/internal.js'), 'utf8');
|
|
expect(snap).toContain("probabilityContract').shadowState().shadow === 'ON'");
|
|
expect(route).toContain("probabilityContract').shadowState()");
|
|
// and NEITHER may read the raw env directly
|
|
for (const src of [snap, route]) {
|
|
expect(src.includes('process.env.PROBABILITY_CONTRACT_SHADOW')).toBe(false);
|
|
}
|
|
});
|
|
});
|