feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix

v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a
glowing orb materialises in the cosmic center (camera-relative z=-40),
gestates for ~800ms growing from a tiny spark into a full orb, then arcs
along a dynamic quadratic Bezier curve to the live position of the real
node, and on arrival hands off to the existing RainbowBurst + Shockwave +
RippleWave cascade. The target position is re-resolved every frame so
the force simulation can move the destination during flight without the
orb losing its mark.

**New primitive — EffectManager.createBirthOrb()** (effects.ts):
  Accepts a camera, a color, a live target-position getter, and an
  arrival callback. Owns a sprite pair (outer halo + inner bright core),
  both depthTest:false with renderOrder 999/1000 so the orb is always
  visible through the starfield and the graph.
  - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints
    from neutral to event color as the ritual charges.
  - Flight phase: QuadraticBezierCurve3 with control point at midpoint
    raised on Y by 30 + 15% of orb-to-target distance (shooting-star
    arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target.
  - Arrival: fires onArrive callback once, then fades out over 8 frames
    while expanding slightly (energy dispersal).
  - Caller's onArrive triggers the burst cascade at arrivePos (NOT the
    original spawnPos — the force sim may have moved the target during
    the ritual, so we re-read nodeManager.positions on arrival).
  - Dispose path integrated with existing EffectManager.dispose().

**Event pipeline fix — Graph3D.processEvents()**:
  Previously tracked `processedEventCount` assuming APPEND order, but
  websocket.ts PREPENDS new events (index 0) and caps the array at
  MAX_EVENTS. Result: only the first MemoryCreated event after page
  load fired correctly; subsequent ones reprocessed the oldest entry.
  Fixed to walk from index 0 until hitting the last-processed event
  by reference identity — correct regardless of array direction or
  eviction pressure. Events are then processed oldest-first so causes
  precede effects. Found while wiring the v2.3 demo button; would have
  manifested as "first orb only" in production.

**Demo trigger** (Settings -> Birth Ritual Preview):
  Button that calls websocket.injectEvent() with a synthetic
  MemoryCreated event, cycling through node types (fact / concept /
  pattern / decision / person / place) to showcase the type-color
  mapping. Downstream consumers can't distinguish synthetic from real,
  so this drives the full ritual end-to-end. Intended for demo clip
  recording for the Wednesday launch.

**Test coverage:**
  - events.test.ts now tests the v2.3 birth ritual path: spawns 2+
    sprites in the scene immediately, and fires the full arrival
    cascade after driving the effects.update() loop past the ritual
    duration.
  - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion,
    Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture,
    and Object3D.quaternion/renderOrder so production code runs unaltered
    in tests.

Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm test: 251/251 pass (net +0 from v2.2)
- npm run build: clean adapter-static output

The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs
server-side event plumbing and is deferred. Ship this as the Wednesday
visual mic-drop.
This commit is contained in:
Sam Valladares 2026-04-20 12:47:37 -05:00
parent f01375b815
commit f40aa2e086
8 changed files with 393 additions and 29 deletions

View file

@ -59,8 +59,13 @@
let nebulaMaterial: THREE.ShaderMaterial;
let postStack: PostProcessingStack;
// Event tracking
let processedEventCount = 0;
// Event tracking — we track the last-processed event by reference identity
// rather than by count, because the WebSocket store PREPENDS new events
// at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water
// mark would drift out of alignment (and did for ~3 versions — v2.3
// demo uncovered this while trying to fire multiple MemoryCreated events
// in sequence).
let lastProcessedEvent: VestigeEvent | null = null;
// Internal tracking: initial nodes + live-added nodes
let allNodes: GraphNode[] = [];
@ -157,10 +162,19 @@
}
function processEvents() {
if (!events || events.length <= processedEventCount) return;
if (!events || events.length === 0) return;
const newEvents = events.slice(processedEventCount);
processedEventCount = events.length;
// Walk the feed from newest (index 0) backward until we hit the last
// event we already processed. Everything between is fresh. This is
// robust against both (a) prepend ordering and (b) the MAX_EVENTS cap
// dropping old entries off the tail.
const fresh: VestigeEvent[] = [];
for (const e of events) {
if (e === lastProcessedEvent) break;
fresh.push(e);
}
if (fresh.length === 0) return;
lastProcessedEvent = events[0];
const mutationCtx: GraphMutationContext = {
effects,
@ -180,8 +194,11 @@
},
};
for (const event of newEvents) {
mapEventToEffects(event, mutationCtx, allNodes);
// Process oldest-first so cause precedes effect (e.g. MemoryCreated
// fires before a ConnectionDiscovered that references the new node).
// `fresh` is newest-first from the walk above, so iterate reversed.
for (let i = fresh.length - 1; i >= 0; i--) {
mapEventToEffects(fresh[i], mutationCtx, allNodes);
}
}

View file

@ -155,7 +155,7 @@ describe('Event-to-Mutation Pipeline', () => {
expect(distToN1).toBeLessThan(20);
});
it('triggers rainbow burst effect', () => {
it('spawns a v2.3 birth orb in the scene', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
@ -168,16 +168,19 @@ describe('Event-to-Mutation Pipeline', () => {
allNodes
);
// Scene should have new particles (rainbow burst + shockwave + possibly more)
expect(scene.children.length).toBeGreaterThan(childrenBefore);
// Birth orb adds a halo sprite + bright core sprite to the scene
// immediately. The arrival-cascade effects (rainbow burst, shockwaves,
// ripple wave) are deferred to the orb's onArrive callback — covered
// by the "fires arrival cascade after ritual" test below.
expect(scene.children.length).toBeGreaterThanOrEqual(childrenBefore + 2);
});
it('triggers double shockwave (second delayed)', () => {
it('fires the arrival cascade after the birth ritual completes', () => {
vi.useFakeTimers();
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'double-shock',
id: 'cascade-check',
content: 'test',
node_type: 'fact',
}),
@ -185,13 +188,23 @@ describe('Event-to-Mutation Pipeline', () => {
allNodes
);
const initialChildren = scene.children.length;
const afterSpawn = scene.children.length;
// Advance past the setTimeout
vi.advanceTimersByTime(200);
// Drive the effects update loop past the full ritual duration
// (gestation 48 + flight 90 = 138 frames). Each tick is one frame;
// we run 150 to give onArrive room to fire.
for (let i = 0; i < 150; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// Second shockwave should have been added
expect(scene.children.length).toBeGreaterThan(initialChildren);
// Advance the setTimeout that schedules the delayed second shockwave.
vi.advanceTimersByTime(250);
// The arrival cascade should have added a rainbow burst, shockwave,
// ripple wave, and delayed second shockwave to the scene. Even after
// the orb fades out and is removed, the burst particles persist long
// enough that children.length should exceed the post-spawn count.
expect(scene.children.length).toBeGreaterThan(afterSpawn);
vi.useRealTimers();
});

View file

@ -93,6 +93,52 @@ export class Vector3 {
this.z = s;
return this;
}
addVectors(a: Vector3, b: Vector3) {
this.x = a.x + b.x;
this.y = a.y + b.y;
this.z = a.z + b.z;
return this;
}
applyQuaternion(_q: Quaternion) {
// Mock: identity transform. Tests don't care about actual
// camera-relative positioning; production uses real THREE math.
return this;
}
}
export class Quaternion {
x = 0;
y = 0;
z = 0;
w = 1;
}
export class QuadraticBezierCurve3 {
v0: Vector3;
v1: Vector3;
v2: Vector3;
constructor(v0: Vector3, v1: Vector3, v2: Vector3) {
this.v0 = v0;
this.v1 = v1;
this.v2 = v2;
}
getPoint(t: number): Vector3 {
// Standard quadratic Bezier evaluation, faithful enough for tests
// that only care that points land on the curve.
const one = 1 - t;
return new Vector3(
one * one * this.v0.x + 2 * one * t * this.v1.x + t * t * this.v2.x,
one * one * this.v0.y + 2 * one * t * this.v1.y + t * t * this.v2.y,
one * one * this.v0.z + 2 * one * t * this.v1.z + t * t * this.v2.z
);
}
}
export class Texture {
needsUpdate = false;
dispose() {}
}
export class Vector2 {
@ -157,6 +203,13 @@ export class Color {
offsetHSL(_h: number, _s: number, _l: number) {
return this;
}
multiplyScalar(s: number) {
this.r *= s;
this.g *= s;
this.b *= s;
return this;
}
}
export class BufferAttribute {
@ -329,6 +382,8 @@ export class SpriteMaterial extends BaseMaterial {
export class Object3D {
position = new Vector3();
scale = new Vector3(1, 1, 1);
quaternion = new Quaternion();
renderOrder = 0;
userData: Record<string, unknown> = {};
children: Object3D[] = [];
parent: Object3D | null = null;
@ -428,6 +483,9 @@ export function installThreeMock() {
Vector3,
Vector2,
Color,
Quaternion,
QuadraticBezierCurve3,
Texture,
BufferAttribute,
BufferGeometry,
SphereGeometry,

View file

@ -1,4 +1,5 @@
import * as THREE from 'three';
import { getGlowTexture } from './nodes';
export interface PulseEffect {
nodeId: string;
@ -49,6 +50,28 @@ interface ConnectionFlash {
intensity: number;
}
// v2.3 Memory Birth Ritual. The orb gestates at a camera-relative "cosmic
// center" point for `gestationFrames`, then flies along a dynamic quadratic
// Bezier curve to the live position of its target node for `flightFrames`,
// then calls `onArrive` and disposes itself. The target position is
// resolved via `getTargetPos` on every frame so the force simulation can
// move the node during the flight and the orb stays glued to it.
interface BirthOrb {
sprite: THREE.Sprite;
core: THREE.Sprite;
startPos: THREE.Vector3;
getTargetPos: () => THREE.Vector3 | undefined;
color: THREE.Color;
age: number;
gestationFrames: number;
flightFrames: number;
arriveFired: boolean;
onArrive: () => void;
/** Last known target position. If the target disappears mid-flight we keep
* using this snapshot so the orb still lands somewhere sensible. */
lastTargetPos: THREE.Vector3;
}
export class EffectManager {
pulseEffects: PulseEffect[] = [];
private spawnBursts: SpawnBurst[] = [];
@ -57,6 +80,7 @@ export class EffectManager {
private implosions: ImplosionEffect[] = [];
private shockwaves: Shockwave[] = [];
private connectionFlashes: ConnectionFlash[] = [];
private birthOrbs: BirthOrb[] = [];
private scene: THREE.Scene;
constructor(scene: THREE.Scene) {
@ -231,6 +255,88 @@ export class EffectManager {
this.connectionFlashes.push({ line, intensity: 1.0 });
}
/**
* v2.3 Memory Birth Ritual. Spawn a glowing orb at a point in front of the
* camera ("cosmic center"), gestate for ~800ms, then arc along a quadratic
* Bezier curve to the live position of the target node, which is resolved
* on every frame via `getTargetPos`. On arrival, `onArrive` fires caller
* is responsible for adding the real node to the graph and triggering
* arrival-time bursts.
*
* The target getter can return undefined if the node has been removed
* mid-flight; the orb then flies to the last known target position so the
* burst still fires somewhere coherent rather than snapping to origin.
*/
createBirthOrb(
camera: THREE.Camera,
color: THREE.Color,
getTargetPos: () => THREE.Vector3 | undefined,
onArrive: () => void,
opts: { gestationFrames?: number; flightFrames?: number; distanceFromCamera?: number } = {}
) {
const gestationFrames = opts.gestationFrames ?? 48; // ~800ms
const flightFrames = opts.flightFrames ?? 90; // ~1500ms
const distanceFromCamera = opts.distanceFromCamera ?? 40;
// Place the orb slightly in front of the camera, in view-space,
// projected back into world coordinates. This way the orb always
// appears "right in front of the user's face" regardless of where
// the camera has been orbited to.
const startPos = new THREE.Vector3(0, 0, -distanceFromCamera)
.applyQuaternion(camera.quaternion)
.add(camera.position);
// Outer glow halo
const haloMat = new THREE.SpriteMaterial({
map: getGlowTexture(),
color: color.clone(),
transparent: true,
opacity: 0.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false, // always visible, even through other nodes
});
const sprite = new THREE.Sprite(haloMat);
sprite.position.copy(startPos);
sprite.scale.set(0.5, 0.5, 1);
sprite.renderOrder = 999;
// Inner bright core — stays hot white during gestation, tints at launch
const coreMat = new THREE.SpriteMaterial({
map: getGlowTexture(),
color: new THREE.Color(0xffffff),
transparent: true,
opacity: 0.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
});
const core = new THREE.Sprite(coreMat);
core.position.copy(startPos);
core.scale.set(0.2, 0.2, 1);
core.renderOrder = 1000;
this.scene.add(sprite);
this.scene.add(core);
// Snapshot the current target so we have a fallback.
const initialTarget = getTargetPos()?.clone() ?? startPos.clone();
this.birthOrbs.push({
sprite,
core,
startPos,
getTargetPos,
color: color.clone(),
age: 0,
gestationFrames,
flightFrames,
arriveFired: false,
onArrive,
lastTargetPos: initialTarget,
});
}
update(
nodeMeshMap: Map<string, THREE.Mesh>,
camera: THREE.Camera,
@ -431,6 +537,97 @@ export class EffectManager {
}
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
}
// v2.3 Birth orbs — gestate at cosmic center, then arc to live node
// position along a quadratic Bezier curve. Target position is
// re-resolved every frame so the force simulation can move the
// destination during flight without the orb losing its mark.
for (let i = this.birthOrbs.length - 1; i >= 0; i--) {
const orb = this.birthOrbs[i];
orb.age++;
const totalFrames = orb.gestationFrames + orb.flightFrames;
// Refresh the live target snapshot — if the node still exists we
// track it, otherwise we fall back to the last known position.
const live = orb.getTargetPos();
if (live) orb.lastTargetPos.copy(live);
const haloMat = orb.sprite.material as THREE.SpriteMaterial;
const coreMat = orb.core.material as THREE.SpriteMaterial;
if (orb.age <= orb.gestationFrames) {
// Gestation phase — pulse brighter + grow from a tiny spark
// into a full orb. Sits still at the cosmic center.
const t = orb.age / orb.gestationFrames;
const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic
const pulse = 0.85 + Math.sin(orb.age * 0.35) * 0.15;
const haloScale = 0.5 + ease * 4.5 * pulse;
const coreScale = 0.2 + ease * 1.8 * pulse;
orb.sprite.scale.set(haloScale, haloScale, 1);
orb.core.scale.set(coreScale, coreScale, 1);
haloMat.opacity = ease * 0.95;
coreMat.opacity = ease;
// Subtle warm-up — core white, halo tints toward the event
// color as gestation completes.
haloMat.color.copy(orb.color).multiplyScalar(0.7 + ease * 0.3);
orb.sprite.position.copy(orb.startPos);
orb.core.position.copy(orb.startPos);
} else if (orb.age <= totalFrames) {
// Flight phase — Bezier along a dynamic curve. Arc the
// control point upward for a shooting-star trajectory.
const t = (orb.age - orb.gestationFrames) / orb.flightFrames;
const ease = t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad
const target = orb.lastTargetPos;
const control = new THREE.Vector3()
.addVectors(orb.startPos, target)
.multiplyScalar(0.5);
control.y += 30 + target.distanceTo(orb.startPos) * 0.15;
const curve = new THREE.QuadraticBezierCurve3(orb.startPos, control, target);
const p = curve.getPoint(ease);
orb.sprite.position.copy(p);
orb.core.position.copy(p);
// Trail effect — shrink + brighten as it approaches target
const flightProgress = ease;
const shrink = 1 - flightProgress * 0.35;
orb.sprite.scale.setScalar(5 * shrink);
orb.core.scale.setScalar(2 * shrink);
haloMat.opacity = 0.95;
coreMat.opacity = 1.0;
// Shift halo fully to the event color during flight
haloMat.color.copy(orb.color);
} else if (!orb.arriveFired) {
// Docking — fire the arrival callback once. Let the caller
// trigger burst/ripple effects at the exact target point.
orb.arriveFired = true;
try {
orb.onArrive();
} catch (e) {
// Effects must never take down the render loop.
// eslint-disable-next-line no-console
console.warn('[birth-orb] onArrive threw', e);
}
// Fade the orb out over a few more frames instead of popping.
} else {
// Post-arrival fade (8 frames ≈ 130ms)
const fadeAge = orb.age - totalFrames;
const fade = Math.max(0, 1 - fadeAge / 8);
haloMat.opacity = 0.95 * fade;
coreMat.opacity = 1.0 * fade;
orb.sprite.scale.setScalar(5 * (1 + (1 - fade) * 2));
if (fade <= 0) {
this.scene.remove(orb.sprite);
this.scene.remove(orb.core);
haloMat.dispose();
coreMat.dispose();
this.birthOrbs.splice(i, 1);
}
}
}
}
dispose() {
@ -464,6 +661,12 @@ export class EffectManager {
flash.line.geometry.dispose();
(flash.line.material as THREE.Material).dispose();
}
for (const orb of this.birthOrbs) {
this.scene.remove(orb.sprite);
this.scene.remove(orb.core);
(orb.sprite.material as THREE.Material).dispose();
(orb.core.material as THREE.Material).dispose();
}
this.pulseEffects = [];
this.spawnBursts = [];
this.rainbowBursts = [];
@ -471,5 +674,6 @@ export class EffectManager {
this.implosions = [];
this.shockwaves = [];
this.connectionFlashes = [];
this.birthOrbs = [];
}
}

View file

@ -125,7 +125,11 @@ export function mapEventToEffects(
// Find spawn position near related nodes
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
// Add to all managers
// Add to all managers NOW so the force simulation can start
// settling the target position during the birth orb's flight.
// NodeManager materialises from scale 0 via easeOutElastic, so the
// node grows in as the orb descends onto it — visually the orb
// looks like it pulls the memory into existence on contact.
const pos = nodeManager.addNode(newNode, spawnPos);
forceSim.addNode(data.id, pos);
@ -133,17 +137,35 @@ export function mapEventToEffects(
liveSpawnedNodes.push(data.id);
evictOldestLiveNode(ctx, allNodes);
// Spectacular effects: rainbow burst + double shockwave + ripple wave
// v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight,
// arrival burst cascade. The old burst/ripple/shockwave cascade
// now fires on arrival at the docking target, not at spawn, so
// the camera's eye has time to track the orb in.
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
effects.createRainbowBurst(spawnPos, color);
effects.createShockwave(spawnPos, color, camera);
// Second shockwave, hue-shifted, delayed via smaller initial scale
const hueShifted = color.clone();
hueShifted.offsetHSL(0.15, 0, 0);
setTimeout(() => {
effects.createShockwave(spawnPos, hueShifted, camera);
}, 166); // ~10 frames at 60fps
effects.createRippleWave(spawnPos);
effects.createBirthOrb(
camera,
color,
// Re-resolve the live target position every frame — the node
// is being pushed around by the force sim during flight.
() => nodeManager.positions.get(newNode.id),
() => {
// Dock. Fire the arrival cascade at the node's current
// position (not the original spawnPos — it has moved).
const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos;
effects.createRainbowBurst(arrivePos, color);
effects.createShockwave(arrivePos, color, camera);
effects.createRippleWave(arrivePos);
// Second shockwave, hue-shifted, ~166ms delay for a layered
// crash feel rather than a single pop.
setTimeout(() => {
const delayedPos = nodeManager.positions.get(newNode.id) ?? arrivePos;
effects.createShockwave(delayedPos, hueShifted, camera);
}, 166);
}
);
onMutation({ type: 'nodeAdded', node: newNode });
break;

View file

@ -67,7 +67,7 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string {
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
// soft radial gradient gives a real round halo and lets bloom do its job.
let sharedGlowTexture: THREE.Texture | null = null;
function getGlowTexture(): THREE.Texture {
export function getGlowTexture(): THREE.Texture {
if (sharedGlowTexture) return sharedGlowTexture;
const size = 128;
const canvas = document.createElement('canvas');

View file

@ -81,11 +81,26 @@ function createWebSocketStore() {
update(s => ({ ...s, events: [] }));
}
/**
* Inject a synthetic event into the feed as if it had arrived over the
* WebSocket. Used by the dev-mode "Preview Birth Ritual" button on the
* Settings page to let Sam trigger a demo of the v2.3 Memory Birth
* Ritual without ingesting a real memory. Downstream consumers
* InsightToast, Graph3D cannot distinguish synthetic from real.
*/
function injectEvent(event: VestigeEvent) {
update(s => {
const events = [event, ...s.events].slice(0, MAX_EVENTS);
return { ...s, events };
});
}
return {
subscribe,
connect,
disconnect,
clearEvents
clearEvents,
injectEvent
};
}

View file

@ -1,9 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$stores/api';
import { isConnected, memoryCount, avgRetention } from '$stores/websocket';
import { websocket, isConnected, memoryCount, avgRetention } from '$stores/websocket';
import { fireDemoSequence } from '$stores/toast';
// v2.3 Birth Ritual demo — injects a synthetic MemoryCreated event so
// Graph3D spawns a birth orb without needing a real ingest. Node types
// cycle so back-to-back clicks show different colors. Pure dev/demo
// affordance; production users see orbs fire on real ingests.
const DEMO_NODE_TYPES = ['fact', 'concept', 'pattern', 'decision', 'person', 'place'];
let birthCount = $state(0);
function fireBirthRitualDemo() {
const type = DEMO_NODE_TYPES[birthCount % DEMO_NODE_TYPES.length];
birthCount++;
websocket.injectEvent({
type: 'MemoryCreated',
data: {
id: `demo-birth-${Date.now()}`,
content: `Demo memory #${birthCount} — ${type}`,
node_type: type,
tags: ['demo', 'v2.3-birth-ritual'],
retention: 0.9,
},
});
}
// Operation states
let consolidating = $state(false);
let dreaming = $state(false);
@ -108,6 +129,20 @@
</div>
</div>
<!-- v2.3 Terrarium — demo the Memory Birth Ritual on the Graph page -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text font-medium">Birth Ritual Preview</div>
<div class="text-xs text-dim">Inject a synthetic memory — switch to Graph to watch the orb fly in</div>
</div>
<button onclick={fireBirthRitualDemo}
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition flex items-center gap-2">
<span></span> Trigger Birth
</button>
</div>
</div>
<!-- Consolidation -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="flex items-center justify-between">