Retention: fill enrichment fields + page on a zero-write slot

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
This commit is contained in:
Kev
2026-07-20 02:00:40 -04:00
parent c2f6041406
commit 5a5e37e32e
6 changed files with 217 additions and 6 deletions
+45
View File
@@ -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,
+43
View File
@@ -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 },
+27 -6
View File
@@ -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,
})),
+15
View File
@@ -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) {
+43
View File
@@ -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);
});
});
+44
View File
@@ -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([]);
});
});