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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user