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:
Kev
2026-07-19 23:01:12 -04:00
parent 04a09ec1b2
commit d3ffa1b8c2
7 changed files with 506 additions and 5 deletions
+16 -3
View File
@@ -48,7 +48,7 @@ function dedupeProps(props, limit) {
// engine1 is direction-aware, so a prop grades differently over vs under.
// Grade both sides and keep the higher-confidence verdict — that's the
// side the engine actually favors.
async function gradeBestSide(grade, prop, sport) {
async function gradeBestSide(grade, prop, sport, opts = {}) {
const base = {
player: prop.player,
stat_type: prop.stat_type,
@@ -68,12 +68,25 @@ async function gradeBestSide(grade, prop, sport) {
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
// Session 64 — RETENTION HOOK. Fires with BOTH sides, graded AND refused,
// before any filtering. Refusals never reach the slate or the ledger, so this
// is the only point where "the gate refused this prop" is observable — and a
// gate that refuses winners is invisible without it. Best-effort: a retention
// collector must never affect grading.
if (typeof opts.onGraded === 'function') {
try { opts.onGraded(base, sides); } catch { /* never breaks the slate */ }
}
// Session 58 (work-order 1.5) — a refused read (insufficient_data /
// no grade) never enters the graded slate: no hollow rows in the grades
// cache, the snapshot, or the ledger.
const cands = sides.filter((s) => s && s.grade && !s.insufficient_data);
if (cands.length === 0) return null;
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
const winner = cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
// Strip the internal retention fields so they never reach a cache or payload.
delete winner._features;
delete winner._grade_11;
return winner;
}
// Run an async mapper over items with a bounded concurrency.
@@ -122,7 +135,7 @@ async function gradeAndCacheSlate(sport, props, opts = {}) {
const unique = dedupeProps(props, limit);
if (unique.length === 0) return { written: false, count: 0 };
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport)))
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts)))
.filter(Boolean)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));