diff --git a/src/services/opsWatch.js b/src/services/opsWatch.js index 949ed56..d21a143 100644 --- a/src/services/opsWatch.js +++ b/src/services/opsWatch.js @@ -192,7 +192,52 @@ function buildPulseMessage(pieces = {}) { ].join('\n'); } +/** + * RETENTION ZERO-WRITE ALARM (Session 64, Phase 3). + * + * `model_snapshots` is the moat's raw material and features are IRREPLACEABLE — + * a night not captured cannot be reconstructed later (unlike outcomes, which + * re-derive from box scores). Retention is deliberately best-effort so it can + * never break a snapshot, which means a broken write is SILENT by construction. + * This is the counterweight: if a slot graded props but retention wrote fewer + * rows than the slate, that is a paged event at missed-snapshot severity. + * + * Pure. Caller supplies what actually happened; this only decides. + * + * @param {Array} results per-sport snapshot results: { sport, status, gradeCount } + * @param {Object} written map of sport -> rows retention reported writing + * @returns {{alarm:boolean, reason:string|null, detail:Array}} + */ +function retentionZeroWriteAlarm(results = [], written = {}) { + const detail = []; + let alarm = false; + let reason = null; + for (const r of results || []) { + if (!r || r.status !== 'ok') continue; // only slots that DID grade + const graded = Number(r.gradeCount) || 0; + if (graded <= 0) continue; + const rows = Number(written[r.sport]); + // Retention writes BOTH sides plus refusals, so rows should be >= graded. + // Fewer rows than graded props means capture is dropping history. + if (!Number.isFinite(rows) || rows <= 0) { + alarm = true; + detail.push({ sport: r.sport, graded, rows: Number.isFinite(rows) ? rows : null, kind: 'zero' }); + } else if (rows < graded) { + alarm = true; + detail.push({ sport: r.sport, graded, rows, kind: 'short' }); + } else { + detail.push({ sport: r.sport, graded, rows, kind: 'ok' }); + } + } + if (alarm) { + const bad = detail.filter((d) => d.kind !== 'ok'); + reason = bad.map((d) => `${String(d.sport).toUpperCase()} graded ${d.graded} but retention wrote ${d.rows === null ? 'NOTHING' : d.rows}`).join('; '); + } + return { alarm, reason, detail }; +} + module.exports = { + retentionZeroWriteAlarm, createFailureTracker, isBadSnapshotResult, zeroSettleAlarm, diff --git a/src/services/retentionService.js b/src/services/retentionService.js index 6420940..09f659c 100644 --- a/src/services/retentionService.js +++ b/src/services/retentionService.js @@ -168,6 +168,48 @@ async function persist(rows, deps = {}) { return out; } +/** + * Fill archetype / team / opponent onto collected rows from the ENRICHED grades. + * + * Retention collects at GRADE time, which is the only moment the feature vector + * exists — but archetype/team/opponent are attached later, during snapshot + * enrichment. Capturing at grade time alone left all three permanently null, + * which specifically blocks the archetype-baselined metrics work. + * + * CONTRACT: this ONLY fills those three fields. It must never touch `features` + * or any model output — grade-time values are the record, and enrichment must + * not rewrite history. Unmatched rows (e.g. refusals, which never reach the + * enriched slate) pass through untouched with the fields left null: honestly + * absent, not guessed. + */ +function mergeEnrichment(rows, enrichedGrades) { + if (!Array.isArray(rows) || !rows.length) return rows || []; + const byPlayer = new Map(); + for (const g of enrichedGrades || []) { + const raw = g && (g.player || g.player_name); + if (!raw) continue; + const k = nameKey(raw); + // First enriched grade per player wins; archetype/team are player-level. + if (!byPlayer.has(k)) { + byPlayer.set(k, { + archetype: g.archetype ?? null, + team: g.team ?? null, + opponent: g.opponent ?? null, + }); + } + } + return rows.map((r) => { + const e = byPlayer.get(r.player_key); + if (!e) return r; + return { + ...r, + archetype: r.archetype ?? e.archetype ?? null, + team: r.team ?? e.team ?? null, + opponent: r.opponent ?? e.opponent ?? null, + }; + }); +} + function newSnapshotId() { return crypto.randomUUID(); } @@ -177,6 +219,7 @@ module.exports = { codeSha, rowsFromSides, createCollector, + mergeEnrichment, persist, newSnapshotId, __internals: { numOrNull, intOrNull, boolOrNull }, diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 4c73321..205ed2f 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -312,18 +312,30 @@ async function runSnapshot(sport, opts = {}) { onGraded: collector ? collector.onGraded : undefined, }); - // Persist retention BEFORE the early return on an empty slate — a slate that - // graded nothing but refused everything is exactly the case worth recording. - if (retention && collector && collector.rows.length) { + // Retention is COLLECTED here (grade time — features must be exactly what the + // model saw) but PERSISTED after enrichment below, so archetype/team/opponent + // are filled in. `persistRetention` is called on BOTH exits, including the + // empty-slate early return: a slate that graded nothing but refused + // everything is exactly the case worth recording. + let retentionRows = 0; + const persistRetention = async (enrichedGrades) => { + if (!retention || !collector || !collector.rows.length) return; try { - const r = await retention.persist(collector.rows); + const rows = retention.mergeEnrichment + ? retention.mergeEnrichment(collector.rows, enrichedGrades || []) + : collector.rows; + const r = await retention.persist(rows); + retentionRows = r.written || 0; console.log(`[snapshot] retention ${sp}: ${r.written}/${r.attempted} rows${r.skipped ? ' (skipped — no supabase env)' : ''}${r.error ? ` ERROR: ${r.error}` : ''}`); } catch (e) { console.warn(`[snapshot] retention write failed for ${sp} (snapshot continues):`, e.message); } - } + }; const rawGraded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : []; - if (rawGraded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 }; + if (rawGraded.length === 0) { + await persistRetention([]); // refusal-only slate is still history + return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 }; + } // Session 48 — normalize player display names + dedupe variant grades at the // SOURCE so every consumer (GameCard, Explore, leaders, profile) gets clean, @@ -455,6 +467,11 @@ async function runSnapshot(sport, opts = {}) { }; }); + // Session 64 — retention persists HERE, after enrichment, so archetype/team/ + // opponent are populated. Feature values were captured at grade time and are + // NOT touched by the merge (mergeEnrichment only fills the three null fields). + await persistRetention(enriched); + // Line deltas vs the previous snapshot's locked lines. const prev = await deps.cacheGet(`snapshot:${sp}:latest`); const deltas = computeLineDeltas(enriched, prev && prev.grades); @@ -510,6 +527,10 @@ async function runSnapshot(sport, opts = {}) { status: 'ok', gradeCount: enriched.length, ledgerWritten, + // Session 64 — surfaced so the scheduler can page when retention silently + // writes nothing. Retention is best-effort by design, which makes a broken + // write invisible without this. + retentionRows, topGrades: enriched.filter((g) => isTopGrade(g.grade)).slice(0, 5).map((g) => ({ player: g.player || g.player_name, stat: g.stat_type || g.stat, grade: g.grade, archetype: g.archetype, })), diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index 134fd96..8cc0831 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -223,6 +223,21 @@ function startSnapshotScheduler(opts = {}) { const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0); await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] }); } + // Session 64 — RETENTION ZERO-WRITE ALARM. Features are irreplaceable: a + // night not captured cannot be reconstructed. Retention is best-effort so + // it never breaks a snapshot, which means a broken write is silent — + // this is the counterweight, at missed-snapshot severity. + try { + const written = {}; + for (const r of results) written[r.sport] = r.retentionRows; + const rz = opsWatch.retentionZeroWriteAlarm(results, written); + if (rz.alarm) { + await notify(`RETENTION NOT CAPTURING at ${h}:00 UTC — ${rz.reason}. Feature vectors for this slate are irreplaceable and are being lost.`, { + title: 'VYNDR retention', priority: 'high', tags: ['rotating_light'], + }); + } + } catch { /* alarm evaluation must never break the tick */ } + // Session 8 — persistent-failure pager: 3+ CONSECUTIVE erroring slots for // a sport pages once (single-slot errors are normal before lines post). for (const r of results) { diff --git a/tests/unit/opsWatch.test.js b/tests/unit/opsWatch.test.js index 6a0076f..918551d 100644 --- a/tests/unit/opsWatch.test.js +++ b/tests/unit/opsWatch.test.js @@ -239,3 +239,46 @@ describe('dateET', () => { expect(dateET(new Date('2026-07-11T15:00:00Z'))).toBe('2026-07-11'); }); }); + +describe('retentionZeroWriteAlarm (Session 64)', () => { + const opsWatch = require('../../src/services/opsWatch'); + + test('pages when a slot graded props but retention wrote zero rows', () => { + const r = opsWatch.retentionZeroWriteAlarm( + [{ sport: 'mlb', status: 'ok', gradeCount: 7 }], { mlb: 0 }); + expect(r.alarm).toBe(true); + expect(r.reason).toMatch(/MLB graded 7 but retention wrote 0/); + }); + + test('an ABSENT count reads as NOTHING, distinct from a reported 0', () => { + const r = opsWatch.retentionZeroWriteAlarm( + [{ sport: 'mlb', status: 'ok', gradeCount: 7 }], {}); + expect(r.alarm).toBe(true); + expect(r.reason).toMatch(/wrote NOTHING/); + }); + + test('pages when retention wrote FEWER rows than the graded slate', () => { + const r = opsWatch.retentionZeroWriteAlarm( + [{ sport: 'wnba', status: 'ok', gradeCount: 25 }], { wnba: 4 }); + expect(r.alarm).toBe(true); + expect(r.reason).toMatch(/wrote 4/); + }); + + test('quiet when retention wrote at least the slate (both sides + refusals)', () => { + const r = opsWatch.retentionZeroWriteAlarm( + [{ sport: 'mlb', status: 'ok', gradeCount: 7 }], { mlb: 50 }); + expect(r.alarm).toBe(false); + }); + + test('a slot that graded nothing never false-pages', () => { + expect(opsWatch.retentionZeroWriteAlarm( + [{ sport: 'soccer', status: 'skipped', reason: 'no props', gradeCount: 0 }], {}).alarm).toBe(false); + expect(opsWatch.retentionZeroWriteAlarm( + [{ sport: 'mlb', status: 'ok', gradeCount: 0 }], { mlb: 0 }).alarm).toBe(false); + }); + + test('a missing/undefined count is treated as zero, not as fine', () => { + const r = opsWatch.retentionZeroWriteAlarm([{ sport: 'mlb', status: 'ok', gradeCount: 7 }], {}); + expect(r.alarm).toBe(true); + }); +}); diff --git a/tests/unit/retentionService.test.js b/tests/unit/retentionService.test.js index 24d040e..97e554c 100644 --- a/tests/unit/retentionService.test.js +++ b/tests/unit/retentionService.test.js @@ -138,3 +138,47 @@ describe('persist — best-effort contract', () => { 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([]); + }); +});