5a5e37e32e
PHASE 1 — cron capture needed NO wiring. Verified in code: the scheduler tick calls runAll = snapshotService.runAllSnapshots, which loops runSnapshot per sport, which already carries the onGraded -> retention hook. The scheduled path and the manual path are the SAME function. The reason no cron cycle had been captured is simply that no slot has fired since retention deployed (slots are 14/19/22/1/3 UTC; retention landed ~02:55). Induced proof follows the deploy. PHASE 2 — archetype/team/opponent were permanently null because retention persisted at GRADE time, before enrichment attaches them. Retention still COLLECTS at grade time (the only moment the feature vector exists) but now PERSISTS after enrichment, merging those three fields via retentionService.mergeEnrichment. The merge is pure and fills ONLY those three fields — features and every model output are grade-time values and must never be rewritten by enrichment; a test asserts that. Unmatched rows (refusals not in the enriched slate) keep nulls rather than guesses. The empty-slate early return now persists too: a refusal-only slate is still history worth keeping. PHASE 3 — ZERO-WRITE ALARM. opsWatch.retentionZeroWriteAlarm pages at missed-snapshot severity when a slot GRADED props but retention wrote fewer rows than the slate (or nothing). runSnapshot now returns retentionRows so the scheduler can evaluate it. Retention is best-effort by design so it can never break a snapshot — which means a broken write is silent by construction. This is the counterweight. A slot that graded nothing never false-pages; an absent count reads as NOTHING and still pages, distinct from a reported 0. Suite 280/3349 green, build exit 0. Outcome stamping deliberately NOT implemented (depends on the settlement fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
185 lines
7.3 KiB
JavaScript
185 lines
7.3 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' });
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|