/** * Session 64 — model_snapshots retention. * * The point of this store is that it captures what the ledger throws away: * the model's INPUTS, the pre-collapse grade, and REFUSALS. These lock that. */ const retention = require('../../src/services/retentionService'); const CTX = { snapshotId: '00000000-0000-4000-8000-000000000001', capturedAt: '2026-07-20T03:00:00.000Z', cycleHourUtc: 3, sport: 'mlb', gameDate: '2026-07-19', gameIdFor: () => 'mlb:2026-07-19:NYY@BOS', }; const base = { player: 'José Ramírez', stat_type: 'hits', line: 0.5, over_odds: -140, under_odds: 115, book: 'fanduel', }; const graded = { player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B', _grade_11: 'B-', confidence: 57, confidence_basis: 'grade_band', p_win: 0.61, ev_pct: 4.6, model_odds: -156, projection: 0.9, edge_pct: 20, takeable: true, value: true, book_odds: -140, fair_odds: -125, fair_prob: 0.556, overround: 0.041, devig_method: 'multiplicative', _features: { l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 }, }; const refused = { player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'under', grade: null, insufficient_data: true, suppressed: true, suppressed_reason: 'juiced_no_edge', }; describe('rowsFromSides', () => { test('captures the feature vector — the counterfactual enabler', () => { const [row] = retention.rowsFromSides(base, [graded], CTX); expect(row.features).toEqual({ l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 }); }); test('captures the PRE-COLLAPSE 11-step grade, not just the 4-letter', () => { const [row] = retention.rowsFromSides(base, [graded], CTX); expect(row.grade).toBe('B'); expect(row.grade_11).toBe('B-'); }); test('REFUSALS are stored with their reason — the ledger drops these', () => { const rows = retention.rowsFromSides(base, [refused], CTX); expect(rows).toHaveLength(1); expect(rows[0].refused).toBe(true); expect(rows[0].refusal_reason).toBe('juiced_no_edge'); expect(rows[0].grade).toBeNull(); }); test('a refusal with no features stores null, never a fabricated {}', () => { const [row] = retention.rowsFromSides(base, [refused], CTX); expect(row.features).toBeNull(); }); test('both sides of one prop are captured (graded AND refused)', () => { const rows = retention.rowsFromSides(base, [graded, refused], CTX); expect(rows).toHaveLength(2); expect(rows.map((r) => r.side).sort()).toEqual(['over', 'under']); }); test('every row is stamped with a model version — eras must never mix', () => { const [row] = retention.rowsFromSides(base, [graded], CTX); expect(row.model_version).toBe(retention.MODEL_VERSION); expect(row.model_version).toBeTruthy(); }); test('player name is normalized and keyed like the ledger', () => { const [row] = retention.rowsFromSides(base, [graded], CTX); expect(row.player_key).toBe('jose ramirez'); expect(row.player_name).toBe('José Ramírez'); }); test('market values carry through; absent stays absent (no Number(null)=0)', () => { const [row] = retention.rowsFromSides(base, [{ ...graded, book_odds: null, ev_pct: null }], CTX); expect(row.book_odds).toBeNull(); expect(row.ev_pct).toBeNull(); expect(row.over_odds).toBe(-140); // from the prop }); test('a side inherits identity from the prop it was graded from', () => { // A refusal often carries no echoed identity — base supplies it. This is // correct, not junk: the prop is what we refused. const [row] = retention.rowsFromSides(base, [{ direction: 'over' }], CTX); expect(row.player_key).toBe('jose ramirez'); expect(row.refused).toBe(true); expect(row.refusal_reason).toBe('no_grade'); }); test('drops rows with no usable identity ANYWHERE rather than writing junk', () => { expect(retention.rowsFromSides({}, [{ direction: 'over' }], CTX)).toHaveLength(0); expect(retention.rowsFromSides({}, [{ player: 'X', direction: 'over' }], CTX)).toHaveLength(0); // no stat/line expect(retention.rowsFromSides(base, [null, undefined], CTX)).toHaveLength(0); expect(retention.rowsFromSides(base, [{ ...graded, direction: 'sideways' }], CTX)).toHaveLength(0); }); }); describe('createCollector', () => { test('accumulates across props and never throws into the grader', () => { const c = retention.createCollector(CTX); c.onGraded(base, [graded, refused]); c.onGraded(base, [graded]); expect(c.rows).toHaveLength(3); expect(() => c.onGraded(null, 'not-an-array')).not.toThrow(); }); }); describe('persist — best-effort contract', () => { test('no rows → no-op, no error', async () => { expect(await retention.persist([])).toMatchObject({ attempted: 0, written: 0 }); }); test('no supabase client → SKIPPED, not an error (tests/local never write)', async () => { const r = await retention.persist([{ a: 1 }], { getClient: () => null }); expect(r.skipped).toBe(true); expect(r.error).toBeNull(); }); test('a database error is captured and RETURNED, never thrown', async () => { const getClient = () => ({ from: () => ({ upsert: async () => ({ error: { message: 'boom' } }) }), }); const r = await retention.persist([{ a: 1 }], { getClient }); expect(r.error).toBe('boom'); expect(r.written).toBe(0); }); test('a thrown client is captured, never propagated (must not break a snapshot)', async () => { const getClient = () => { throw new Error('no client'); }; await expect(retention.persist([{ a: 1 }], { getClient })).resolves.toMatchObject({ error: 'no client' }); }); }); describe('mergeEnrichment (archetype/team/opponent were always null)', () => { const rows = [ { player_key: 'jose ramirez', side: 'over', archetype: null, team: null, opponent: null, features: { l5_avg: 0.8 }, grade: 'B', p_win: 0.61 }, { player_key: 'jose ramirez', side: 'under', archetype: null, team: null, opponent: null, features: null, grade: null, refused: true }, { player_key: 'nobody here', side: 'over', archetype: null, team: null, opponent: null }, ]; const enriched = [{ player: 'José Ramírez', archetype: 'TORCH', team: 'CLE', opponent: 'NYY' }]; test('fills archetype/team/opponent from the enriched slate', () => { const [a] = retention.mergeEnrichment(rows, enriched); expect(a.archetype).toBe('TORCH'); expect(a.team).toBe('CLE'); expect(a.opponent).toBe('NYY'); }); test('NEVER mutates grade-time features or model output', () => { const out = retention.mergeEnrichment(rows, enriched); expect(out[0].features).toEqual({ l5_avg: 0.8 }); expect(out[0].grade).toBe('B'); expect(out[0].p_win).toBe(0.61); // original array untouched (pure) expect(rows[0].archetype).toBeNull(); }); test('refusals for a matched player still get team context', () => { const out = retention.mergeEnrichment(rows, enriched); expect(out[1].team).toBe('CLE'); expect(out[1].features).toBeNull(); // still honestly absent }); test('unmatched rows stay null — never guessed', () => { const out = retention.mergeEnrichment(rows, enriched); expect(out[2].archetype).toBeNull(); expect(out[2].team).toBeNull(); }); test('empty/absent enrichment is a safe no-op', () => { expect(retention.mergeEnrichment(rows, [])).toHaveLength(3); expect(retention.mergeEnrichment([], enriched)).toEqual([]); }); });