Session 58: Phase 1 — Truth Infrastructure (2327 tests)

ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.

- ledgerService: pipeline pre-grade upserts (public model record, user_id
  null, idempotent), closing capture on every snapshot (last write before
  game start = the close), settlement with SIGNED CLV (over = locked -
  closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
  ledger for authenticated users only (anon never touches the public
  record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
  grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
  longer displays the line as the model projection (the audit's
  model==line / +0% edge degenerate); the card renders absent states.
  projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
  only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
  deferred-render strip on landing + player hero. CLV + outcome chips,
  revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
  amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
  market value is handled (Number(null)===0 would have fabricated lines).

Backend 2309 -> 2327 tests (201 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-10 21:34:26 -04:00
parent 2c79373a3b
commit d296e40cb6
29 changed files with 1578 additions and 223 deletions
+38 -3
View File
@@ -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 = lockedclosing),
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
+37
View File
@@ -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)
+29
View File
@@ -60,6 +60,35 @@ build-out target.
Scope key: S ≈ ½ session, M ≈ 1 session, L ≈ 12 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 (~noonmidnight ET):
- Lightweight ODDS-ONLY refresh every 1530 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)
+17
View File
@@ -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 {
+83 -4
View File
@@ -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;
+8 -1
View File
@@ -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: {} });
+4 -1
View File
@@ -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));
}
+47 -17
View File
@@ -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,
+379
View File
@@ -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,
},
};
+4
View File
@@ -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 },
+14 -1
View File
@@ -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',
+18
View File
@@ -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,
})),
+10
View File
@@ -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');
+91
View File
@@ -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();
});
});
+36 -7
View File
@@ -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', () => {
+5 -2
View File
@@ -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',
+212
View File
@@ -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
});
});
+26
View File
@@ -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 });
}
}
+26
View File
@@ -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 });
}
}
+80 -1
View File
@@ -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<ReturnType<typeof getServiceRoleSupabase>>,
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);
}
}
+235 -166
View File
@@ -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<Sport, 'ALL'>;
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<Exclude<Sport, 'ALL'>, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
mlb: '#1E90FF',
wnba: '#FFB347',
soccer: '#7BC96F',
};
export default function LedgerPage() {
const [sport, setSport] = useState<Sport>('ALL');
const { session } = useAuth();
const [tab, setTab] = useState<Tab>('mine');
const [sport, setSport] = useState<SportFilter>('ALL');
const [tier, setTier] = useState<TierFilter>('ALL');
const [entries, setEntries] = useState<LedgerEntry[] | null>(null);
const [accuracy, setAccuracy] = useState<AccuracyBucket[] | null>(null);
const [rows, setRows] = useState<LedgerRow[] | null>(null);
const [aggregate, setAggregate] = useState<ModelAggregate | null>(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 (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 32 }}>
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
The Ledger.
</h1>
@@ -76,122 +110,49 @@ export default function LedgerPage() {
</p>
</header>
{/* Accuracy header strip */}
{accuracy && accuracy.length > 0 && (
<section
className="surface diagonal-cut animate-fade-up"
style={{
padding: 24,
marginBottom: 24,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 12,
}}
>
{accuracy.map((b) => (
<AccuracyTile key={b.tier} bucket={b} />
))}
{overall && (
<AccuracyTile
bucket={{ tier: 'Overall', hits: overall.hits, losses: overall.losses, pct: overall.pct }}
highlight
/>
)}
</section>
{/* Tabs */}
<div role="tablist" aria-label="Ledger view" style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)' }}>
{([['mine', 'MY READS'], ['model', 'MODEL']] as [Tab, string][]).map(([id, label]) => {
const active = tab === id;
return (
<button
key={id}
role="tab"
aria-selected={active}
onClick={() => setTab(id)}
className="mono"
style={{
padding: '12px 20px', background: 'transparent', border: 'none',
borderBottom: `2px solid ${active ? 'var(--g-a, #00D4A0)' : 'transparent'}`,
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: 700, fontSize: 12, letterSpacing: '0.08em', cursor: 'pointer', marginBottom: -1,
}}
>
{label}
</button>
);
})}
</div>
{/* MODEL aggregate header — never a percentage under min_sample. */}
{tab === 'model' && aggregate && (
<ModelHeader agg={aggregate} minSample={minSample} />
)}
{/* Filters */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} />
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as SportFilter)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C', 'D']} />
</div>
{/* Grid */}
{entries === null ? (
{rows === null ? (
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading</p>
) : entries.length === 0 ? (
<div
className="surface diagonal-cut tex-scan"
style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>
No grades yet.
</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Read your first prop to start building your Ledger. Every grade you run shows up here, with the result.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</div>
) : rows.length === 0 ? (
<EmptyLedger tab={tab} />
) : (
<div
style={{
display: 'grid',
gap: 12,
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}
>
{entries.map((entry, i) => (
<article
key={entry.id}
className={`surface diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
style={{ padding: 16 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span
className="mono"
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
background: `${SPORT_COLOR[entry.sport]}1F`,
color: SPORT_COLOR[entry.sport],
}}
>
{entry.sport}
</span>
<GradePill grade={entry.grade} />
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{entry.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 12 }}>
{entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{entry.actual != null ? `Actual ${entry.actual}` : 'Pending'}
</span>
{entry.hit !== null && (
<span
className="mono"
style={{
fontSize: 12,
fontWeight: 700,
color: entry.hit ? 'var(--grade-a)' : 'var(--grade-d)',
}}
>
{entry.hit ? 'HIT' : 'MISS'}
</span>
)}
</div>
{entry.hit === false && entry.miss_reason && (
<p
style={{
marginTop: 12,
padding: 10,
fontSize: 12,
color: 'var(--grade-d)',
background: 'rgba(255,107,107,0.10)',
borderRadius: 8,
border: '1px solid rgba(255,107,107,0.30)',
}}
>
<strong style={{ fontWeight: 700 }}>Why we missed:</strong> {entry.miss_reason}
</p>
)}
</article>
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{rows.map((row, i) => (
<LedgerCard key={row.id} row={row} index={i} />
))}
</div>
)}
@@ -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 (
<div
style={{
padding: 16,
borderRadius: 12,
background: highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
border: highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
}}
className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
{bucket.tier.toUpperCase()}
{ready ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
MODEL · LAST 30D
</span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
RECORD BUILDING
</p>
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
Every read settles here, misses included.{' '}
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement
</span>
{agg.settled > 0 && (
<span className="mono" style={{ color: 'var(--text-tertiary)' }}> · {agg.settled} settled</span>
)}
</p>
</div>
)}
</div>
);
}
function OutcomeChip({ row }: { row: LedgerRow }) {
if (!row.outcome) {
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
}
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 (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
</span>
);
}
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 (
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
</span>
);
}
function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
return (
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
{row.sport.toUpperCase()}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* Phase 2.5 — a revised grade is public, never silent. */}
{row.revised_from_grade && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{row.revised_from_grade}
</span>
)}
<GradePill grade={row.grade} />
</span>
</div>
<div className="mono" style={{ fontSize: 24, fontWeight: 800, color: highlight ? 'var(--grade-a)' : 'var(--text-primary)', marginTop: 4 }}>
{bucket.pct}%
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
{bucket.hits}-{bucket.losses}
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{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 && <span> · MODEL {row.model_value}</span>}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<OutcomeChip row={row} />
<ClvChip row={row} />
</div>
</article>
);
}
function EmptyLedger({ tab }: { tab: Tab }) {
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
{tab === 'mine' ? (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
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.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</>
) : (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Every pre-graded prop lands here hits, misses, pushes, and closing-line value. Nothing is deleted.
</p>
</>
)}
</div>
);
}
+7 -1
View File
@@ -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() {
<Hero />
{/* Session 55 — tonight's real top signals + live accuracy (the system works). */}
<TopSignals />
{/* 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. */}
<div style={{ padding: '4px 16px 12px' }}>
<ModelRecord />
</div>
{/* Founder-seat scarcity meter (§12) */}
<div style={{ padding: '0 16px 8px' }}>
<ClaimMeter />
+7
View File
@@ -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 && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
</div>
{/* 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. */}
<div style={{ marginTop: 10 }}>
<ModelRecord player={p.player} sport={p.sport} align="left" />
</div>
{p.archetype?.blend?.length > 0 && (
<div style={{ marginTop: 15 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
+27 -2
View File
@@ -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() {
</div>
)}
{/* 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 && (
<div
className="surface scanlines"
style={{ marginTop: 32, padding: 32, textAlign: 'center', border: '1px solid var(--border-hi)', borderRadius: 10, display: 'grid', gap: 10, justifyItems: 'center' }}
>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>
INSUFFICIENT DATA NO READ
</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>
The model has no projection for this prop, so it refuses to grade it.
No number gets invented here that&apos;s the deal.
</p>
<button onClick={reset} className="btn-ghost" style={{ marginTop: 6, padding: '10px 18px' }}>
Read another prop
</button>
</div>
)}
{/* 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 && (
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<ProcessingGrade
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
+14 -7
View File
@@ -18,8 +18,8 @@ export interface GradeResultData {
side: 'Over' | 'Under';
grade: string;
confidence: number;
edge: number;
projection: number;
edge: number | null;
projection: number | null;
phosphorConfirmed?: boolean;
signals: string[];
killConditions?: string[];
@@ -145,8 +145,12 @@ export default function GradeResultCard({
{/* 3. CONFIDENCE STRIP */}
<div className="mono" style={{ position: 'relative', zIndex: 2, marginTop: 8, fontSize: 15, fontWeight: 600, color: 'var(--text-0)', display: 'flex', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ color: hex, fontWeight: 800 }}>{d.grade}</span>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
{d.edge != null && (
<>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
</>
)}
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span>{d.confidence}% confidence</span>
</div>
@@ -159,12 +163,15 @@ export default function GradeResultCard({
)}
</div>
{/* 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%. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', borderBottom: '1px solid var(--border)' }}>
{[
{ 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) => (
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
+25 -8
View File
@@ -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<SnapshotSummary | null>(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 (
<div
className="scanlines"
@@ -127,8 +144,8 @@ export function HeartbeatBar() {
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
</span>
)}
<span className="mono" style={{ color: stale ? 'var(--amber)' : 'var(--text-2)', flexShrink: 0 }}>
SYNC {elapsed !== null ? syncLabel(elapsed) : '—'}
<span className="mono" style={{ color: syncColor, fontWeight: tier === 'stale' ? 700 : 400, flexShrink: 0 }}>
{tier === 'stale' ? 'STALE' : 'SYNC'} {elapsed !== null ? syncLabel(elapsed) : '—'}
</span>
</div>
);
+91
View File
@@ -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<Aggregate | null>(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 (
<div
className="mono"
style={{
display: 'flex',
justifyContent: align === 'center' ? 'center' : 'flex-start',
alignItems: 'baseline',
gap: 12,
flexWrap: 'wrap',
fontSize: 11.5,
letterSpacing: '0.06em',
}}
>
{ready ? (
<>
<span style={{ color: 'var(--text-2, #6A6A7A)', fontWeight: 700 }}>MODEL 30D</span>
<span style={{ color: 'var(--g-a, #00D4A0)', fontWeight: 800 }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span style={{ color: 'var(--text-0, #EDEDF2)', fontWeight: 700 }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
</>
) : (
<span style={{ color: 'var(--amber, #FFB347)', fontWeight: 700 }}>
RECORD BUILDING · {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement
</span>
)}
</div>
);
}
+1
View File
@@ -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';
+7 -2
View File
@@ -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,