Retention: model_snapshots live + base64 SSH key support
RETENTION (Phase 2, priority zero). History starts compounding tonight. migration 025 model_snapshots — APPLIED to prod. Append-only, one row per graded prop PER SIDE PER CYCLE, with a unique index on (snapshot_id, player_key, stat, line, side) so a retried cycle cannot duplicate. RLS on, service-role writes only. What it captures that the ledger never did: - features jsonb — the model's INPUTS. Without these a backtest can only grade our own homework; with them any future model can be replayed against the exact conditions this one faced. - REFUSALS (refused + refusal_reason). The ledger drops them, so a gate refusing props that would have WON is invisible — unmeasurable lost edge. Captured via a new onGraded hook in gradeSlateService that fires with BOTH sides before any filtering. - grade_11, the pre-collapse grade. The 4-letter map throws away the entire live C-/C/C+/B- range. - model_version + code_sha on every row. ledger_entries mixes pre/post-fix grades with no marker and cannot be separated retroactively. - p_win / ev_pct / fair_odds / takeable / value — none of which any permanent store held. Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore = internal); gradeSlateService fires onGraded then STRIPS them so they never reach a cache or API payload; snapshotService builds rows and persists best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so rows share the ledger's natural key exactly — otherwise the settle pass could never join outcomes onto them. Rows are written BEFORE the empty- slate early return: a slate that refused everything is exactly the case worth recording. CONTRACT HELD: retention is injectable and every path is caught. persist() returns errors, never throws; a missing Supabase client is SKIPPED, not an error. A retention failure can never break a snapshot. BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended — survives env-var newline mangling, which is how injected SSH keys usually break silently) OR raw PEM, detected by decoding and looking for the PEM header. Verified both forms detect correctly against a real generated key. Suite 279/3325 green, build exit 0. 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:
@@ -0,0 +1,183 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
function newSnapshotId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MODEL_VERSION,
|
||||
codeSha,
|
||||
rowsFromSides,
|
||||
createCollector,
|
||||
persist,
|
||||
newSnapshotId,
|
||||
__internals: { numOrNull, intOrNull, boolOrNull },
|
||||
};
|
||||
Reference in New Issue
Block a user