d3ffa1b8c2
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
141 lines
5.5 KiB
JavaScript
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' });
|
|
});
|
|
});
|