Files
vyndr/tests/unit/retentionService.test.js
T
builtbykev d3ffa1b8c2 Retention: model_snapshots live + base64 SSH key support
RETENTION (Phase 2, priority zero). History starts compounding tonight.

migration 025 model_snapshots — APPLIED to prod. Append-only, one row per
graded prop PER SIDE PER CYCLE, with a unique index on
(snapshot_id, player_key, stat, line, side) so a retried cycle cannot
duplicate. RLS on, service-role writes only.

What it captures that the ledger never did:
- features jsonb — the model's INPUTS. Without these a backtest can only
  grade our own homework; with them any future model can be replayed
  against the exact conditions this one faced.
- REFUSALS (refused + refusal_reason). The ledger drops them, so a gate
  refusing props that would have WON is invisible — unmeasurable lost
  edge. Captured via a new onGraded hook in gradeSlateService that fires
  with BOTH sides before any filtering.
- grade_11, the pre-collapse grade. The 4-letter map throws away the
  entire live C-/C/C+/B- range.
- model_version + code_sha on every row. ledger_entries mixes pre/post-fix
  grades with no marker and cannot be separated retroactively.
- p_win / ev_pct / fair_odds / takeable / value — none of which any
  permanent store held.

Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore =
internal); gradeSlateService fires onGraded then STRIPS them so they never
reach a cache or API payload; snapshotService builds rows and persists
best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so
rows share the ledger's natural key exactly — otherwise the settle pass
could never join outcomes onto them. Rows are written BEFORE the empty-
slate early return: a slate that refused everything is exactly the case
worth recording.

CONTRACT HELD: retention is injectable and every path is caught. persist()
returns errors, never throws; a missing Supabase client is SKIPPED, not an
error. A retention failure can never break a snapshot.

BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended —
survives env-var newline mangling, which is how injected SSH keys usually
break silently) OR raw PEM, detected by decoding and looking for the PEM
header. Verified both forms detect correctly against a real generated key.

Suite 279/3325 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 23:01:12 -04:00

141 lines
5.5 KiB
JavaScript

/**
* 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' });
});
});