{entry.player}
-- {entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')} -
-- Why we missed: {entry.miss_reason} -
- )} -diff --git a/BUILD-STATE.md b/BUILD-STATE.md
index a0da096..8a55a9e 100755
--- a/BUILD-STATE.md
+++ b/BUILD-STATE.md
@@ -4,9 +4,44 @@
2026-07-10
## Current Phase
-SHIP BUILD v57.0 — Phase 0 "Kill the Lies" (work order, Jul 10 live audit):
-every fabricated UI element deleted or rewired to real data. Next: Phase 1
-(ledger persistence + settlement — the truth infrastructure).
+SHIP BUILD v58.0 — Phase 1 "Truth Infrastructure": ledger_entries live in
+Supabase (migration applied + RLS verified), both write paths wired, nightly
+settlement + CLV, /ledger MY READS | MODEL tabs, honest scan refusals.
+Next: work-order Phase 2 (slate UX) + Phase 3 (mobile), then Phase 2.5.
+
+## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE
+
+Backend 2309 → **2327 tests** (+18), 201 suites. Web build clean (exit 0).
+Spec: `specs/phase-1-truth-infrastructure.md`. Migration
+`supabase/migrations/019_ledger_entries.sql` APPLIED to prod (betonblk) —
+dedupe (`UNIQUE NULLS NOT DISTINCT`) + RLS verified live (anon reads public
+rows only; anon INSERT rejected 42501).
+
+- **ledgerService** — recordPipelineGrades (public model record, idempotent
+ ignoreDuplicates upsert), captureClosing (every snapshot overwrites today's
+ closing_line/odds from the real feed; last write before game start = close),
+ settleLedger (outcome + actual + SIGNED CLV: over = locked−closing),
+ getModelAggregate (30d; percentages NULL under 20 settles). No-ops without
+ SUPABASE env. `Number(null)===0` fabrication bug caught by tests → strict
+ numOrNull everywhere.
+- **Write paths:** snapshotService → ledger (user_id null, priority path);
+ Next /api/scan → ledger row for AUTHED users only (anon would pollute the
+ public record). Refused reads write NOTHING (no scan_history either) and
+ don't burn a scan.
+- **Honest refusal (work-order 1.5):** analyzeViaEngine1 returns
+ `insufficient_data: true, grade: null` when the model has no projection
+ (l5→l20→{stat}_per_90→xG for soccer). The web gradeAdapter no longer
+ displays the LINE as the projection (the audit's model==line degenerate);
+ GradeResultCard renders "—" absent states; scan page renders
+ "INSUFFICIENT DATA — NO READ".
+- **/ledger:** MY READS | MODEL tabs; model header shows "H-M · X% HIT ·
+ Y% BEAT CLOSE" only at n≥20, else "RECORD BUILDING" + pending count.
+ ModelRecord component mounted on landing + player hero (deferred-render).
+- **SYNC (Task 5):** thresholds vs SNAPSHOT_EXPECTED_INTERVAL (default
+ 18000s): normal <1.5x · amber ≥1.5x · STALE red ≥3x.
+- **Phase 2.5 LOGGED** in specs/vyndr-roadmap.md (intraday refresh +
+ STEAM/VALUE/revision handling; build after Phase 3).
+- **Settle trigger:** scheduler settle pass + POST /api/internal/ledger/settle.
## Session 57 (2026-07-10) — SHIPPED ✅ PHASE 0: KILL THE LIES
diff --git a/CLAUDE.md b/CLAUDE.md
index ee13ef2..5c22018 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -710,6 +710,43 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
- **Empty slates use `web/src/lib/emptyState.js`** (month-aware per-sport
copy). It's CommonJS + unit-tested; add sports there, not inline.
+## Phase 1 — Truth Infrastructure (Session 58 — non-obvious)
+- **DATA SEMANTICS RULE (system-wide):** VYNDR never generates lines/odds —
+ market values are REAL book numbers captured at a timestamp; only
+ `model_value`/grade/edge/confidence are model output, always labeled MODEL
+ in the UI. Any path that would fill in a market value must show an absent
+ state. `Number(null) === 0` is the classic fabrication bug — use
+ `numOrNull` (ledgerService) style strict parsing.
+- **`ledger_entries`** (migration 019, APPLIED): user_id NULL rows = the
+ PUBLIC model record (pipeline-only — never write anon scans there!).
+ Dedupe = `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line,
+ side, game_id)`; upserts use `ignoreDuplicates: true` so re-runs never
+ overwrite the original lock. RLS: clients read own rows + public rows;
+ ALL writes via service role. player_key = `nameKey()`.
+- **Write paths:** pipeline → `snapshotService` calls
+ `ledgerService.recordPipelineGrades` + `captureClosing` (best-effort,
+ never breaks the snapshot; no-op without SUPABASE env). User scans →
+ the NEXT `/api/scan` route (`writeLedgerEntry`), authed users only.
+- **Closing/CLV:** captureClosing overwrites today's unsettled rows'
+ closing_line/odds on EVERY snapshot — last write before game start is the
+ close. CLV is SIGNED BY SIDE: over = locked − closing (positive = market
+ moved toward the grade = 'beat'); under = inverse. Settlement
+ (`settleLedger`) runs in the scheduler's pre-grade settle pass and via
+ POST /api/internal/ledger/settle; idempotent (`.is('outcome', null)` guard).
+- **Insufficient data (work-order 1.5):** `analyzeViaEngine1` REFUSES
+ (grade null + `insufficient_data: true`) when `projectionFor` finds no
+ model reference (l5→l20→`{stat}_per_90`→xG-for-goals). gradeSlateService
+ filters refusals out of the slate; parlay legs degrade to the F-stub; the
+ scan route writes nothing and doesn't burn a scan. If you add a sport,
+ make sure its feature extractor emits a projection field or every prop
+ refuses.
+- **n≥20 rule:** `getModelAggregate` returns `hit_pct`/`beat_close_pct` as
+ NULL under 20 settles; every surface (ledger MODEL tab, `ModelRecord`)
+ renders "RECORD BUILDING" instead. Never render a % on a small sample.
+- **SYNC thresholds** come from `SNAPSHOT_EXPECTED_INTERVAL` (s; default
+ 18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops
+ the value to the intraday cadence — no UI change needed.
+
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
diff --git a/specs/vyndr-roadmap.md b/specs/vyndr-roadmap.md
index c3d73f2..beeb37b 100644
--- a/specs/vyndr-roadmap.md
+++ b/specs/vyndr-roadmap.md
@@ -60,6 +60,35 @@ build-out target.
Scope key: S ≈ ½ session, M ≈ 1 session, L ≈ 1–2 sessions.
+> NOTE (Session 58): the numbered plan above predates the overhaul work
+> order. The work order's phases (0 = kill the lies ✅ S57, 1 = truth
+> infrastructure ✅ S58, 2 = slate UX, 3 = mobile, **2.5 below**, 4 = scan/
+> parlay, 5 = records, 6 = landing/content) take sequencing priority; the
+> table's items slot in where they don't conflict.
+
+## Phase 2.5 — Intraday line refresh + directional movement (LOGGED Session 58)
+
+Authoritative scope from the Phase 1 GO prompt. **Build after Phase 3
+(mobile), before Phase 4.** During slate hours (~noon–midnight ET):
+
+- Lightweight ODDS-ONLY refresh every 15–30 min (no full re-grade run).
+ Recompute the signed delta per graded prop RELATIVE TO THE GRADED SIDE —
+ direction is the signal, not magnitude alone.
+- **Moved WITH the grade** (market chasing our number): PropRow badge
+ `STEAM ▲ +N`. Good for the record; entry edge compressed. No re-grade.
+- **Moved AGAINST the grade ≥ 1.0** (or odds-equivalent): auto re-grade THAT
+ PROP ONLY.
+ - Grade holds → badge `VALUE ▲ better entry` (better number, same read).
+ - Grade drops → update the grade with `revised_from_grade` set (column
+ already exists in `ledger_entries`) + a visible revision marker
+ (original grade struck through — the ledger UI already renders it).
+ Revisions are ALWAYS public — never silently regrade, per Ledger ethos.
+- All displayed lines remain real book values from the refresh — the
+ refresh CAPTURES market numbers, never computes them.
+- After ship: drop `SNAPSHOT_EXPECTED_INTERVAL` to the refresh cadence —
+ the HeartbeatBar SYNC badge (normal <1.5x · amber ≥1.5x · STALE ≥3x)
+ goes genuinely live with zero further UI changes.
+
---
## Stat-type coverage target (fully built)
diff --git a/src/routes/internal.js b/src/routes/internal.js
index 4ef556e..df9b399 100644
--- a/src/routes/internal.js
+++ b/src/routes/internal.js
@@ -180,6 +180,23 @@ router.post('/outcomes/all', async (req, res) => {
}
});
+/**
+ * POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
+ * persistent ledger (outcome + actual_value + CLV) across every sport.
+ * Idempotent — safe to re-run; already-settled rows are never touched.
+ */
+router.post('/ledger/settle', async (req, res) => {
+ const ledger = require('../services/ledgerService');
+ try {
+ const results = await ledger.settleAllLedgers();
+ return res.json({ ok: true, results });
+ } catch (err) {
+ const message = err && err.message ? err.message : String(err);
+ console.error('[internal/ledger/settle] failed:', message);
+ return res.status(500).json({ ok: false, error: message });
+ }
+});
+
router.post('/outcomes/:sport', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
diff --git a/src/routes/ledger.js b/src/routes/ledger.js
index aad152e..204b244 100644
--- a/src/routes/ledger.js
+++ b/src/routes/ledger.js
@@ -1,20 +1,45 @@
'use strict';
/**
- * GET /api/ledger/accuracy (Session 55) — grade-tier buckets for the ledger UI.
+ * /api/ledger — the truth surface (Sessions 55 + 58).
*
- * The Next proxy `web/src/app/api/ledger/accuracy` has expected a `{ buckets }`
- * shape since before a writer existed; the self-learning loop now fills it.
- * Public, cache-only (reads outcomeService's persisted accuracy record).
+ * GET /accuracy — grade-tier buckets (Session 55, snapshot-log based).
+ * GET /mine — the caller's own ledger_entries, newest first (Phase 1).
+ * GET /model — the PUBLIC model record (user_id NULL rows) + the 30d
+ * aggregate {hit_pct, beat_close_pct, pending}. Percentages
+ * are null below MIN_AGG_SAMPLE (20) — the UI renders
+ * "RECORD BUILDING" instead. Never a % on a small n.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
+const { requireAuth } = require('../middleware/auth');
const outcomeService = require('../services/outcomeService');
+const ledgerService = require('../services/ledgerService');
+const { nameKey } = require('../utils/playerName');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
+const VALID_SPORTS = new Set(['nba', 'mlb', 'wnba', 'soccer']);
+const VALID_TIERS = new Set(['A', 'B', 'C', 'D', 'F']);
+const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
+
+function sbOrNull() {
+ try {
+ if (!ledgerService.__internals.isConfigured()) return null;
+ return require('../utils/supabase').getSupabaseServiceClient();
+ } catch { return null; }
+}
+
+function applyFilters(query, req) {
+ const sport = String(req.query.sport || '').toLowerCase();
+ const tier = String(req.query.tier || req.query.grade || '').toUpperCase();
+ if (VALID_SPORTS.has(sport)) query = query.eq('sport', sport);
+ if (VALID_TIERS.has(tier)) query = query.ilike('grade', `${tier}%`);
+ return query;
+}
+
router.get('/accuracy', async (req, res) => {
try {
const acc = await outcomeService.getAccuracy();
@@ -27,4 +52,58 @@ router.get('/accuracy', async (req, res) => {
}
});
+// The caller's own reads. Service-role read scoped to req.user.id — clients
+// never query the table directly (RLS blocks writes; reads go through here).
+router.get('/mine', requireAuth, async (req, res) => {
+ const sb = sbOrNull();
+ if (!sb) return res.json({ entries: [] });
+ try {
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 100));
+ let q = sb.from('ledger_entries')
+ .select(ROW_COLUMNS)
+ .eq('user_id', req.user.id);
+ q = applyFilters(q, req);
+ const { data, error } = await q
+ .order('graded_at', { ascending: false })
+ .limit(limit);
+ if (error) throw new Error(error.message);
+ return res.json({ entries: data || [] });
+ } catch (err) {
+ console.error('[ledger/mine]', err.message);
+ return res.status(200).json({ entries: [] });
+ }
+});
+
+// The public model record: every pipeline pre-grade, settled or pending,
+// misses included. This is the product's proof surface.
+router.get('/model', async (req, res) => {
+ const sb = sbOrNull();
+ try {
+ const aggregate = await ledgerService.getModelAggregate({
+ sport: VALID_SPORTS.has(String(req.query.sport || '').toLowerCase())
+ ? String(req.query.sport).toLowerCase() : undefined,
+ // VYNDR-on-player (work-order 1.4/5.2): per-player public record.
+ playerKey: req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : undefined,
+ });
+ let entries = [];
+ if (sb) {
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60));
+ let q = sb.from('ledger_entries')
+ .select(ROW_COLUMNS)
+ .is('user_id', null);
+ q = applyFilters(q, req);
+ const { data, error } = await q
+ .order('graded_at', { ascending: false })
+ .limit(limit);
+ if (error) throw new Error(error.message);
+ entries = data || [];
+ }
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ entries, aggregate, min_sample: ledgerService.MIN_AGG_SAMPLE });
+ } catch (err) {
+ console.error('[ledger/model]', err.message);
+ return res.status(200).json({ entries: [], aggregate: null, min_sample: ledgerService.MIN_AGG_SAMPLE });
+ }
+});
+
module.exports = router;
diff --git a/src/routes/snapshot.js b/src/routes/snapshot.js
index 629ebc3..f70daf6 100644
--- a/src/routes/snapshot.js
+++ b/src/routes/snapshot.js
@@ -61,8 +61,15 @@ router.get('/summary', async (req, res) => {
const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null;
const sports = {};
for (const r of reads) sports[r.sport] = r.graded;
+ // Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's
+ // EXPECTED cadence, not a flat 5 minutes: normal < 1.5x, amber ≥ 1.5x,
+ // STALE red ≥ 3x. Default = the 5h max cron gap; when Phase 2.5's
+ // intraday refresh ships, drop the env value and the badge goes live
+ // with zero UI changes.
+ const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
+ ? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000;
res.set('Cache-Control', 'public, max-age=30');
- return res.json({ graded, updated_at, sports });
+ return res.json({ graded, updated_at, sports, expected_interval_s });
} catch (err) {
console.error('[snapshot/summary]', err.message);
return res.status(200).json({ graded: 0, updated_at: null, sports: {} });
diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js
index 939a17d..de1829c 100644
--- a/src/services/gradeSlateService.js
+++ b/src/services/gradeSlateService.js
@@ -64,7 +64,10 @@ async function gradeBestSide(grade, prop, sport) {
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
- const cands = sides.filter(Boolean);
+ // Session 58 (work-order 1.5) — a refused read (insufficient_data /
+ // no grade) never enters the graded slate: no hollow rows in the grades
+ // cache, the snapshot, or the ledger.
+ const cands = sides.filter((s) => s && s.grade && !s.insufficient_data);
if (cands.length === 0) return null;
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
}
diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js
index a625297..bd53e84 100644
--- a/src/services/intelligence/analyzeViaEngine1.js
+++ b/src/services/intelligence/analyzeViaEngine1.js
@@ -232,34 +232,52 @@ function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, pr
};
}
-// edge_pct in the legacy shape compares the relevant average to the line.
-// We use l5_avg when present (matches legacy "recent form" weighting),
-// fall back to l20_avg, otherwise return 0 so the field is always present.
+// The model's projection: the same reference the edge is computed from.
+// Recent-form averages first (l5, else l20); soccer props (which carry
+// per-90 rates instead of game averages) fall back to `{stat}_per_90`,
+// then xG for goals. This is a REAL model number — it is never the line.
+// When it's null the model has no projection and the read must refuse
+// (insufficient_data), not ship a hollow grade.
+function projectionFor(features, prop) {
+ const f = features || {};
+ const round2 = (n) => Math.round(n * 100) / 100;
+ if (Number.isFinite(f.l5_avg)) return round2(f.l5_avg);
+ if (Number.isFinite(f.l20_avg)) return round2(f.l20_avg);
+ const stat = String(prop?.stat_type || '').toLowerCase();
+ const per90 = f[`${stat}_per_90`];
+ if (Number.isFinite(per90)) return round2(per90);
+ if (stat === 'goals' && Number.isFinite(f.xg_per_90)) return round2(f.xg_per_90);
+ return null;
+}
+
+// edge_pct in the legacy shape compares the model projection to the line.
function edgePctFor(features, prop) {
- const ref = Number.isFinite(features?.l5_avg) ? features.l5_avg
- : Number.isFinite(features?.l20_avg) ? features.l20_avg
- : null;
+ const ref = projectionFor(features, prop);
if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref);
return Math.round((signed / prop.line) * 1000) / 10;
}
-// When computeFeatures fails so badly that even a partial feature vector
-// is empty, return a legacy-shaped low-confidence result rather than
-// asking engine1 to grade nothing.
-function fallbackLegacyResult(rawProp, errors) {
+// Session 58 (work-order 1.5) — when the model has NO projection there is no
+// read. This used to return grade 'C' / confidence 10 / edge 0 — the exact
+// "hollow C" the live audit caught (model==line, +0% edge). A refused read
+// builds more trust than a fake one: grade is null, insufficient_data is
+// true, and NOTHING downstream (grades cache, snapshot, ledger) persists it.
+function insufficientDataResult(rawProp, errors) {
return {
player: rawProp.player ?? null,
stat_type: rawProp.stat_type ?? null,
line: rawProp.line ?? null,
direction: rawProp.direction ?? null,
book: rawProp.book || 'unknown',
- grade: 'C',
- confidence: 10,
+ grade: null,
+ insufficient_data: true,
+ confidence: 0,
edge_pct: 0,
+ projection: null,
kill_conditions_triggered: [],
reasoning: {
- summary: `Unable to compute full analysis. ${explainErrors(errors) || ''} Grade is provisional.`.trim(),
+ summary: `INSUFFICIENT DATA — no read. ${explainErrors(errors) || 'The model has no projection for this prop.'}`.trim(),
steps: [],
},
};
@@ -334,12 +352,19 @@ async function analyzeViaEngine1(rawProp = {}) {
const featureResult = await computeFeaturesForProp(rawProp);
const { features, trap, consistency, prop, meta } = featureResult;
- // Hard fallback only when computeFeatures couldn't produce anything
- // useful at all (no features AND no consistency input).
+ // Hard refusal when computeFeatures couldn't produce anything useful at
+ // all (no features AND no consistency input) — there is nothing to grade.
if ((!features || Object.keys(features).length === 0)
&& (!consistency || consistency.consistency === 'unknown')
&& (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) {
- return fallbackLegacyResult(rawProp, meta?.errors);
+ return insufficientDataResult(rawProp, meta?.errors);
+ }
+
+ // Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model
+ // reference the edge is fictional and the grade would be hollow.
+ const projection = projectionFor(features, { ...rawProp, line: prop.line });
+ if (projection == null) {
+ return insufficientDataResult(rawProp, meta?.errors);
}
// Engine 1: deterministic rule-based grade on the feature vector.
@@ -377,6 +402,10 @@ async function analyzeViaEngine1(rawProp = {}) {
// intelligence). Optional + self-hiding on the card; zero extra I/O.
Object.assign(legacy, buildIntelFields(features));
+ // Session 58 — the model's REAL projection (never the line). Persisted as
+ // ledger model_value and rendered under the MODEL label on the card.
+ legacy.projection = projection;
+
return legacy;
}
@@ -385,7 +414,8 @@ module.exports = {
__internals: {
buildConcreteReasoning,
edgePctFor,
- fallbackLegacyResult,
+ projectionFor,
+ insufficientDataResult,
explainErrors,
ERROR_EXPLANATIONS,
buildIntelFields,
diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js
new file mode 100644
index 0000000..630a4b3
--- /dev/null
+++ b/src/services/ledgerService.js
@@ -0,0 +1,379 @@
+'use strict';
+
+/**
+ * ledgerService — the truth infrastructure (Session 58, work-order Phase 1).
+ *
+ * Persists every grade to Supabase `ledger_entries`:
+ * - Pipeline pre-grades (user_id NULL) — the PUBLIC model record. Written by
+ * snapshotService after each snapshot locks. This is the priority path:
+ * hundreds of settles per night vs user scans trickling in.
+ * - User scans are written by the web /api/scan route (it owns the user
+ * identity); this service owns settlement + closing capture for BOTH.
+ *
+ * DATA SEMANTICS: `line` / `locked_odds` / `book` / `closing_line` /
+ * `closing_odds` are REAL book values captured at grade / refresh time —
+ * never computed. `model_value` is VYNDR's projection. A grade with no
+ * projection (insufficient_data) is never written — no hollow rows.
+ *
+ * Closing-line value: captureClosing runs on EVERY snapshot and overwrites
+ * closing_line/closing_odds for today's unsettled rows with the CURRENT feed
+ * values — the last write before the game starts is the closing line (once a
+ * game starts its props leave the feed, so updates stop naturally).
+ * settleLedger then computes `clv` SIGNED BY SIDE: for an OVER, a closing
+ * line BELOW the locked line = the market moved toward the graded side =
+ * positive = 'beat'. For an UNDER, the inverse.
+ *
+ * Everything is injectable; without SUPABASE env the service no-ops
+ * gracefully (tests / local dev without a database).
+ */
+
+const { nameKey, normalizeName } = require('../utils/playerName');
+const { settleResult, statValue } = require('./outcomeService');
+
+const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
+const UPSERT_CHUNK = 200;
+const SETTLE_FETCH_LIMIT = 500;
+const AGG_WINDOW_DAYS = 30;
+const AGG_FETCH_LIMIT = 5000;
+/** Below this many settled rows, callers must not render a percentage. */
+const MIN_AGG_SAMPLE = 20;
+
+function isConfigured() {
+ return Boolean(process.env.SUPABASE_URL
+ && (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY));
+}
+
+function defaultClient() {
+ return require('../utils/supabase').getSupabaseServiceClient();
+}
+
+/** ET calendar date (YYYY-MM-DD) of an ISO timestamp; null when unparseable. */
+function dateET(iso) {
+ if (!iso) return null;
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return null;
+ return new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
+ }).format(d);
+}
+
+const todayET = () => dateET(new Date().toISOString());
+
+/** Derived game id when the feed carries no event id: sport:date:AWAY@HOME. */
+function gameIdFor(sport, prop, gameDate) {
+ const away = String((prop && prop.away_team) || 'UNK').replace(/\s+/g, '');
+ const home = String((prop && prop.home_team) || 'UNK').replace(/\s+/g, '');
+ return `${sport}:${gameDate}:${away}@${home}`;
+}
+
+const sideOf = (direction) =>
+ (String(direction || 'over').toLowerCase() === 'under' ? 'under' : 'over');
+
+// Strict numeric parse: null/undefined stay null (Number(null) is 0 — a
+// fabricated zero line/odds is exactly what the data-semantics rule forbids).
+const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
+
+/** Index odds props by nameKey|stat for lock/closing lookups. */
+function indexProps(props) {
+ const map = {};
+ for (const p of props || []) {
+ if (!p || !p.player || !p.stat_type) continue;
+ const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
+ if (!map[k]) map[k] = p;
+ }
+ return map;
+}
+
+function oddsForSide(prop, side) {
+ if (!prop) return null;
+ const v = side === 'under'
+ ? (prop.under_odds ?? prop.under ?? null)
+ : (prop.over_odds ?? prop.over ?? null);
+ return v == null ? null : String(v);
+}
+
+/**
+ * Build ledger rows from a snapshot's enriched grades + the raw odds props.
+ * Skips anything without a real grade or without a captured line — the
+ * ledger never holds a fabricated market value or a refused read.
+ */
+function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
+ const sp = String(sport || '').toLowerCase();
+ const byKey = indexProps(oddsProps);
+ const rows = [];
+ for (const g of grades || []) {
+ if (!g || !g.grade || g.insufficient_data) continue;
+ const player = g.player || g.player_name;
+ if (!player) continue;
+ const stat = String(g.stat_type || g.stat || '').toLowerCase();
+ if (!stat) continue;
+ const side = sideOf(g.direction);
+ const locked = g.gradedAt || {};
+ const line = numOrNull(locked.line) ?? numOrNull(g.line);
+ if (line == null) continue; // no real captured line → no row
+ const prop = byKey[`${nameKey(player)}|${stat}`] || null;
+ const gradedTs = locked.timestamp || nowIso;
+ const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET();
+ rows.push({
+ user_id: null,
+ player_key: nameKey(player),
+ player_name: normalizeName(player).display || player,
+ sport: sp,
+ stat,
+ line,
+ side,
+ locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(prop, side),
+ book: (prop && prop.book) || g.book || null,
+ grade: g.grade,
+ edge: numOrNull(g.edge_pct),
+ confidence: numOrNull(g.confidence),
+ model_value: numOrNull(g.projection),
+ graded_at: gradedTs,
+ game_id: gameIdFor(sp, prop, gameDate),
+ game_date: gameDate,
+ });
+ }
+ return rows;
+}
+
+/**
+ * Upsert the pipeline's pre-grades (user_id NULL — the public model record).
+ * Idempotent: re-runs hit the dedupe constraint and are IGNORED, so the
+ * original locked line/odds are never overwritten by a later run.
+ */
+async function recordPipelineGrades(sport, grades, oddsProps, opts = {}) {
+ if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', written: 0 };
+ const sb = opts.sb || defaultClient();
+ const nowIso = (opts.now || (() => new Date().toISOString()))();
+ const rows = rowsFromSnapshot(sport, grades, oddsProps, nowIso);
+ if (rows.length === 0) return { written: 0 };
+ let written = 0;
+ for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
+ const chunk = rows.slice(i, i + UPSERT_CHUNK);
+ const { error } = await sb.from('ledger_entries')
+ .upsert(chunk, { onConflict: CONFLICT, ignoreDuplicates: true });
+ if (error) return { written, error: error.message };
+ written += chunk.length;
+ }
+ return { written };
+}
+
+/**
+ * Overwrite closing_line/closing_odds on today's UNSETTLED rows from the
+ * current (real) odds feed. Runs on every snapshot; the last capture before
+ * game start is the closing line. Matches by player_key+stat — the closing
+ * line may legitimately differ from the locked line (that's CLV).
+ */
+async function captureClosing(sport, oddsProps, opts = {}) {
+ if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
+ const sb = opts.sb || defaultClient();
+ const sp = String(sport || '').toLowerCase();
+ const gameDate = opts.gameDate || todayET();
+ const byKey = indexProps(oddsProps);
+ if (Object.keys(byKey).length === 0) return { updated: 0 };
+
+ const { data: open, error } = await sb.from('ledger_entries')
+ .select('id, player_key, stat, side')
+ .eq('sport', sp)
+ .eq('game_date', gameDate)
+ .is('outcome', null)
+ .limit(SETTLE_FETCH_LIMIT);
+ if (error) return { updated: 0, error: error.message };
+
+ let updated = 0;
+ // Group row ids by identical closing values → one UPDATE per prop.
+ const groups = new Map();
+ for (const row of open || []) {
+ const prop = byKey[`${row.player_key}|${row.stat}`];
+ const closingLine = prop ? numOrNull(prop.line) : null;
+ if (closingLine == null) continue;
+ const closingOdds = oddsForSide(prop, row.side);
+ const gk = `${closingLine}|${closingOdds ?? ''}`;
+ if (!groups.has(gk)) groups.set(gk, { line: closingLine, odds: closingOdds, ids: [] });
+ groups.get(gk).ids.push(row.id);
+ }
+ for (const g of groups.values()) {
+ const { error: upErr } = await sb.from('ledger_entries')
+ .update({ closing_line: g.line, closing_odds: g.odds })
+ .in('id', g.ids);
+ if (!upErr) updated += g.ids.length;
+ }
+ return { updated };
+}
+
+/**
+ * Signed CLV per the Phase 1 amendment: positive = the market moved TOWARD
+ * the graded side. OVER: locked − closing (closing dropped ⇒ positive).
+ * UNDER: closing − locked.
+ */
+function computeClv(side, lockedLine, closingLine) {
+ const locked = numOrNull(lockedLine);
+ const closing = numOrNull(closingLine);
+ if (locked == null || closing == null) return null;
+ const raw = sideOf(side) === 'over' ? locked - closing : closing - locked;
+ return Math.round(raw * 100) / 100;
+}
+
+function clvResultOf(clv) {
+ if (clv == null) return null;
+ if (clv > 0) return 'beat';
+ if (clv < 0) return 'faded';
+ return 'flat';
+}
+
+/**
+ * Settle unsettled ledger rows with game_date <= yesterday against the real
+ * stat result (same free MLB game-log source outcomeService uses; other
+ * sports stay pending until they have a settled-result feed). Also computes
+ * CLV from the captured closing line. Idempotent: only rows with
+ * outcome IS NULL are fetched, and a row is written at most once.
+ */
+async function settleLedger(sport, opts = {}) {
+ if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', settled: 0, pending: 0 };
+ const sb = opts.sb || defaultClient();
+ const sp = String(sport || '').toLowerCase();
+ const nowIso = (opts.now || (() => new Date().toISOString()))();
+ const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats;
+ const cutoff = opts.beforeDate || todayET(); // settle strictly-before today
+
+ const { data: open, error } = await sb.from('ledger_entries')
+ .select('id, player_key, player_name, stat, line, side, closing_line')
+ .eq('sport', sp)
+ .is('outcome', null)
+ .lt('game_date', cutoff)
+ .order('game_date', { ascending: true })
+ .limit(SETTLE_FETCH_LIMIT);
+ if (error) return { settled: 0, pending: 0, error: error.message };
+ if (!open || open.length === 0) return { settled: 0, pending: 0 };
+
+ // We need each row's game_date for the log match — refetch with it included.
+ const { data: rows } = await sb.from('ledger_entries')
+ .select('id, player_name, stat, line, side, closing_line, game_date')
+ .in('id', open.map((r) => r.id));
+
+ // One game-log fetch per unique player.
+ const players = [...new Set((rows || []).map((r) => r.player_name))];
+ const logByPlayer = {};
+ for (const player of players) {
+ try {
+ const stats = await getPlayerStats(player, sp);
+ logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
+ } catch { logByPlayer[player] = []; }
+ }
+
+ let settled = 0;
+ let pending = 0;
+ for (const row of rows || []) {
+ const log = logByPlayer[row.player_name] || [];
+ const gameRow = log.find((r) => r && r.date === row.game_date);
+ if (!gameRow) { pending += 1; continue; }
+ const actual = statValue(gameRow.stat, row.stat);
+ if (actual == null) { pending += 1; continue; }
+ const outcome = settleResult(row.side, actual, row.line);
+ if (!outcome) { pending += 1; continue; }
+ const clv = computeClv(row.side, row.line, row.closing_line);
+ const { error: upErr } = await sb.from('ledger_entries')
+ .update({
+ outcome,
+ actual_value: actual,
+ settled_at: nowIso,
+ clv,
+ clv_result: clvResultOf(clv),
+ })
+ .eq('id', row.id)
+ .is('outcome', null); // double-settle guard even across concurrent runs
+ if (upErr) { pending += 1; continue; }
+ settled += 1;
+ }
+ return { settled, pending };
+}
+
+async function settleAllLedgers(opts = {}) {
+ const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
+ const results = [];
+ for (const sp of sports) {
+ try { results.push({ sport: sp, ...(await settleLedger(sp, opts)) }); }
+ catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
+ }
+ return results;
+}
+
+async function defaultGetPlayerStats(name, sport) {
+ if (String(sport).toLowerCase() === 'mlb') {
+ return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
+ }
+ return { found: false }; // no free settled-result feed yet → pending
+}
+
+/**
+ * 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
+ * beat-the-close rate, pending count. Percentages are null below
+ * MIN_AGG_SAMPLE — the UI must show "record building" instead.
+ */
+async function getModelAggregate(opts = {}) {
+ const empty = {
+ window_days: AGG_WINDOW_DAYS, min_sample: MIN_AGG_SAMPLE,
+ settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null,
+ clv_sample: 0, clv_beat: 0, clv_faded: 0, clv_flat: 0, beat_close_pct: null,
+ pending: 0,
+ };
+ if (!opts.sb && !isConfigured()) return empty;
+ const sb = opts.sb || defaultClient();
+ const nowMs = (opts.nowMs || (() => Date.now()))();
+ const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
+
+ let settledQ = sb.from('ledger_entries')
+ .select('outcome, clv_result, player_key')
+ .is('user_id', null)
+ .not('outcome', 'is', null)
+ .gte('game_date', since)
+ .limit(AGG_FETCH_LIMIT);
+ if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
+ if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
+ const { data: settledRows, error } = await settledQ;
+ if (error) return { ...empty, error: error.message };
+
+ let pendingQ = sb.from('ledger_entries')
+ .select('id', { count: 'exact', head: true })
+ .is('user_id', null)
+ .is('outcome', null);
+ if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
+ if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
+ const { count: pending } = await pendingQ;
+
+ const agg = { ...empty, pending: pending || 0 };
+ for (const r of settledRows || []) {
+ agg.settled += 1;
+ if (r.outcome === 'hit') agg.hits += 1;
+ else if (r.outcome === 'miss') agg.misses += 1;
+ else if (r.outcome === 'push') agg.pushes += 1;
+ if (r.clv_result) {
+ agg.clv_sample += 1;
+ if (r.clv_result === 'beat') agg.clv_beat += 1;
+ else if (r.clv_result === 'faded') agg.clv_faded += 1;
+ else agg.clv_flat += 1;
+ }
+ }
+ const decided = agg.hits + agg.misses;
+ // n<20 → null: never render a percentage on a small sample.
+ if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
+ agg.hit_pct = Math.round((agg.hits / decided) * 100);
+ }
+ if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
+ agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100);
+ }
+ return agg;
+}
+
+module.exports = {
+ recordPipelineGrades,
+ captureClosing,
+ settleLedger,
+ settleAllLedgers,
+ getModelAggregate,
+ MIN_AGG_SAMPLE,
+ __internals: {
+ rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
+ dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
+ },
+};
diff --git a/src/services/outcomeService.js b/src/services/outcomeService.js
index 706d43a..ea3a733 100644
--- a/src/services/outcomeService.js
+++ b/src/services/outcomeService.js
@@ -292,6 +292,10 @@ module.exports = {
getAccuracy,
computeAccuracy,
accuracyBuckets,
+ // Session 58 — settlement primitives shared with ledgerService (single
+ // source of truth for hit/miss/push + MLB log-field resolution).
+ settleResult,
+ statValue,
SPORTS,
MIN_SAMPLE,
__internals: { settleResult, gradeBucket, dateStrings, statValue, outcomeKey, MLB_LOG_FIELD, TIERS },
diff --git a/src/services/parlayScanService.js b/src/services/parlayScanService.js
index edf9b24..4963bad 100644
--- a/src/services/parlayScanService.js
+++ b/src/services/parlayScanService.js
@@ -34,7 +34,20 @@ async function scanParlay(user, legs) {
// crashing the whole parlay.
const settled = await Promise.allSettled(legs.map((leg) => analyzeViaEngine1(leg)));
const legResults = settled.map((s, i) => {
- if (s.status === 'fulfilled') return s.value;
+ if (s.status === 'fulfilled') {
+ // Session 58 (work-order 1.5) — a refused read carries grade null;
+ // the parlay flow needs a letter per leg, so it takes the existing
+ // failed-leg convention (F / 0 confidence) with an honest summary.
+ if (s.value && s.value.insufficient_data) {
+ return {
+ ...s.value,
+ grade: 'F',
+ insufficient_data: true,
+ reasoning: { summary: 'INSUFFICIENT DATA — no read for this leg.' },
+ };
+ }
+ return s.value;
+ }
return {
...legs[i],
error: s.reason?.message || 'analysis_failed',
diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js
index 6171671..9b515bd 100644
--- a/src/services/snapshotService.js
+++ b/src/services/snapshotService.js
@@ -188,6 +188,9 @@ async function runSnapshot(sport, opts = {}) {
notify: opts.notify || require('../utils/opsNotify').notify,
sleep: opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))),
retryDelayMs: opts.retryDelayMs != null ? opts.retryDelayMs : 60_000,
+ // Session 58 — Phase 1 truth infrastructure. ledger no-ops without
+ // SUPABASE env, so tests / local dev never touch a database.
+ ledger: opts.ledger || require('./ledgerService'),
};
const start = deps.nowMs();
const ts = deps.now();
@@ -291,6 +294,20 @@ async function runSnapshot(sport, opts = {}) {
const events = generateTickerEvents(sp, enriched, deltas, ts);
await pushTickerItems(events, deps);
+ // Session 58 — Phase 1 truth infrastructure. (a) Upsert the public model
+ // record (user_id NULL, idempotent — re-runs never duplicate or overwrite
+ // the original lock). (b) Overwrite today's closing_line/odds from the
+ // CURRENT feed — the last capture before game start IS the closing line.
+ // Best-effort: the ledger must never break the snapshot.
+ let ledgerWritten = 0;
+ try {
+ const rec = await deps.ledger.recordPipelineGrades(sp, enriched, props, { now: deps.now });
+ ledgerWritten = rec.written || 0;
+ await deps.ledger.captureClosing(sp, props);
+ } catch (e) {
+ console.warn(`[snapshot] ledger write failed for ${sp}:`, e.message);
+ }
+
// Session 56 — success alert, enriched with the rolling accuracy (if settled).
let accPart = '';
try {
@@ -307,6 +324,7 @@ async function runSnapshot(sport, opts = {}) {
sport: sp,
status: 'ok',
gradeCount: enriched.length,
+ ledgerWritten,
topGrades: enriched.filter((g) => isTopGrade(g.grade)).slice(0, 5).map((g) => ({
player: g.player || g.player_name, stat: g.stat_type || g.stat, grade: g.grade, archetype: g.archetype,
})),
diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js
index f089d25..1b1f0a4 100644
--- a/src/snapshotScheduler.js
+++ b/src/snapshotScheduler.js
@@ -55,6 +55,9 @@ function startSnapshotScheduler(opts = {}) {
// real (now-completed) results BEFORE grading the fresh slate, so the accuracy
// record reflects yesterday's games each cycle.
const settleAll = opts.settleAllOutcomes || require('./services/outcomeService').settleAllOutcomes;
+ // Session 58 — Phase 1 truth infrastructure: settle the persistent ledger
+ // (outcome + actual + CLV) in the same pre-grade settle pass.
+ const settleLedgers = opts.settleAllLedgers || require('./services/ledgerService').settleAllLedgers;
const notify = opts.notify || require('./utils/opsNotify').notify;
const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet;
const now = opts.now || (() => new Date());
@@ -96,6 +99,13 @@ function startSnapshotScheduler(opts = {}) {
} catch (e) {
console.warn('[outcomes] settle run failed:', e.message);
}
+ try {
+ const ledger = await settleLedgers();
+ const n = ledger.reduce((t, r) => t + (r.settled || 0), 0);
+ console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`);
+ } catch (e) {
+ console.warn('[ledger] settle run failed:', e.message);
+ }
try {
const results = await runAll();
const ok = results.filter((r) => r.status === 'ok');
diff --git a/tests/integration/ledgerRoutes.test.js b/tests/integration/ledgerRoutes.test.js
new file mode 100644
index 0000000..8f0a91a
--- /dev/null
+++ b/tests/integration/ledgerRoutes.test.js
@@ -0,0 +1,91 @@
+// Session 58 (Phase 1) — /api/ledger/mine (auth-scoped) + /api/ledger/model
+// (public record + aggregate). Supabase + auth are mocked; the routes'
+// scoping and honest-aggregate contracts are what's under test.
+
+const express = require('express');
+const request = require('supertest');
+
+// requireAuth stub: Authorization present → user u1; else 401.
+jest.mock('../../src/middleware/auth', () => ({
+ requireAuth: (req, res, next) => {
+ if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' });
+ req.user = { id: 'u1', tier: 'analyst' };
+ return next();
+ },
+}));
+
+// Supabase service client stub — records filters so we can assert scoping.
+const mockCaptured = { filters: [], rows: [] };
+function mockChain() {
+ const b = {
+ _filters: [],
+ select() { return b; },
+ eq(col, val) { b._filters.push(['eq', col, val]); return b; },
+ is(col, val) { b._filters.push(['is', col, val]); return b; },
+ not(col, op, val) { b._filters.push(['not', col, op, val]); return b; },
+ gte(col, val) { b._filters.push(['gte', col, val]); return b; },
+ ilike(col, val) { b._filters.push(['ilike', col, val]); return b; },
+ order() { return b; },
+ limit() {
+ mockCaptured.filters.push(b._filters);
+ return Promise.resolve({ data: mockCaptured.rows, error: null, count: 0 });
+ },
+ then(resolve, reject) {
+ mockCaptured.filters.push(b._filters);
+ return Promise.resolve({ data: mockCaptured.rows, error: null, count: 0 }).then(resolve, reject);
+ },
+ };
+ return b;
+}
+jest.mock('../../src/utils/supabase', () => ({
+ getSupabaseServiceClient: () => ({ from: () => mockChain() }),
+}));
+
+process.env.SUPABASE_URL = 'https://test.supabase.co';
+process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key';
+
+function mountApp() {
+ delete require.cache[require.resolve('../../src/routes/ledger')];
+ const routes = require('../../src/routes/ledger');
+ const app = express();
+ app.use('/api/ledger', routes);
+ return app;
+}
+
+beforeEach(() => {
+ mockCaptured.filters.length = 0;
+ mockCaptured.rows.length = 0;
+});
+
+describe('GET /api/ledger/mine', () => {
+ test('401 without auth', async () => {
+ const res = await request(mountApp()).get('/api/ledger/mine');
+ expect(res.status).toBe(401);
+ });
+
+ test('scopes rows to the authenticated user', async () => {
+ mockCaptured.rows.push({ id: 'r1', player_name: 'Judge', user_id: 'u1' });
+ const res = await request(mountApp())
+ .get('/api/ledger/mine?sport=mlb&tier=A')
+ .set('Authorization', 'Bearer token');
+ expect(res.status).toBe(200);
+ expect(res.body.entries).toHaveLength(1);
+ const filters = mockCaptured.filters[0];
+ expect(filters).toContainEqual(['eq', 'user_id', 'u1']);
+ expect(filters).toContainEqual(['eq', 'sport', 'mlb']);
+ expect(filters).toContainEqual(['ilike', 'grade', 'A%']);
+ });
+});
+
+describe('GET /api/ledger/model', () => {
+ test('public — returns the user_id-null record + an aggregate with the n<20 rule', async () => {
+ const res = await request(mountApp()).get('/api/ledger/model');
+ expect(res.status).toBe(200);
+ expect(res.body.min_sample).toBe(20);
+ expect(res.body.aggregate).toBeTruthy();
+ expect(res.body.aggregate.hit_pct).toBeNull(); // 0 settles → no percentage
+ // The entries query must be scoped to the PUBLIC record.
+ const entriesFilters = mockCaptured.filters.find((f) => f.some((x) => x[0] === 'is' && x[1] === 'user_id'));
+ expect(entriesFilters).toBeTruthy();
+ });
+});
diff --git a/tests/unit/analyzeViaEngine1.test.js b/tests/unit/analyzeViaEngine1.test.js
index 2abf581..26f0440 100644
--- a/tests/unit/analyzeViaEngine1.test.js
+++ b/tests/unit/analyzeViaEngine1.test.js
@@ -129,14 +129,18 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
player: 'Ghost', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
- expect(out.grade).toBe('C');
- expect(out.confidence).toBe(10);
- expect(out.reasoning.summary).toMatch(/Unable to compute|provisional/);
+ // Session 58 (work-order 1.5) — this used to ship a hollow C at 10%
+ // confidence (the audit's model==line degenerate). Now it REFUSES.
+ expect(out.grade).toBeNull();
+ expect(out.insufficient_data).toBe(true);
+ expect(out.confidence).toBe(0);
+ expect(out.projection).toBeNull();
+ expect(out.reasoning.summary).toMatch(/INSUFFICIENT DATA — no read/);
expect(out.reasoning.summary).toContain("couldn't find");
expect(out.kill_conditions_triggered).toEqual([]);
});
- test('partial data (player found, no game) still grades via engine1', async () => {
+ test('partial data WITHOUT a projection refuses honestly (no hollow grade)', async () => {
mockComputeReturn.current = {
features: {},
trap: { composite: 0, signals: {} },
@@ -155,11 +159,36 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
- // Did NOT fall through to fallbackLegacyResult — engine1 was invoked.
- expect(out.grade).toBe('C');
- expect(out.confidence).toBe(40);
+ // No l5/l20 reference ⇒ the model has no projection ⇒ no read. The old
+ // behavior graded C/40 here — a grade the model couldn't actually back.
+ expect(out.grade).toBeNull();
+ expect(out.insufficient_data).toBe(true);
expect(out.reasoning.summary).toContain('No game scheduled');
});
+
+ test('a projection unlocks the grade AND is attached to the result', async () => {
+ mockComputeReturn.current = {
+ features: { l5_avg: 28.4, l20_avg: 26.1 },
+ trap: { composite: 0, signals: {} },
+ consistency: { consistency: 'reliable', cv: 0.2, score: 0.7, games: 20 },
+ prop: { line: 25, direction: 'over' },
+ meta: { player: 'P', statType: 'points', book: 'dk', sport: 'nba',
+ teamAbbr: 'NYK', opponentAbbr: null, gameId: null, isHome: null,
+ gameLogs: [{ points: 25 }], errors: [] },
+ };
+ mockEngine1Return.current = {
+ grade: 'B', confidence: 0.6,
+ top_factors: [], all_factors: [],
+ };
+
+ const out = await analyzeViaEngine1({
+ player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
+ });
+
+ expect(out.grade).toBe('B');
+ expect(out.insufficient_data).toBeUndefined();
+ expect(out.projection).toBe(28.4); // the REAL model reference, never the line
+ });
});
describe('analyzeViaEngine1 — interface verifications', () => {
diff --git a/tests/unit/analyzeViaEngine1Soccer.test.js b/tests/unit/analyzeViaEngine1Soccer.test.js
index 6a89049..5420bd3 100644
--- a/tests/unit/analyzeViaEngine1Soccer.test.js
+++ b/tests/unit/analyzeViaEngine1Soccer.test.js
@@ -73,8 +73,10 @@ describe('analyzeViaEngine1 — soccer reasoning', () => {
});
test('altitude impact surfaces with venue context', async () => {
+ // goals_per_90 present: the model needs a projection to grade at all
+ // (Session 58 — no projection ⇒ INSUFFICIENT DATA refusal).
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
- altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
+ goals_per_90: 0.4, altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
}, { venue: 'Estadio Azteca' }));
const result = await analyzeViaEngine1({
player: 'Visitor', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
@@ -95,8 +97,9 @@ describe('analyzeViaEngine1 — soccer reasoning', () => {
});
test('referee card rate surfaces when present', async () => {
+ // l5_avg present: the model needs a projection to grade (Session 58).
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
- referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
+ l5_avg: 1.2, referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
}));
const result = await analyzeViaEngine1({
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', sport: 'soccer',
diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js
new file mode 100644
index 0000000..4201aa3
--- /dev/null
+++ b/tests/unit/ledgerService.test.js
@@ -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
+ });
+});
diff --git a/web/src/app/api/ledger/mine/route.ts b/web/src/app/api/ledger/mine/route.ts
new file mode 100644
index 0000000..a31e855
--- /dev/null
+++ b/web/src/app/api/ledger/mine/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
+
+/**
+ * Personal ledger proxy (Session 58, Phase 1) — forwards GET /api/ledger/mine
+ * with the caller's Authorization header (Express requireAuth scopes rows to
+ * the authenticated user).
+ */
+export async function GET(req: NextRequest) {
+ const auth = req.headers.get('authorization');
+ if (!auth) return NextResponse.json({ entries: [] }, { status: 401 });
+ try {
+ const qs = req.nextUrl.searchParams.toString();
+ const upstream = await fetch(`${BACKEND_URL}/api/ledger/mine${qs ? `?${qs}` : ''}`, {
+ headers: { Accept: 'application/json', Authorization: auth },
+ cache: 'no-store',
+ });
+ const data = await upstream.json().catch(() => ({ entries: [] }));
+ return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
+ } catch {
+ return NextResponse.json({ entries: [] }, { status: 200 });
+ }
+}
diff --git a/web/src/app/api/ledger/model/route.ts b/web/src/app/api/ledger/model/route.ts
new file mode 100644
index 0000000..2a45f4c
--- /dev/null
+++ b/web/src/app/api/ledger/model/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
+
+/**
+ * Public model-record proxy (Session 58, Phase 1) — forwards
+ * GET /api/ledger/model (the pipeline's user_id-null rows + 30d aggregate).
+ */
+export async function GET(req: NextRequest) {
+ try {
+ const qs = req.nextUrl.searchParams.toString();
+ const upstream = await fetch(`${BACKEND_URL}/api/ledger/model${qs ? `?${qs}` : ''}`, {
+ headers: { Accept: 'application/json' },
+ cache: 'no-store',
+ });
+ const data = await upstream.json().catch(() => ({ entries: [], aggregate: null }));
+ return NextResponse.json(data, {
+ status: upstream.ok ? 200 : upstream.status,
+ headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' },
+ });
+ } catch {
+ return NextResponse.json({ entries: [], aggregate: null }, { status: 200 });
+ }
+}
diff --git a/web/src/app/api/scan/route.ts b/web/src/app/api/scan/route.ts
index 27f5626..654e7fe 100644
--- a/web/src/app/api/scan/route.ts
+++ b/web/src/app/api/scan/route.ts
@@ -115,7 +115,21 @@ export async function POST(req: NextRequest) {
let scansRemaining: number | null = null;
- if (user && sb) {
+ // Session 58 (work-order 1.5) — a refused read (no projection) writes
+ // NOTHING: no scan_history, no ledger row. No hollow rows anywhere.
+ const refused = data?.insufficient_data === true || !data?.grade;
+
+ if (user && sb && !refused) {
+ // Phase 1 — persist the read to the ledger (authenticated users only;
+ // anonymous scans are never written: a null user_id row would pollute
+ // the PUBLIC model record, which is pipeline-only). The line/book are
+ // the REAL book values the slate pre-filled; locked odds are enriched
+ // from the cached odds feed when the prop matches. Fire-and-forget —
+ // the scan response never waits on the ledger.
+ void writeLedgerEntry(sb, user.id, body, data);
+ }
+
+ if (user && sb && !refused) {
void sb.rpc('increment_parlay_leg_frequency', {
p_player: body.player,
p_stat: body.stat,
@@ -162,3 +176,68 @@ export async function POST(req: NextRequest) {
return jsonError(502, 'The engine hit a wall. Try that read again.');
}
}
+
+/**
+ * Session 58 (Phase 1) — persist a completed user scan to ledger_entries.
+ *
+ * DATA SEMANTICS: `line`/`book` are the real book values the user scanned
+ * (the slate pre-fills them from the odds feed). `locked_odds` attaches ONLY
+ * when the cache-only snapshot carries the SAME line for this prop — odds
+ * from a different line would be a fabrication, so absent beats wrong.
+ * Upsert on the dedupe constraint: a double-tap never duplicates.
+ */
+async function writeLedgerEntry(
+ sb: NonNullable
The Ledger.
@@ -76,122 +110,49 @@ export default function LedgerPage() {
Loading…
- ) : entries.length === 0 ? ( -LEDGER EMPTY
-- Read your first prop to start building your Ledger. Every grade you run shows up here, with the result. -
- - Read a Prop → - -- {entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')} -
-- Why we missed: {entry.miss_reason} -
- )} -+ RECORD BUILDING +
++ Every read settles here, misses included.{' '} + + {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement + + {agg.settled > 0 && ( + · {agg.settled} settled + )} +
++ {row.side} {row.line} {row.stat.replace(/_/g, ' ')} +
++ {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} + {/* model_value is MODEL output — always labeled, never blended with market numbers. */} + {row.model_value != null && · MODEL {row.model_value}} +
+LEDGER EMPTY
+ {tab === 'mine' ? ( + <> ++ Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes — and settles against the real result. +
+ + Read a Prop → + + > + ) : ( + <> ++ Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted. +
+ > + )}+ INSUFFICIENT DATA — NO READ +
++ The model has no projection for this prop, so it refuses to grade it. + No number gets invented here — that's the deal. +
+ +