feat(observatory): dream consolidation storm on the live field (Phase 3)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 22:07:32 -07:00
parent d7f5657bc7
commit 1b23516579
5 changed files with 438 additions and 32 deletions

View file

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

View file

@ -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<typeof buildObservatoryGraph> | 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<GraphNode> & { 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<typeof buildObservatoryGraph> {
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<string, unknown>, 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);
});
});

View file

@ -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<number, number> = {
[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<string>();
/** 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<string, number>;
@ -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, oldestnewest, 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 browserbackend 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 : '';
}

View file

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

View file

@ -169,9 +169,26 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
// 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<f32>(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<f32>(vel, node.vel_retention.w);
node.pos_radius = vec4<f32>(pos + vel, node.pos_radius.w);