mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
d7f5657bc7
commit
1b23516579
5 changed files with 438 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
224
apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts
Normal file
224
apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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, 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 : '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue