Files
vyndr/src/services/retentionService.js
T
builtbykev f61ec6b391 Read integrity, as-of context, and the shadow matchup resolve (A1-A7)
Seven orders of measurement-first repair. The served grade does not move.

A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS:
410-617 of 2,490 duplicated with an equal number never returned, while
rows.length matched the server exactly. safePaginate orders on a real unique
key, verifies the tuple at runtime, and THROWS on a query error instead of
treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn
through that reader, and defense_by_direction's distinct-n was likely below
the gate floor all along.

A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from
pg_index (the context tables are dated-composite and had no single unique
column). The unordered helper is deleted, not parked.

A3 — ledgerService and retentionService defaulted the SAME env var to
DIFFERENT versions, so no ledger row ever carried the marker eligibility
requires. One source now. model_snapshots settlement moved onto the cron:
15,484 -> 28,894 settled, repaired-champion 0 -> 7,556.

A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no
row at-or-before the date means the factor does not apply, never the nearest
row. Live path unchanged, proven 400/400 on real rows.

A5 — factor_inputs freezes what the factor READ, never the multiplier, so an
audit can recompute and check. It also recorded the finding: the three hits
factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by
the resolver and written by nothing.

A6/A7 — matchupKeys resolves those keys from the posted lineup plus the
schedule's probable pitchers, and fires the factors into a SHADOW freeze:
248 fires on 308 props, 245 of which would move the grade. The served
forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test
that decides whether they ever go live.

Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay
withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity
harness 34/34.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:49:56 -04:00

264 lines
9.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');
/** ET calendar date of an ISO timestamp (shares gameBinder's rule). */
function etDateOf(iso) {
if (!iso) return null;
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(t);
}
/**
* Bump when the grading model changes in a way that makes rows non-comparable.
* This is the marker `ledger_entries` never had.
*/
/**
* CHAMPION VERSION — the eligibility marker for every forward re-audit.
*
* Bumped when the forecaster itself changes, so a settled row is
* self-identifying: rows tagged `engine1@2026-08-07-fullwindow` were produced by
* the REPAIRED champion (full season log, recency weight 0.20); anything earlier
* came from the retired ten-game forecaster.
*
* This is what makes the re-audit rule mechanical rather than a promise.
* Calibration may only be re-fit, and factor verdicts may only be re-audited, on
* rows carrying the current marker — never on reconstructions of a retired
* forecast, and never on a mixture of the two, which is the trap that would
* otherwise be invisible once both generations sit in the same table.
*/
// FIX A3 — ONE SOURCE (src/config/modelVersion.js). Re-exported so existing
// importers keep working, but never re-declared: the twin default in
// ledgerService is exactly how these two drifted apart.
const { MODEL_VERSION, REPAIRED_CHAMPION_VERSION } = require('../config/modelVersion');
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}`),
// Session 64 (Order 1.5) — the GAME's date, from the bound game_time,
// never the snapshot clock. ctx.gameDate is only a last resort for props
// the binder could not tie to a real game.
game_date: etDateOf(base && base.game_time) || 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,
// FIX A5 — the RAW inputs the hits factors read, frozen at grade time so a
// re-audit reads evidence instead of reconstructing context. Inputs only:
// the multiplier is recomputable from them and is deliberately not stored.
// Null on every non-hits row and on any row with no factor context.
factor_inputs: s.factor_inputs || 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,
REPAIRED_CHAMPION_VERSION,
codeSha,
rowsFromSides,
createCollector,
mergeEnrichment,
persist,
newSnapshotId,
__internals: { numOrNull, intOrNull, boolOrNull },
};