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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user