feat(observatory): live event bridge + live FSRS decay field (Phase 0+1)

The raw-WebGPU field was inert — it ran the deterministic DemoClock and
never heard the backend. This wires it to the real event stream and makes
every memory visibly forget on its true FSRS curve.

- LiveBridge (new): lock-free per-frame drain via engine.preFrameHook,
  ingests $eventFeed oldest-first, decodes real events into live lanes.
- fsrs.ts (new): exact TS port of retrievability_with_decay (w20=0.1542).
- params 12→16 floats (capture_mode + 4 live lanes); all 8 WGSL Params
  structs updated in lockstep. _pad renamed to its real meaning.
- node-renderer: live-retention GPU buffer (binding 4); simulate.wgsl
  overwrites vel_retention.w so render-nodes dims on the real curve.
- ObservatoryStage: `live` prop, $eventFeed subscription, persistent
  FORGETTING HORIZON scrubber (project the curve forward N days, honest).
- backend: get_graph node json now carries stability + lastAccessed.

Verified live vs the real 1241-memory brain: +300d scrub visibly decays
low-stability nodes while high-stability survive; per-node FSRS math
confirmed. svelte-check 0 err, 1033 tests, build, cargo check all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 21:41:18 -07:00
parent 9f5a1fde4a
commit addbf260f1
17 changed files with 770 additions and 21 deletions

View file

@ -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}
<div class="absolute inset-0 z-10 pointer-events-none">
<!-- v2.3 living field — forward-projection scrubber. Real per-memory FSRS
decay drifts too slowly to watch in one session, so this projects the
field N days forward on the SAME true forgetting curve (honest, not
faked). Only shown when the backend serves real FSRS state. -->
{#if live && liveDecayReady}
<div
class="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-auto
flex items-center gap-3 px-4 py-2.5 rounded-xl border border-[#22C7DE]/25
bg-[#05060a]/80 backdrop-blur-sm font-mono text-[11px] tracking-wide"
>
<span class="text-[#22C7DE]/80 uppercase">Forgetting horizon</span>
<input
type="range"
min="0"
max="365"
step="1"
bind:value={projectionDays}
class="w-40 sm:w-56 accent-[#22C7DE] cursor-pointer"
aria-label="Project the forgetting curve forward N days"
title="Project every memory forward on its real FSRS curve"
/>
<span class="text-[#7fe6c0] w-16 text-right tabular-nums">
{projectionDays === 0 ? 'now' : `+${projectionDays}d`}
</span>
{#if projectionDays !== 0}
<button
onclick={() => (projectionDays = 0)}
class="text-[#22C7DE]/60 hover:text-[#22C7DE] transition-colors"
title="Return to now"
>
reset
</button>
{/if}
</div>
{/if}
{#if chrome === 'full'}
<!-- Top telemetry strip -->
<TelemetryStrip

View file

@ -88,6 +88,17 @@ export class ObservatoryEngine {
private _status: EngineStatus = { state: 'booting' };
private statusListeners = new Set<(s: EngineStatus) => 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<boolean> {
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;

View file

@ -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);
}

View file

@ -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<number, number> = {
[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<string, number>;
/** 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<string, unknown>;
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;
}

View file

@ -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;

View file

@ -12,7 +12,7 @@
* frames 510719: 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<u32>) {
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;
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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<storage, read> path: array<vec4<u32>>;
// x source index, y target node index (Increment 7: force simulation edges)
@group(0) @binding(3) var<storage, read> edges: array<vec2<u32>>;
// 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<storage, read> live_retention: array<f32>;
// --- Force-simulation helpers (Increment 7) ---
@ -100,12 +108,25 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
// 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<f32>(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<u32>) {
node.vel_retention = vec4<f32>(vel, node.vel_retention.w);
node.pos_radius = vec4<f32>(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;

View file

@ -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
};
}

View file

@ -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'

View file

@ -320,6 +320,7 @@ disown</code>
<div class="absolute inset-0">
<ObservatoryStage
embedded
live
chrome="none"
demo="recall-path"
showSwitcher={false}

View file

@ -1239,6 +1239,12 @@ pub async fn get_graph(
"createdAt": n.created_at.to_rfc3339(),
"updatedAt": n.updated_at.to_rfc3339(),
"isCenter": n.id == center_id,
// v2.3 living field — real FSRS state so the dashboard graph can
// render LIVE per-memory decay (retrievability recomputed each
// frame on the true forgetting curve). Already emitted by the
// single-node handlers; the field just needed it per-node too.
"stability": n.stability,
"lastAccessed": n.last_accessed.to_rfc3339(),
})
})
.collect();