'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, logRowOnDate } = require('./outcomeService'); const CONFLICT = 'user_id,player_key,stat,line,side,game_id'; const UPSERT_CHUNK = 200; const SETTLE_FETCH_LIMIT = 500; // Session 64 — bounded retry: a row that cannot be resolved after this many // date-targeted attempts becomes 'unrecoverable' rather than pending forever. const SETTLE_ATTEMPT_CAP = Number(process.env.SETTLE_ATTEMPT_CAP || 4); // Bump when the settlement RULE changes, so healed rows are distinguishable // from originals and the harness can filter by how a row was scored. const SETTLEMENT_VERSION = Number(process.env.SETTLEMENT_VERSION || 2); const settleSource = require('./settleSource'); const MODEL_ERA_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20'; /** Day's games for a sport (date-pinned since S57) — tells us what a player's * ABSENCE means: DNP, postponed, or simply not final yet. */ async function getScheduleFn(sport, date) { // Never reach the network from a test run (the opsNotify/refreshTeamStats // precedent) — a schedule lookup inside the settle loop would hang the suite. if (process.env.NODE_ENV === 'test') return []; try { return await require('./scheduleService').getSchedule(sport, date); } catch { return []; } } 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; // Truth-Everywhere Part 2 (item 7) — CLV capture is broken (closing_line == // locked_line; see the C4 finding). Until C4 records a real closing line, // beat_close/CLV are suppressed everywhere. Read at call time (not module load) // so C4 can flip it via CLV_CAPTURE_RELIABLE=1 without a redeploy, and tests can // exercise the CLV math directly. function clvCaptureReliable() { return process.env.CLV_CAPTURE_RELIABLE === '1'; } /** * S6 (A1 board) — CLV distribution buckets (the MODEL tab strip). Signed CLV: * positive = beat the close. Outliers clamp into the edge buckets so every * settled clv lands somewhere. `side` drives the UI color (green = beat, * red = faded, dim = flat) — same meanings as clv_result. */ const CLV_BUCKETS = [ { label: '[-2,-1)', min: -2, max: -1, side: 'faded' }, { label: '[-1,-.5)', min: -1, max: -0.5, side: 'faded' }, { label: '[-.5,0)', min: -0.5, max: 0, side: 'faded' }, { label: '0', min: 0, max: 0, side: 'flat' }, { label: '(0,.5]', min: 0, max: 0.5, side: 'beat' }, { label: '(.5,1]', min: 0.5, max: 1, side: 'beat' }, { label: '(1,2]', min: 1, max: 2, side: 'beat' }, ]; /** Bucket index for one signed clv value (clamped into the edge buckets). */ function clvBucketIndex(clv) { const v = numOrNull(clv); // strict — Number(null) is 0, a fabricated CLV if (v == null) return -1; if (v === 0) return 3; if (v < 0) { if (v >= -0.5) return 2; if (v >= -1) return 1; return 0; // ≤ -1 clamps into [-2,-1) } if (v <= 0.5) return 4; if (v <= 1) return 5; return 6; // > 1 clamps into (1,2] } 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)); // Nickname token (last word, lowercased) — the stable cross-source team // identifier ("New York Yankees" ↔ "Yankees" ↔ "NYY" won't match, but // full-name feeds match full-name feeds; abbr feeds match abbr feeds). const nickToken = (name) => { const w = String(name || '').trim().split(/\s+/); return (w[w.length - 1] || '').toLowerCase().replace(/[^a-z]/g, ''); }; const teamsMatch = (a, b) => { if (!a || !b) return false; const sa = String(a).toLowerCase(), sb = String(b).toLowerCase(); return sa === sb || nickToken(a) === nickToken(b); }; /** * Session 59 — team/opponent from the REAL feed. The player's team comes * from the stats resolve (g.team); the opponent is the other side of the * prop's game IF the team matches one of its participants. No match → * opponent stays null — never guessed. */ function teamOpponentFor(g, prop) { const team = g && g.team ? String(g.team) : null; if (!team || !prop) return { team, opponent: null }; if (teamsMatch(team, prop.home_team)) return { team, opponent: prop.away_team || null }; if (teamsMatch(team, prop.away_team)) return { team, opponent: prop.home_team || null }; return { team, opponent: null }; } /** Index odds props by nameKey|stat for lock/closing lookups. * Session 61 — prefer a book row with BOTH sides priced (same rule as * snapshotService.indexOdds): fewer genuinely-absent locked/closing odds * when another book carried the side. Real rows only, never synthesized. */ function indexProps(props) { const map = {}; const bothSides = (p) => p && p.over_odds != null && p.under_odds != null; 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] || (!bothSides(map[k]) && bothSides(p))) 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(); let skippedUnbound = 0; 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; // Session 64 (Order 1.5) — a game date comes from the GAME, never from the // grade clock. The old `|| dateET(gradedTs) || todayET()` fallback is what // filed tonight's props under yesterday and made settlement impossible. // gameBinder attaches game_time upstream; if a prop still has none, the // row is UNRESOLVED and is skipped — the ledger holds real values or // nothing, and a mis-dated row is fabricated data. const gameDate = dateET(prop && prop.game_time) || (prop && prop.game_date) || null; if (!gameDate) { skippedUnbound += 1; continue; } const { team, opponent } = teamOpponentFor(g, prop); rows.push({ team, opponent, 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, // Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are // labelled 'pre-retention-unknown' by migration 026; they cannot be // resolved retroactively. model_version: MODEL_ERA_VERSION, game_id: gameIdFor(sp, prop, gameDate), game_date: gameDate, }); } if (skippedUnbound > 0) { console.warn(`[ledger] ${skippedUnbound} ${sp} rows SKIPPED — no real game time; refusing to date them from the grade clock`); } 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. */ /** * Per-read directional CLV for one settling row (Session 64). * * THE JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date) * — WITHOUT `line`, because a close that moved off the graded line is the whole * point. Verified clean: 164 identity groups, zero ambiguity. * * The LOCK end reads model_snapshots (both side prices + an already-de-vigged * fair_prob from the same devig function), NOT ledger_entries.locked_odds, * which is single-side and cannot be de-vigged. * * Never throws: a CLV failure must not block a settlement. */ async function computeDirectionalForRow(sb, sport, row, deps = {}) { try { const dclv = deps.directionalClv || require('./directionalClv'); const [{ data: snaps }, { data: closes }] = await Promise.all([ sb.from('model_snapshots') .select('fair_prob, over_odds, under_odds, captured_at') .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat) .eq('side', row.side).eq('game_date', row.game_date) .order('captured_at', { ascending: true }).limit(1), sb.from('closing_captures') .select('over_odds, under_odds, missed_reason, captured_at') .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat) .eq('side', row.side).eq('game_date', row.game_date) .order('captured_at', { ascending: false }).limit(1), ]); const lock = snaps && snaps[0]; const close = closes && closes[0]; if (!lock) return null; // no retained lock → nothing to compare return dclv.computeDirectionalClv({ side: row.side, lockFairProb: lock.fair_prob, lockOverOdds: lock.over_odds, lockUnderOdds: lock.under_odds, closeOverOdds: close ? close.over_odds : null, closeUnderOdds: close ? close.under_odds : null, missedReason: close ? close.missed_reason : null, }); } catch (e) { console.warn('[ledger] directional CLV failed (settlement continues):', e.message); return null; } } 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; // Session 64 — injectable like every other dep: tests must never reach the // network, and a schedule lookup inside the settle loop would otherwise hang // the suite. const getSchedule = opts.getSchedule || getScheduleFn; 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, settle_attempts') .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); // Session 64 — prefer the FULL season log for settlement. MLB already // fetched it (getPlayerStats was discarding it via .slice(-10)), so this // costs nothing and removes window-decay entirely. Fall back to last10 // for sources that only expose a window (ESPN). const full = stats && Array.isArray(stats.fullLog) ? stats.fullLog : null; const win = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : []; logByPlayer[player] = (full && full.length) ? full : win; } catch { logByPlayer[player] = []; } } let settled = 0; let pending = 0; let voided = 0; let unrecoverable = 0; for (const row of rows || []) { const log = logByPlayer[row.player_name] || []; // Session 64 — DATE-TARGETED settlement. The old code searched a ROLLING // window and, on a miss, did `pending += 1` with no terminal state: a row // that could never settle (player DNP, or the window rolled past the game) // looked identical to one settling tomorrow, and pended forever. // `resolveOutcome` reads the FULL log for that exact date and, when the // player is absent, consults the day's SCHEDULE to learn what the absence // MEANS — DNP vs postponed vs not-yet-final. const res = await settleSource.resolveOutcome({ sport: sp, playerName: row.player_name, gameDate: row.game_date, statType: row.stat, getFullLog: async () => log, getSchedule, matchesDate: (r, d) => logRowOnDate(r, d, sp), statValue: (statObj, st) => statValue(statObj, st, sp), }); const attempts = Number(row.settle_attempts || 0) + 1; // Not final yet (scheduled / in progress / SUSPENDED) → stay pending. A // suspended game resumes and settles later; voiding it would destroy a // real bet. if (res.state === 'pending') { await sb.from('ledger_entries').update({ settle_attempts: attempts }) .eq('id', row.id).is('outcome', null); pending += 1; continue; } // Could not determine — bounded retry, then a terminal state. Nothing is // immortal. if (res.state === 'unknown') { const terminal = attempts >= SETTLE_ATTEMPT_CAP; await sb.from('ledger_entries').update({ settle_attempts: attempts, ...(terminal ? { outcome: 'unrecoverable', settled_at: nowIso, settlement_source: res.source || 'unknown', settlement_version: SETTLEMENT_VERSION, } : {}), }).eq('id', row.id).is('outcome', null); if (terminal) unrecoverable += 1; else pending += 1; continue; } // No bet existed — DNP or postponed/cancelled. VOID is a terminal truth, // not a failure, and it is excluded from every record denominator. if (res.state === 'void') { const { error: vErr } = await sb.from('ledger_entries').update({ outcome: 'void', settled_at: nowIso, settle_attempts: attempts, settlement_source: res.reason || 'void', settlement_version: SETTLEMENT_VERSION, }).eq('id', row.id).is('outcome', null); if (vErr) { pending += 1; continue; } voided += 1; continue; } const actual = res.value; const outcome = settleResult(row.side, actual, row.line); if (!outcome) { pending += 1; continue; } const clv = computeClv(row.side, row.line, row.closing_line); // Session 64 — DIRECTIONAL CLV is computed HERE, in the settle pass. This // is the trigger: at settle the game is final, so the close has landed and // the read is final — the only moment BOTH ends of the comparison exist. // (Grade + locked prices are written hours earlier; the close at lock. A // CLV function without this trigger would be a correct dead wire.) const dclvRes = await computeDirectionalForRow(sb, sp, row, opts); const { error: upErr } = await sb.from('ledger_entries') .update({ outcome, actual_value: actual, settled_at: nowIso, clv, clv_result: clvResultOf(clv), ...(dclvRes ? { dclv: dclvRes.clv, dclv_state: dclvRes.state, dclv_fair_lock: dclvRes.fair_lock, dclv_fair_close: dclvRes.fair_close, dclv_computed_at: nowIso, } : {}), settle_attempts: attempts, settlement_source: res.source || 'date_log', settlement_version: SETTLEMENT_VERSION, }) .eq('id', row.id) .is('outcome', null); // double-settle guard even across concurrent runs if (upErr) { pending += 1; continue; } settled += 1; } return { settled, voided, unrecoverable, pending }; } /** * Phase 2.5 (Session 60) — a PUBLIC grade revision. The intraday refresh * re-graded a prop whose line moved ≥1.0 against the graded side and the * grade dropped: update the locked row's grade and set revised_from_grade * ONCE (the original letter is preserved forever — revisions are public, * never silent). Only unsettled public rows for today are touched. */ async function applyRevision(sport, { playerKey, stat, line, side, newGrade, fromGrade }, opts = {}) { if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured' }; const sb = opts.sb || defaultClient(); const { error } = await sb.from('ledger_entries') .update({ grade: newGrade, revised_from_grade: fromGrade }) .is('user_id', null) .eq('sport', String(sport).toLowerCase()) .eq('player_key', playerKey) .eq('stat', stat) .eq('line', line) .eq('side', side) .is('outcome', null); return error ? { error: error.message } : { revised: true }; } 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) { const sp = String(sport || '').toLowerCase(); if (sp === 'mlb') { return require('./adapters/mlbStatsAdapter').getPlayerStats(name); } // Wave 1 — NBA/WNBA settle against the FREE ESPN per-game log. if (sp === 'nba' || sp === 'wnba') { return require('./adapters/espnStatsAdapter').getPlayerGameLog(name, sp); } return { found: false }; // soccer — no free settled-result feed yet → pending } /** * Session 8 (A1 board, ops) — count of ledger rows for one game_date (the * daily pulse's "rows written yesterday"). Returns null (NOT 0) when Supabase * isn't configured or the count fails — the pulse renders "n/a", never a * fabricated zero. */ async function countRowsForDate(gameDate, opts = {}) { if (!gameDate) return null; if (!opts.sb && !isConfigured()) return null; try { const sb = opts.sb || defaultClient(); const { count, error } = await sb.from('ledger_entries') .select('id', { count: 'exact', head: true }) .eq('game_date', gameDate); if (error) return null; return count || 0; } catch { return null; } } /** * 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. * * A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)` * scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same * n≥20 gate) over one user's own ledger, powering public profiles. The * public default is untouched. */ 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, clv_distribution: null, // S6 — set past the n≥20 gate only 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, clv, player_key, grade, model_value'); settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null); settledQ = settledQ .not('outcome', 'is', null) // Session 64 — void (no bet existed: DNP/postponed) and unrecoverable // (truth not fetchable) are TERMINAL but are NOT results. They must never // enter a record denominator, exactly as pushes are excluded from hit_pct. // Without this, voiding a row would silently move the public record. .not('outcome', 'in', '("void","unrecoverable")') // Session 64 (Order 2) — QUARANTINE. A row whose GRADE is untrustworthy // (wrong_opponent_grade) stays a real public settled result but must never // train or validate a model, so it leaves the denominator exactly as // void/unrecoverable do. NOTE: `analysis_flags` (e.g. doubleheader) is // deliberately NOT filtered here — those rows settle validly and belong in // the record; they are excluded only from per-game/opponent analysis. .is('quarantine_reason', null) // 2026-07 — a grade with a non-positive model_value had NO real projection // (the pre-fix degradation). Those locks are kept in the append-only ledger // but must not count toward the public model record — their hit/miss is // noise, not model skill. `.gt` also excludes NULL model_value. Post-fix no // such row can be written (projection<=0 now refuses), so this only filters // the historical blast radius. .gt('model_value', 0) .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); if (opts.team) settledQ = settledQ.eq('team', opts.team); // Session 60 (5.2) — VYNDR-on-team 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 }); pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null); pendingQ = pendingQ.is('outcome', null).gt('model_value', 0); // same real-projection filter if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase()); if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey); if (opts.team) pendingQ = pendingQ.eq('team', opts.team); const { count: pending } = await pendingQ; const agg = { ...empty, pending: pending || 0 }; // Session 60 (5.5) — calibration by grade tier (A+ alone, then first // letter). Same n≥20 rule PER TIER: a tier below threshold reports a // null pct and the UI shows "building", never a small-sample %. const tierOf = (g) => { const s = String(g || '').trim().toUpperCase(); if (!s) return null; return s === 'A+' ? 'A+' : s[0]; }; const byTier = {}; 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 t = tierOf(r.grade); if (t) { byTier[t] = byTier[t] || { settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null }; const b = byTier[t]; b.settled += 1; if (r.outcome === 'hit') b.hits += 1; else if (r.outcome === 'miss') b.misses += 1; else if (r.outcome === 'push') b.pushes += 1; } } for (const t of Object.keys(byTier)) { const b = byTier[t]; const d = b.hits + b.misses; if (b.settled >= MIN_AGG_SAMPLE && d > 0) b.hit_pct = Math.round((b.hits / d) * 100); } agg.by_tier = byTier; 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); } // Truth-Everywhere Part 2 (item 7) — CLV is currently MEASURED WRONG: // captureClosing re-records the LOCKED line as the "closing" line // (closing_line == locked_line across the whole sample), so every row's CLV // computes to 0/flat and beat_close reads a fabricated-looking 0%. That's // comparing a number to itself. Until C4 (real closing-line capture) lands, // CLV_CAPTURE_RELIABLE stays false and beat_close_pct / clv_distribution are // suppressed at the SOURCE — every public surface hides BEAT CLOSE rather // than showing a measured-wrong zero. Flip this to true when C4 ships. if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) { agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100); } // S6 (A1 board) — clv_distribution rides the SAME n≥20 gate (this is the // single home of the gate — consumers never re-derive it). Null below the // sample floor or with zero settled clv values; the UI renders nothing. agg.clv_distribution = null; if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) { const dist = CLV_BUCKETS.map((b) => ({ ...b, count: 0 })); let counted = 0; for (const r of settledRows || []) { const i = clvBucketIndex(r.clv); if (i >= 0) { dist[i].count += 1; counted += 1; } } if (counted > 0) agg.clv_distribution = dist; } return agg; } // Truth-Everywhere Part 2 (item 7) — the public 30D accuracy VIEW, built from // the CLEAN ledger aggregate (model_value > 0), NOT the Redis outcome log // (which still counts degraded projection-0 rows and can't be filtered). Same // shape the AccuracyBadge / buckets consumed from outcomeService, so no // frontend change. Redis is a cache; when a cache can't be filtered, read truth. const ACCURACY_VIEW_SPORTS = ['mlb', 'wnba', 'nba', 'soccer']; function _aggToRecord(agg, sport) { const byGrade = {}; for (const [tier, b] of Object.entries(agg.by_tier || {})) { byGrade[tier] = { hits: b.hits, misses: b.misses, pushes: b.pushes, total: b.hits + b.misses + b.pushes, pct: b.hit_pct ?? null, }; } return { sport, updated_at: null, window_days: agg.window_days, sample: agg.settled, min_sample: agg.min_sample, overall: { hits: agg.hits, misses: agg.misses, pushes: agg.pushes, total: agg.hits + agg.misses + agg.pushes, pct: agg.hit_pct ?? null, }, byGrade, }; } async function getAccuracyView(opts = {}) { const base = { sb: opts.sb, nowMs: opts.nowMs }; const overallAgg = await getModelAggregate(base); const sports = {}; for (const s of ACCURACY_VIEW_SPORTS) { const a = await getModelAggregate({ ...base, sport: s }); if (a.settled > 0) sports[s] = _aggToRecord(a, s); } return { overall: _aggToRecord(overallAgg, 'overall'), sports, min_sample: overallAgg.min_sample, updated_at: null, }; } // Grade-tier buckets for the ledger accuracy strip, from the clean aggregate. function accuracyBucketsFromAgg(agg) { // First-letter buckets (A+ folds into A for the public strip, matching the // old outcomeService.accuracyBuckets contract), n≥20 gate per bucket. const order = ['A', 'B', 'C', 'D', 'F']; const rolled = {}; for (const [tier, b] of Object.entries(agg.by_tier || {})) { const k = tier === 'A+' ? 'A' : tier[0]; rolled[k] = rolled[k] || { hits: 0, misses: 0, total: 0 }; rolled[k].hits += b.hits; rolled[k].misses += b.misses; rolled[k].total += b.hits + b.misses + b.pushes; } return order .filter((k) => rolled[k] && rolled[k].total > 0) .map((k) => { const r = rolled[k]; const decided = r.hits + r.misses; const pct = r.total >= MIN_AGG_SAMPLE && decided > 0 ? Math.round((r.hits / decided) * 100) : null; return { grade: k, hits: r.hits, total: r.total, pct }; }); } module.exports = { recordPipelineGrades, captureClosing, settleLedger, settleAllLedgers, applyRevision, countRowsForDate, getModelAggregate, getAccuracyView, accuracyBucketsFromAgg, MIN_AGG_SAMPLE, __internals: { rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, dateET, sideOf, oddsForSide, isConfigured, CONFLICT, teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS, }, };