Files
vyndr/tests/unit/bookPriceStore.test.js
T
builtbykev e81c9b8c51 Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).

Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).

Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.

Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.

HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-27 00:45:01 -04:00

136 lines
6.3 KiB
JavaScript

// Book Comparison order (Phase 1) — display-only per-book price store.
// Locks: capture correctness, the STRUCTURAL FENCE (no mutation, no grade-path
// read), and grade-byte-identical proof through runSnapshot.
const fs = require('fs');
const path = require('path');
const { captureBookPrices, bookKey, booksAtLine } = require('../../src/services/bookPriceStore');
const snapshot = require('../../src/services/snapshotService');
// One prop (Judge TB 1.5) across 3 books at the shared line + a 1-book prop.
const multiBookProps = [
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'draftkings' },
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -110, under_odds: -110, book: 'betmgm' },
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -120, under_odds: 100, book: 'pinnacle' },
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: 120, under_odds: -150, book: 'draftkings' },
];
describe('captureBookPrices — capture correctness', () => {
it('groups one entry per normalized player+stat with every real book row', () => {
const out = captureBookPrices(multiBookProps, { now: () => 'T' });
expect(out.updated_at).toBe('T');
expect(out.count).toBe(2);
const judge = out.props.find((p) => p.key === bookKey('Aaron Judge', 'total_bases'));
expect(judge.player).toBe('Aaron Judge');
expect(judge.books).toHaveLength(3);
expect(judge.books.map((b) => b.book).sort()).toEqual(['betmgm', 'draftkings', 'pinnacle']);
const betts = out.props.find((p) => p.key === bookKey('Mookie Betts', 'hits'));
expect(betts.books).toHaveLength(1); // single-book prop kept honestly
});
it('drops rows with no price on either side (never a fabricated row)', () => {
const out = captureBookPrices([
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: null, under_odds: null, book: 'draftkings' },
]);
expect(out.count).toBe(0);
});
it('de-dupes a repeated book+line (first row wins) and requires book/line', () => {
const out = captureBookPrices([
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, under_odds: -110, book: 'draftkings' },
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: 999, under_odds: 999, book: 'draftkings' },
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, book: null }, // no book → skip
]);
expect(out.props[0].books).toHaveLength(1);
expect(out.props[0].books[0].over_odds).toBe(-110); // first-wins
});
it('booksAtLine restricts to the shared line only', () => {
const entry = { books: [{ book: 'a', line: 1.5 }, { book: 'b', line: 1.5 }, { book: 'c', line: 2.5 }] };
expect(booksAtLine(entry, 1.5).map((b) => b.book)).toEqual(['a', 'b']);
expect(booksAtLine(entry, 2.5)).toHaveLength(1);
});
});
describe('STRUCTURAL FENCE', () => {
it('never mutates the props it reads (deep-frozen input)', () => {
const frozen = multiBookProps.map((p) => Object.freeze({ ...p }));
Object.freeze(frozen);
expect(() => captureBookPrices(frozen)).not.toThrow();
});
it('no grade-path module references the bookprices store', () => {
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(/bookprices/);
expect(src).not.toMatch(/bookPriceStore/);
}
});
});
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, extra = {}) => ({
getOdds: async () => ({ props: multiBookProps, provider: 'test' }),
gradeAndCacheSlate: fakeGrade(),
resolveStats: async () => ({ found: false }),
classify: () => ({ primary: null }),
cacheGet: cache.cacheGet,
cacheSet: cache.cacheSet,
now: () => '2026-07-27T00: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: 0, alreadyHad: 2, unresolved: 0, ambiguous: 0 }) },
...extra,
});
it('produces identical grades whether or not the capture runs, and writes the fenced key', async () => {
// WITH capture (default dep).
const c1 = memCache();
await snapshot.runSnapshot('mlb', baseOpts(c1));
// WITHOUT capture (inject a no-op that writes nothing).
const c2 = memCache();
await snapshot.runSnapshot('mlb', baseOpts(c2, { captureBookPrices: () => ({ updated_at: 'x', props: [], count: 0 }) }));
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']));
expect(JSON.stringify(c1.store['grades:mlb'].grades.map((g) => g.grade)))
.toBe(JSON.stringify(c2.store['grades:mlb'].grades.map((g) => g.grade)));
// The fenced store IS written, with real book rows.
const bp = c1.store['bookprices:mlb'];
expect(bp.count).toBe(2);
expect(bp.props.find((p) => p.stat_type === 'total_bases').books).toHaveLength(3);
});
});