From d296e40cb636416f3ceb9209efcb38cecdf9387f Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 10 Jul 2026 21:34:26 -0400 Subject: [PATCH] =?UTF-8?q?Session=2058:=20Phase=201=20=E2=80=94=20Truth?= =?UTF-8?q?=20Infrastructure=20(2327=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BUILD-STATE.md | 41 +- CLAUDE.md | 37 ++ specs/vyndr-roadmap.md | 29 ++ src/routes/internal.js | 17 + src/routes/ledger.js | 87 +++- src/routes/snapshot.js | 9 +- src/services/gradeSlateService.js | 5 +- .../intelligence/analyzeViaEngine1.js | 64 ++- src/services/ledgerService.js | 379 +++++++++++++++++ src/services/outcomeService.js | 4 + src/services/parlayScanService.js | 15 +- src/services/snapshotService.js | 18 + src/snapshotScheduler.js | 10 + tests/integration/ledgerRoutes.test.js | 91 ++++ tests/unit/analyzeViaEngine1.test.js | 43 +- tests/unit/analyzeViaEngine1Soccer.test.js | 7 +- tests/unit/ledgerService.test.js | 212 +++++++++ web/src/app/api/ledger/mine/route.ts | 26 ++ web/src/app/api/ledger/model/route.ts | 26 ++ web/src/app/api/scan/route.ts | 81 +++- web/src/app/ledger/page.tsx | 401 ++++++++++-------- web/src/app/page.tsx | 8 +- web/src/app/player/[name]/page.tsx | 7 + web/src/app/scan/page.tsx | 29 +- web/src/components/vyndr/GradeResultCard.tsx | 21 +- web/src/components/vyndr/LiveLayer.tsx | 33 +- web/src/components/vyndr/ModelRecord.tsx | 91 ++++ web/src/components/vyndr/index.ts | 1 + web/src/lib/gradeAdapter.js | 9 +- 29 files changed, 1578 insertions(+), 223 deletions(-) create mode 100644 src/services/ledgerService.js create mode 100644 tests/integration/ledgerRoutes.test.js create mode 100644 tests/unit/ledgerService.test.js create mode 100644 web/src/app/api/ledger/mine/route.ts create mode 100644 web/src/app/api/ledger/model/route.ts create mode 100644 web/src/components/vyndr/ModelRecord.tsx 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>, + userId: string, + body: ScanBody, + data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number }, +) { + try { + const { nameKey, normalizeName } = await import('@/lib/playerName'); + const sport = body.sport.toLowerCase(); + const playerKey = nameKey(body.player); + const gameDate = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date()); + + // Cache-only snapshot read (never triggers an odds fetch → no quota). + let lockedOdds: string | null = null; + try { + const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }).then((r) => (r.ok ? r.json() : null)); + const match = (snap?.grades || []).find( + (g: { player?: string; player_name?: string; stat_type?: string; stat?: string; gradedAt?: { line?: number; odds?: number | string | null } }) => + nameKey(g.player || g.player_name || '') === playerKey + && String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase() + && g.gradedAt && Number(g.gradedAt.line) === Number(body.line), + ); + if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds); + } catch { /* absent beats wrong */ } + + await sb.from('ledger_entries').upsert( + { + user_id: userId, + player_key: playerKey, + player_name: normalizeName(body.player).display || body.player, + sport, + stat: body.stat.toLowerCase(), + line: body.line, + side: body.direction, + locked_odds: lockedOdds, + book: body.book ?? 'draftkings', + grade: data.grade, + edge: typeof data.edge_pct === 'number' ? data.edge_pct : null, + confidence: typeof data.confidence === 'number' ? data.confidence : null, + model_value: typeof data.projection === 'number' ? data.projection : null, + graded_at: new Date().toISOString(), + game_id: `manual:${sport}:${gameDate}:${playerKey}`, + game_date: gameDate, + }, + { onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true }, + ); + } catch (err) { + console.warn('[scan] ledger write failed', err); + } +} diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 6aaabf3..56312c9 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -1,73 +1,107 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; +import { useAuth } from '@/contexts/AuthContext'; -type Sport = 'ALL' | 'NBA' | 'MLB' | 'WNBA'; -type TierFilter = 'ALL' | 'A' | 'B' | 'C'; +/** + * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the + * persistent `ledger_entries` table. + * + * MY READS — the user's own scans, written the moment a scan completes. + * MODEL — the PUBLIC pipeline record (every pre-grade, misses included). + * + * DATA SEMANTICS: lines/odds/books shown here are real captured book values + * (mono, rendered as fact). model_value is VYNDR output and always carries + * the MODEL label. The aggregate header NEVER renders a percentage under 20 + * settles — it shows the building record honestly instead. + */ -interface LedgerEntry { +type Tab = 'mine' | 'model'; +type SportFilter = 'ALL' | 'NBA' | 'MLB' | 'WNBA'; +type TierFilter = 'ALL' | 'A' | 'B' | 'C' | 'D'; + +interface LedgerRow { id: string; - player: string; + player_name: string; + sport: string; stat: string; line: number; - direction: 'over' | 'under'; - sport: Exclude; + side: 'over' | 'under'; + locked_odds?: string | null; + book?: string | null; grade: string; - projection?: number; - actual?: number; - hit: boolean | null; - miss_reason?: string; + edge?: number | null; + confidence?: number | null; + model_value?: number | null; graded_at: string; + game_date: string; + closing_line?: number | null; + clv?: number | null; + clv_result?: 'beat' | 'faded' | 'flat' | null; + outcome?: 'hit' | 'miss' | 'push' | null; + actual_value?: number | null; + revised_from_grade?: string | null; } -interface AccuracyBucket { - tier: string; +interface ModelAggregate { + settled: number; hits: number; - losses: number; - pct: number; + misses: number; + pushes: number; + hit_pct: number | null; + clv_sample: number; + clv_beat: number; + beat_close_pct: number | null; + pending: number; + min_sample?: number; } -const SPORT_COLOR: Record, string> = { - NBA: '#E94B3C', - MLB: '#1E90FF', - WNBA: '#FFB347', +const SPORT_COLOR: Record = { + nba: '#E94B3C', + mlb: '#1E90FF', + wnba: '#FFB347', + soccer: '#7BC96F', }; export default function LedgerPage() { - const [sport, setSport] = useState('ALL'); + const { session } = useAuth(); + const [tab, setTab] = useState('mine'); + const [sport, setSport] = useState('ALL'); const [tier, setTier] = useState('ALL'); - const [entries, setEntries] = useState(null); - const [accuracy, setAccuracy] = useState(null); + const [rows, setRows] = useState(null); + const [aggregate, setAggregate] = useState(null); + const [minSample, setMinSample] = useState(20); - useEffect(() => { + const load = useCallback(async () => { + setRows(null); const params = new URLSearchParams(); - if (sport !== 'ALL') params.set('sport', sport); + if (sport !== 'ALL') params.set('sport', sport.toLowerCase()); if (tier !== 'ALL') params.set('tier', tier); + try { + if (tab === 'mine') { + const token = session?.access_token; + if (!token) { setRows([]); return; } + const data = await fetch(`/api/ledger/mine?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => r.json()); + setRows(Array.isArray(data?.entries) ? data.entries : []); + } else { + const data = await fetch(`/api/ledger/model?${params}`).then((r) => r.json()); + setRows(Array.isArray(data?.entries) ? data.entries : []); + setAggregate(data?.aggregate ?? null); + if (Number(data?.min_sample) > 0) setMinSample(Number(data.min_sample)); + } + } catch { + setRows([]); + } + }, [tab, sport, tier, session]); - Promise.all([ - fetch(`/api/ledger?${params}`).then((r) => r.json()).catch(() => ({ entries: [] })), - fetch('/api/ledger/accuracy').then((r) => r.json()).catch(() => ({ buckets: [] })), - ]).then(([entriesData, accuracyData]) => { - setEntries(Array.isArray(entriesData?.entries) ? entriesData.entries : []); - setAccuracy(Array.isArray(accuracyData?.buckets) ? accuracyData.buckets : []); - }); - }, [sport, tier]); - - const overall = useMemo(() => { - if (!accuracy?.length) return null; - const totals = accuracy.reduce( - (acc, b) => ({ h: acc.h + b.hits, l: acc.l + b.losses }), - { h: 0, l: 0 }, - ); - const total = totals.h + totals.l; - if (!total) return null; - return { hits: totals.h, losses: totals.l, pct: Math.round((totals.h / total) * 100) }; - }, [accuracy]); + useEffect(() => { void load(); }, [load]); return (
-
+

The Ledger.

@@ -76,122 +110,49 @@ export default function LedgerPage() {

- {/* Accuracy header strip */} - {accuracy && accuracy.length > 0 && ( -
- {accuracy.map((b) => ( - - ))} - {overall && ( - - )} -
+ {/* Tabs */} +
+ {([['mine', 'MY READS'], ['model', 'MODEL']] as [Tab, string][]).map(([id, label]) => { + const active = tab === id; + return ( + + ); + })} +
+ + {/* MODEL aggregate header — never a percentage under min_sample. */} + {tab === 'model' && aggregate && ( + )} {/* Filters */}
- setSport(v as Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} /> - setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} /> + setSport(v as SportFilter)} options={['ALL', 'NBA', 'MLB', 'WNBA']} /> + setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C', 'D']} />
- {/* Grid */} - {entries === null ? ( + {rows === null ? (

Loading…

- ) : entries.length === 0 ? ( -
-

LEDGER EMPTY

-

- No grades yet. -

-

- Read your first prop to start building your Ledger. Every grade you run shows up here, with the result. -

- - Read a Prop → - -
+ ) : rows.length === 0 ? ( + ) : ( -
- {entries.map((entry, i) => ( -
-
- - {entry.sport} - - -
-

{entry.player}

-

- {entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')} -

-
- - {entry.actual != null ? `Actual ${entry.actual}` : 'Pending'} - - {entry.hit !== null && ( - - {entry.hit ? 'HIT' : 'MISS'} - - )} -
- {entry.hit === false && entry.miss_reason && ( -

- Why we missed: {entry.miss_reason} -

- )} -
+
+ {rows.map((row, i) => ( + ))}
)} @@ -199,25 +160,133 @@ export default function LedgerPage() { ); } -function AccuracyTile({ bucket, highlight }: { bucket: AccuracyBucket; highlight?: boolean }) { +function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { + const ready = agg.settled >= minSample && agg.hit_pct != null; return (
-
- {bucket.tier.toUpperCase()} + {ready ? ( +
+ + MODEL · LAST 30D + + + {agg.hits}-{agg.misses} · {agg.hit_pct}% HIT + + {agg.beat_close_pct != null && ( + + {agg.beat_close_pct}% BEAT CLOSE + + )} + + {agg.pending} pending + +
+ ) : ( +
+

+ RECORD BUILDING +

+

+ Every read settles here, misses included.{' '} + + {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement + + {agg.settled > 0 && ( + · {agg.settled} settled + )} +

+
+ )} +
+ ); +} + +function OutcomeChip({ row }: { row: LedgerRow }) { + if (!row.outcome) { + return PENDING; + } + const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)' + : row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)'; + const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : '– PUSH'; + return ( + + {mark}{row.actual_value != null ? ` (${row.actual_value})` : ''} + + ); +} + +function ClvChip({ row }: { row: LedgerRow }) { + if (!row.clv_result || row.clv == null) return null; + const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)' + : row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; + return ( + + CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()} + + ); +} + +function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { + const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)'; + return ( +
+
+ + {row.sport.toUpperCase()} + + + {/* Phase 2.5 — a revised grade is public, never silent. */} + {row.revised_from_grade && ( + + {row.revised_from_grade} + + )} + +
-
- {bucket.pct}% -
-
- {bucket.hits}-{bucket.losses} +

{row.player_name}

+

+ {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}} +

+
+ +
+
+ ); +} + +function EmptyLedger({ tab }: { tab: Tab }) { + return ( +
+

LEDGER EMPTY

+ {tab === 'mine' ? ( + <> +

No reads yet.

+

+ 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 → + + + ) : ( + <> +

The model record starts with the next pipeline run.

+

+ Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted. +

+ + )}
); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 7c90ccd..57de3ef 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import Hero from '@/components/Hero'; -import { ClaimMeter } from '@/components/vyndr'; +import { ClaimMeter, ModelRecord } from '@/components/vyndr'; // Session 55 — live top A-rated grades pulled from tonight's real snapshot, // with the self-learning loop's accuracy line. The product shown, not described. import TopSignals from '@/components/TopSignals'; @@ -50,6 +50,12 @@ export default function Home() { {/* Session 55 — tonight's real top signals + live accuracy (the system works). */} + {/* Session 58 (work-order 1.4) — the public model record (proof-strip + footer). Deferred-render: "RECORD BUILDING" until 20 settles, then + the real hit% + beat-close%. Self-hides with no data. */} +
+ +
{/* Founder-seat scarcity meter (§12) */}
diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index d228e10..7100d97 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -6,6 +6,7 @@ import SportBadge from '@/components/vyndr/SportBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; +import ModelRecord from '@/components/vyndr/ModelRecord'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string } @@ -93,6 +94,12 @@ export default function PlayerProfilePage() { {p.team && {p.team}} {sportLabel(p.sport)}
+ {/* Session 58 (work-order 1.4/§11) — VYNDR-on-player: the model's + settled record on THIS player. Deferred-render; self-hides + until the ledger has rows for them. */} +
+ +
{p.archetype?.blend?.length > 0 && (
ARCHETYPE DNA
diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index f909149..62050e5 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -37,6 +37,8 @@ interface Player { interface ScanResponse { grade: string; + // Session 58 (work-order 1.5) — the model refused: no projection, no read. + insufficient_data?: boolean; projection?: number; confidence?: number; sample_size?: number; @@ -266,7 +268,8 @@ export default function ScanPage() { return; } setResult(data); - bumpScanCount(); + // Session 58 — a refused read (insufficient data) doesn't burn a scan. + if (!data.insufficient_data) bumpScanCount(); trackScanCompleted({ sport, player: selectedPlayer, @@ -689,9 +692,31 @@ export default function ScanPage() {
)} + {/* Session 58 (work-order 1.5) — the honest refusal. When the model has + no projection there is NO read: no grade letter, no fake +0% edge, + and nothing writes to the ledger. A refused read builds more trust + than a hollow one. */} + {result && result.insufficient_data && ( +
+

+ 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. +

+ +
+ )} + {/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35). Engine output is mapped to the §7 contract and tier-gated by the adapter. */} - {result && ( + {result && !result.insufficient_data && (
{d.grade} - · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge != null && ( + <> + · + {d.edge >= 0 ? '+' : ''}{d.edge}% edge + + )} · {d.confidence}% confidence
@@ -159,12 +163,15 @@ export default function GradeResultCard({ )}
- {/* 4. PROJECTION ROW */} + {/* 4. PROJECTION ROW — DATA SEMANTICS (Session 58): LINE is a real + market number (fact); MODEL/EDGE are model output. When the model + has no projection they render an absent state ("—"), never the + line or a fake +0%. */}
{[ - { l: 'MODEL', v: d.projection, col: 'var(--g-a)' }, + { l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'LINE', v: d.line, col: 'var(--text-0)' }, - { l: 'EDGE', v: `${d.edge >= 0 ? '+' : ''}${d.edge}%`, col: 'var(--g-a)' }, + { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' }, ].map((x, i) => (
{x.l}
diff --git a/web/src/components/vyndr/LiveLayer.tsx b/web/src/components/vyndr/LiveLayer.tsx index a42a218..e5b164d 100644 --- a/web/src/components/vyndr/LiveLayer.tsx +++ b/web/src/components/vyndr/LiveLayer.tsx @@ -42,10 +42,17 @@ const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50'; interface SnapshotSummary { graded: number; updated_at: string | null; + /** The pipeline's expected cadence (s) — SYNC thresholds key off this. */ + expected_interval_s?: number; } const SUMMARY_POLL_MS = 60_000; -const SYNC_AMBER_MS = 5 * 60_000; // §6 SyncClock — amber past 5 min +// Session 58 (Task 5) — SYNC thresholds are relative to the pipeline's +// EXPECTED interval (SNAPSHOT_EXPECTED_INTERVAL server-side), not a flat 5 +// minutes: normal < 1.5x · amber ≥ 1.5x · STALE red ≥ 3x. When Phase 2.5's +// intraday refresh ships, the server drops the interval and this goes +// genuinely live with zero changes here. +const DEFAULT_EXPECTED_S = 18_000; // 5h max cron gap /** Elapsed-since-snapshot label: MM:SS under an hour, then Xh Ym. */ function syncLabel(elapsedMs: number): string { @@ -59,9 +66,9 @@ function syncLabel(elapsedMs: number): string { * * Session 57 (Phase 0) — every number here is REAL now. The graded count is * the snapshot's actual prop count from /api/snapshot/summary (small and true - * beats big and fake), the fabricated brain-% stat is gone entirely, and SYNC counts - * up from the last real pipeline run (amber once it's >5 min stale). No - * summary data → "SYNC —" and no invented count. */ + * beats big and fake), the fabricated brain-% stat is gone entirely, and SYNC + * counts up from the last real pipeline run, tiered against the expected + * cadence (Session 58). No summary data → "SYNC —" and no invented count. */ export function HeartbeatBar() { const live = useLive(); // 1s pulse — re-renders the elapsed clock const [summary, setSummary] = useState(null); @@ -74,7 +81,11 @@ export function HeartbeatBar() { if (!r.ok) return; const data = await r.json(); if (active && typeof data?.graded === 'number') { - setSummary({ graded: data.graded, updated_at: data.updated_at ?? null }); + setSummary({ + graded: data.graded, + updated_at: data.updated_at ?? null, + expected_interval_s: Number(data.expected_interval_s) > 0 ? Number(data.expected_interval_s) : undefined, + }); } } catch { /* keep last known real values — never invent */ @@ -88,7 +99,13 @@ export function HeartbeatBar() { void live.tick; // subscription drives the 1s clock re-render const syncedAt = summary?.updated_at ? Date.parse(summary.updated_at) : NaN; const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null; - const stale = elapsed !== null && elapsed > SYNC_AMBER_MS; + const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000; + const tier: 'normal' | 'amber' | 'stale' = + elapsed === null ? 'normal' + : elapsed >= 3 * expectedMs ? 'stale' + : elapsed >= 1.5 * expectedMs ? 'amber' + : 'normal'; + const syncColor = tier === 'stale' ? 'var(--miss)' : tier === 'amber' ? 'var(--amber)' : 'var(--text-2)'; return (
graded )} - - SYNC {elapsed !== null ? syncLabel(elapsed) : '—'} + + {tier === 'stale' ? 'STALE' : 'SYNC'} {elapsed !== null ? syncLabel(elapsed) : '—'}
); diff --git a/web/src/components/vyndr/ModelRecord.tsx b/web/src/components/vyndr/ModelRecord.tsx new file mode 100644 index 0000000..b91e5d9 --- /dev/null +++ b/web/src/components/vyndr/ModelRecord.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * ModelRecord (Session 58, work-order 1.4) — the public model record as a + * deferred-render strip/chip. Reads the /api/ledger/model 30d aggregate: + * + * n ≥ min_sample → "MODEL 30D: H-M · X% HIT [· Y% BEAT CLOSE]" (green) + * settles building → "RECORD BUILDING · N reads pending settlement" (amber) + * nothing at all / fetch failed → renders NOTHING. + * + * Never a percentage on a small sample; never an invented number. Ship the + * wiring now — the numbers appear on their own as settles accumulate. + * Optional `sport` / `player` scope the record (VYNDR-on-player, §11). + */ + +interface Aggregate { + settled: number; + hits: number; + misses: number; + hit_pct: number | null; + beat_close_pct: number | null; + pending: number; +} + +export default function ModelRecord({ + sport, + player, + align = 'center', +}: { + sport?: string; + player?: string; + align?: 'center' | 'left'; +}) { + const [agg, setAgg] = useState(null); + const [minSample, setMinSample] = useState(20); + + useEffect(() => { + let active = true; + const params = new URLSearchParams({ limit: '1' }); + if (sport) params.set('sport', sport.toLowerCase()); + if (player) params.set('player', player); + fetch(`/api/ledger/model?${params}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (!active || !data?.aggregate) return; + setAgg(data.aggregate); + if (Number(data.min_sample) > 0) setMinSample(Number(data.min_sample)); + }) + .catch(() => { /* self-hide */ }); + return () => { active = false; }; + }, [sport, player]); + + if (!agg) return null; + const ready = agg.settled >= minSample && agg.hit_pct != null; + if (!ready && agg.pending === 0 && agg.settled === 0) return null; // nothing to claim yet + + return ( +
+ {ready ? ( + <> + MODEL 30D + + {agg.hits}-{agg.misses} · {agg.hit_pct}% HIT + + {agg.beat_close_pct != null && ( + + {agg.beat_close_pct}% BEAT CLOSE + + )} + + ) : ( + + RECORD BUILDING · {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement + + )} +
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 7aa40c2..db85104 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -15,6 +15,7 @@ export { default as GameCard } from './GameCard'; export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard'; export { default as ClaimMeter } from './ClaimMeter'; export { default as AccuracyBadge } from './AccuracyBadge'; +export { default as ModelRecord } from './ModelRecord'; /* Player Intelligence (Session 42) */ export { default as ArchetypeBadge } from './ArchetypeBadge'; diff --git a/web/src/lib/gradeAdapter.js b/web/src/lib/gradeAdapter.js index b103841..7c19cde 100644 --- a/web/src/lib/gradeAdapter.js +++ b/web/src/lib/gradeAdapter.js @@ -77,8 +77,13 @@ function mapScanToGradeResult(input = {}) { side, grade: input.grade || '—', confidence, - edge: computeEdge(projection, line, direction), - projection: projection != null ? Math.round(projection * 10) / 10 : line, + // DATA SEMANTICS (Session 58): projection is MODEL output and must never + // be fabricated. The old fallback displayed the LINE as the projection — + // the audit's model==line / +0% edge degenerate. No projection → the + // MODEL row is absent and edge is null (the card renders an absent state, + // not a fake zero-edge read). + edge: projection != null ? computeEdge(projection, line, direction) : null, + projection: projection != null ? Math.round(projection * 10) / 10 : null, phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size), signals, killConditions,