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
This commit is contained in:
Kev
2026-07-27 00:45:01 -04:00
parent 914a057611
commit e81c9b8c51
9 changed files with 704 additions and 52 deletions
+19
View File
@@ -284,6 +284,10 @@ async function runSnapshot(sport, opts = {}) {
// suite that doesn't know about this dep can never make a live ESPN call.
// Session 64 — model-snapshot retention. Injectable; null disables it.
retention: opts.retention !== undefined ? opts.retention : require('./retentionService'),
// Book Comparison order (Phase 1) — DISPLAY-ONLY per-book price capture.
// 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,
refreshTeamStats: opts.refreshTeamStats
|| (process.env.NODE_ENV === 'test'
? async () => null
@@ -335,6 +339,21 @@ async function runSnapshot(sport, opts = {}) {
console.warn(`[snapshot] game binding failed for ${sp} (rows without a real game time will be skipped):`, e.message);
}
// Book Comparison order (Phase 1) — CAPTURE PER-BOOK PRICES BEFORE DEDUP.
// `props` still carries one row per book here (dedupeProps runs downstream
// inside gradeAndCacheSlate). The capture only READS props and writes its own
// `bookprices:{sport}` key at SNAP_TTL — long enough to outlive the cron gap
// (the 1h odds cache would blank between runs). Best-effort and structurally
// fenced: nothing on the grade path reads this key, and the graded slate is
// byte-identical whether or not this runs (bookPriceStore.test.js locks it).
try {
const bookStore = deps.captureBookPrices(props, { now: deps.now });
await deps.cacheSet(`bookprices:${sp}`, bookStore, SNAP_TTL);
console.log(`[snapshot] book prices ${sp}: ${bookStore.count} props with book rows captured`);
} catch (e) {
console.warn(`[snapshot] book-price capture failed for ${sp} (display-only, grading continues):`, e.message);
}
// Session 63 — REFRESH TEAM STATS BEFORE GRADING.
// `refreshTeamStats` is the ONLY writer of `team_stats:{sport}:{abbr}`, which
// is the ONLY source of `opp_rank_stat` — and it had zero production callers,