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
227 lines
7.7 KiB
JavaScript
227 lines
7.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* MODEL SNAPSHOT RETENTION (Session 64, Phase 2 — priority zero).
|
|
*
|
|
* As of 2026-07-20 the only store of model history was `ledger_entries`: 640
|
|
* rows, 6 game days, and NO MODEL INPUTS. Every other warehouse table in the
|
|
* schema was empty. That meant we could score the grades we emitted but could
|
|
* not replay a different model against the same conditions — the only question
|
|
* a backtest exists to answer, and the gate the metrics-engine north star
|
|
* requires ("does this metric predict better than without it?").
|
|
*
|
|
* This writes `model_snapshots`: one APPEND-ONLY row per graded prop per
|
|
* snapshot cycle, carrying the market values, the model output, the refusal
|
|
* (if any), and the FEATURE VECTOR that produced it.
|
|
*
|
|
* CONTRACT: best-effort, exactly like the ledger write. A retention failure
|
|
* must NEVER break a snapshot. Every path returns a summary; none throw.
|
|
*
|
|
* Two deliberate choices:
|
|
* - REFUSALS ARE STORED. The ledger drops them, so a gate that refuses props
|
|
* which would have won is invisible. That is lost edge we cannot measure.
|
|
* - EVERY ROW IS STAMPED with model_version + code_sha. A backtest that mixes
|
|
* model eras is worthless, and `ledger_entries` is already permanently
|
|
* contaminated across the 2026-07-19 fix boundary with no way to separate.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const { normalizeName, nameKey } = require('../utils/playerName');
|
|
|
|
/**
|
|
* Bump when the grading model changes in a way that makes rows non-comparable.
|
|
* This is the marker `ledger_entries` never had.
|
|
*/
|
|
const MODEL_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';
|
|
|
|
function codeSha() {
|
|
return process.env.SOURCE_COMMIT || process.env.GIT_SHA || process.env.COOLIFY_GIT_COMMIT_SHA || null;
|
|
}
|
|
|
|
/** Strict numeric parse — `Number(null) === 0` is this codebase's classic
|
|
* fabrication bug, so absent must stay absent. */
|
|
function numOrNull(v) {
|
|
if (v == null || v === '') return null;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function intOrNull(v) {
|
|
const n = numOrNull(typeof v === 'string' ? v.replace('+', '') : v);
|
|
return n == null ? null : Math.round(n);
|
|
}
|
|
|
|
function boolOrNull(v) {
|
|
return typeof v === 'boolean' ? v : null;
|
|
}
|
|
|
|
/**
|
|
* Build retention rows from ONE prop's graded sides (both over and under,
|
|
* graded or refused). `base` is the prop the grader was called with.
|
|
*/
|
|
function rowsFromSides(base, sides, ctx = {}) {
|
|
const rows = [];
|
|
const list = Array.isArray(sides) ? sides : [];
|
|
for (const s of list) {
|
|
if (!s) continue;
|
|
const player = s.player || base.player;
|
|
if (!player) continue;
|
|
const stat = s.stat_type || base.stat_type;
|
|
const line = numOrNull(s.line != null ? s.line : base.line);
|
|
const side = String(s.direction || '').toLowerCase();
|
|
if (!stat || line == null || (side !== 'over' && side !== 'under')) continue;
|
|
|
|
const refused = !!(s.insufficient_data || !s.grade);
|
|
|
|
rows.push({
|
|
snapshot_id: ctx.snapshotId,
|
|
captured_at: ctx.capturedAt,
|
|
cycle_hour_utc: ctx.cycleHourUtc ?? null,
|
|
model_version: MODEL_VERSION,
|
|
code_sha: codeSha(),
|
|
|
|
sport: ctx.sport,
|
|
game_id: ctx.gameIdFor ? ctx.gameIdFor(base, s) : (base.game_id || `${ctx.sport}:${ctx.gameDate}`),
|
|
game_date: ctx.gameDate,
|
|
player_key: nameKey(player),
|
|
player_name: normalizeName(player).display || player,
|
|
team: s.team || base.team || null,
|
|
opponent: s.opponent || base.opponent || null,
|
|
stat,
|
|
line,
|
|
side,
|
|
|
|
book: s.book || base.book || null,
|
|
book_odds: intOrNull(s.book_odds),
|
|
over_odds: intOrNull(base.over_odds),
|
|
under_odds: intOrNull(base.under_odds),
|
|
fair_odds: intOrNull(s.fair_odds),
|
|
fair_prob: numOrNull(s.fair_prob),
|
|
overround: numOrNull(s.overround),
|
|
devig_method: s.devig_method || null,
|
|
|
|
grade: s.grade || null,
|
|
grade_11: s._grade_11 || null,
|
|
confidence: numOrNull(s.confidence),
|
|
confidence_basis: s.confidence_basis || null,
|
|
p_win: numOrNull(s.p_win),
|
|
ev_pct: numOrNull(s.ev_pct),
|
|
projection: numOrNull(s.projection),
|
|
edge_pct: numOrNull(s.edge_pct),
|
|
takeable: boolOrNull(s.takeable),
|
|
value: boolOrNull(s.value),
|
|
archetype: s.archetype || null,
|
|
|
|
refused,
|
|
refusal_reason: refused
|
|
? (s.suppressed_reason || (s.insufficient_data ? 'insufficient_data' : 'no_grade'))
|
|
: null,
|
|
|
|
// The counterfactual enabler. Absent on pre-feature refusals (the juice
|
|
// gate runs before features are computed) — honestly null, never faked.
|
|
features: s._features && Object.keys(s._features).length ? s._features : null,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** A collector to hand to gradeAndCacheSlate's `onGraded` hook. */
|
|
function createCollector(ctx) {
|
|
const rows = [];
|
|
return {
|
|
rows,
|
|
onGraded(base, sides) {
|
|
try {
|
|
rows.push(...rowsFromSides(base, sides, ctx));
|
|
} catch { /* collection never affects grading */ }
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Persist rows. Chunked, upsert-on-conflict-ignore so a retried cycle can never
|
|
* duplicate. No-ops (successfully) without Supabase env, so tests and local dev
|
|
* never touch a database.
|
|
*/
|
|
async function persist(rows, deps = {}) {
|
|
const out = { attempted: Array.isArray(rows) ? rows.length : 0, written: 0, skipped: false, error: null };
|
|
if (!out.attempted) return out;
|
|
try {
|
|
const getClient = deps.getClient || require('../utils/supabase').getSupabaseServiceClient;
|
|
const supabase = getClient();
|
|
if (!supabase) { out.skipped = true; return out; }
|
|
const CHUNK = 250;
|
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
const chunk = rows.slice(i, i + CHUNK);
|
|
const { error } = await supabase
|
|
.from('model_snapshots')
|
|
.upsert(chunk, {
|
|
onConflict: 'snapshot_id,player_key,stat,line,side',
|
|
ignoreDuplicates: true,
|
|
});
|
|
if (error) { out.error = error.message; break; }
|
|
out.written += chunk.length;
|
|
}
|
|
} catch (e) {
|
|
out.error = e && e.message ? e.message : String(e);
|
|
}
|
|
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();
|
|
}
|
|
|
|
module.exports = {
|
|
MODEL_VERSION,
|
|
codeSha,
|
|
rowsFromSides,
|
|
createCollector,
|
|
mergeEnrichment,
|
|
persist,
|
|
newSnapshotId,
|
|
__internals: { numOrNull, intOrNull, boolOrNull },
|
|
};
|