Session 58: Phase 1 — Truth Infrastructure (2327 tests)
ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.
- ledgerService: pipeline pre-grade upserts (public model record, user_id
null, idempotent), closing capture on every snapshot (last write before
game start = the close), settlement with SIGNED CLV (over = locked -
closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
ledger for authenticated users only (anon never touches the public
record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
longer displays the line as the model projection (the audit's
model==line / +0% edge degenerate); the card renders absent states.
projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
deferred-render strip on landing + player hero. CLV + outcome chips,
revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
market value is handled (Number(null)===0 would have fabricated lines).
Backend 2309 -> 2327 tests (201 suites), web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// Session 58 (Phase 1) — ledgerService: the truth infrastructure.
|
||||
// Everything runs against a fake Supabase client — zero network, zero env.
|
||||
|
||||
const ledger = require('../../src/services/ledgerService');
|
||||
const { rowsFromSnapshot, computeClv, clvResultOf } = ledger.__internals;
|
||||
|
||||
// ---- fake Supabase client ------------------------------------------------
|
||||
// Minimal chainable stub for the exact query shapes ledgerService uses.
|
||||
function fakeSb() {
|
||||
const calls = { upserts: [], updates: [] };
|
||||
const state = { selectResults: [], selectCursor: 0, countResult: 0 };
|
||||
|
||||
function builder() {
|
||||
const b = {
|
||||
_update: null,
|
||||
upsert(rows, opts) {
|
||||
calls.upserts.push({ rows, opts });
|
||||
return Promise.resolve({ error: null });
|
||||
},
|
||||
update(values) { b._update = values; return b; },
|
||||
select() { return b; },
|
||||
eq() { return b; },
|
||||
is() { return b; },
|
||||
not() { return b; },
|
||||
lt() { return b; },
|
||||
gte() { return b; },
|
||||
in(col, ids) {
|
||||
if (b._update) {
|
||||
calls.updates.push({ values: b._update, ids });
|
||||
return Promise.resolve({ error: null });
|
||||
}
|
||||
return terminal();
|
||||
},
|
||||
order() { return b; },
|
||||
limit() { return terminal(); },
|
||||
then(resolve, reject) { return terminal().then(resolve, reject); },
|
||||
};
|
||||
function terminal() {
|
||||
if (b._update) {
|
||||
calls.updates.push({ values: b._update });
|
||||
return Promise.resolve({ error: null });
|
||||
}
|
||||
const data = state.selectResults[state.selectCursor] ?? [];
|
||||
state.selectCursor += 1;
|
||||
return Promise.resolve({ data, error: null, count: state.countResult });
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
return { from: () => builder(), _calls: calls, _state: state };
|
||||
}
|
||||
|
||||
// ---- fixtures --------------------------------------------------------------
|
||||
const NOW = '2026-07-10T18:00:00.000Z';
|
||||
const GRADE = {
|
||||
player: 'Aaron Judge', player_name: 'Aaron Judge', stat_type: 'home_runs',
|
||||
line: 0.5, direction: 'over', grade: 'A', confidence: 78, edge_pct: 12.4,
|
||||
projection: 0.9,
|
||||
gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
|
||||
};
|
||||
const PROP = {
|
||||
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5,
|
||||
home_team: 'NYY', away_team: 'BOS', game_time: '2026-07-10T23:05:00Z',
|
||||
book: 'draftkings', over_odds: -115, under_odds: -105,
|
||||
};
|
||||
|
||||
describe('rowsFromSnapshot — the write shape', () => {
|
||||
test('builds a public row with REAL captured market values', () => {
|
||||
const rows = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0];
|
||||
expect(r.user_id).toBeNull();
|
||||
expect(r.player_key).toBe('aaron judge');
|
||||
expect(r.line).toBe(0.5); // the locked book line
|
||||
expect(r.locked_odds).toBe('-115'); // real odds at grade time
|
||||
expect(r.book).toBe('draftkings');
|
||||
expect(r.model_value).toBe(0.9); // MODEL output, distinct from line
|
||||
expect(r.game_date).toBe('2026-07-10'); // 23:05Z = 19:05 ET same day
|
||||
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
|
||||
});
|
||||
|
||||
test('refused reads and grade-less entries never become rows', () => {
|
||||
const refused = { ...GRADE, grade: null, insufficient_data: true };
|
||||
const gradeless = { ...GRADE, grade: undefined };
|
||||
expect(rowsFromSnapshot('mlb', [refused, gradeless], [PROP], NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a grade with no captured line is dropped — never a fabricated line', () => {
|
||||
const noLine = { ...GRADE, line: undefined, gradedAt: { line: null, odds: null, timestamp: NOW } };
|
||||
expect(rowsFromSnapshot('mlb', [noLine], [PROP], NOW)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordPipelineGrades — idempotent upsert', () => {
|
||||
test('upserts with ignoreDuplicates on the dedupe constraint', async () => {
|
||||
const sb = fakeSb();
|
||||
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb, now: () => NOW });
|
||||
expect(res.written).toBe(1);
|
||||
expect(sb._calls.upserts).toHaveLength(1);
|
||||
const { opts } = sb._calls.upserts[0];
|
||||
expect(opts.onConflict).toBe('user_id,player_key,stat,line,side,game_id');
|
||||
expect(opts.ignoreDuplicates).toBe(true); // re-runs never overwrite the lock
|
||||
});
|
||||
|
||||
test('no-ops without supabase env when no client injected', async () => {
|
||||
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], {});
|
||||
expect(res.skipped).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeClv — signed by side (Phase 1 amendment)', () => {
|
||||
test('OVER: closing below the locked line = positive = beat', () => {
|
||||
expect(computeClv('over', 1.5, 1.0)).toBe(0.5);
|
||||
expect(clvResultOf(computeClv('over', 1.5, 1.0))).toBe('beat');
|
||||
});
|
||||
test('OVER: closing above the locked line = negative = faded', () => {
|
||||
expect(computeClv('over', 1.5, 2.0)).toBe(-0.5);
|
||||
expect(clvResultOf(computeClv('over', 1.5, 2.0))).toBe('faded');
|
||||
});
|
||||
test('UNDER: inverse signs', () => {
|
||||
expect(computeClv('under', 1.5, 2.0)).toBe(0.5); // market rose toward the under
|
||||
expect(clvResultOf(computeClv('under', 1.5, 2.0))).toBe('beat');
|
||||
expect(computeClv('under', 1.5, 1.0)).toBe(-0.5);
|
||||
expect(clvResultOf(computeClv('under', 1.5, 1.0))).toBe('faded');
|
||||
});
|
||||
test('unchanged line = flat; missing closing = null (absent, not zero)', () => {
|
||||
expect(clvResultOf(computeClv('over', 1.5, 1.5))).toBe('flat');
|
||||
expect(computeClv('over', 1.5, null)).toBeNull();
|
||||
expect(clvResultOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleLedger — outcome + CLV vs the real result', () => {
|
||||
test('settles hit/miss/push from the game log and stamps CLV', async () => {
|
||||
const sb = fakeSb();
|
||||
// 1st select: open ids; 2nd: full rows.
|
||||
sb._state.selectResults = [
|
||||
[{ id: 'r1' }, { id: 'r2' }],
|
||||
[
|
||||
{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: 0.5, game_date: '2026-07-09' },
|
||||
{ id: 'r2', player_name: 'Aaron Judge', stat: 'hits', line: 1.5, side: 'over', closing_line: 2.5, game_date: '2026-07-09' },
|
||||
],
|
||||
];
|
||||
const getPlayerStats = async () => ({
|
||||
found: true,
|
||||
last10: [{ date: '2026-07-09', stat: { homeRuns: 1, hits: 1 } }],
|
||||
});
|
||||
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
|
||||
expect(res.settled).toBe(2);
|
||||
const byOutcome = sb._calls.updates.map((u) => u.values);
|
||||
expect(byOutcome[0]).toMatchObject({ outcome: 'hit', actual_value: 1, clv: 0, clv_result: 'flat' });
|
||||
// 1 hit vs o1.5 = miss; locked 1.5 closed 2.5 for an over = faded.
|
||||
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
|
||||
});
|
||||
|
||||
test('no game-log row for the date → stays pending (never guesses)', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
[{ id: 'r1' }],
|
||||
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
|
||||
];
|
||||
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
|
||||
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
|
||||
expect(res.settled).toBe(0);
|
||||
expect(res.pending).toBe(1);
|
||||
expect(sb._calls.updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureClosing — real feed values only', () => {
|
||||
test('updates open rows from the current odds; unmatched rows untouched', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [[
|
||||
{ id: 'r1', player_key: 'aaron judge', stat: 'home_runs', side: 'over' },
|
||||
{ id: 'r2', player_key: 'ghost player', stat: 'hits', side: 'over' },
|
||||
]];
|
||||
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
|
||||
expect(res.updated).toBe(1); // only the matched prop
|
||||
expect(sb._calls.updates[0].values).toEqual({ closing_line: 0.5, closing_odds: '-115' });
|
||||
expect(sb._calls.updates[0].ids).toEqual(['r1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelAggregate — never a % under min sample', () => {
|
||||
test('below 20 settles → hit_pct/beat_close_pct null, counts real', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
|
||||
];
|
||||
sb._state.countResult = 42;
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
expect(agg.settled).toBe(5);
|
||||
expect(agg.hits).toBe(5);
|
||||
expect(agg.hit_pct).toBeNull(); // n<20 — RECORD BUILDING
|
||||
expect(agg.beat_close_pct).toBeNull();
|
||||
expect(agg.pending).toBe(42);
|
||||
});
|
||||
|
||||
test('at 20+ settles → both percentages render', async () => {
|
||||
const sb = fakeSb();
|
||||
const rows = [
|
||||
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
|
||||
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
|
||||
];
|
||||
sb._state.selectResults = [rows];
|
||||
sb._state.countResult = 3;
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
expect(agg.settled).toBe(20);
|
||||
expect(agg.hit_pct).toBe(65); // 13 / (13+7)
|
||||
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user