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