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
+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');