Files
vyndr/tests/unit/lockLineCapture.test.js
builtbykev c7067c80c4 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
2026-07-29 02:47:47 -04:00

125 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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')]));
});
});