diff --git a/scripts/teeth-artifact-governance.js b/scripts/teeth-artifact-governance.js index 7d3ad6b..b13d60b 100644 --- a/scripts/teeth-artifact-governance.js +++ b/scripts/teeth-artifact-governance.js @@ -310,8 +310,8 @@ inject(41, 'raw model probability is erased when live serves', 'tests/unit/servedProbability.test.js'); inject(42, 'the serving boundary bypasses the seam', 'src/utils/snapshotGating.js', - ` try { rows = require('../services/model/servedProbability').applyToRows(rows); }`, - ` try { rows = rows; }`, + ` try { rows = require('../services/model/servedProbability').applyToRows(grades); }`, + ` try { rows = grades; }`, 'tests/unit/servedProbability.test.js'); inject(43, 'the monitor reaches a verdict from one settled date', 'src/services/model/forwardMonitor.js', @@ -356,6 +356,25 @@ inject(49, 'a certified row shows the raw probability as confidence', ` out.confidence = Math.round(resolution.raw_model_probability * 100);`, 'tests/unit/liveRenderContract.test.js'); +// ── THE SEAM MUST ACTUALLY FIRE (the release it did not) ───────────────── +inject(50, 'the seam is wired to an undeclared variable and silently no-ops', + 'src/utils/snapshotGating.js', + ` let rows = grades; + try { rows = require('../services/model/servedProbability').applyToRows(grades); }`, + ` let rows = grades; + try { rows = require('../services/model/servedProbability').applyToRows(undefinedRows); }`, + 'tests/unit/servedProbability.test.js'); +inject(51, 'the entitled path returns the uncalibrated originals', + 'src/utils/snapshotGating.js', + ` if (entitledToModelPrice(tierName)) return rows;`, + ` if (entitledToModelPrice(tierName)) return grades;`, + 'tests/unit/servedProbability.test.js'); +inject(52, 'a failing seam is swallowed silently', + 'src/utils/snapshotGating.js', + ` console.warn('[served-probability] seam skipped, serving uncalibrated:', e && e.message);`, + ``, + 'tests/unit/servedProbability.test.js'); + const landed = results.filter((r) => r.landed).length; console.log(JSON.stringify({ teeth_landed: `${landed}/${results.length}`, results }, null, 2)); process.exit(landed === results.length ? 0 : 1); diff --git a/src/utils/snapshotGating.js b/src/utils/snapshotGating.js index e5286f8..e53d39d 100644 --- a/src/utils/snapshotGating.js +++ b/src/utils/snapshotGating.js @@ -61,12 +61,21 @@ function stripModelPrice(grades, tierName) { // Placed BEFORE the tier strip on purpose: calibration decides what the number // IS, entitlement decides who may see it. Reversing them would calibrate // fields that had already been removed. - try { rows = require('../services/model/servedProbability').applyToRows(rows); } - catch { /* serving must never fail because calibration could not answer */ } - + // It must never break serving — but a SILENT catch is how this seam spent a + // release doing nothing: the first version assigned to an undeclared `rows` + // instead of `grades`, threw a ReferenceError under strict mode, and the bare + // catch swallowed it. Live reported ON while every row was served raw. The + // failure is now named in the log. if (!Array.isArray(grades)) return grades; - if (entitledToModelPrice(tierName)) return grades; - return grades.map((g) => { + let rows = grades; + try { rows = require('../services/model/servedProbability').applyToRows(grades); } + catch (e) { + rows = grades; + console.warn('[served-probability] seam skipped, serving uncalibrated:', e && e.message); + } + + if (entitledToModelPrice(tierName)) return rows; + return rows.map((g) => { if (!g || typeof g !== 'object') return g; const out = { ...g }; for (const f of MODEL_FIELDS) delete out[f]; diff --git a/tests/unit/servedProbability.test.js b/tests/unit/servedProbability.test.js index 165a947..80e0ef9 100644 --- a/tests/unit/servedProbability.test.js +++ b/tests/unit/servedProbability.test.js @@ -112,7 +112,11 @@ describe('one seam', () => { it('serving routes reach it through stripModelPrice, not by duplicating logic', () => { const fs = require('fs'); const path = require('path'); const gsrc = fs.readFileSync(path.join(__dirname, '../../src/utils/snapshotGating.js'), 'utf8'); - expect(gsrc).toContain("require('../services/model/servedProbability').applyToRows(rows)"); + // NOTE: a source grep is NOT proof the seam runs — the first wiring + // contained this exact require and did nothing. The behavioural proof is in + // "THE SEAM MUST FIRE THROUGH THE REAL BOUNDARY" below; this only checks + // that consumers do not duplicate the logic. + expect(gsrc).toContain("servedProbability').applyToRows(grades)"); // no consumer may hold calibration logic of its own for (const f of ['src/routes/snapshot.js', 'src/routes/heroProp.js', 'src/services/topGradedService.js']) { const src = fs.readFileSync(path.join(__dirname, '../../', f), 'utf8') @@ -123,3 +127,81 @@ describe('one seam', () => { } }); }); + +describe('THE SEAM MUST FIRE THROUGH THE REAL BOUNDARY', () => { + /** + * The first wiring assigned to an undeclared `rows` instead of `grades`, + * threw a ReferenceError under strict mode, and a bare catch swallowed it. + * Live reported ON while every row was served raw, for a whole release. + * + * The old tests could not see it: they called `applyToRows` directly and + * grepped the source for the require line. Neither exercises the boundary. + * These call `stripModelPrice` and assert on its RETURN VALUE. + */ + const OLD = process.env.PROBABILITY_CONTRACT_LIVE; + beforeAll(() => { process.env.PROBABILITY_CONTRACT_LIVE = '1'; }); + afterAll(() => { + if (OLD === undefined) delete process.env.PROBABILITY_CONTRACT_LIVE; + else process.env.PROBABILITY_CONTRACT_LIVE = OLD; + }); + + const batch = () => ([ + row({ p_win: 0.706, confidence: 71, ev_pct: 8.7, value: false, book_odds: -185, fair_odds: -160, grade: 'B' }), + row({ p_win: 0.858, confidence: 86, ev_pct: 51.8, value: true, book_odds: -130, fair_odds: -110, grade: 'B+', side: 'under' }), + row({ stat_type: 'rbi', p_win: 0.65, confidence: 65, ev_pct: 12, value: true, grade: 'C+' }), + ]); + + it('an ENTITLED caller receives the CALIBRATED rows, not the originals', () => { + const src = batch(); + const out = gating.stripModelPrice(src, 'desk'); + const expected = registry.applyCurve(A, 0.706); + expect(out[0].p_win).toBe(Math.round(expected * 1000) / 1000); + expect(out[0].p_win).not.toBe(0.706); // the seam actually ran + expect(out[0].probability_state).toBe(pc.STATE.CERTIFIED_CALIBRATED); + expect(out[0].raw_model_probability).toBe(0.706); + }); + + it('EV is recomputed from the served probability at the boundary', () => { + const out = gating.stripModelPrice(batch(), 'desk'); + const { evPct } = require('../../src/utils/devig'); + expect(out[0].ev_pct).toBe(evPct(out[0].p_win, -185)); + expect(out[0].ev_pct).not.toBe(8.7); // the raw-derived value + }); + + it('an UNCERTIFIED row loses its number and its TRUE value badge', () => { + const out = gating.stripModelPrice(batch(), 'desk'); + expect(out[1].p_win).toBeNull(); + expect(out[1].confidence).toBeNull(); + expect(out[1].ev_pct).toBeNull(); + expect(out[1].value).toBeNull(); // was true + expect(out[1].grade).toBe('B+'); // grade survives + expect(out[1].raw_model_probability).toBe(0.858); + }); + + it('a non-hits row is untouched at the boundary', () => { + const out = gating.stripModelPrice(batch(), 'desk'); + expect(out[2].p_win).toBe(0.65); + expect(out[2].probability_state).toBeUndefined(); + }); + + it('an UNENTITLED caller still gets the model fields stripped', () => { + const out = gating.stripModelPrice(batch(), 'free'); + for (const r of out) { + expect(r.p_win).toBeUndefined(); + expect(r.ev_pct).toBeUndefined(); + expect(r.value).toBeUndefined(); + } + }); + + it('a throwing seam degrades to uncalibrated rows and SAYS SO', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const mod = require('../../src/services/model/servedProbability'); + const real = mod.applyToRows; + mod.applyToRows = () => { throw new Error('boom'); }; + try { + const out = gating.stripModelPrice(batch(), 'desk'); + expect(out[0].p_win).toBe(0.706); // unchanged, not lost + expect(warn).toHaveBeenCalled(); // and not silent + } finally { mod.applyToRows = real; warn.mockRestore(); } + }); +});