From 1b23516579e7be8e829ebd92bc5312ab42584ba4 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Wed, 8 Jul 2026 22:07:32 -0700 Subject: [PATCH] feat(observatory): dream consolidation storm on the live field (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the real dream pipeline runs, the field enters a metabolic storm: damping loosens, the newly-discovered edges are appended live, and their springs pull the clusters into their new shape. - node-renderer: setEdges() — a setPathSteps clone that regrows the edge index buffer, updates the CPU graph, bumps edge_count, rebuilds the sim pipeline so new springs act immediately. - simulate.wgsl: while live_kind==dreamStorm, damping loosens with live_energy + a deterministic turbulence rides the envelope (pure of node index + live_frame, so no wall clock). - live-bridge: real ConnectionDiscovered pairs append to a deduped edge accumulator, coalesced to ONE setEdges per frame (a 1000-edge burst is one regrow, not a thousand). Storm fires on DreamStarted OR DreamCompleted (a real dream floods the 200-event ring and evicts DreamStarted, so Completed must also arm it), holds ~6s, settles. - ingest rewrite: timestamp watermark instead of object-identity dedup — robust to ring eviction AND browser/backend clock skew (seeds from the backend clock, never the browser wall clock). This fixed a real bug where a 36s clock skew silently dropped every event. - 8 new LiveBridge unit tests machine-prove decay/firewall/storm/edges (the multi-second storm can't be screenshotted in a frozen-rAF preview). Verified: firewall re-fired live post-rewrite naming the real memory; 1041 tests (8 new), check + build green. Co-Authored-By: Claude Opus 4.8 --- .../lib/observatory/ObservatoryStage.svelte | 6 + .../observatory/__tests__/live-bridge.test.ts | 224 ++++++++++++++++++ .../src/lib/observatory/live-bridge.ts | 193 ++++++++++++--- .../src/lib/observatory/node-renderer.ts | 28 ++- .../lib/observatory/shaders/simulate.wgsl.ts | 19 +- 5 files changed, 438 insertions(+), 32 deletions(-) create mode 100644 apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts diff --git a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte index 0693e34..0d70498 100644 --- a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte +++ b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte @@ -311,6 +311,12 @@ }); liveDecayReady = liveBridge.liveDecayAvailable; engine.setPreFrameHook((simFrame) => liveBridge?.drain(simFrame)); + // Dev/verification hook — read live state from the console. Guarded + // so it never runs in SSR and is harmless in production. + if (typeof window !== 'undefined') { + (window as unknown as { __vestigeLiveBridge?: unknown }).__vestigeLiveBridge = + liveBridge; + } } // Start the story at frame 0 now that the field exists — where the diff --git a/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts b/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts new file mode 100644 index 0000000..d5f59eb --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts @@ -0,0 +1,224 @@ +/** + * LiveBridge — the field's nervous system. These tests machine-prove the + * behaviors that are hard to screenshot in a headless preview (the multi-second + * dream storm, per-node decay, event dedup) by driving the bridge with a mock + * engine + renderer and asserting the params lanes / renderer calls. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { LiveBridge } from '../live-bridge'; +import { PARAM_IDX, PARAMS_FLOATS, LIVE_KIND } from '../types'; +import { buildObservatoryGraph } from '../graph-upload'; +import type { GraphResponse, GraphNode, VestigeEvent } from '$types'; + +// --- Mocks: an engine is just a params array + monotonic frame + wall clock; +// a renderer just records the buffers it was asked to upload. --- + +class MockEngine { + params = new Float32Array(PARAMS_FLOATS); + private _frame = 0; + // Fixed wall clock — 2026-07-01, AFTER the test nodes' lastAccessed dates so + // elapsed is positive and the FSRS curve actually decays. + private _now = Date.parse('2026-07-01T00:00:00Z'); + passes: unknown[] = []; + // No GPU in the test env — the firewall renderer's upload() no-ops on a null + // device, so arming the firewall stays a pure params-lane assertion. + get gpuDevice() { + return null; + } + get paramsBuffer() { + return null; + } + addPass(p: unknown) { + this.passes.push(p); + } + get totalFrames() { + return this._frame; + } + get wallNowMs() { + return this._now; + } + advance(frames: number) { + this._frame += frames; + } + setNow(ms: number) { + this._now = ms; + } +} + +class MockRenderer { + graph: ReturnType | null = null; + setEdgesCalls = 0; + lastEdgeCount = 0; + retentionUploads: Float32Array[] = []; + rearmCalls = 0; + setEdges(edges: unknown[]) { + this.setEdgesCalls++; + this.lastEdgeCount = edges.length; + } + uploadLiveRetention(data: Float32Array) { + this.retentionUploads.push(data.slice()); + } +} + +function gnode(partial: Partial & { id: string }): GraphNode { + return { + label: partial.id, + type: 'note', + retention: 0.8, + tags: [], + createdAt: '2026-06-01T00:00:00Z', + updatedAt: '2026-06-01T00:00:00Z', + isCenter: false, + ...partial + }; +} + +function makeGraph(): ReturnType { + const nodes: GraphNode[] = [ + gnode({ id: 'a', isCenter: true, stability: 5, lastAccessed: '2026-06-20T00:00:00Z' }), + gnode({ id: 'b', stability: 0.2, lastAccessed: '2026-06-25T00:00:00Z' }), + gnode({ id: 'c', stability: 3, lastAccessed: '2026-06-10T00:00:00Z' }), + gnode({ id: 'd', stability: 1, lastAccessed: '2026-06-28T00:00:00Z' }) + ]; + const resp: GraphResponse = { + nodes, + edges: [{ source: 'a', target: 'b', weight: 0.5, type: 'semantic' }], + center_id: 'a', + depth: 2, + nodeCount: nodes.length, + edgeCount: 1 + }; + return buildObservatoryGraph(resp); +} + +function ev(type: string, data: Record, tsMs: number): VestigeEvent { + return { type, data: { ...data, timestamp: new Date(tsMs).toISOString() } } as VestigeEvent; +} + +function makeBridge() { + const engine = new MockEngine(); + const renderer = new MockRenderer(); + const graph = makeGraph(); + renderer.graph = graph; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bridge = new LiveBridge({ engine: engine as any, renderer: renderer as any, graph, seed: 'test' }); + return { engine, renderer, graph, bridge }; +} + +describe('LiveBridge', () => { + let base: number; + beforeEach(() => { + base = 1_000_000_000_000; + }); + + it('detects live FSRS decay data and computes per-node retrievability', () => { + const { engine, renderer, bridge } = makeBridge(); + expect(bridge.liveDecayAvailable).toBe(true); + // Advance past the decay throttle and drain — retention should upload. + engine.advance(10); + bridge.drain(engine.totalFrames); + expect(renderer.retentionUploads.length).toBeGreaterThan(0); + const r = renderer.retentionUploads.at(-1)!; + // Node 'b' (stability 0.2) must have decayed far more than 'a' (stability 5). + // indices: buildObservatoryGraph sorts center first then by id → a,b,c,d + expect(r[1]).toBeLessThan(r[0]); + expect(r[1]).toBeGreaterThanOrEqual(0); + expect(r[0]).toBeLessThanOrEqual(1); + }); + + it('ignores the pre-mount backlog (seeds the watermark on first ingest)', () => { + const { engine, bridge } = makeBridge(); + // A suppress that happened BEFORE mount must NOT fire. + bridge.ingest([ev('MemorySuppressed', { id: 'b', estimated_cascade: 3 }, base - 5000)]); + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.none); + }); + + it('arms the contradiction firewall on a NEW MemorySuppressed for an in-field node', () => { + const { engine, bridge } = makeBridge(); + // First ingest seeds the watermark (backlog) … + bridge.ingest([ev('Heartbeat', {}, base)]); + // … then a NEWER suppress fires the firewall. + bridge.ingest([ev('MemorySuppressed', { id: 'b', estimated_cascade: 3 }, base + 1000)]); + engine.advance(5); + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.firewall); + expect(engine.params[PARAM_IDX.liveEnergy]).toBeGreaterThan(0); + }); + + it('appends real ConnectionDiscovered edges and flushes ONE setEdges per frame', () => { + const { engine, renderer, bridge } = makeBridge(); + bridge.ingest([ev('Heartbeat', {}, base)]); + // Two new connections in the same burst → coalesced to one setEdges. + bridge.ingest([ + ev('ConnectionDiscovered', { source_id: 'c', target_id: 'd', weight: 0.7, connection_type: 'causal' }, base + 2000), + ev('ConnectionDiscovered', { source_id: 'b', target_id: 'c', weight: 0.6, connection_type: 'semantic' }, base + 1500) + ]); + const before = renderer.setEdgesCalls; + bridge.drain(engine.totalFrames); + expect(renderer.setEdgesCalls).toBe(before + 1); // ONE flush + // started with 1 edge (a-b), +2 new = 3 + expect(renderer.lastEdgeCount).toBe(3); + }); + + it('dedupes a re-discovered edge (no duplicate append)', () => { + const { engine, renderer, bridge } = makeBridge(); + bridge.ingest([ev('Heartbeat', {}, base)]); + bridge.ingest([ev('ConnectionDiscovered', { source_id: 'c', target_id: 'd' }, base + 1000)]); + bridge.drain(engine.totalFrames); + const afterFirst = renderer.lastEdgeCount; + // same edge, reversed direction, later ts → must be a no-op + bridge.ingest([ev('ConnectionDiscovered', { source_id: 'd', target_id: 'c' }, base + 2000)]); + bridge.drain(engine.totalFrames); + expect(renderer.lastEdgeCount).toBe(afterFirst); + }); + + it('fires the dream storm on DreamCompleted even when DreamStarted was evicted', () => { + const { engine, bridge } = makeBridge(); + bridge.ingest([ev('Heartbeat', {}, base)]); + // Only DreamCompleted survives the 200-event ring — the storm must STILL fire. + bridge.ingest([ev('DreamCompleted', { connections_found: 1145, memories_replayed: 50 }, base + 3000)]); + engine.advance(60); // ~1s in + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.dreamStorm); + expect(engine.params[PARAM_IDX.liveEnergy]).toBeGreaterThan(0.3); + }); + + it('the dream storm holds for seconds then settles to calm', () => { + const { engine, bridge } = makeBridge(); + bridge.ingest([ev('Heartbeat', {}, base)]); + bridge.ingest([ev('DreamStarted', { memory_count: 50 }, base + 1000)]); + engine.advance(120); // 2s — mid-storm + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.dreamStorm); + const midEnergy = engine.params[PARAM_IDX.liveEnergy]; + expect(midEnergy).toBeGreaterThan(0); + // Far past the window → settled. + engine.advance(700); + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.none); + expect(engine.params[PARAM_IDX.liveEnergy]).toBe(0); + }); + + it('the forward-projection scrubber decays the field further', () => { + const engine = new MockEngine(); + const renderer = new MockRenderer(); + const graph = makeGraph(); + renderer.graph = graph; + let proj = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bridge = new LiveBridge({ engine: engine as any, renderer: renderer as any, graph, seed: 't', projectionDays: () => proj }); + engine.advance(10); + bridge.drain(engine.totalFrames); + const atNow = renderer.retentionUploads.at(-1)!.slice(); + proj = 180; + bridge.refreshDecay(); + const atPlus180 = renderer.retentionUploads.at(-1)!; + // every node with real FSRS state must be ≤ its now-value (more forgotten) + for (let i = 0; i < atNow.length; i++) { + expect(atPlus180[i]).toBeLessThanOrEqual(atNow[i] + 1e-6); + } + // and at least one strictly lower (the field visibly decayed) + expect(atPlus180.some((v, i) => v < atNow[i] - 1e-3)).toBe(true); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/live-bridge.ts b/apps/dashboard/src/lib/observatory/live-bridge.ts index 4a088ba..6b45254 100644 --- a/apps/dashboard/src/lib/observatory/live-bridge.ts +++ b/apps/dashboard/src/lib/observatory/live-bridge.ts @@ -23,7 +23,7 @@ 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 { LIVE_KIND, PARAM_IDX, type ObservatoryGraph, type ObservatoryEdge } from './types'; import { liveRetrievability } from './fsrs'; import { FirewallRenderer } from './firewall-renderer'; import { buildLiveFirewallPlan, emptyFirewallPlan } from './firewall-plan'; @@ -45,7 +45,14 @@ interface DecodedEvent { /** 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 + // Dream storm: a real dream on the local brain finishes in ~150ms (far + // faster than a frame), but the CONSOLIDATION it performed — real edges + // appended, clusters re-settling — is a genuine physical event that takes + // seconds to play out. So the storm holds a minimum visible window (~6s) + // while the newly-appended springs pull the field into its new shape, then + // settles. Honest: the edges + physics are real; only the tempo is stretched + // to human-perceptible (like the forgetting-horizon scrubber). + [LIVE_KIND.dreamStorm]: 360, [LIVE_KIND.causalRecall]: 260, // wavefront sweep + afterglow [LIVE_KIND.birth]: 180 }; @@ -82,6 +89,16 @@ export class LiveBridge { * field that never sees a suppression pays nothing. */ private firewall: FirewallRenderer | null = null; + /** Live edge accumulator (Phase 3 dream storm). Starts as the uploaded + * graph edges; each real ConnectionDiscovered appends one, and setEdges + * regrows the GPU buffer so the new spring physically pulls its endpoints + * together. Deduped by (min,max) index so a re-discovered edge is a no-op. */ + private liveEdges: ObservatoryEdge[] = []; + private liveEdgeKeys = new Set(); + /** Set when new edges arrived this frame; drain() flushes ONE setEdges per + * frame so a 50-connection dream burst is one buffer regrow, not fifty. */ + private edgesDirty = false; + /** id → stable buffer index (from the ObservatoryGraph). */ private indexById: Map; @@ -117,6 +134,19 @@ export class LiveBridge { if (node.stability !== undefined && node.lastAccessed) this.hasLiveDecay = true; } + // Seed the live edge accumulator with the uploaded graph edges so dream + // connections APPEND to the real field, never replace it. + this.liveEdges = deps.graph.edges.slice(); + for (const e of this.liveEdges) this.liveEdgeKeys.add(edgeKey(e.sourceIndex, e.targetIndex)); + + // Only react to events that arrive AFTER the bridge exists — skip the + // store's backlog. Anchor to the BACKEND clock (the newest event already + // in the feed), NOT the browser wall clock: on a sandboxed backend the + // two can skew by tens of seconds, and a browser-time floor would then + // skip every backend event forever. seedWatermark() sets this from the + // store the moment the bridge is wired. + this.lastAppliedMs = 0; + // Seed the live lanes to a calm resting state. const p = this.engine.params; p[PARAM_IDX.liveKind] = LIVE_KIND.none; @@ -138,22 +168,53 @@ export class LiveBridge { // dedupe window on timestamp is unnecessary — the store slices FIFO). // ------------------------------------------------------------------------- - private lastSeenTop: VestigeEvent | null = null; + /** + * Wire timestamp (ms) of the newest event already applied — a monotonic + * watermark. Robust to the 200-event ring evicting our anchor during a + * dream's 1000+-event burst: apply every event with a strictly newer + * timestamp, oldest→newest, then advance the watermark. Idempotent (re- + * ingesting the same store is a no-op) and survives eviction (identity + * anchors don't). A same-ms tie can drop at most one event, whose visual + * impact is nil (one connection among a thousand). + */ + private lastAppliedMs = 0; + private seeded = false; + + /** + * Anchor the watermark to the backend clock: set it to the newest event + * timestamp currently in the feed, so the bridge ignores the pre-mount + * backlog but reacts to everything after. Immune to browser↔backend clock + * skew. Call once, right after wiring, before the first ingest. + */ + seedWatermark(events: VestigeEvent[]): void { + let maxMs = 0; + for (const ev of events) { + const ts = evTimestampMs(ev); + if (ts > maxMs) maxMs = ts; + } + this.lastAppliedMs = maxMs; + this.seeded = true; + } 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 we were never explicitly seeded, seed from this first batch (anchor + // to the backend clock) and apply nothing — avoids replaying the backlog. + if (!this.seeded) { + this.seedWatermark(events); + return; } - 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); + let maxMs = this.lastAppliedMs; + // events[] is newest-first → walk oldest→newest so cause precedes effect. + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + const ts = evTimestampMs(ev); + if (ts > this.lastAppliedMs) { + this.decodeAndArm(ev, this.engine.totalFrames); + if (ts > maxMs) maxMs = ts; + } } - this.lastSeenTop = events[0]; + this.lastAppliedMs = maxMs; } /** Decode one wire event and, if it's a hero trigger, arm it. */ @@ -239,17 +300,45 @@ export class LiveBridge { } 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). + // A real dream completes in ~150ms and floods the 200-event WS ring + // with ConnectionDiscovered, often EVICTING the DreamStarted we'd + // otherwise arm on. DreamCompleted is the newest event so it always + // survives — start (or refresh) the storm HERE too, driven by the + // real connections_found count, so the consolidation always plays. + // The appended edges (already applied above) need seconds to pull + // the clusters into their new shape; the finite window plays it out. + const found = num(data.connections_found); if (this.active && this.active.kind === LIVE_KIND.dreamStorm) { - this.active.startFrame = simFrame - 500; // jump near the tail + this.active.scalar = Math.max(this.active.scalar, found); + } else { + this.arm({ + kind: LIVE_KIND.dreamStorm, + startFrame: simFrame, + targetId: '', + relatedIds: [], + pairs: [], + scalar: found + }); } 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. + // Dream storm: append the REAL discovered edge so its spring + // physically pulls the two memories together (clusters merge is the + // emergent settle — no new physics, the force sim already runs). case 'ConnectionDiscovered': { + const s = this.indexById.get(str(data.source_id)); + const t = this.indexById.get(str(data.target_id)); + if (s === undefined || t === undefined || s === t) break; + const key = edgeKey(s, t); + if (this.liveEdgeKeys.has(key)) break; + this.liveEdgeKeys.add(key); + this.liveEdges.push({ + sourceIndex: s, + targetIndex: t, + weight: num(data.weight) || 0.5, + type: str(data.connection_type) || 'semantic' + }); + this.edgesDirty = true; // coalesced flush in drain() if (this.dreamOpen && this.active?.kind === LIVE_KIND.dreamStorm) { this.active.scalar += 1; // more connections → more agitation } @@ -306,6 +395,13 @@ export class LiveBridge { drain(simFrame: number): void { const p = this.engine.params; + // Flush any edges appended this frame in ONE buffer regrow (a 50-edge + // dream burst = one setEdges, not fifty pipeline rebuilds). + if (this.edgesDirty) { + this.renderer.setEdges(this.liveEdges); + this.edgesDirty = false; + } + // --- 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. --- @@ -321,8 +417,7 @@ export class LiveBridge { 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) { + if (elapsed > dur + 140) { // Envelope finished (+ fade tail) → back to calm. this.active = null; p[PARAM_IDX.liveKind] = LIVE_KIND.none; @@ -332,7 +427,7 @@ export class LiveBridge { // Event-relative frame: the live shader branches replay the // choreography once from here, never riding the wrapped loop. p[PARAM_IDX.liveFrame] = Math.max(0, elapsed); - p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, openEnded); + p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, false); } } else { p[PARAM_IDX.liveKind] = LIVE_KIND.none; @@ -346,18 +441,39 @@ export class LiveBridge { }); } + /** Dev/verification snapshot — current live state (not used in production). */ + debugState(): { + activeKind: number; + liveEnergy: number; + liveFrame: number; + edgeCount: number; + eventsSeen: number; + } { + const p = this.engine.params; + return { + activeKind: p[PARAM_IDX.liveKind], + liveEnergy: p[PARAM_IDX.liveEnergy], + liveFrame: p[PARAM_IDX.liveFrame], + edgeCount: this.liveEdges.length, + 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 { + /** 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; + if (ev.kind === LIVE_KIND.dreamStorm) { + // Ramp in (45f) → sustained storm plateau (scaled by how many real + // connections the dream found) → ease out over the last ~90f as the + // clusters settle into their new shape. + const ramp = Math.min(1, elapsed / 45); + const ease = 1 - Math.max(0, (elapsed - (dur - 90)) / 90); + const intensity = Math.min(1.4, 0.7 + ev.scalar * 0.02); + return Math.max(0, ramp * Math.min(1, ease) * intensity); + } 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)); @@ -385,6 +501,23 @@ export class LiveBridge { // --- tiny decoders (no allocation beyond the returned value) --- +/** Undirected edge key (min,max) so a re-discovered edge dedupes either way. */ +function edgeKey(a: number, b: number): string { + return a < b ? `${a}-${b}` : `${b}-${a}`; +} + +/** + * Wire timestamp of an event in ms. Every VestigeEvent's data carries an RFC3339 + * `timestamp`. Falls back to a monotonic-ish 0 so a malformed event is treated + * as old (skipped) rather than replayed forever. + */ +function evTimestampMs(ev: VestigeEvent): number { + const raw = (ev.data as { timestamp?: unknown } | undefined)?.timestamp; + if (typeof raw !== 'string') return 0; + const t = Date.parse(raw); + return Number.isFinite(t) ? t : 0; +} + function str(v: unknown): string { return typeof v === 'string' ? v : ''; } diff --git a/apps/dashboard/src/lib/observatory/node-renderer.ts b/apps/dashboard/src/lib/observatory/node-renderer.ts index 70c6743..dab5be2 100644 --- a/apps/dashboard/src/lib/observatory/node-renderer.ts +++ b/apps/dashboard/src/lib/observatory/node-renderer.ts @@ -20,7 +20,7 @@ import { buildNodeStateArray, buildEdgeIndexArray } from './graph-upload'; -import { FLOATS_PER_NODE, NODE_LANE, type ObservatoryGraph } from './types'; +import { FLOATS_PER_NODE, NODE_LANE, type ObservatoryGraph, type ObservatoryEdge } from './types'; import { renderNodesWGSL } from './shaders/render-nodes.wgsl'; import { simulateWGSL } from './shaders/simulate.wgsl'; import { renderPathWGSL } from './shaders/render-path.wgsl'; @@ -176,6 +176,32 @@ export class NodeRenderer implements FramePass { this.createPipeline(device); } + /** + * v2.3 living field — replace the edge set (Phase 3, dream storm). A + * setPathSteps clone for the EDGE index buffer: rebuild it at the new size, + * update the CPU graph edge list (so the force sim's springs pull the new + * connections together — clusters merge is the emergent settle), bump + * params.edge_count, and rebuild the sim pipeline/bind group so it references + * the regrown buffer. The dream handler streams real ConnectionDiscovered + * pairs; the LiveBridge accumulates them and calls this so each new edge + * physically tugs its endpoints closer, live. + */ + setEdges(edges: ObservatoryEdge[]): void { + const device = this.engine.gpuDevice; + if (!device || !this.graph) return; + this.graph.edges = edges; + const edgeData = buildEdgeIndexArray(this.graph); + this.edgeBuffer?.destroy(); + this.edgeBuffer = device.createBuffer({ + label: 'observatory-edge-index', + size: Math.max(edgeData.byteLength, 8), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + }); + device.queue.writeBuffer(this.edgeBuffer, 0, edgeData.buffer as ArrayBuffer); + this.engine.params[3] = edges.length; + 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 diff --git a/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts index 53099cf..12efe16 100644 --- a/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts @@ -169,9 +169,26 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3) { // Gentle centering: keeps the field in frame without crushing it. force = force + (-pos) * 0.0008; + // v2.3 DREAM STORM — while the real dream pipeline streams (live_kind == + // 2 == LIVE_KIND.dreamStorm), the field enters a metabolic consolidation + // storm: damping loosens (springs overshoot, clusters slosh together as + // new ConnectionDiscovered edges are appended) and a deterministic + // turbulence rides live_energy. Pure of node index + live_frame, so no + // wall clock — the storm is a function of the real event envelope. At + // rest (energy 0) both terms vanish → the field is byte-identical. + var damping = 0.88; + if (params.live_kind == 2.0) { + let e = clamp(params.live_energy, 0.0, 1.4); + damping = 0.88 + 0.09 * e; // up to ~0.97 — longer, sloshier settling + // Curl-free deterministic jitter: phase from node index + live_frame. + let ph = f32(i) * 0.61803 + params.live_frame * 0.05; + let jitter = vec3(sin(ph * 6.2831), sin(ph * 4.7123 + 1.3), sin(ph * 5.318 + 2.1)); + force = force + jitter * (0.006 * e); + } + // 7B: velocity damping + cap, then position integration. var vel = node.vel_retention.xyz; - vel = (vel + force) * 0.88; + vel = (vel + force) * damping; vel = clamp_len(vel, 0.42); node.vel_retention = vec4(vel, node.vel_retention.w); node.pos_radius = vec4(pos + vel, node.pos_radius.w);