c7067c80c4
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
125 lines
6.7 KiB
JavaScript
125 lines
6.7 KiB
JavaScript
// Lock-line capture — persist multi-book lines at the lock moment (measurement-only).
|
||
// Locks: build correctness, honest-absent single-book, the STRUCTURAL FENCE (no mutation,
|
||
// no grade-path read), and grade byte-identical through runSnapshot.
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const llc = require('../../src/services/lockLineCapture');
|
||
const snapshot = require('../../src/services/snapshotService');
|
||
const { nameKey } = require('../../src/utils/playerName');
|
||
|
||
// Judge TB across 3 books + Betts hits (1 book) + a NON-graded prop.
|
||
const props = [
|
||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'draftkings', game_time: '2026-07-29T23:00:00Z' },
|
||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -110, under_odds: -110, book: 'betmgm', game_time: '2026-07-29T23:00:00Z' },
|
||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -120, under_odds: 100, book: 'pinnacle', game_time: '2026-07-29T23:00:00Z' },
|
||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: 120, under_odds: -150, book: 'draftkings', game_time: '2026-07-29T23:00:00Z' },
|
||
{ player: 'Not Graded', stat_type: 'hits', line: 0.5, over_odds: -110, under_odds: -110, book: 'draftkings', game_time: '2026-07-29T23:00:00Z' },
|
||
];
|
||
const gradedKeys = new Set([`${nameKey('Aaron Judge')}|total_bases`, `${nameKey('Mookie Betts')}|hits`]);
|
||
|
||
describe('buildLockRows', () => {
|
||
it('emits one row per graded prop×book, both odds, sharp tag on pinnacle', () => {
|
||
const rows = llc.buildLockRows('mlb', props, gradedKeys, { lockedAt: 'T0' });
|
||
const judge = rows.filter((r) => r.stat === 'total_bases');
|
||
expect(judge).toHaveLength(3);
|
||
expect(judge.map((r) => r.book).sort()).toEqual(['betmgm', 'draftkings', 'pinnacle']);
|
||
expect(judge.every((r) => r.over_odds != null && r.under_odds != null)).toBe(true);
|
||
expect(judge.every((r) => r.locked_at === 'T0')).toBe(true);
|
||
expect(judge.find((r) => r.book === 'pinnacle').line_type).toBe('sharp');
|
||
expect(judge.find((r) => r.book === 'draftkings').line_type).toBe('book');
|
||
expect(judge[0].player_key).toBe(nameKey('Aaron Judge'));
|
||
});
|
||
|
||
it('HONEST-ABSENT: a single-book graded prop persists as ONE row, never a fake second', () => {
|
||
const rows = llc.buildLockRows('mlb', props, gradedKeys, { lockedAt: 'T0' });
|
||
const betts = rows.filter((r) => r.stat === 'hits' && r.player_key === nameKey('Mookie Betts'));
|
||
expect(betts).toHaveLength(1);
|
||
expect(betts[0].book).toBe('draftkings');
|
||
});
|
||
|
||
it('excludes NON-graded props (only the locked props are persisted)', () => {
|
||
const rows = llc.buildLockRows('mlb', props, gradedKeys, { lockedAt: 'T0' });
|
||
expect(rows.some((r) => r.player_key === nameKey('Not Graded'))).toBe(false);
|
||
});
|
||
|
||
it('drops a book row with neither price (not a real line)', () => {
|
||
const rows = llc.buildLockRows('mlb', [
|
||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: null, under_odds: null, book: 'draftkings' },
|
||
], new Set([`${nameKey('X')}|hits`]), { lockedAt: 'T0' });
|
||
expect(rows).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('STRUCTURAL FENCE', () => {
|
||
it('never mutates the props it reads (deep-frozen input)', () => {
|
||
const frozen = Object.freeze(props.map((p) => Object.freeze({ ...p })));
|
||
expect(() => llc.buildLockRows('mlb', frozen, gradedKeys, { lockedAt: 'T0' })).not.toThrow();
|
||
});
|
||
|
||
it('no grade-path module reads lock_lines / lockLineCapture', () => {
|
||
const gradePathFiles = [
|
||
'src/services/gradeSlateService.js',
|
||
'src/services/ledgerService.js',
|
||
'src/services/challengerProjection.js',
|
||
'src/services/contactChallenger.js',
|
||
'src/services/projectionChallenger.js',
|
||
'src/services/intelligence/analyzeViaEngine1.js',
|
||
];
|
||
for (const rel of gradePathFiles) {
|
||
const src = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
||
expect(src).not.toMatch(/lock_lines/);
|
||
expect(src).not.toMatch(/lockLineCapture/);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('GRADE BYTE-IDENTICAL through runSnapshot', () => {
|
||
function memCache() {
|
||
const store = {};
|
||
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
|
||
}
|
||
const grades = [
|
||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 71, edge_pct: 4.2 },
|
||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'under', grade: 'B', confidence: 60, edge_pct: 1.1 },
|
||
];
|
||
const fakeGrade = () => async (_s, _p, opts) => {
|
||
await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' });
|
||
return { written: true, count: grades.length };
|
||
};
|
||
const baseOpts = (cache, lockDep) => ({
|
||
getOdds: async () => ({ props, provider: 'test' }),
|
||
gradeAndCacheSlate: fakeGrade(),
|
||
resolveStats: async () => ({ found: false }),
|
||
classify: () => ({ primary: null }),
|
||
cacheGet: cache.cacheGet,
|
||
cacheSet: cache.cacheSet,
|
||
now: () => '2026-07-29T00:00:00.000Z',
|
||
nowMs: () => 1000,
|
||
notify: async () => {},
|
||
retention: null,
|
||
ledger: { recordPipelineGrades: async () => ({ written: 0 }), captureClosing: async () => {}, __internals: require('../../src/services/ledgerService').__internals },
|
||
refreshTeamStats: async () => null,
|
||
buildEspnIndex: async () => ({}),
|
||
gameBinder: { attachGameTimes: async () => ({ bound: 2, alreadyHad: 0, unresolved: 0, ambiguous: 0 }) },
|
||
lockLineCapture: lockDep,
|
||
});
|
||
|
||
it('grades identical whether persist runs or is a no-op, and lock rows target graded props at ts', async () => {
|
||
const captured = [];
|
||
const withPersist = { buildLockRows: llc.buildLockRows, persist: async (rows) => { captured.push(...rows); return { written: rows.length, attempted: rows.length }; } };
|
||
const noop = { buildLockRows: () => [], persist: async () => ({ written: 0, attempted: 0 }) };
|
||
|
||
const c1 = memCache(); await snapshot.runSnapshot('mlb', baseOpts(c1, withPersist));
|
||
const c2 = memCache(); await snapshot.runSnapshot('mlb', baseOpts(c2, noop));
|
||
|
||
const strip = (snap) => JSON.stringify((snap.grades || []).map((g) => ({ player: g.player, stat_type: g.stat_type, line: g.line, direction: g.direction, grade: g.grade, gradedAt: g.gradedAt })));
|
||
expect(strip(c1.store['snapshot:mlb:latest'])).toBe(strip(c2.store['snapshot:mlb:latest']));
|
||
|
||
// lock rows were built for the graded props, timestamped at the lock moment (ts).
|
||
expect(captured.length).toBeGreaterThan(0);
|
||
expect(captured.every((r) => r.locked_at === '2026-07-29T00:00:00.000Z')).toBe(true);
|
||
expect(new Set(captured.map((r) => r.player_key))).toEqual(new Set([nameKey('Aaron Judge'), nameKey('Mookie Betts')]));
|
||
});
|
||
});
|