'use strict'; /** * The two-part gate for a CONTINUOUS prediction. * * Same discipline as factorGate, different units — and the same dangerous * failure: a link that moves off the naive baseline while predicting nothing * makes the projection LOOK like it read the game script. */ const pg = require('../../src/services/model/predictionGate'); /** n rows where the prediction tracks truth to a given degree. */ function rows(n, { skill = 1, clusters = 60, seed = 5 } = {}) { let s = seed; const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648; const out = []; for (let i = 0; i < n; i += 1) { const actual = 20 + (rnd() - 0.5) * 12; const noise = (rnd() - 0.5) * 12; out.push({ cluster: `c${i % clusters}`, baseline: 20, prediction: 20 + skill * (actual - 20) + (1 - skill) * noise, actual, }); } return out; } describe('a link that genuinely predicts', () => { it('PROVES when it beats the naive baseline out-of-sample', () => { const v = pg.adjudicate(rows(1200, { skill: 0.8 }), { link: 'good' }); expect(v.verdict).toBe('PROVES'); expect(v.improvement.loss_delta).toBeLessThan(0); expect(v.improvement.ci[1]).toBeLessThan(0); }); it('does NOT binarise the target — that is why factorGate cannot do this job', () => { // Brier collapses the outcome to 0/1. A target like "batters faced" would be // destroyed by that, so the loss here stays on the real scale. const v = pg.adjudicate(rows(1200, { skill: 0.9 }), { link: 'scale' }); expect(v.improvement.loss_baseline).toBeGreaterThan(1); }); }); describe('the failures it must name', () => { it('THEATER — moves off the baseline and predicts nothing', () => { const v = pg.adjudicate(rows(1200, { skill: 0 }), { link: 'noise' }); expect(v.verdict).toBe('THEATER'); expect(v.movement.mean_abs_shift).toBeGreaterThan(0); expect(v.consequence).toMatch(/LOOK like it read/); }); it('INERT — never departs from the baseline at all', () => { const flat = rows(1200, { skill: 0 }).map((r) => ({ ...r, prediction: r.baseline })); const v = pg.adjudicate(flat, { link: 'flat', minMovement: 0.01 }); expect(v.verdict).toBe('INERT'); }); it('thin sample is PENDING, never a verdict', () => { const v = pg.adjudicate(rows(100, { skill: 0.9 }), { link: 'thin' }); expect(v.verdict).toBe('PENDING_SAMPLE'); expect(v.rows_needed).toBe(400); }); }); describe('replication is counted in arms, not in starts', () => { it('refuses when the entity it rides on has too few clusters', () => { // 39,629 post-starter plate appearances across 30 bullpens is 30 readings. const v = pg.adjudicate(rows(5000, { skill: 0.9, clusters: 30 }), { link: 'bullpen' }); expect(v.verdict).toBe('PENDING_SAMPLE'); expect(v.reason).toMatch(/30 independent clusters < 40/); expect(v.clusters_needed).toBe(10); }); it('the clustered interval is wider than the unclustered one', () => { const r = rows(1500, { skill: 0.5, clusters: 45 }); const clustered = pg.adjudicate(r, { link: 'a' }); const flat = pg.adjudicate(r.map(({ cluster, ...x }) => x), { link: 'b' }); const w = (v) => v.improvement.ci[1] - v.improvement.ci[0]; expect(w(clustered)).toBeGreaterThan(w(flat)); }); it('the cumulative correction widens the interval', () => { const r = rows(1500, { skill: 0.6 }); const one = pg.adjudicate(r, { cumulativeTests: 1 }); const many = pg.adjudicate(r, { cumulativeTests: 108 }); expect(many.improvement.ci_level).toBeGreaterThan(one.improvement.ci_level); }); }); describe('honesty', () => { it('an unreadable row is dropped, never zero-filled', () => { const r = rows(600, { skill: 0.8 }); r[0].prediction = null; r[1].actual = null; r[2].baseline = null; const v = pg.adjudicate(r, { minN: 100 }); expect(v.improvement.n).toBe(597); }); });