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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user