Skip to content

Commit f1206c7

Browse files
committed
feat(brain): ripple engine — BFS-timed activation drives pulse shader on click
P4 of Constellation Lattice redesign. - Add brain/easing.js — clamp01, easeOutCubic, easeInQuad, expDecay (canonical exp(-t/tau)), riseDecay (cubic rise to 1.0 over riseMs, then exp decay with tau). - Add brain/RippleEngine.js — depth-capped BFS (cap = 2), per-hop delay 110ms, rise 80ms, tau 280ms, depth attenuation 0.55^depth, RIPPLE_LIFE_MS = depth*step + rise + 500ms tail. attachMesh binds the EdgeMesh activation buffer + DataTexture; buildAdjacency keys by edgeIndex from the mesh; fire(nodeId, now) runs BFS once and pushes a ripple; tick(now) zeroes the buffer, accumulates riseDecay × depth attenuation across active ripples, clamps at 1.0, sets texture.needsUpdate. Multiple ripples are additive. fireAmbient picks a random adjacency node (used in P5 for auto-rotate ambient firing). Observer hooks for fire / reach. - Wire BrainVisualizer.jsx — instantiate RippleEngine on graphData change, attach to the merged edge mesh, run engine.tick(now) inside the existing edge-mesh rAF loop alongside tickEdgeMaterialTime. selectGraphNode now calls rippleEngineRef.current?.fire(nextNode.id, performance.now()) on selection — every click animates a propagating ripple along the edge shader. - Tests: brain/__tests__/RippleEngine.test.js — depth cap, additivity + clamp, decay-to-zero after lifetime, fire/reach observers, fireAmbient. brain/__tests__/easing.test.js — boundaries, monotonic, exp decay points, riseDecay envelope. brain-visualizer.test.js — assert engine wiring (import, instantiate, attachMesh, buildAdjacency, tick, fire on click). Tests: 144 passed. Build: vite green (BrainVisualizer 1.43MB / 384KB gzip).
1 parent 5b7f16c commit f1206c7

6 files changed

Lines changed: 353 additions & 1 deletion

File tree

desktop/cortex-control-center/src/BrainVisualizer.jsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { applyShellLayout, createShellProjectionForce } from "./brain/ShellLayou
1111
import { BRAIN_LAYERS, assignLayer, markBloom } from "./brain/RenderLayers.js";
1212
import { attachBloom } from "./brain/PostFx.js";
1313
import { buildEdgeMesh, disposeEdgeMesh, tickEdgeMaterialTime } from "./brain/EdgeMesh.js";
14+
import { RippleEngine } from "./brain/RippleEngine.js";
1415

1516
const BRAIN_NODE_COLORS = Object.freeze({
1617
memory: "#22d3ee",
@@ -206,6 +207,7 @@ function BrainVisualizerComponent({ api = null, cortexBase = "http://127.0.0.1:7
206207
const bloomRef = useRef(null);
207208
const edgeMeshRef = useRef(null);
208209
const edgeTickRef = useRef(null);
210+
const rippleEngineRef = useRef(null);
209211
const [graphData, setGraphData] = useState({ nodes: [], links: [] });
210212
const [bloomActive, setBloomActive] = useState(true);
211213
const [useShellSplit, setUseShellSplit] = useState(true);
@@ -349,10 +351,17 @@ function BrainVisualizerComponent({ api = null, cortexBase = "http://127.0.0.1:7
349351
edgeMeshRef.current = mesh;
350352
bloomRef.current?.refreshSelection?.();
351353

354+
const engine = new RippleEngine();
355+
engine.attachMesh(mesh);
356+
engine.buildAdjacency(graphData.links);
357+
rippleEngineRef.current = engine;
358+
352359
const start = performance.now();
353360
const tick = () => {
354-
const elapsedSec = (performance.now() - start) * 0.001;
361+
const now = performance.now();
362+
const elapsedSec = (now - start) * 0.001;
355363
tickEdgeMaterialTime(mesh, elapsedSec);
364+
engine.tick(now);
356365
edgeTickRef.current = requestAnimationFrame(tick);
357366
};
358367
edgeTickRef.current = requestAnimationFrame(tick);
@@ -363,6 +372,10 @@ function BrainVisualizerComponent({ api = null, cortexBase = "http://127.0.0.1:7
363372
cancelAnimationFrame(edgeTickRef.current);
364373
edgeTickRef.current = null;
365374
}
375+
if (rippleEngineRef.current) {
376+
rippleEngineRef.current.reset();
377+
rippleEngineRef.current = null;
378+
}
366379
if (edgeMeshRef.current) {
367380
disposeEdgeMesh(edgeMeshRef.current);
368381
edgeMeshRef.current = null;
@@ -676,6 +689,7 @@ function BrainVisualizerComponent({ api = null, cortexBase = "http://127.0.0.1:7
676689

677690
setAutoRotate(false);
678691
if (nextNode && graphRef.current) focusGraphNode(graphRef.current, nextNode);
692+
if (nextNode) rippleEngineRef.current?.fire(nextNode.id, performance.now());
679693

680694
selectedNodeRef.current = nextNode;
681695
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);

desktop/cortex-control-center/src/brain-visualizer.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,13 @@ describe("Brain visualizer", () => {
171171
expect(source).toContain("controls.dampingFactor = 0.085");
172172
expect(source).toContain("controls.zoomToCursor = true");
173173
});
174+
175+
it("BrainVisualizer instantiates RippleEngine, ticks per frame, and fires on click", () => {
176+
expect(source).toContain("import { RippleEngine }");
177+
expect(source).toContain("new RippleEngine()");
178+
expect(source).toContain("engine.attachMesh(mesh)");
179+
expect(source).toContain("engine.buildAdjacency(graphData.links)");
180+
expect(source).toContain("engine.tick(now)");
181+
expect(source).toContain("rippleEngineRef.current?.fire(nextNode.id, performance.now())");
182+
});
174183
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { riseDecay } from "./easing.js";
2+
3+
const DEPTH_CAP = 2;
4+
const STEP_MS = 110;
5+
const RISE_MS = 80;
6+
const TAU_MS = 280;
7+
const DEPTH_ATTENUATION = 0.55;
8+
const RIPPLE_VISIBLE_TAIL_MS = 500;
9+
const RIPPLE_LIFE_MS = DEPTH_CAP * STEP_MS + RISE_MS + RIPPLE_VISIBLE_TAIL_MS;
10+
11+
function endpointId(endpoint) {
12+
if (endpoint && typeof endpoint === "object") return endpoint.id;
13+
return endpoint;
14+
}
15+
16+
function edgeKey(sourceId, targetId, type = "semantic") {
17+
return `${sourceId}>${targetId}>${type}`;
18+
}
19+
20+
export class RippleEngine {
21+
constructor() {
22+
this.adjacency = new Map();
23+
this.edgeIndex = new Map();
24+
this.activationData = null;
25+
this.activationTexture = null;
26+
this.edgeCount = 0;
27+
this.ripples = [];
28+
this.observers = { fire: new Set(), reach: new Set() };
29+
}
30+
31+
attachMesh(mesh) {
32+
if (!mesh?.userData) return;
33+
this.edgeIndex = mesh.userData.edgeIndex || new Map();
34+
this.activationData = mesh.userData.activationData || null;
35+
this.activationTexture = mesh.userData.activationTexture || null;
36+
this.edgeCount = mesh.userData.edgeCount || 0;
37+
}
38+
39+
buildAdjacency(links) {
40+
const adjacency = new Map();
41+
for (const link of links || []) {
42+
const sourceId = endpointId(link.source);
43+
const targetId = endpointId(link.target);
44+
const type = link.type || "semantic";
45+
const key = edgeKey(sourceId, targetId, type);
46+
const edgeId = this.edgeIndex.get(key);
47+
if (edgeId === undefined) continue;
48+
49+
if (!adjacency.has(sourceId)) adjacency.set(sourceId, []);
50+
if (!adjacency.has(targetId)) adjacency.set(targetId, []);
51+
adjacency.get(sourceId).push({ neighborId: targetId, edgeId });
52+
adjacency.get(targetId).push({ neighborId: sourceId, edgeId });
53+
}
54+
this.adjacency = adjacency;
55+
}
56+
57+
bfs(sourceId) {
58+
const visitedNodes = new Map();
59+
const visitedEdges = new Map();
60+
const reachOrder = [];
61+
visitedNodes.set(sourceId, 0);
62+
63+
let frontier = [sourceId];
64+
for (let depth = 1; depth <= DEPTH_CAP; depth += 1) {
65+
const next = [];
66+
for (const nodeId of frontier) {
67+
const neighbors = this.adjacency.get(nodeId) || [];
68+
for (const { neighborId, edgeId } of neighbors) {
69+
if (!visitedEdges.has(edgeId)) {
70+
visitedEdges.set(edgeId, depth - 1);
71+
}
72+
if (!visitedNodes.has(neighborId)) {
73+
visitedNodes.set(neighborId, depth);
74+
reachOrder.push({ neighborId, depth });
75+
next.push(neighborId);
76+
}
77+
}
78+
}
79+
frontier = next;
80+
if (!frontier.length) break;
81+
}
82+
83+
return { visitedNodes, visitedEdges, reachOrder };
84+
}
85+
86+
fire(sourceId, now = performance.now()) {
87+
if (!this.adjacency.has(sourceId)) return null;
88+
const result = this.bfs(sourceId);
89+
const ripple = {
90+
sourceId,
91+
startTime: now,
92+
visitedNodes: result.visitedNodes,
93+
visitedEdges: result.visitedEdges,
94+
reachOrder: result.reachOrder,
95+
reached: new Set(),
96+
};
97+
this.ripples.push(ripple);
98+
this.notify("fire", { sourceId, time: now });
99+
return ripple;
100+
}
101+
102+
fireAmbient(now = performance.now()) {
103+
const ids = [...this.adjacency.keys()];
104+
if (!ids.length) return null;
105+
const id = ids[Math.floor(Math.random() * ids.length)];
106+
return this.fire(id, now);
107+
}
108+
109+
on(event, callback) {
110+
this.observers[event]?.add(callback);
111+
return () => this.observers[event]?.delete(callback);
112+
}
113+
114+
notify(event, payload) {
115+
const set = this.observers[event];
116+
if (!set) return;
117+
for (const cb of set) {
118+
try { cb(payload); } catch { /* observer errors must not break ticks */ }
119+
}
120+
}
121+
122+
tick(now = performance.now()) {
123+
if (!this.activationData || !this.activationTexture) return;
124+
125+
this.activationData.fill(0);
126+
const next = [];
127+
128+
for (const ripple of this.ripples) {
129+
const elapsed = now - ripple.startTime;
130+
if (elapsed > RIPPLE_LIFE_MS) continue;
131+
132+
for (const [edgeId, depth] of ripple.visitedEdges) {
133+
const t = elapsed - depth * STEP_MS;
134+
if (t < 0) continue;
135+
const value = riseDecay(t, RISE_MS, TAU_MS) * Math.pow(DEPTH_ATTENUATION, depth);
136+
const current = this.activationData[edgeId] || 0;
137+
this.activationData[edgeId] = Math.min(1, current + value);
138+
}
139+
140+
for (const reach of ripple.reachOrder) {
141+
if (ripple.reached.has(reach.neighborId)) continue;
142+
const arrival = reach.depth * STEP_MS;
143+
if (elapsed >= arrival) {
144+
ripple.reached.add(reach.neighborId);
145+
this.notify("reach", { nodeId: reach.neighborId, depth: reach.depth });
146+
}
147+
}
148+
149+
next.push(ripple);
150+
}
151+
152+
this.ripples = next;
153+
this.activationTexture.needsUpdate = true;
154+
}
155+
156+
reset() {
157+
this.ripples = [];
158+
if (this.activationData) this.activationData.fill(0);
159+
if (this.activationTexture) this.activationTexture.needsUpdate = true;
160+
}
161+
162+
static get DEPTH_CAP() { return DEPTH_CAP; }
163+
static get STEP_MS() { return STEP_MS; }
164+
static get RISE_MS() { return RISE_MS; }
165+
static get TAU_MS() { return TAU_MS; }
166+
static get DEPTH_ATTENUATION() { return DEPTH_ATTENUATION; }
167+
static get RIPPLE_LIFE_MS() { return RIPPLE_LIFE_MS; }
168+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from "vitest";
2+
import { RippleEngine } from "../RippleEngine.js";
3+
4+
function makeMesh(edgeIndex, edgeCount) {
5+
return {
6+
userData: {
7+
edgeIndex,
8+
edgeCount,
9+
activationData: new Float32Array(edgeCount),
10+
activationTexture: { needsUpdate: false },
11+
},
12+
};
13+
}
14+
15+
function buildLineGraph(n) {
16+
const links = [];
17+
const edgeIndex = new Map();
18+
for (let i = 0; i < n - 1; i += 1) {
19+
const link = { source: `n${i}`, target: `n${i + 1}`, type: "semantic" };
20+
edgeIndex.set(`n${i}>n${i + 1}>semantic`, i);
21+
links.push(link);
22+
}
23+
return { links, edgeIndex, edgeCount: n - 1 };
24+
}
25+
26+
describe("RippleEngine", () => {
27+
it("respects depth cap of 2 hops on a long line graph", () => {
28+
const graph = buildLineGraph(8);
29+
const mesh = makeMesh(graph.edgeIndex, graph.edgeCount);
30+
const engine = new RippleEngine();
31+
engine.attachMesh(mesh);
32+
engine.buildAdjacency(graph.links);
33+
34+
const ripple = engine.fire("n0", 0);
35+
const visitedDepths = [...ripple.visitedNodes.values()];
36+
expect(Math.max(...visitedDepths)).toBe(RippleEngine.DEPTH_CAP);
37+
expect(ripple.visitedNodes.has("n4")).toBe(false);
38+
expect(ripple.visitedNodes.get("n2")).toBe(2);
39+
});
40+
41+
it("activations are additive across simultaneous ripples and clamp at 1.0", () => {
42+
const graph = buildLineGraph(4);
43+
const mesh = makeMesh(graph.edgeIndex, graph.edgeCount);
44+
const engine = new RippleEngine();
45+
engine.attachMesh(mesh);
46+
engine.buildAdjacency(graph.links);
47+
48+
engine.fire("n0", 0);
49+
engine.fire("n2", 0);
50+
engine.tick(60);
51+
const buffer = mesh.userData.activationData;
52+
for (const value of buffer) {
53+
expect(value).toBeGreaterThanOrEqual(0);
54+
expect(value).toBeLessThanOrEqual(1);
55+
}
56+
expect(buffer[1]).toBeGreaterThan(0);
57+
});
58+
59+
it("decays back to zero after RIPPLE_LIFE_MS", () => {
60+
const graph = buildLineGraph(3);
61+
const mesh = makeMesh(graph.edgeIndex, graph.edgeCount);
62+
const engine = new RippleEngine();
63+
engine.attachMesh(mesh);
64+
engine.buildAdjacency(graph.links);
65+
engine.fire("n0", 0);
66+
engine.tick(RippleEngine.RIPPLE_LIFE_MS + 50);
67+
for (const value of mesh.userData.activationData) {
68+
expect(value).toBe(0);
69+
}
70+
expect(engine.ripples.length).toBe(0);
71+
});
72+
73+
it("notifies fire and reach observers", () => {
74+
const graph = buildLineGraph(3);
75+
const mesh = makeMesh(graph.edgeIndex, graph.edgeCount);
76+
const engine = new RippleEngine();
77+
engine.attachMesh(mesh);
78+
engine.buildAdjacency(graph.links);
79+
const fired = [];
80+
const reached = [];
81+
engine.on("fire", payload => fired.push(payload));
82+
engine.on("reach", payload => reached.push(payload));
83+
engine.fire("n0", 0);
84+
engine.tick(120);
85+
expect(fired.length).toBe(1);
86+
expect(fired[0].sourceId).toBe("n0");
87+
expect(reached.some(r => r.nodeId === "n1" && r.depth === 1)).toBe(true);
88+
});
89+
90+
it("fireAmbient picks a random node and produces a ripple", () => {
91+
const graph = buildLineGraph(5);
92+
const mesh = makeMesh(graph.edgeIndex, graph.edgeCount);
93+
const engine = new RippleEngine();
94+
engine.attachMesh(mesh);
95+
engine.buildAdjacency(graph.links);
96+
const ripple = engine.fireAmbient(0);
97+
expect(ripple).not.toBeNull();
98+
expect(ripple.visitedNodes.size).toBeGreaterThan(0);
99+
});
100+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import { clamp01, easeOutCubic, expDecay, riseDecay } from "../easing.js";
3+
4+
describe("easing helpers", () => {
5+
it("clamp01 caps to [0,1]", () => {
6+
expect(clamp01(-2)).toBe(0);
7+
expect(clamp01(0.4)).toBe(0.4);
8+
expect(clamp01(2)).toBe(1);
9+
});
10+
11+
it("easeOutCubic boundaries and monotonic", () => {
12+
expect(easeOutCubic(0)).toBe(0);
13+
expect(easeOutCubic(1)).toBe(1);
14+
let prev = -Infinity;
15+
for (let i = 0; i <= 20; i += 1) {
16+
const v = easeOutCubic(i / 20);
17+
expect(v).toBeGreaterThanOrEqual(prev);
18+
prev = v;
19+
}
20+
});
21+
22+
it("expDecay matches exp(-t/tau) at canonical points", () => {
23+
const tau = 280;
24+
expect(Math.abs(expDecay(0, tau) - 1)).toBeLessThan(1e-6);
25+
expect(Math.abs(expDecay(tau * Math.LN2, tau) - 0.5)).toBeLessThan(1e-3);
26+
expect(Math.abs(expDecay(tau * Math.log(6), tau) - 1 / 6)).toBeLessThan(0.01);
27+
});
28+
29+
it("riseDecay rises within riseMs then decays exp afterwards", () => {
30+
const value0 = riseDecay(0, 80, 280);
31+
const valueRise = riseDecay(80, 80, 280);
32+
const valueAfter = riseDecay(80 + 280 * Math.LN2, 80, 280);
33+
expect(value0).toBe(0);
34+
expect(valueRise).toBeCloseTo(1, 5);
35+
expect(Math.abs(valueAfter - 0.5)).toBeLessThan(1e-2);
36+
});
37+
});

0 commit comments

Comments
 (0)