Persist lock-time multi-book lines to lock_lines (unblocks the staleness audit)
The over-side skew audit's confirming check — was our locked line stale-high vs consensus AT LOCK — was BLOCKED because multi-book lines at lock were never persisted (bookprices is Redis current-only). This persists them. - migration 033: lock_lines table (tracked + applied to prod). One row per (graded prop × book) with both odds + a lock timestamp. RLS enabled, NO policies -> service-role only (fence). UNIQUE key -> idempotent re-runs. - lockLineCapture.js: buildLockRows (pure, graded-props only, honest-absent single-book) + idempotent upsert persist. Built from the in-memory props at the LOCK moment (ts) -> no Redis re-read, no TTL race. - snapshotService: persist right after `enriched` (the lock moment; gradedAt uses the same ts). Best-effort + fenced. FENCE (measurement-only): lock_lines is read by NOTHING on the grade path (gradeSlateService, snapshot dedup/indexOdds, challengers, selector, ledger) — a grep test asserts it, and RLS locks it to the service role. Grade byte- identical proven: runSnapshot grades are identical with persist on/off (test). Volume ~1.5-3k rows/day (graded props x books x 5 snapshots); weeks retained, no pruning needed short-term. Does NOT retroactively fix the existing 62 rows — future accrual only; confirmation still needs weeks of settled rows. Full suite 3842 green, web build exit 0. No grade/locked_odds/outcome/served surface changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* LOCK-LINE CAPTURE — the lock-time analog of `closingCapture` (measurement-only).
|
||||
*
|
||||
* WHY: the over-side skew audit (2026-07-29) returned SURVIVES-BASELINE for the
|
||||
* champion's takeable-MLB-over edge, but the confirming check — was our locked line
|
||||
* stale-high vs consensus AT LOCK — is BLOCKED because multi-book lines at lock were
|
||||
* never persisted (`bookprices` is Redis, current-only). This persists them: as a grade
|
||||
* LOCKS, each book's line + both-side prices for that graded prop is written to
|
||||
* `lock_lines`, timestamped at the lock moment. A future audit joins it to
|
||||
* `closing_captures` to run the blocked staleness check.
|
||||
*
|
||||
* STRUCTURAL FENCE (enforced, not a comment):
|
||||
* 1. This module only READS the `props` array + a set of graded keys and RETURNS rows /
|
||||
* writes its OWN table (`lock_lines`). It never mutates `props`.
|
||||
* 2. `lock_lines` is read by NOTHING on the grade path — not gradeSlateService,
|
||||
* snapshotService's dedup/indexOdds, any challenger, the selector, or the ledger.
|
||||
* A test greps the grade-path files and asserts zero `lock_lines`/`lockLineCapture`
|
||||
* references; migration 033 puts RLS-with-no-policies on the table (service-role
|
||||
* only).
|
||||
* 3. In runSnapshot the persist is a best-effort leaf whose result is used by nothing
|
||||
* downstream — the graded slate is byte-identical whether or not it runs (locked by
|
||||
* a runSnapshot grade-identity test).
|
||||
*
|
||||
* NO TTL RACE: rows are built from the in-memory `props` array synchronously at the lock
|
||||
* moment (`ts`), never by re-reading the Redis `bookprices` key later — so the snapshot
|
||||
* we persist is exactly the one that locked, not a later/expired one.
|
||||
*
|
||||
* HONEST-ABSENT: a graded prop with only one priced book persists as ONE row. Never a
|
||||
* fabricated second book. A book row with neither price is not a line and is skipped.
|
||||
*/
|
||||
|
||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||
|
||||
// Sharp/no-vig reference book (mirrors closingCapture). Tagged so the audit can weight
|
||||
// pinnacle as the sharp consensus vs the retail books.
|
||||
const SHARP_BOOKS = new Set(['pinnacle']);
|
||||
|
||||
function etDate(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);
|
||||
}
|
||||
|
||||
function numOrNull(v) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Canonical graded-prop key (player + stat), matching how the ledger/snapshot key. */
|
||||
function lockKey(player, stat) {
|
||||
return `${nameKey(player)}|${String(stat || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flat lock-line rows for the GRADED props only (targeted → low volume). One row
|
||||
* per (prop × book) carrying both over/under prices. PURE — no I/O; never mutates props.
|
||||
*
|
||||
* @param {string} sport
|
||||
* @param {Array} props multi-book flat props (oddsNormalizer shape)
|
||||
* @param {Set} gradedKeys Set of `${nameKey}|${stat}` for props that locked a grade
|
||||
* @param {Object} [opts] { lockedAt } — the exact lock timestamp (snapshot ts)
|
||||
*/
|
||||
function buildLockRows(sport, props, gradedKeys, opts = {}) {
|
||||
const lockedAt = opts.lockedAt || new Date().toISOString();
|
||||
const seen = new Set(); // dedupe (prop × book) within this lock moment
|
||||
const out = [];
|
||||
for (const p of props || []) {
|
||||
if (!p || !p.player || !p.stat_type || !p.book) continue;
|
||||
const stat = String(p.stat_type).toLowerCase();
|
||||
const k = lockKey(p.player, stat);
|
||||
if (gradedKeys && !gradedKeys.has(k)) continue; // graded props only
|
||||
|
||||
const over = numOrNull(p.over_odds);
|
||||
const under = numOrNull(p.under_odds);
|
||||
if (over == null && under == null) continue; // not a real line — honest-absent, skip
|
||||
|
||||
const dedupe = `${k}|${p.book}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
|
||||
out.push({
|
||||
sport,
|
||||
player_key: nameKey(p.player),
|
||||
player_name: normalizeName(p.player).display || p.player,
|
||||
stat,
|
||||
game_date: etDate(p.game_time),
|
||||
game_time: p.game_time || null,
|
||||
book: p.book,
|
||||
line_type: SHARP_BOOKS.has(String(p.book || '').toLowerCase()) ? 'sharp' : 'book',
|
||||
line: numOrNull(p.line),
|
||||
over_odds: over,
|
||||
under_odds: under,
|
||||
locked_at: lockedAt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist append-only. Idempotent via the UNIQUE key (a snapshot retry never
|
||||
* double-inserts). Best-effort — a persistence failure never breaks the snapshot.
|
||||
*/
|
||||
async function persist(rows, deps = {}) {
|
||||
const out = { attempted: 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 sb = getClient();
|
||||
if (!sb) { 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 sb.from('lock_lines').upsert(chunk, {
|
||||
onConflict: 'sport,player_key,stat,game_date,book,locked_at',
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = { buildLockRows, persist, lockKey, SHARP_BOOKS };
|
||||
@@ -288,6 +288,10 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// Fenced: written to its own `bookprices:{sport}` key, read by nothing on
|
||||
// the grade path. Injectable; a failure never touches grading.
|
||||
captureBookPrices: opts.captureBookPrices || require('./bookPriceStore').captureBookPrices,
|
||||
// Lock-line persistence (measurement-only). Persists multi-book lines to the DB at
|
||||
// the lock moment so a future audit can run the currently-BLOCKED staleness check.
|
||||
// Fenced: writes its own `lock_lines` table, read by nothing on the grade path.
|
||||
lockLineCapture: opts.lockLineCapture || require('./lockLineCapture'),
|
||||
refreshTeamStats: opts.refreshTeamStats
|
||||
|| (process.env.NODE_ENV === 'test'
|
||||
? async () => null
|
||||
@@ -557,6 +561,22 @@ async function runSnapshot(sport, opts = {}) {
|
||||
};
|
||||
});
|
||||
|
||||
// LOCK-LINE PERSISTENCE (measurement-only). At THIS moment the grades have locked to
|
||||
// their lines (`gradedAt` uses `ts`), so `props` is the multi-book snapshot AS IT
|
||||
// EXISTED AT LOCK. Persist each graded prop's per-book lines to `lock_lines`,
|
||||
// timestamped `ts`, so a future audit can check whether our locked line was stale-high
|
||||
// vs consensus at lock. Built from the in-memory `props` (no Redis re-read → no TTL
|
||||
// race). Best-effort + structurally fenced: nothing on the grade path reads lock_lines,
|
||||
// and the graded slate is byte-identical whether or not this runs.
|
||||
try {
|
||||
const gradedKeys = new Set(enriched.map((g) => `${norm(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}`));
|
||||
const lockRows = deps.lockLineCapture.buildLockRows(sp, props, gradedKeys, { lockedAt: ts });
|
||||
const lr = await deps.lockLineCapture.persist(lockRows);
|
||||
console.log(`[lock-lines] ${sp}: ${lr.written}/${lr.attempted} multi-book rows persisted at lock${lr.skipped ? ' (skipped — no supabase env)' : ''}${lr.error ? ` ERROR: ${lr.error}` : ''}`);
|
||||
} catch (e) {
|
||||
console.warn(`[lock-lines] ${sp} persist failed (measurement-only, snapshot continues):`, e.message);
|
||||
}
|
||||
|
||||
// Session 64 — retention persists HERE, after enrichment, so archetype/team/
|
||||
// opponent are populated. Feature values were captured at grade time and are
|
||||
// NOT touched by the merge (mergeEnrichment only fills the three null fields).
|
||||
|
||||
Reference in New Issue
Block a user