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}
+ (projectionDays = 0)}
+ class="text-[#22C7DE]/60 hover:text-[#22C7DE] transition-colors"
+ title="Return to now"
+ >
+ reset
+
+ {/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