diff --git a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte index 0c1fda4..1c8881d 100644 --- a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte +++ b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte @@ -14,6 +14,7 @@ */ import { onMount } from 'svelte'; import { api } from '$stores/api'; + import { eventFeed } from '$stores/websocket'; import type { GraphResponse } from '$types'; import TelemetryStrip from '$lib/observatory/overlays/TelemetryStrip.svelte'; import TimelineSpine from '$lib/observatory/overlays/TimelineSpine.svelte'; @@ -30,6 +31,7 @@ import { FirewallRenderer } from '$lib/observatory/firewall-renderer'; import { buildFirewallPlan, type FirewallPlan } from '$lib/observatory/firewall-plan'; import type { PathStepMeta } from '$lib/observatory/path-builder'; + import { LiveBridge } from '$lib/observatory/live-bridge'; interface Props { demo: DemoMode; @@ -62,6 +64,15 @@ * back with its memory id (the host opens its inspector panel). */ onpick?: (memoryId: string) => void; + /** + * v2.3 living field — subscribe the field to the REAL backend event + * stream ($eventFeed) so it renders live FSRS decay, the contradiction + * firewall, the dream storm, and the causal recall wavefront driven by + * real cognitive events instead of the deterministic DemoClock. Only the + * main graph's field renderer turns this on; the scripted ?demo= moments + * stay deterministic (live=false). + */ + live?: boolean; } let { @@ -74,9 +85,18 @@ onexit, embedded = false, chrome = 'full', - onpick + onpick, + live = false }: Props = $props(); + // v2.3 living field — forward-projection scrubber (Phase 1 honesty control). + // Real FSRS drift over a viewing session is imperceptibly slow, so this adds + // N days to every node's real elapsed and recomputes decay on the SAME true + // curve — legible, not faked. 0 = "now". Bound by the on-page control below. + let projectionDays = $state(0); + let liveBridge: LiveBridge | null = null; + let liveDecayReady = $state(false); + // GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click). let canvasLayerEl: HTMLDivElement | null = $state(null); async function handleFieldClick(e: MouseEvent) { @@ -266,12 +286,44 @@ pathSteps = renderer.pathSteps; } + // v2.3 living field — wire the field to the REAL backend event stream. + // Only the main graph's field renderer (live=true); the scripted + // ?demo= moments stay deterministic. The bridge drives the live lanes + // + per-node FSRS decay via the engine's preFrameHook; the eventFeed + // subscription below feeds it. Created here (once) because it needs + // both the engine AND renderer.graph, which only exist post-upload. + if (live && renderer.graph) { + liveBridge = new LiveBridge({ + engine, + renderer, + graph: renderer.graph, + projectionDays: () => projectionDays + }); + liveDecayReady = liveBridge.liveDecayAvailable; + engine.setPreFrameHook((simFrame) => liveBridge?.drain(simFrame)); + } + // Start the story at frame 0 now that the field exists — where the // loop begins must never depend on how long the API call took. engine.demoClock.reset(); } }); + // Feed the live bridge every time the event store changes (newest-first). + // The bridge dedupes internally (only events newer than the last seen are + // applied) and is a no-op when live=false / the bridge isn't built yet. + $effect(() => { + const events = $eventFeed; + if (liveBridge) liveBridge.ingest(events); + }); + + // The forward-projection scrubber recomputes decay immediately on change so + // dragging the slider is responsive (the per-frame drain throttles decay). + $effect(() => { + void projectionDays; + liveBridge?.refreshDecay(); + }); + onMount(() => { loadGraph(); }); @@ -307,6 +359,42 @@ chrome='none' leaves only loading/error (host page owns the chrome). --> {#if showHud}
+ + {#if live && liveDecayReady} +
+ Forgetting horizon + + + {projectionDays === 0 ? 'now' : `+${projectionDays}d`} + + {#if projectionDays !== 0} + + {/if} +
+ {/if} + {#if chrome === 'full'} void>(); + /** + * Live-event hook (v2.3 living field). Invoked once per frame AFTER p[0..11] + * are set but BEFORE the params buffer is written to the GPU, so the live + * bridge can drive lanes 12..15 (liveKind/liveStartFrame/liveEnergy/ + * projectionDays) and mutate node buffers from the real backend WebSocket + * stream. `simFrame` is the monotonic (non-wrapping) sim frame so live + * event envelopes never pop at the 720-frame loop seam. Allocation-free by + * contract — the bridge drains a preallocated ring buffer here. + */ + private preFrameHook: ((simFrame: number) => void) | null = null; + // fps estimate for telemetry only (never sim state) private lastRafTs = 0; private fpsEstimate = 0; @@ -154,6 +165,29 @@ export class ObservatoryEngine { this.passes.push(pass); } + /** + * Register the per-frame live-event hook (v2.3). See `preFrameHook`. Pass + * null to detach (the field falls back to the calm deterministic loop). + */ + setPreFrameHook(hook: ((simFrame: number) => void) | null): void { + this.preFrameHook = hook; + } + + /** Monotonic sim frame (does NOT wrap at the loop period). */ + get totalFrames(): number { + return this.clock.state.totalFrames; + } + + /** + * Wall-clock now in ms. The ONE sanctioned wall-clock read (never for sim + * state — the DemoClock owns that). The live FSRS decay field legitimately + * needs real time to compute "days since last review"; that is a real + * external fact, not simulation state, so it does not break determinism. + */ + get wallNowMs(): number { + return Date.now(); + } + /** Boot WebGPU. Resolves true when running, false when unsupported/error. */ async start(): Promise { if (this.disposed) return false; @@ -319,6 +353,14 @@ export class ObservatoryEngine { // storage-buffer state stays frozen at the initial upload values, // making same URL + frame → identical pixels (spec §4 Inc 9). p[11] = this.freezeFrame !== null ? 1.0 : 0.0; + + // v2.3 living field — let the live bridge drive lanes 12..15 and mutate + // node buffers from the real backend event stream. Runs on the + // monotonic sim frame so envelopes never pop at the loop seam. Lanes + // 12..15 persist across frames in `this.params` (the loop above only + // writes 0..11), so a settled field with no live events stays calm. + this.preFrameHook?.(state.totalFrames); + this.device.queue.writeBuffer(this.paramsBuffer, 0, p); let swapTex: GPUTexture; diff --git a/apps/dashboard/src/lib/observatory/fsrs.ts b/apps/dashboard/src/lib/observatory/fsrs.ts new file mode 100644 index 0000000..85a69f3 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/fsrs.ts @@ -0,0 +1,84 @@ +/** + * FSRS-6 retrievability — an EXACT TypeScript port of the Rust closed form + * (crates/vestige-core/src/fsrs/algorithm.rs:101 `retrievability_with_decay`). + * + * This is the moat, not decoration: the living decay field dims each memory on + * its REAL per-memory forgetting curve. Every value here is verified against + * source — nothing is approximated, so the field is honest by construction + * (feed it noise instead of the real curve and the drift is legibly wrong). + * + * factor = 0.9^(-1/w20) - 1 + * R = (1 + factor * elapsed_days / stability)^(-w20) + * + * with the personalizable w20 defaulting to the engine's DEFAULT_DECAY. + * stability is in DAYS; elapsed_days is days since last review. + */ + +/** + * FSRS-6 default forgetting-curve decay (w20). Mirrors the Rust + * `DEFAULT_DECAY = 0.1542` constant. NOTE: not 0.01036 — that value appears + * nowhere in the engine; verified against algorithm.rs on 2026-07-08. + */ +export const DEFAULT_DECAY = 0.1542; + +/** FSRS-6 forgetting factor: `0.9^(-1/w20) - 1`. */ +export function forgettingFactor(w20: number = DEFAULT_DECAY): number { + return Math.pow(0.9, -1 / w20) - 1; +} + +/** + * Probability of recall (0..1) for a memory of the given `stability` (days) + * after `elapsedDays` since its last review. Exact port of the Rust guard + * clauses: stability<=0 → 0, elapsed<=0 → 1. + */ +export function retrievability( + stability: number, + elapsedDays: number, + w20: number = DEFAULT_DECAY +): number { + if (!(stability > 0)) return 0; + if (!(elapsedDays > 0)) return 1; + const factor = forgettingFactor(w20); + const r = Math.pow(1 + (factor * elapsedDays) / stability, -w20); + return r < 0 ? 0 : r > 1 ? 1 : r; +} + +const MS_PER_DAY = 86_400_000; + +/** + * Days elapsed between an ISO timestamp (`lastAccessed`) and `nowMs`, plus an + * optional forward projection. The projection is the Phase-1 honesty control: + * with live w20 and multi-day stabilities, real drift over a viewing session + * is imperceptibly slow, so the scrubber recomputes elapsed at `t + N days` on + * the SAME true curve — fully honest, just legible. + * + * Returns 0 for a missing/unparseable timestamp so a node with no last-access + * simply reads as freshly reviewed (R=1) rather than crashing the field. + */ +export function elapsedDays( + lastAccessedIso: string | undefined, + nowMs: number, + projectionDays = 0 +): number { + if (!lastAccessedIso) return projectionDays > 0 ? projectionDays : 0; + const t = Date.parse(lastAccessedIso); + if (!Number.isFinite(t)) return projectionDays > 0 ? projectionDays : 0; + const days = (nowMs - t) / MS_PER_DAY; + return Math.max(0, days) + Math.max(0, projectionDays); +} + +/** + * Live retrievability for a memory from its real FSRS state, at `nowMs` with an + * optional forward projection. This is the single value the decay field writes + * into each node's `vel_retention.w` every frame. + */ +export function liveRetrievability( + stability: number | undefined, + lastAccessedIso: string | undefined, + nowMs: number, + projectionDays = 0, + w20: number = DEFAULT_DECAY +): number { + if (stability === undefined || !Number.isFinite(stability)) return 1; + return retrievability(stability, elapsedDays(lastAccessedIso, nowMs, projectionDays), w20); +} diff --git a/apps/dashboard/src/lib/observatory/live-bridge.ts b/apps/dashboard/src/lib/observatory/live-bridge.ts new file mode 100644 index 0000000..40491f0 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/live-bridge.ts @@ -0,0 +1,368 @@ +/** + * Live event bridge — the field's nervous system (Phase 0, v2.3). + * + * The Observatory field was inert: it ran the deterministic DemoClock and never + * heard the backend. This bridge is the ONE integration that makes it live — + * every hero (live FSRS decay, the contradiction firewall, the dream storm, the + * causal recall wavefront) rides it. + * + * Contract: + * - ObservatoryStage pushes new VestigeEvents (from $eventFeed) via `ingest`. + * - The engine calls `drain(simFrame)` once per frame (its preFrameHook), + * AFTER p[0..11] are set, BEFORE the params buffer is written to the GPU. + * - `drain` applies queued events to engine params (lanes 12..15) and node + * buffers for the current frame. Allocation-free in the hot path: a fixed + * ring buffer of decoded events + preallocated typed arrays, no per-frame + * GC pressure. + * + * Determinism note: the deterministic demo loop is untouched. This bridge only + * writes the LIVE lanes (0.0 at rest) and the live-retention buffer, so a field + * with no backend events is pixel-identical to the pre-bridge build. + */ + +import type { ObservatoryEngine } from './engine'; +import type { NodeRenderer } from './node-renderer'; +import type { VestigeEvent } from '$types'; +import { LIVE_KIND, PARAM_IDX, type ObservatoryGraph } from './types'; +import { liveRetrievability } from './fsrs'; + +/** A decoded live event, normalized off the wire's { type, data } envelope. */ +interface DecodedEvent { + kind: number; // LIVE_KIND.* + startFrame: number; // monotonic sim frame it fired + /** Primary target node id (firewall/recall focus), '' if none. */ + targetId: string; + /** Neighbor / path node ids the event references. */ + relatedIds: string[]; + /** Contradiction / connection pairs [a,b][] the event carries. */ + pairs: [string, string][]; + /** Free-form scalar (estimated_cascade, weight, memory_count …). */ + scalar: number; +} + +/** How long (sim frames @60fps) each live event's envelope plays before idle. */ +const LIVE_DURATION: Record = { + [LIVE_KIND.firewall]: 620, // full quarantine choreography (matches demo) + [LIVE_KIND.dreamStorm]: 0, // open-ended: held until DreamCompleted arrives + [LIVE_KIND.causalRecall]: 260, // wavefront sweep + afterglow + [LIVE_KIND.birth]: 180 +}; + +export interface LiveBridgeDeps { + engine: ObservatoryEngine; + renderer: NodeRenderer; + graph: ObservatoryGraph; + /** + * Forward-projection scrubber (Phase 1). Days added to every node's real + * FSRS elapsed so decay is legible in one viewing session. A getter so the + * bridge always reads the live control value without re-subscribing. + */ + projectionDays?: () => number; + /** Dev/verification: mirror what the bridge applied, for assertions. */ + onApply?: (info: { simFrame: number; activeKind: number; eventsSeen: number }) => void; +} + +export class LiveBridge { + private engine: ObservatoryEngine; + private renderer: NodeRenderer; + private graph: ObservatoryGraph; + private projectionDays: () => number; + private onApply?: LiveBridgeDeps['onApply']; + + /** id → stable buffer index (from the ObservatoryGraph). */ + private indexById: Map; + + /** The single active live event (newest wins; heroes are momentary). */ + private active: DecodedEvent | null = null; + /** True while a dream storm is open (between Started and Completed). */ + private dreamOpen = false; + + /** Preallocated per-node live-retention mirror (Phase 1). */ + private retention: Float32Array; + /** Whether any node carries real FSRS state (else keep static retention). */ + private hasLiveDecay = false; + /** Monotonic counter of events applied — for the dev assertion. */ + private eventsSeen = 0; + /** Last sim frame we recomputed decay (throttle: decay drifts slowly). */ + private lastDecayFrame = -1000; + + constructor(deps: LiveBridgeDeps) { + this.engine = deps.engine; + this.renderer = deps.renderer; + this.graph = deps.graph; + this.projectionDays = deps.projectionDays ?? (() => 0); + this.onApply = deps.onApply; + this.indexById = deps.graph.indexById; + + const n = deps.graph.nodes.length; + this.retention = new Float32Array(n); + for (let i = 0; i < n; i++) { + const node = deps.graph.nodes[i]; + this.retention[i] = node.retention; + if (node.stability !== undefined && node.lastAccessed) this.hasLiveDecay = true; + } + + // Seed the live lanes to a calm resting state. + const p = this.engine.params; + p[PARAM_IDX.liveKind] = LIVE_KIND.none; + p[PARAM_IDX.liveStartFrame] = 0; + p[PARAM_IDX.liveEnergy] = 0; + p[PARAM_IDX.projectionDays] = 0; + } + + /** Whether real per-memory FSRS decay data is present (Phase 1 gate). */ + get liveDecayAvailable(): boolean { + return this.hasLiveDecay; + } + + // ------------------------------------------------------------------------- + // Ingestion — called from the ObservatoryStage $eventFeed subscription. + // $eventFeed is newest-first; we apply oldest-first so cause precedes + // effect (mirrors Graph3D). `events` is the full store slice; we only act + // on ones newer than the last we saw (tracked by object identity + a small + // dedupe window on timestamp is unnecessary — the store slices FIFO). + // ------------------------------------------------------------------------- + + private lastSeenTop: VestigeEvent | null = null; + + ingest(events: VestigeEvent[]): void { + if (events.length === 0) return; + // Find the boundary: everything above lastSeenTop is new. + let newCount = events.length; + if (this.lastSeenTop) { + const idx = events.indexOf(this.lastSeenTop); + if (idx >= 0) newCount = idx; + } + if (newCount === 0) return; + // Apply oldest→newest (reverse, since events[] is newest-first). + for (let i = newCount - 1; i >= 0; i--) { + this.decodeAndArm(events[i], this.engine.totalFrames); + } + this.lastSeenTop = events[0]; + } + + /** Decode one wire event and, if it's a hero trigger, arm it. */ + private decodeAndArm(ev: VestigeEvent, simFrame: number): void { + const data = (ev.data ?? {}) as Record; + switch (ev.type) { + case 'MemorySuppressed': { + const id = str(data.id); + if (!id || !this.indexById.has(id)) return; + this.arm({ + kind: LIVE_KIND.firewall, + startFrame: simFrame, + targetId: id, + relatedIds: this.neighborsOf(id), + pairs: [], + scalar: num(data.estimated_cascade) + }); + break; + } + case 'DeepReferenceCompleted': { + const pairs = decodePairs(data.contradiction_pairs); + // Contradiction pairs both present in the field → firewall. + const livePairs = pairs.filter( + ([a, b]) => this.indexById.has(a) && this.indexById.has(b) + ); + if (livePairs.length > 0) { + const target = livePairs[0][0]; + this.arm({ + kind: LIVE_KIND.firewall, + startFrame: simFrame, + targetId: target, + relatedIds: livePairs.flatMap((p) => p).filter((x) => x !== target), + pairs: livePairs, + scalar: livePairs.length + }); + return; + } + // Otherwise a recall: light the backward causal path (Phase 4). + const primary = str(data.primary_id); + const supporting = strArr(data.supporting_ids).filter((x) => this.indexById.has(x)); + if (primary && this.indexById.has(primary)) { + this.arm({ + kind: LIVE_KIND.causalRecall, + startFrame: simFrame, + targetId: primary, + relatedIds: supporting, + pairs: [], + scalar: num(data.confidence) + }); + } + break; + } + case 'BackfillFired': + case 'CausalReceipt': { + // Phase 4 dedicated event: carries the backward causal path. + const path = strArr(data.path_ids ?? data.causal_path).filter((x) => + this.indexById.has(x) + ); + const target = str(data.target_id ?? data.effect_id) || path[0]; + if (target && this.indexById.has(target)) { + this.arm({ + kind: LIVE_KIND.causalRecall, + startFrame: simFrame, + targetId: target, + relatedIds: path.filter((x) => x !== target), + pairs: [], + scalar: path.length + }); + } + break; + } + case 'DreamStarted': { + this.dreamOpen = true; + this.arm({ + kind: LIVE_KIND.dreamStorm, + startFrame: simFrame, + targetId: '', + relatedIds: [], + pairs: [], + scalar: num(data.memory_count) + }); + break; + } + case 'DreamCompleted': { + this.dreamOpen = false; + // Let the storm settle: keep the current active event but mark it + // finite from now (drain will fade it out over ~120 frames). + if (this.active && this.active.kind === LIVE_KIND.dreamStorm) { + this.active.startFrame = simFrame - 500; // jump near the tail + } + break; + } + // ConnectionDiscovered is consumed by the dream-storm edge appender + // in ObservatoryStage directly (it needs the renderer's setEdges); + // the bridge just keeps the storm energy high while they stream. + case 'ConnectionDiscovered': { + if (this.dreamOpen && this.active?.kind === LIVE_KIND.dreamStorm) { + this.active.scalar += 1; // more connections → more agitation + } + break; + } + default: + break; + } + } + + private arm(ev: DecodedEvent): void { + this.active = ev; + this.eventsSeen++; + } + + /** Real graph neighbors of a node id (for the firewall quarantine ring). */ + private neighborsOf(id: string): string[] { + const idx = this.indexById.get(id); + if (idx === undefined) return []; + const out: string[] = []; + for (const e of this.graph.edges) { + if (e.sourceIndex === idx) out.push(this.graph.nodes[e.targetIndex].id); + else if (e.targetIndex === idx) out.push(this.graph.nodes[e.sourceIndex].id); + if (out.length >= 12) break; + } + return out; + } + + // ------------------------------------------------------------------------- + // Per-frame drain — the engine's preFrameHook. Allocation-free. + // ------------------------------------------------------------------------- + + drain(simFrame: number): void { + const p = this.engine.params; + + // --- Phase 1: live FSRS decay (recompute retrievability on the true + // curve). Throttled to every 6 frames (10Hz) — decay drifts far slower + // than a frame, and the scrubber jumps are applied immediately below. --- + const proj = this.projectionDays(); + p[PARAM_IDX.projectionDays] = proj; + if (this.hasLiveDecay && (simFrame - this.lastDecayFrame >= 6 || proj !== this.lastProj)) { + this.recomputeDecay(proj); + this.lastDecayFrame = simFrame; + this.lastProj = proj; + } + + // --- Live event envelope: lanes 12..14. --- + if (this.active) { + const dur = LIVE_DURATION[this.active.kind] ?? 300; + const elapsed = simFrame - this.active.startFrame; + const openEnded = dur === 0 && this.dreamOpen; + if (!openEnded && dur > 0 && elapsed > dur + 140) { + // Envelope finished (+ fade tail) → back to calm. + this.active = null; + p[PARAM_IDX.liveKind] = LIVE_KIND.none; + p[PARAM_IDX.liveEnergy] = 0; + } else { + p[PARAM_IDX.liveKind] = this.active.kind; + p[PARAM_IDX.liveStartFrame] = this.active.startFrame; + p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, openEnded); + } + } else { + p[PARAM_IDX.liveKind] = LIVE_KIND.none; + p[PARAM_IDX.liveEnergy] = 0; + } + + this.onApply?.({ + simFrame, + activeKind: p[PARAM_IDX.liveKind], + eventsSeen: this.eventsSeen + }); + } + + private lastProj = -1; + + /** 0..1 agitation envelope for the active event at `elapsed` frames. */ + private energyEnvelope(ev: DecodedEvent, elapsed: number, openEnded: boolean): number { + if (elapsed < 0) return 0; + if (openEnded) { + // Dream storm: ramp in over 45f, hold high, agitation scales with + // how many connections have streamed in (scalar). + const ramp = Math.min(1, elapsed / 45); + return ramp * Math.min(1.4, 0.6 + ev.scalar * 0.04); + } + const dur = LIVE_DURATION[ev.kind] ?? 300; + const attack = Math.min(1, elapsed / 24); + const release = 1 - Math.max(0, (elapsed - dur) / 140); + return Math.max(0, attack * Math.min(1, release)); + } + + /** Recompute per-node live retrievability and push to the GPU (Phase 1). */ + private recomputeDecay(projectionDays: number): void { + const nowMs = this.engine.wallNowMs; + const nodes = this.graph.nodes; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + this.retention[i] = + n.stability !== undefined && n.lastAccessed + ? liveRetrievability(n.stability, n.lastAccessed, nowMs, projectionDays) + : n.retention; + } + this.renderer.uploadLiveRetention(this.retention); + } + + /** Force a decay recompute now (e.g. the scrubber moved). */ + refreshDecay(): void { + if (this.hasLiveDecay) this.recomputeDecay(this.projectionDays()); + } +} + +// --- tiny decoders (no allocation beyond the returned value) --- + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} +function num(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +} +function strArr(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} +function decodePairs(v: unknown): [string, string][] { + if (!Array.isArray(v)) return []; + const out: [string, string][] = []; + for (const pair of v) { + if (Array.isArray(pair) && pair.length >= 2 && typeof pair[0] === 'string' && typeof pair[1] === 'string') { + out.push([pair[0], pair[1]]); + } + } + return out; +} diff --git a/apps/dashboard/src/lib/observatory/node-renderer.ts b/apps/dashboard/src/lib/observatory/node-renderer.ts index e250dad..70c6743 100644 --- a/apps/dashboard/src/lib/observatory/node-renderer.ts +++ b/apps/dashboard/src/lib/observatory/node-renderer.ts @@ -47,6 +47,13 @@ export class NodeRenderer implements FramePass { private simBindGroup: GPUBindGroup | null = null; private pathBuffer: GPUBuffer | null = null; + // v2.3 living field — per-node LIVE retrievability (one f32/node), recomputed + // on the real FSRS curve each frame by the LiveBridge and read by the sim + // compute pass to overwrite vel_retention.w. Created at upload with node + // count; seeded to the static retention snapshot so a pre-bridge field is + // unchanged. + private liveRetentionBuffer: GPUBuffer | null = null; + // Path edge wavefront (Increment 6) private pathPipeline: GPURenderPipeline | null = null; private pathBindGroup: GPUBindGroup | null = null; @@ -102,6 +109,20 @@ export class NodeRenderer implements FramePass { }); device.queue.writeBuffer(this.edgeBuffer, 0, edgeData.buffer as ArrayBuffer); + // v2.3 live retrievability — one f32 per node, seeded to each node's + // static retention so the field is unchanged until the LiveBridge starts + // recomputing on the real FSRS curve. Padded to ≥16 bytes so a tiny + // graph still makes a valid storage buffer. + const liveRet = new Float32Array(Math.max(nodeCount, 4)); + for (let i = 0; i < nodeCount; i++) liveRet[i] = graph.nodes[i].retention; + this.liveRetentionBuffer?.destroy(); + this.liveRetentionBuffer = device.createBuffer({ + label: 'observatory-live-retention', + size: liveRet.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + }); + device.queue.writeBuffer(this.liveRetentionBuffer, 0, liveRet.buffer as ArrayBuffer); + // Recall path: deterministic story beats → PathStep storage buffer. // (Skipped for demo modes with their own choreography — the buffer is // still created so the sim bind group stays valid, just with 0 steps.) @@ -155,6 +176,21 @@ export class NodeRenderer implements FramePass { this.createPipeline(device); } + /** + * v2.3 living field — push freshly-computed per-node live retrievability to + * the GPU. The LiveBridge calls this (throttled) with a Float32Array whose + * length is the node count; the sim compute pass reads it to overwrite each + * node's vel_retention.w, so render-nodes dims every memory on its REAL FSRS + * curve. One writeBuffer, no pipeline rebuild — the buffer is already bound. + */ + uploadLiveRetention(data: Float32Array): void { + const device = this.engine.gpuDevice; + if (!device || !this.liveRetentionBuffer) return; + const n = Math.min(data.length, this.nodeCount); + if (n <= 0) return; + device.queue.writeBuffer(this.liveRetentionBuffer, 0, data.buffer as ArrayBuffer, 0, n * 4); + } + private createPipeline(device: GPUDevice): void { if (!this.engine.paramsBuffer || !this.cameraBuffer || !this.nodeBuffer) return; @@ -177,6 +213,9 @@ export class NodeRenderer implements FramePass { if (this.edgeBuffer) { simEntries.push({ binding: 3, resource: { buffer: this.edgeBuffer } }); } + if (this.liveRetentionBuffer) { + simEntries.push({ binding: 4, resource: { buffer: this.liveRetentionBuffer } }); + } this.simBindGroup = device.createBindGroup({ label: 'observatory-recall-sim-bind', layout: this.simPipeline.getBindGroupLayout(0), @@ -402,10 +441,12 @@ export class NodeRenderer implements FramePass { this.edgeBuffer?.destroy(); this.cameraBuffer?.destroy(); this.pathBuffer?.destroy(); + this.liveRetentionBuffer?.destroy(); this.nodeBuffer = null; this.edgeBuffer = null; this.cameraBuffer = null; this.pathBuffer = null; + this.liveRetentionBuffer = null; this.pipeline = null; this.bindGroup = null; this.simPipeline = null; diff --git a/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts index 1bd405b..aaed94f 100644 --- a/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts @@ -12,7 +12,7 @@ * frames 510–719: stabilization (hold, then reset) * * All time terms are integer-cycles per 720 frames so the loop seam is - * invisible. Capture mode (params._pad == 1.0) skips integration. + * invisible. Capture mode (params.capture_mode == 1.0) skips integration. * * Particle layout (16 floats / 64 bytes per particle): * start_life : xyz start position, w phase offset (stagger) @@ -33,7 +33,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; // 16 floats / 64 bytes per particle (matches birth-plan.ts layout). @@ -54,9 +58,9 @@ fn birth_compute(@builtin(global_invocation_id) id: vec3) { return; } - // Capture mode (params._pad == 1.0): skip physics integration. + // Capture mode (params.capture_mode == 1.0): skip physics integration. // The storage-buffer state stays frozen at initial upload values. - if (params._pad == 1.0) { + if (params.capture_mode == 1.0) { return; } diff --git a/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts index 18aae15..9b79cfc 100644 --- a/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts @@ -44,7 +44,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Node { diff --git a/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts index 32bd356..b6b4ed4 100644 --- a/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts @@ -39,7 +39,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Node { diff --git a/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts index ab3bc32..244b0ef 100644 --- a/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts @@ -23,7 +23,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Camera { diff --git a/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts index 070e387..0daaac3 100644 --- a/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts @@ -22,7 +22,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Camera { diff --git a/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts index 6bff3cd..b13204c 100644 --- a/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts @@ -22,7 +22,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Camera { diff --git a/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts index ee2689d..2ee1fc5 100644 --- a/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts @@ -47,7 +47,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Node { diff --git a/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts index f4e08a3..1477d47 100644 --- a/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts @@ -33,7 +33,11 @@ struct Params { brightness: f32, demo_id: f32, time: f32, - _pad: f32, + capture_mode: f32, + live_kind: f32, + live_start_frame: f32, + live_energy: f32, + projection_days: f32, }; struct Node { @@ -49,6 +53,10 @@ struct Node { @group(0) @binding(2) var path: array>; // x source index, y target node index (Increment 7: force simulation edges) @group(0) @binding(3) var edges: array>; +// v2.3 living field — per-node LIVE retrievability (real FSRS curve, recomputed +// on the CPU by the LiveBridge). One f32 per node. read to overwrite +// vel_retention.w so render-nodes dims each memory on its true forgetting curve. +@group(0) @binding(4) var live_retention: array; // --- Force-simulation helpers (Increment 7) --- @@ -100,12 +108,25 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3) { // Write recall intensity (existing behavior preserved). node.demo.x = intensity; + // v2.3 LIVE FSRS decay — overwrite retention with the real forgetting-curve + // value the LiveBridge computed for this node on the CPU. This is the #1 + // moat: render-nodes already dims by vel_retention.w (line ~183), so writing + // the true retrievability here makes every memory visibly forget on its own + // curve. Guarded so a graph with no live-decay data (all zeros) keeps its + // static snapshot instead of collapsing to black. + if (i < arrayLength(&live_retention)) { + let lr = live_retention[i]; + if (lr > 0.0) { + node.vel_retention = vec4(node.vel_retention.xyz, lr); + } + } + // --- Increment 7: force simulation --- - // Capture mode (params._pad == 1.0): skip physics integration entirely. - // The storage-buffer state stays frozen at initial upload values, - // making same URL + frame → identical pixels (spec §4 Inc 9). - if (params._pad == 0.0) { + // Capture mode (params.capture_mode == 1.0): skip physics integration + // entirely. The storage-buffer state stays frozen at initial upload + // values, making same URL + frame → identical pixels (spec §4 Inc 9). + if (params.capture_mode == 0.0) { // 7B: center anchor — center node never moves. // (WGSL forbids swizzle stores — reconstruct the vec4, preserving .w.) if (is_center) { @@ -155,7 +176,7 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3) { node.vel_retention = vec4(vel, node.vel_retention.w); node.pos_radius = vec4(pos + vel, node.pos_radius.w); } - // When capture_mode (params._pad == 1.0), node is NOT written back — + // When capture_mode (params.capture_mode == 1.0), node is NOT written back — // the storage buffer retains its initial upload values, guaranteeing // deterministic pixels for the same frame index. nodes[i] = node; diff --git a/apps/dashboard/src/lib/observatory/types.ts b/apps/dashboard/src/lib/observatory/types.ts index b7cc119..2cacb81 100644 --- a/apps/dashboard/src/lib/observatory/types.ts +++ b/apps/dashboard/src/lib/observatory/types.ts @@ -104,9 +104,57 @@ export const PATH_KIND = { // [8] brightness (from graphState) // [9] demoId (DemoMode index) // [10] time (fixed sim seconds = frame / fps; NOT wall clock) -// [11] _pad -export const PARAMS_FLOATS = 12; -export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 48 (multiple of 16) +// [11] captureMode (1.0 = ?frame=N freeze — simulate.wgsl skips physics) +// +// --- LIVE lanes (v2.3: the field driven by the real backend event stream) --- +// These carry the state of the ONE active live cognitive event so every +// shader can react to real backend data instead of the deterministic +// DemoClock. All are 0.0 at rest (a calm field), so a build with no live +// bridge is pixel-identical to before. +// [12] liveKind (LiveKind index — 0 none, see LIVE_KIND below) +// [13] liveStartFrame (absolute sim frame the live event fired — its +// animation envelope anchor; NOT the wrapped loop +// frame, so live events never pop at the 720 seam) +// [14] liveEnergy (0..1+ global agitation — dream storm loosens the +// sim, firewall shock rings the field, etc.) +// [15] projectionDays (Phase 1 forward-scrub: added to every node's real +// FSRS elapsed so decay is legible in one session) +export const PARAMS_FLOATS = 16; +export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 64 (multiple of 16) + +/** + * Live-event kinds written into params[12] (liveKind). The field's shaders + * branch on this the same way they branch on demo_id, but it is driven by the + * real WebSocket stream, not the DemoClock. 0 = nothing live (calm field). + */ +export const LIVE_KIND = { + none: 0, + firewall: 1, // MemorySuppressed / contradiction quarantine on camera + dreamStorm: 2, // DreamStarted..DreamCompleted consolidation storm + causalRecall: 3, // BackfillFired / DeepReferenceCompleted backward path + birth: 4 // MemoryCreated (deferred hero — reserved) +} as const; +export type LiveKind = (typeof LIVE_KIND)[keyof typeof LIVE_KIND]; + +/** Param array indices for the live lanes (mirror the WGSL Params order). */ +export const PARAM_IDX = { + frame: 0, + loopPhase: 1, + nodeCount: 2, + edgeCount: 3, + pathCount: 4, + pulse: 5, + viewportW: 6, + viewportH: 7, + brightness: 8, + demoId: 9, + time: 10, + captureMode: 11, + liveKind: 12, + liveStartFrame: 13, + liveEnergy: 14, + projectionDays: 15 +} as const; export function demoModeId(mode: DemoMode): number { const i = DEMO_MODES.indexOf(mode); @@ -125,6 +173,15 @@ export interface ObservatoryNode { tags: string[]; isCenter: boolean; suppressed: boolean; + /** + * v2.3 living field — real FSRS state. `stability` (days) + `lastAccessed` + * (ISO) let the live decay pass recompute retrievability every frame on the + * true forgetting curve (fsrs.ts). Undefined when the backend serializer + * predates the get_graph stability/lastAccessed add — the field then holds + * the static `retention` snapshot instead (graceful, still honest). + */ + stability?: number; + lastAccessed?: string; } export interface ObservatoryEdge { @@ -152,7 +209,9 @@ export function toObservatoryNode(n: GraphNode, index: number): ObservatoryNode retention: typeof n.retention === 'number' ? n.retention : 0, tags: Array.isArray(n.tags) ? n.tags : [], isCenter: !!n.isCenter, - suppressed: (n.suppression_count ?? 0) > 0 + suppressed: (n.suppression_count ?? 0) > 0, + stability: typeof n.stability === 'number' ? n.stability : undefined, + lastAccessed: typeof n.lastAccessed === 'string' ? n.lastAccessed : undefined }; } diff --git a/apps/dashboard/src/lib/types/index.ts b/apps/dashboard/src/lib/types/index.ts index 1673501..638c05e 100644 --- a/apps/dashboard/src/lib/types/index.ts +++ b/apps/dashboard/src/lib/types/index.ts @@ -77,6 +77,12 @@ export interface GraphNode { // v2.0.5 Active Forgetting — top-down suppression state suppression_count?: number; suppressed_at?: string; + // v2.3 living field — real FSRS state so the graph can render LIVE decay + // (retrievability recomputed each frame on the true forgetting curve). + // stability in days; lastAccessed is an ISO timestamp. Optional so an old + // serializer (pre-2026-07) still parses. + stability?: number; + lastAccessed?: string; } export interface GraphEdge { @@ -167,6 +173,11 @@ export type VestigeEventType = | 'ActivationSpread' | 'ImportanceScored' | 'DeepReferenceCompleted' + // v2.3 living field — RSB causal recall receipt (Phase 4): a failure- + // triggered backward-only causal path with shared-entity join keys, so the + // field lights the REAL cause chain instead of a random pulse. + | 'BackfillFired' + | 'CausalReceipt' | 'HookVerdictRecorded' | 'TraceEvent' | 'MemoryPrOpened' diff --git a/apps/dashboard/src/routes/(app)/graph/+page.svelte b/apps/dashboard/src/routes/(app)/graph/+page.svelte index b518285..57c97e5 100644 --- a/apps/dashboard/src/routes/(app)/graph/+page.svelte +++ b/apps/dashboard/src/routes/(app)/graph/+page.svelte @@ -320,6 +320,7 @@ disown