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:
+17
-2
@@ -76,8 +76,23 @@ RSYNC_SSH="ssh -p ${BACKUP_SSH_PORT:-23} -o StrictHostKeyChecking=accept-new -o
|
||||
if [ -n "${BACKUP_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_FILE="$(mktemp)"
|
||||
chmod 600 "${SSH_KEY_FILE}"
|
||||
# Accept the key with literal \n escapes (how env vars usually carry it).
|
||||
printf '%b\n' "${BACKUP_SSH_KEY}" | sed -e 's/[[:space:]]*$//' > "${SSH_KEY_FILE}"
|
||||
# Accept EITHER form (Session 64):
|
||||
# 1. base64 (recommended — `base64 -w0`; survives any env-var mangling of
|
||||
# newlines, which is the usual way an injected SSH key silently breaks)
|
||||
# 2. raw PEM with literal \n escapes
|
||||
# Detect base64 by trying to decode and checking for the PEM header.
|
||||
DECODED="$(printf '%s' "${BACKUP_SSH_KEY}" | base64 -d 2>/dev/null || true)"
|
||||
case "${DECODED}" in
|
||||
*"PRIVATE KEY"*)
|
||||
printf '%s\n' "${DECODED}" > "${SSH_KEY_FILE}"
|
||||
echo "ssh key: base64-decoded"
|
||||
;;
|
||||
*)
|
||||
printf '%b\n' "${BACKUP_SSH_KEY}" | sed -e 's/[[:space:]]*$//' > "${SSH_KEY_FILE}"
|
||||
echo "ssh key: used raw (not base64)"
|
||||
;;
|
||||
esac
|
||||
chmod 600 "${SSH_KEY_FILE}"
|
||||
RSYNC_SSH="${RSYNC_SSH} -i ${SSH_KEY_FILE}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -471,6 +471,14 @@ async function analyzeViaEngine1(rawProp = {}) {
|
||||
// ledger model_value and rendered under the MODEL label on the card.
|
||||
legacy.projection = projection;
|
||||
|
||||
// Session 64 — RETENTION: expose the feature vector + the pre-collapse
|
||||
// 11-step grade so `model_snapshots` can store the model's INPUTS, not just
|
||||
// its output. Without inputs a backtest can only grade our own homework.
|
||||
// Underscore-prefixed = internal: gradeSlateService strips these before the
|
||||
// grade reaches any cache or API payload.
|
||||
legacy._features = features;
|
||||
legacy._grade_11 = engine1Result && engine1Result.grade ? engine1Result.grade : null;
|
||||
|
||||
// Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page
|
||||
// sold it; the adapter/card were already built for it; nothing produced
|
||||
// it. Re-grade the SAME feature vector at shifted lines (zero extra I/O)
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -231,6 +231,8 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// Session 63 — the opponent-rank feed. Injectable so tests never hit ESPN;
|
||||
// under NODE_ENV=test it defaults to a no-op (the opsNotify precedent) so a
|
||||
// suite that doesn't know about this dep can never make a live ESPN call.
|
||||
// Session 64 — model-snapshot retention. Injectable; null disables it.
|
||||
retention: opts.retention !== undefined ? opts.retention : require('./retentionService'),
|
||||
refreshTeamStats: opts.refreshTeamStats
|
||||
|| (process.env.NODE_ENV === 'test'
|
||||
? async () => null
|
||||
@@ -278,6 +280,28 @@ async function runSnapshot(sport, opts = {}) {
|
||||
console.warn(`[snapshot] team stats refresh failed for ${sp} (grading continues):`, e.message);
|
||||
}
|
||||
|
||||
// Session 64 — RETENTION (Phase 2, priority zero). Collect one row per graded
|
||||
// prop per SIDE — graded AND refused — with the feature vector that produced
|
||||
// it, so history compounds from tonight and a future model can be replayed
|
||||
// against the exact conditions this one faced. Refusals are included on
|
||||
// purpose: the ledger drops them, so a gate refusing props that would have
|
||||
// won is otherwise invisible.
|
||||
const retention = deps.retention;
|
||||
// Reuse the LEDGER's date + game-id helpers so retention rows share the
|
||||
// ledger's natural key exactly — otherwise the settle pass could never join
|
||||
// outcomes onto them.
|
||||
const ledgerInternals = (deps.ledger && deps.ledger.__internals) || require('./ledgerService').__internals;
|
||||
const retentionGameDate = ledgerInternals.dateET(ts) || ledgerInternals.dateET(new Date().toISOString());
|
||||
const retentionCtx = {
|
||||
snapshotId: retention ? retention.newSnapshotId() : null,
|
||||
capturedAt: ts,
|
||||
cycleHourUtc: new Date(ts).getUTCHours(),
|
||||
sport: sp,
|
||||
gameDate: retentionGameDate,
|
||||
gameIdFor: (base) => ledgerInternals.gameIdFor(sp, base, retentionGameDate),
|
||||
};
|
||||
const collector = retention ? retention.createCollector(retentionCtx) : null;
|
||||
|
||||
// Grade the slate via the existing service; capture the envelope instead of
|
||||
// letting it write (we re-write an ENRICHED version below).
|
||||
let envelope = null;
|
||||
@@ -285,7 +309,19 @@ async function runSnapshot(sport, opts = {}) {
|
||||
source: (odds && odds.provider) || 'odds-api',
|
||||
now: deps.now,
|
||||
cacheSet: async (_k, v) => { envelope = v; },
|
||||
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) {
|
||||
try {
|
||||
const r = await retention.persist(collector.rows);
|
||||
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 };
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
-- 025 — MODEL SNAPSHOTS (retention store). Phase 2, priority zero.
|
||||
--
|
||||
-- Why this exists: as of 2026-07-20 the ONLY store of model history was
|
||||
-- ledger_entries (640 rows, 6 game days) and it holds no model INPUTS. Every
|
||||
-- other warehouse table in the schema is empty. That means we could score the
|
||||
-- grades we emitted but could NOT replay a different model against the same
|
||||
-- conditions — which is the only question a backtest exists to answer.
|
||||
--
|
||||
-- This table captures inputs AND outputs at lock time, immutably, so history
|
||||
-- starts compounding tonight.
|
||||
--
|
||||
-- Append-only: ONE ROW PER GRADED PROP PER SNAPSHOT CYCLE. A re-grade at the
|
||||
-- next cycle writes a NEW row on purpose — that is what lets us ask whether the
|
||||
-- 14:00 read or the 22:00 read was sharper.
|
||||
|
||||
create table if not exists public.model_snapshots (
|
||||
id bigserial primary key,
|
||||
|
||||
-- provenance — never mix model eras (ledger_entries already has this
|
||||
-- contamination with no marker; everything from here is stamped)
|
||||
snapshot_id uuid not null,
|
||||
captured_at timestamptz not null,
|
||||
cycle_hour_utc smallint,
|
||||
model_version text not null,
|
||||
code_sha text,
|
||||
|
||||
-- identity (mirrors the ledger natural key so outcomes can be stamped)
|
||||
sport text not null,
|
||||
game_id text not null,
|
||||
game_date date not null,
|
||||
player_key text not null,
|
||||
player_name text not null,
|
||||
team text,
|
||||
opponent text,
|
||||
stat text not null,
|
||||
line numeric not null,
|
||||
side text not null,
|
||||
|
||||
-- MARKET — real book numbers captured at a timestamp, never model output
|
||||
book text,
|
||||
book_odds integer,
|
||||
over_odds integer,
|
||||
under_odds integer,
|
||||
fair_odds integer,
|
||||
fair_prob numeric,
|
||||
overround numeric,
|
||||
devig_method text,
|
||||
|
||||
-- MODEL OUTPUT
|
||||
grade text,
|
||||
grade_11 text,
|
||||
confidence numeric,
|
||||
confidence_basis text,
|
||||
p_win numeric,
|
||||
ev_pct numeric,
|
||||
projection numeric,
|
||||
edge_pct numeric,
|
||||
takeable boolean,
|
||||
value boolean,
|
||||
archetype text,
|
||||
|
||||
-- REFUSALS ARE TRAINING DATA. The ledger drops them entirely, so "was the
|
||||
-- gate right to refuse this?" is currently unanswerable. A too-aggressive
|
||||
-- gate costs real edge and is invisible without these rows.
|
||||
refused boolean not null default false,
|
||||
refusal_reason text,
|
||||
|
||||
-- THE COUNTERFACTUAL ENABLER. Schema-free so a new feature never needs a
|
||||
-- migration. Nulled after 90 days by the retention sweep; scalars stay forever.
|
||||
features jsonb,
|
||||
|
||||
-- OUTCOME — stamped later by the settle pass, per cycle row
|
||||
outcome text,
|
||||
actual_value numeric,
|
||||
settled_at timestamptz,
|
||||
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- One row per prop per cycle; a retry of the same cycle must not duplicate.
|
||||
create unique index if not exists model_snapshots_cycle_prop_uniq
|
||||
on public.model_snapshots (snapshot_id, player_key, stat, line, side);
|
||||
|
||||
create index if not exists model_snapshots_date_sport_idx
|
||||
on public.model_snapshots (game_date, sport);
|
||||
create index if not exists model_snapshots_player_idx
|
||||
on public.model_snapshots (player_key, stat, game_date);
|
||||
create index if not exists model_snapshots_version_idx
|
||||
on public.model_snapshots (model_version, game_date);
|
||||
-- Settle pass looks up unsettled rows by natural key.
|
||||
create index if not exists model_snapshots_settle_idx
|
||||
on public.model_snapshots (game_date, player_key, stat, line, side)
|
||||
where outcome is null;
|
||||
|
||||
comment on table public.model_snapshots is
|
||||
'Append-only model history: inputs (features) + outputs per graded prop per snapshot cycle. Feeds the backtest harness, calibration, and metric validation. Features nulled after 90 days; scalars kept forever.';
|
||||
comment on column public.model_snapshots.features is
|
||||
'Full feature vector at lock time. THE counterfactual enabler — without it a backtest can only grade our own homework. Nulled after 90 days by the retention sweep.';
|
||||
comment on column public.model_snapshots.refused is
|
||||
'True when the engine refused to grade. Refusals are training data: the only way to detect a gate that is refusing props that would have won.';
|
||||
comment on column public.model_snapshots.model_version is
|
||||
'Which engine produced this row. A backtest that mixes model eras is worthless. ledger_entries lacks this and is permanently contaminated across the 2026-07-19 fix boundary.';
|
||||
|
||||
-- RLS: service-role writes only, same posture as ledger_entries. This is
|
||||
-- internal model telemetry — no client reads it.
|
||||
alter table public.model_snapshots enable row level security;
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Session 64 — model_snapshots retention.
|
||||
*
|
||||
* The point of this store is that it captures what the ledger throws away:
|
||||
* the model's INPUTS, the pre-collapse grade, and REFUSALS. These lock that.
|
||||
*/
|
||||
|
||||
const retention = require('../../src/services/retentionService');
|
||||
|
||||
const CTX = {
|
||||
snapshotId: '00000000-0000-4000-8000-000000000001',
|
||||
capturedAt: '2026-07-20T03:00:00.000Z',
|
||||
cycleHourUtc: 3,
|
||||
sport: 'mlb',
|
||||
gameDate: '2026-07-19',
|
||||
gameIdFor: () => 'mlb:2026-07-19:NYY@BOS',
|
||||
};
|
||||
|
||||
const base = {
|
||||
player: 'José Ramírez', stat_type: 'hits', line: 0.5,
|
||||
over_odds: -140, under_odds: 115, book: 'fanduel',
|
||||
};
|
||||
|
||||
const graded = {
|
||||
player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'over',
|
||||
grade: 'B', _grade_11: 'B-', confidence: 57, confidence_basis: 'grade_band',
|
||||
p_win: 0.61, ev_pct: 4.6, model_odds: -156, projection: 0.9, edge_pct: 20,
|
||||
takeable: true, value: true, book_odds: -140, fair_odds: -125,
|
||||
fair_prob: 0.556, overround: 0.041, devig_method: 'multiplicative',
|
||||
_features: { l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 },
|
||||
};
|
||||
|
||||
const refused = {
|
||||
player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'under',
|
||||
grade: null, insufficient_data: true, suppressed: true,
|
||||
suppressed_reason: 'juiced_no_edge',
|
||||
};
|
||||
|
||||
describe('rowsFromSides', () => {
|
||||
test('captures the feature vector — the counterfactual enabler', () => {
|
||||
const [row] = retention.rowsFromSides(base, [graded], CTX);
|
||||
expect(row.features).toEqual({ l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 });
|
||||
});
|
||||
|
||||
test('captures the PRE-COLLAPSE 11-step grade, not just the 4-letter', () => {
|
||||
const [row] = retention.rowsFromSides(base, [graded], CTX);
|
||||
expect(row.grade).toBe('B');
|
||||
expect(row.grade_11).toBe('B-');
|
||||
});
|
||||
|
||||
test('REFUSALS are stored with their reason — the ledger drops these', () => {
|
||||
const rows = retention.rowsFromSides(base, [refused], CTX);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].refused).toBe(true);
|
||||
expect(rows[0].refusal_reason).toBe('juiced_no_edge');
|
||||
expect(rows[0].grade).toBeNull();
|
||||
});
|
||||
|
||||
test('a refusal with no features stores null, never a fabricated {}', () => {
|
||||
const [row] = retention.rowsFromSides(base, [refused], CTX);
|
||||
expect(row.features).toBeNull();
|
||||
});
|
||||
|
||||
test('both sides of one prop are captured (graded AND refused)', () => {
|
||||
const rows = retention.rowsFromSides(base, [graded, refused], CTX);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.side).sort()).toEqual(['over', 'under']);
|
||||
});
|
||||
|
||||
test('every row is stamped with a model version — eras must never mix', () => {
|
||||
const [row] = retention.rowsFromSides(base, [graded], CTX);
|
||||
expect(row.model_version).toBe(retention.MODEL_VERSION);
|
||||
expect(row.model_version).toBeTruthy();
|
||||
});
|
||||
|
||||
test('player name is normalized and keyed like the ledger', () => {
|
||||
const [row] = retention.rowsFromSides(base, [graded], CTX);
|
||||
expect(row.player_key).toBe('jose ramirez');
|
||||
expect(row.player_name).toBe('José Ramírez');
|
||||
});
|
||||
|
||||
test('market values carry through; absent stays absent (no Number(null)=0)', () => {
|
||||
const [row] = retention.rowsFromSides(base, [{ ...graded, book_odds: null, ev_pct: null }], CTX);
|
||||
expect(row.book_odds).toBeNull();
|
||||
expect(row.ev_pct).toBeNull();
|
||||
expect(row.over_odds).toBe(-140); // from the prop
|
||||
});
|
||||
|
||||
test('a side inherits identity from the prop it was graded from', () => {
|
||||
// A refusal often carries no echoed identity — base supplies it. This is
|
||||
// correct, not junk: the prop is what we refused.
|
||||
const [row] = retention.rowsFromSides(base, [{ direction: 'over' }], CTX);
|
||||
expect(row.player_key).toBe('jose ramirez');
|
||||
expect(row.refused).toBe(true);
|
||||
expect(row.refusal_reason).toBe('no_grade');
|
||||
});
|
||||
|
||||
test('drops rows with no usable identity ANYWHERE rather than writing junk', () => {
|
||||
expect(retention.rowsFromSides({}, [{ direction: 'over' }], CTX)).toHaveLength(0);
|
||||
expect(retention.rowsFromSides({}, [{ player: 'X', direction: 'over' }], CTX)).toHaveLength(0); // no stat/line
|
||||
expect(retention.rowsFromSides(base, [null, undefined], CTX)).toHaveLength(0);
|
||||
expect(retention.rowsFromSides(base, [{ ...graded, direction: 'sideways' }], CTX)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCollector', () => {
|
||||
test('accumulates across props and never throws into the grader', () => {
|
||||
const c = retention.createCollector(CTX);
|
||||
c.onGraded(base, [graded, refused]);
|
||||
c.onGraded(base, [graded]);
|
||||
expect(c.rows).toHaveLength(3);
|
||||
expect(() => c.onGraded(null, 'not-an-array')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persist — best-effort contract', () => {
|
||||
test('no rows → no-op, no error', async () => {
|
||||
expect(await retention.persist([])).toMatchObject({ attempted: 0, written: 0 });
|
||||
});
|
||||
|
||||
test('no supabase client → SKIPPED, not an error (tests/local never write)', async () => {
|
||||
const r = await retention.persist([{ a: 1 }], { getClient: () => null });
|
||||
expect(r.skipped).toBe(true);
|
||||
expect(r.error).toBeNull();
|
||||
});
|
||||
|
||||
test('a database error is captured and RETURNED, never thrown', async () => {
|
||||
const getClient = () => ({
|
||||
from: () => ({ upsert: async () => ({ error: { message: 'boom' } }) }),
|
||||
});
|
||||
const r = await retention.persist([{ a: 1 }], { getClient });
|
||||
expect(r.error).toBe('boom');
|
||||
expect(r.written).toBe(0);
|
||||
});
|
||||
|
||||
test('a thrown client is captured, never propagated (must not break a snapshot)', async () => {
|
||||
const getClient = () => { throw new Error('no client'); };
|
||||
await expect(retention.persist([{ a: 1 }], { getClient })).resolves.toMatchObject({ error: 'no client' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user