1bcdd8b305
Order 1.5 Phase 1. PropLine emits NO commence_time (grep-verified: zero hits in proplineAdapter), so ledgerService's `dateET(prop.game_time) || dateET(gradedTs)` always fell through to the GRADE timestamp — and a 01:00/03:00 UTC snapshot is 21:00/23:00 ET the PREVIOUS day. Tonight's props were filed under yesterday, settlement correctly found no game there, and Order 1's void logic turned that into 64 destroyed results. gameBinder.attachGameTimes() now matches every prop to a scheduled game by TEAMS across the plausible ET window (grade date, +1, -1) and attaches the GAME'S OWN time/date/id. It runs in snapshotService before grading and before the ledger write, so ledger, retention and settlement all inherit the correct date from one place. HARD CONTRACT: an unbindable prop returns NOTHING. ledgerService no longer has a grade-clock fallback — a row with no real game time is SKIPPED and counted, because a mis-dated row is fabricated data and the ledger holds real values or nothing. A slate that binds nothing pages. DOUBLEHEADERS are reported, never guessed: two games with the same teams on one date mark the binding `ambiguous` so settlement can decline rather than attribute a prop to the wrong game. (Real example already in the data: mlb:2026-07-11:MilwaukeeBrewers@PittsburghPirates(Game1).) Also fixes retention, which had the SAME bug from last night — I had dated model_snapshots rows with the snapshot clock. Rows now take the ET date of the bound game_time. Suite 282/3381 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
240 lines
8.3 KiB
JavaScript
240 lines
8.3 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.
|
|
*/
|
|
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}`),
|
|
// 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,
|
|
});
|
|
}
|
|
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 },
|
|
};
|