feat(observatory): raw-WebGPU living cognitive field with 5 deterministic demos

The Observatory is a full-bleed, zero-library WebGPU surface that renders
the memory graph as a living cognitive field. Five deterministic demo
moments driven by a URL contract (?demo=<name>&seed=...&frame=N):
recall-path, engram-birth, salience-rescue, forgetting-horizon, firewall.
Capture mode (?frame=N) freezes the sim deterministically so the same URL
produces identical pixels, the viral-clip primitive.

Architecture: bare-metal WebGPU engine (no Three.js), seeded demo clock,
per-demo plan + renderer modules, WGSL shaders (simulate, nodes, edges,
path, birth particles, rescue, forgetting, firewall) plus a post-processing
chain (tone mapping, MIP). DOM is instrument overlays only (telemetry strip,
timeline spine, rescue verdict); the layout gives /observatory the same
full-bleed bypass as marketing routes so recordings stay clean.

Reads the real memory graph. Verified live: svelte-check 939 files 0 errors,
96 observatory unit tests green, all 5 demos load at 108-119fps with zero
console errors against the live brain.

Known follow-up: engram-birth capture-mode particle cluster needs a render
fix pass before it is camera-ready; the other 4 demos are camera-ready.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 10:49:18 -07:00
parent 898bd336cc
commit a046c620c8
47 changed files with 9695 additions and 6 deletions

View file

@ -0,0 +1,627 @@
# Vestige Cognitive Observatory WebGPU Implementation Spec
Status: RESEARCH -> SPEC ONLY. Do not implement app code from this file until Sam explicitly asks.
Goal: add a new full-bleed Cognitive Observatory route that reuses the existing graph/cinema/effects substrate, then layers a GPU-resident WebGPU simulation path for viral demo moments. Priority #1 is `?demo=recall-path`: a deterministic loop where recall lights a causal path through real memory nodes.
Ground truth read before this spec:
- `apps/dashboard/src/lib/components/Graph3D.svelte` currently owns the Three/WebGL scene, 60fps governor, `ForceSimulation`, `NodeManager`, `EdgeManager`, `ParticleSystem`, `EffectManager`, DreamMode, post-processing, and event mapping.
- `apps/dashboard/src/lib/graph/force-sim.ts` is a 101-line CPU simulation with O(N^2) repulsion, edge attraction, damping, centering, and frame-count cooldown.
- `apps/dashboard/src/lib/graph/scene.ts` is Three/WebGL `WebGLRenderer` + `EffectComposer`, not WebGPU.
- `apps/dashboard/src/lib/graph/particles.ts` and `effects.ts` still use CPU-updated `THREE.BufferGeometry` attributes and `Math.random()` bursts.
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts` is already deterministic and produces story beats/flow edges. Reuse this for recall paths.
- `apps/dashboard/src/lib/graph/cinema/director.ts` is renderer-agnostic camera choreography. Reuse its path semantics, not its renderer.
- `apps/dashboard/src/routes/(app)/graph/+page.svelte` has protected `MemoryCinema`; do not modify it. Add a separate route.
## 1. Exact WebGPU technique chosen per demo moment
### Shared baseline: GPU-resident node/particle state
Chosen technique: WebGPU Samples compute-boids ping-pong storage-buffer update + instanced sprite render.
Why: it is the cleanest canonical WebGPU pattern: node/particle state is in GPU buffers created with `GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE`; a compute pass writes next state; a render pass reads the current/next state as an instance vertex buffer and draws one tiny sprite mesh per node/particle. The sample uses `@compute @workgroup_size(64)` and dispatches `Math.ceil(numParticles / 64)` workgroups.
Sources:
- https://webgpu.github.io/webgpu-samples/samples/computeBoids/
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/main.ts
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/updateSprites.wgsl
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/sprite.wgsl
- https://webgpufundamentals.org/webgpu/lessons/webgpu-storage-buffers.html
- https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
Concrete pattern to use:
- `NodeState` storage buffer, 16-byte aligned lanes:
- `pos_radius: vec4f` = xyz + visual radius
- `vel_retention: vec4f` = xyz velocity + FSRS retention
- `color_flags: vec4f` = rgb + packed visual flags as f32 for MVP; move to u32 later if needed
- `demo: vec4f` = path phase, birth phase, ripple phase, shock phase
- Two ping-pong `GPUBuffer`s for simulation: previous read-only, next read-write.
- One static `EdgeIndex` storage buffer: `array<vec2u>` source/target indices.
- One `PathStep` storage buffer for demo path: `array<vec4u>` with source index, target index, beat frame, kind.
- One uniform buffer per frame: frame index, fixed time, loop phase, node count, edge count, path count, viewport, camera matrices.
- Compute workgroup size: start at 64 for broad compatibility; allow 128 only after profiling.
- Every WGSL entry checks `if (id.x >= params.nodeCount) { return; }` because dispatch is rounded up.
- CPU writes graph data once on route load, then only writes uniforms and demo controls. No per-frame readback.
### Moment A: `?demo=recall-path` — PATH lighting through nodes
Chosen technique: GPU path-wave scalar + edge glow render from the `PathStep` buffer.
Implementation:
- Use existing `planCinemaPath(nodes, edges, centerId, maxBeats)` to get deterministic beats and `flowEdges`.
- Convert each beat/edge to node indices and upload to `PathStep`.
- Compute pass `recallPathPass` runs per node. For each node, scan the small path buffer (max 7-12 beats) and compute:
- `beatT = smoothstep(beatFrame - 18, beatFrame + 18, frameInLoop)`
- `decayT = 1.0 - smoothstep(beatFrame + 45, beatFrame + 180, frameInLoop)`
- path intensity = max of beat arrival envelope for matching node index.
- Edge render reads source/target node states and path steps. A second `PathEdgeInstance` buffer is optional for MVP; simpler first build draws only path edges as instanced line quads using the `PathStep` source/target index.
- Color: cyan-white core with violet afterglow. Node `demo.x` = recall intensity. Edge alpha = wavefront envelope.
- Render order: background -> non-path edges -> nodes -> path edges additive -> path node halos additive -> DOM overlay.
Sources:
- WebGPU boids storage/render pattern above.
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts` for deterministic story path and flow edges.
- GraphWaGu edge vertex shader pattern, where the vertex shader fetches node positions from a storage buffer by source/target edge indices: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
### Moment B: node BORN — particle convergence into a node
Chosen technique: GPU particle attractor convergence with deterministic seed, not CPU `Math.random()` bursts.
Implementation:
- Add a `BirthParticle` storage buffer sized for e.g. 16k particles shared across all births.
- For each birth event or demo beat, assign a deterministic particle slice: start positions are generated from `hash(seed, particleIndex, nodeIndex)` on GPU or precomputed once on CPU with the same seeded PRNG.
- Compute pass lerps particle position from shell/noise field toward target node position:
- `p = mix(start, target + curlNoiseOffset, easeOutCubic(t))`
- Fade from violet dust to node color; size shrinks on arrival.
- For MVP, render as instanced billboards. Later upgrade to screen-space point splatting if density gets high.
Sources:
- WebGPU Samples compute-boids for GPU particle update/render.
- Particle Life WebGPU for finite-radius particle update discipline and atomics when interactions become local-neighborhood based: https://lisyarus.github.io/blog/posts/particle-life-simulation-in-browser-using-webgpu.html
- Codrops WebGPU fluids on particle simulation and screen-space point-splatting as the real-time surface path: https://tympanus.net/codrops/2025/01/29/particles-progress-and-perseverance-a-journey-into-webgpu-fluids/
### Moment C: backward RIPPLE — radial distortion moving from effect to cause
Chosen technique: screen-space radial distortion post pass + node intensity ring.
Implementation:
- Add a fullscreen post-process pipeline after node/edge render.
- Uniform `Ripple { originClip: vec2f, radius: f32, width: f32, strength: f32, direction: f32 }`.
- Fragment shader samples the scene texture with UV displaced along radial direction:
- `d = distance(uv, originClip)`
- `ring = smoothstep(radius + width, radius, d) * smoothstep(radius - width, radius, d)`
- `uv2 = uv + normalize(uv - originClip) * ring * strength * direction`
- For backward causal recall, `direction = -1` and radius contracts from the failure node toward the cause node, while `PathStep` beats are ordered effect -> cause.
Sources:
- WebGPU Fundamentals render/post basics: https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
- Codrops Shader.se article for scroll/timeline-driven WebGPU scene transitions and shader masking: https://tympanus.net/codrops/2026/05/19/80s-business-tech-seamless-scene-transitions-inside-shader-ses-scroll-driven-webgpu-pipeline/
- Existing `apps/dashboard/src/lib/graph/effects.ts` already has CPU `createRippleWave`; reuse semantics, move rendering to post shader for Observatory.
### Moment D: DRIFT toward a horizon — depth fog and horizon attractor
Chosen technique: GPU drift vector + depth/alpha fog in vertex/fragment shader.
Implementation:
- Add per-node `lifecycle` scalar from retention/state: active=near, dormant=mid, silent=far, unavailable=horizon.
- Compute pass applies a weak drift acceleration toward `horizon = vec3(0, -20, -220)` for low-retention nodes; high-retention nodes resist drift.
- Vertex shader writes depth/fog factor from camera-space z and lifecycle.
- Fragment shader mixes node color toward deep blue/black and reduces alpha with `smoothstep(fogNear, fogFar, depth)`.
Sources:
- Existing `scene.ts` uses Three `FogExp2`; Observatory should port the semantic to WGSL rather than rely on Three.
- Codrops False Earth for large WebGPU procedural world using compute shaders + indirect drawing; use as proof that compute-generated scene density belongs on GPU: https://tympanus.net/codrops/2026/04/21/false-earth-from-webgl-limits-to-a-webgpu-driven-world/
### Moment E: crimson SHOCKWAVE + membrane — firewall / immune response
Chosen technique: expanding instanced ring/membrane mesh + fullscreen chromatic shock pass.
Implementation:
- Keep an `EventPulse` uniform/storage ring buffer with origin, start frame, color, type.
- Node pass reads pulses and adds red rim lighting when `abs(distance(worldPos, origin) - radius) < width`.
- Membrane pass draws a camera-facing ring/quad sphere impostor with fresnel alpha, crimson core, black outer edge.
- Post pass applies a one-frame high-strength radial distortion and red channel bias when shockfront crosses screen-space pixel.
Sources:
- Existing `effects.ts:createShockwave` uses a CPU Three `RingGeometry`; reuse the visual grammar, move to WebGPU instanced membrane.
- Codrops WebGPU fluid articles for atomics/storage buffers/compute simulation made practical in browser: https://tympanus.net/codrops/2025/02/26/webgpu-fluid-simulations-high-performance-real-time-rendering/
- compute.toys gallery for WebGPU compute-shader idioms and GPU toy iteration: https://compute.toys/ and https://github.com/compute-toys/compute.toys
### GPU force-directed graph layout
Chosen technique for v1: GPU force layout with storage buffers, edge-local spring pass, approximate repulsion pass, and integrate pass. Start with exact O(N^2) repulsion for <=2k Observatory demo nodes; add Barnes-Hut / spatial bins before 10k+.
Why: existing `force-sim.ts` is O(N^2) CPU. The right migration is not to immediately rebuild all GraphWaGu complexity; it is to preserve Vestige's force semantics on GPU with the boids ping-pong pattern, then graduate repulsion acceleration once visual requirements exceed a few thousand nodes.
Required compute passes:
1. `clearForcesPass`: one invocation per node, zero `force.xyz`.
2. `edgeSpringPass`: one invocation per edge. Read source/target node positions, compute attraction, accumulate into source/target force. MVP avoids float atomics by writing one force contribution per edge endpoint to `EdgeForce` and reducing per node in pass 3; advanced path uses atomic fixed-point accumulation.
3. `repulsionPass`: one invocation per node; loop all nodes for MVP. For 10k+, replace with GraphWaGu-style Barnes-Hut/Morton tree or Particle Life spatial bins.
4. `integratePass`: one invocation per node; apply centering, damping, max velocity, retention/lifecycle drift, and ping-pong write to next node buffer.
5. `boundsPass` optional: compute bounds for fit camera; do not block v1 on GPU readback.
What moves from `force-sim.ts`:
- `positions: Map<string, THREE.Vector3>` becomes CPU index map + GPU `NodeState` buffers.
- `velocities: Map<string, THREE.Vector3>` becomes `vel_retention.xyz` in `NodeState`.
- `repulsionStrength`, `attractionStrength`, `dampening`, `alpha`, `maxSteps` become uniform fields.
- `tick(edges)` becomes command encoding of the compute passes above.
- `addNode/removeNode/reset` become buffer rebuilds for v1; optimize incremental updates later.
Sources:
- GraphWaGu repo: https://github.com/harp-lab/GraphWaGu
- GraphWaGu force-directed TS with compute pipelines/buffers: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/webgpu/force_directed.ts
- GraphWaGu exact force WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forces.wgsl
- GraphWaGu Barnes-Hut force WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forcesBH.wgsl
- GraphWaGu apply-forces WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/apply_forces.wgsl
- GraphWaGu Morton/tree/radix sort: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/create_tree.wgsl and https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/radix_sort.wgsl
- cosmos.gl / Cosmograph GPU force graph: https://github.com/cosmograph-org/cosmos and https://cosmograph.app/
- cosmos.gl README says computations and drawing occur on GPU and targets hundreds of thousands of points/links: https://raw.githubusercontent.com/cosmograph-org/cosmos/main/README.md
## 2. Precise files to add/change
Add docs/spec only in this run:
- `apps/dashboard/OBSERVATORY-SPEC.md` — this file.
Implementation files for the Qwen coder later:
Create:
- `apps/dashboard/src/routes/(app)/observatory/+page.svelte`
- New route only. Do not touch protected `graph/+page.svelte` or `MemoryCinema` wiring.
- Fetch graph data via existing `api.graph` pattern from graph route.
- Parse `?demo=` and `?seed=` with `URLSearchParams`.
- Render full-bleed `ObservatoryCanvas` plus DOM overlay.
- `apps/dashboard/src/lib/components/ObservatoryCanvas.svelte`
- Svelte wrapper with `onMount`/`onDestroy`, canvas bind, ResizeObserver, WebGPU lifecycle.
- `apps/dashboard/src/lib/observatory/engine.ts`
- Owns adapter/device/context, pipelines, buffers, render loop, resize, dispose.
- `apps/dashboard/src/lib/observatory/types.ts`
- CPU-side `ObservatoryNode`, `ObservatoryEdge`, `DemoMode`, `DemoClock`, buffer layout constants.
- `apps/dashboard/src/lib/observatory/demo-clock.ts`
- Deterministic fixed-step clock, seeded PRNG, loop-frame math.
- `apps/dashboard/src/lib/observatory/graph-upload.ts`
- Convert `GraphNode[]`/`GraphEdge[]` to stable node index map, typed arrays, path steps from `planCinemaPath`.
- `apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte`
- `apps/dashboard/src/lib/observatory/overlays/CommandSurface.svelte`
- `apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte`
- `apps/dashboard/src/lib/observatory/overlays/InspectorPanel.svelte`
- `apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts`
- `apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts`
- `apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts`
- `apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts`
- `apps/dashboard/src/lib/observatory/shaders/post-ripple-shock.wgsl.ts`
- `apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts`
- `apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts`
Extend, do not rewrite:
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts`
- Export or reuse `planCinemaPath` as-is. If extra metadata is needed, add small pure helper `pathToIndexSteps(path, nodeIndexById)` in observatory layer, not inside Cinema.
- `apps/dashboard/src/lib/graph/nodes.ts`
- Reuse `getNodeColor`, `getMemoryState`, `AHAGRAPH_COLORS`. No renderer logic added here.
- `apps/dashboard/src/lib/graph/effects.ts`
- Do not move existing Graph3D effects. Use it as visual reference only.
- `apps/dashboard/src/lib/graph/force-sim.ts`
- Leave CPU simulation intact for Graph3D. Add no WebGPU imports here. Observatory gets its own GPU sim module.
Do not change:
- `apps/dashboard/src/routes/(app)/graph/+page.svelte` protected `MemoryCinema` block.
- `apps/dashboard/src/lib/components/MemoryCinema.svelte` unless Sam explicitly asks.
- `crates/`, `Cargo.toml`, backend APIs.
## 3. `?demo=recall-path` priority build instructions
### 3.1 Route/component structure
1. Create `routes/(app)/observatory/+page.svelte`.
2. On mount:
- Parse `demo = new URLSearchParams(window.location.search).get('demo') ?? 'recall-path'`.
- Parse `seed = ...get('seed') ?? 'vestige-observatory-v1'`.
- Fetch graph with `api.graph({ max_nodes: 300, depth: 3, sort: 'recent' })` for MVP.
- Pass `nodes`, `edges`, `centerId`, `demo`, `seed` into `<ObservatoryCanvas />`.
3. Render route as a full viewport stage:
- parent `class="fixed inset-0 overflow-hidden bg-[#03040a]"`.
- canvas layer absolute inset-0.
- overlay layer absolute inset-0 pointer-events-none.
- specific interactive controls use `pointer-events-auto`.
### 3.2 Deterministic demo clock
Create `demo-clock.ts`:
- `const FPS = 60`.
- `const LOOP_SECONDS = 12` for recall-path; `LOOP_FRAMES = 720`.
- `frame` is an integer, not derived from `Date.now()`.
- In normal interactive mode, rAF accumulates elapsed time and advances fixed steps. In demo mode, each rendered frame advances exactly one fixed frame, or uses `?frame=N` for capture.
- `phase = (frame % LOOP_FRAMES) / LOOP_FRAMES`.
- Use a tiny deterministic PRNG (`xmur3` seed hash + `mulberry32`) in local code. Do not add a dependency for seedrandom unless needed.
- Ban `Math.random()` and `performance.now()` from simulation state. `performance.now()` may schedule frames only; it must not decide positions/colors/path.
Source rationale:
- Fixed timestep pattern: https://gafferongames.com/post/fix_your_timestep/
- URL query parsing: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
- Svelte lifecycle: https://svelte.dev/docs/svelte/lifecycle-hooks
### 3.3 Graph upload
Create `graph-upload.ts`:
1. Stable-sort nodes for deterministic indices:
- center node first if `id === centerId`.
- then descending `updatedAt || createdAt`.
- tie-break by `id.localeCompare`.
2. Build `nodeIndexById: Map<string, number>`.
3. Create `Float32Array nodeData` with 16 floats per node for alignment and future-proofing:
- 0..3: x, y, z, radius
- 4..7: vx, vy, vz, retention
- 8..11: r, g, b, flags
- 12..15: pathIntensity, birthPhase, ripplePhase, shockPhase
4. Initial positions:
- Reuse the golden-angle sphere logic from `NodeManager.createNodes`, but replace randomness with deterministic seed.
- Radius scales from node count: `baseR = clamp(24 + sqrt(n) * 1.2, 35, 140)`.
5. Create `Uint32Array edgeData` with 2 u32 per edge, skipping edges whose node IDs are absent.
6. Call `planCinemaPath(nodes, edges, centerId, 8)` and convert `flowEdges` to `PathStep[]`.
7. Path timing:
- beat 0 at frame 60.
- each next beat +60 frames.
- final 180 frames reserved for afterglow and loop reset.
### 3.4 WebGPU engine setup
Create `engine.ts`:
1. Feature detect:
- `if (!navigator.gpu) throw new Error('WebGPU unavailable')` and render a DOM fallback panel.
2. Request adapter/device.
3. Configure canvas:
- `format = navigator.gpu.getPreferredCanvasFormat()`.
- On resize, set `canvas.width = Math.min(clientWidth * devicePixelRatio, maxTextureDimension2D)` and same for height.
- Reconfigure context and recreate depth/scene textures.
4. Create buffers:
- `nodeA`, `nodeB`: `STORAGE | VERTEX | COPY_DST`.
- `edges`: `STORAGE | COPY_DST`.
- `pathSteps`: `STORAGE | COPY_DST`.
- `params`: `UNIFORM | COPY_DST`.
- sprite vertex buffer for a 2-triangle quad or 3-vertex triangle impostor.
5. Create pipelines:
- `simulatePipeline` with read nodeA/write nodeB.
- `renderEdgesPipeline` reads node current + edges.
- `renderNodesPipeline` uses instance index to fetch node state and draw billboard.
- `renderPathPipeline` draws path edges + additive halos.
- `postPipeline` optional in increment 5.
6. Frame loop command order for recall-path MVP:
- write params uniform for integer frame.
- compute simulate: nodeA -> nodeB.
- render scene using nodeB.
- swap nodeA/nodeB.
### 3.5 WGSL simulation for recall path
MVP `simulate.wgsl.ts`:
- Inputs: params, previous nodes, next nodes, edges, path steps.
- For each node:
- start with previous `pos`, `vel`.
- apply low-cost centering and edge spring only after the first MVP render works; first render may use static positions.
- compute path intensity by scanning `pathSteps`.
- compute horizon drift from retention.
- write next node state.
Pseudo-WGSL shape for Qwen:
```wgsl
struct NodeState {
pos_radius: vec4f,
vel_retention: vec4f,
color_flags: vec4f,
demo: vec4f,
};
struct Params {
frame: u32,
loopFrames: u32,
nodeCount: u32,
edgeCount: u32,
pathCount: u32,
dt: f32,
time: f32,
_pad: f32,
};
struct PathStep { source: u32, target: u32, beatFrame: u32, kind: u32 };
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> prevNodes: array<NodeState>;
@group(0) @binding(2) var<storage, read_write> nextNodes: array<NodeState>;
@group(0) @binding(3) var<storage, read> path: array<PathStep>;
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
fn smoothPulse(frame: f32, beat: f32, attack: f32, release: f32) -> f32 {
let a = smoothstep(beat - attack, beat, frame);
let r = 1.0 - smoothstep(beat, beat + release, frame);
return a * r;
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3u) {
let i = gid.x;
if (i >= params.nodeCount) { return; }
var node = prevNodes[i];
let f = f32(params.frame % params.loopFrames);
var recall = 0.0;
for (var p = 0u; p < params.pathCount; p = p + 1u) {
let step = path[p];
if (step.source == i || step.target == i) {
recall = max(recall, smoothPulse(f, f32(step.beatFrame), 18.0, 150.0));
}
}
node.demo.x = recall;
nextNodes[i] = node;
}
```
### 3.6 Render approach
Nodes:
- Use instanced billboards. Vertex shader fetches `NodeState` by `@builtin(instance_index)`.
- Billboard size = `radius * (1.0 + recallIntensity * 2.0)`.
- Fragment shader radial alpha: `alpha = smoothstep(1.0, 0.0, length(localUv))`.
- Color = `mix(baseColor, vec3(0.8, 0.95, 1.0), recallIntensity)`.
- Additive glow pass can be same shader with bigger billboard and low alpha.
Edges:
- MVP: draw edges as line-list if available; better: instanced thin quads from source to target in screen space.
- For path edges, draw a separate additive path pass from `PathStep` only, with wavefront alpha.
- GraphWaGu source pattern fetches nodes in vertex shader by edge endpoint index; use that for WebGPU: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
Path lighting:
- At beat frame, target node blooms first.
- During frames `beatFrame..beatFrame+45`, edge from source to target gets a traveling pulse:
- `edgeT = saturate((frame - beatFrame) / 45)`.
- Fragment local coordinate along edge `u`; alpha peak at `abs(u - edgeT) < 0.08`.
- Previous path nodes keep afterglow for 2-3 seconds.
### 3.7 DOM overlay
Overlay zones:
- Top telemetry strip: demo mode, seed, node/edge count, frame, fps estimate.
- Left command surface: demo selector buttons (`recall-path`, `birth`, `ripple`, `drift`, `firewall`) and copy demo URL.
- Bottom timeline spine: 720-frame loop with beat tick marks and current frame cursor.
- Right inspector: selected beat/node summary.
CSS pattern:
- Root stage: `position: fixed; inset: 0; isolation: isolate; overflow: hidden;`.
- Canvas: `position:absolute; inset:0; width:100%; height:100%; display:block; z-index:0;`.
- Overlay: `position:absolute; inset:0; z-index:10; pointer-events:none;`.
- Interactive overlay children: `pointer-events:auto;`.
- Avoid KPI cards. These are instruments: thin glass, monospaced labels, tick marks, command rail.
Sources:
- MDN `pointer-events`: https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events
- WebGPU canvas resizing: https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html
- Svelte lifecycle hooks: https://svelte.dev/docs/svelte/lifecycle-hooks
## 4. Build task-list with verification increments
### Increment 1 — route shell and deterministic URL contract
Build:
- Add `routes/(app)/observatory/+page.svelte`.
- Add minimal overlay shell and fetch graph data.
- Parse `?demo=recall-path&seed=...` and display them.
Verify:
- Run `pnpm --filter @vestige/dashboard check`.
- Open `/observatory?demo=recall-path&seed=test`.
- See full-bleed dark stage, top telemetry strip, no KPI cards, no console errors.
### Increment 2 — deterministic clock tests
Build:
- Add `demo-clock.ts` and tests.
- Fixed 60fps loop, 720-frame period, seeded PRNG.
Verify:
- `pnpm --filter @vestige/dashboard test -- demo-clock`
- Same seed produces identical first 100 random values and frame phases.
- Different seed changes generated positions.
### Increment 3 — WebGPU canvas boots and clears
Build:
- Add `ObservatoryCanvas.svelte` and `engine.ts` minimal WebGPU init.
- Canvas clears to near-black and resizes with DPR clamp.
Verify:
- `/observatory?demo=recall-path` displays WebGPU canvas.
- Resize browser; canvas remains sharp and fills screen.
- If WebGPU unavailable, route shows a readable fallback instead of crashing.
### Increment 4 — upload graph and render static nodes
Build:
- Add `graph-upload.ts` and tests.
- Convert graph nodes into stable indexed `Float32Array`.
- Render node billboards from storage buffer; no simulation yet.
Verify:
- Test stable node ordering and edge filtering.
- Browser shows 100-300 nodes in deterministic positions.
- Reload same seed: identical node positions.
### Increment 5 — recall path buffer + node glow
Build:
- Reuse `planCinemaPath` to create path steps.
- Add compute pass that writes `demo.x` recall intensity.
- Node shader blooms active path nodes.
Verify:
- `/observatory?demo=recall-path&seed=A` loops every 12s.
- The same sequence lights the same nodes after reload.
- `?seed=B` changes layout but path timing remains stable.
### Increment 6 — path edge wavefront
Build:
- Add render-path pipeline drawing additive edge wave traveling source -> target.
- Timeline spine shows beat ticks matching wave arrival.
Verify:
- Wavefront visibly travels along each path edge.
- At beat arrival, the target node blooms.
- Loop reset is seamless: final afterglow fades before frame 0.
### Increment 7 — low-cost GPU force sim
Build:
- Add static edge attraction + centering + damping in compute.
- Keep repulsion disabled or low-count O(N^2) only for <=500 nodes.
Verify:
- Nodes subtly settle without CPU `ForceSimulation`.
- Performance remains smooth at 300 nodes.
- No CPU per-frame node position loops.
### Increment 8 — lifecycle effects one by one
Build/verify separately:
- Born convergence particles: trigger in demo and verify deterministic convergence.
- Backward ripple post pass: verify radial distortion travels effect -> cause.
- Horizon drift/fog: silent/unavailable nodes visibly drift/fade toward horizon.
- Firewall shockwave/membrane: crimson ring expands and fades without breaking path demo.
### Increment 9 — capture mode
Build:
- Add `?capture=1` behavior: hide cursor, freeze random seed, optional `?frame=N` exact-frame render.
- Add overlay copy button for canonical demo URL.
Verify:
- Same URL + frame renders identical screenshot.
- 12-second screen recording loops without discontinuity.
### Increment 10 — performance guardrails
Build:
- Add telemetry: CPU frame ms estimate, GPU timestamp query only if supported, node count, buffer mode.
- Cap default demo to 300 real nodes; support synthetic 10k only behind `?stress=10000`.
Verify:
- No `mapAsync` in the frame loop except delayed optional profiling.
- 300-node demo holds 60fps on Sam's M1 Max.
- Stress mode degrades gracefully and can be disabled by removing query param.
## 5. Source index
WebGPU core patterns:
- WebGPU Samples compute boids demo: https://webgpu.github.io/webgpu-samples/samples/computeBoids/
- compute-boids main TS: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/main.ts
- compute-boids update WGSL: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/updateSprites.wgsl
- compute-boids sprite WGSL: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/sprite.wgsl
- WebGPU storage buffers: https://webgpufundamentals.org/webgpu/lessons/webgpu-storage-buffers.html
- WebGPU compute shaders: https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
- WebGPU canvas resize: https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html
- MDN WebGPU API: https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API
Particle/lifecycle shader references:
- Particle Life WebGPU article: https://lisyarus.github.io/blog/posts/particle-life-simulation-in-browser-using-webgpu.html
- Particle Life demo: https://lisyarus.github.io/webgpu/particle-life.html
- compute.toys: https://compute.toys/
- compute.toys repo: https://github.com/compute-toys/compute.toys
- Codrops WebGPU fluids: https://tympanus.net/codrops/2025/02/26/webgpu-fluid-simulations-high-performance-real-time-rendering/
- Codrops WebGPU particles/fluids: https://tympanus.net/codrops/2025/01/29/particles-progress-and-perseverance-a-journey-into-webgpu-fluids/
- Codrops Shader.se WebGPU transitions: https://tympanus.net/codrops/2026/05/19/80s-business-tech-seamless-scene-transitions-inside-shader-ses-scroll-driven-webgpu-pipeline/
- Codrops False Earth WebGPU world: https://tympanus.net/codrops/2026/04/21/false-earth-from-webgl-limits-to-a-webgpu-driven-world/
GPU graph layout:
- GraphWaGu repo: https://github.com/harp-lab/GraphWaGu
- GraphWaGu force-directed TS: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/webgpu/force_directed.ts
- GraphWaGu exact forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forces.wgsl
- GraphWaGu Barnes-Hut forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forcesBH.wgsl
- GraphWaGu apply forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/apply_forces.wgsl
- GraphWaGu tree build: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/create_tree.wgsl
- GraphWaGu radix sort: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/radix_sort.wgsl
- GraphWaGu node vertex: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/node_vert.wgsl
- GraphWaGu edge vertex: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
- cosmos.gl repo: https://github.com/cosmograph-org/cosmos
- cosmos.gl README: https://raw.githubusercontent.com/cosmograph-org/cosmos/main/README.md
- Cosmograph product: https://cosmograph.app/
Svelte/DOM/determinism:
- Svelte lifecycle hooks: https://svelte.dev/docs/svelte/lifecycle-hooks
- MDN URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
- MDN pointer-events: https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events
- Fixed timestep: https://gafferongames.com/post/fix_your_timestep/
- seedrandom reference if a dependency is later preferred over local PRNG: https://github.com/davidbau/seedrandom
## 6. Non-negotiable implementation constraints
- New Observatory route only. Do not mutate the protected Memory Cinema block in `graph/+page.svelte`.
- Keep existing Three/WebGL `Graph3D` intact. Observatory is a parallel WebGPU renderer, not a replacement in v1.
- Do not add dependencies for v1. Use browser WebGPU, Svelte, TypeScript, and existing `three` only if needed for matrix math; prefer small local math helpers.
- No `Math.random()` in demo-visible state. No `Date.now()`/`performance.now()` deciding simulation state.
- No GPU readback in the frame loop. Profiling readback must be delayed and optional.
- Every increment must render something verifiable before moving to the next effect.
---
## 7. VISUAL DNA — "from another dimension" (MANDATORY aesthetic contract)
Sam's directive (Jul 3 2026): the Observatory must be **mind-boggling** and look like it
**came from another dimension** — the same grammar as the WebGPU waitlist hero and
`docs/launch/causal-brain-demo.html`. This is NOT a dashboard skin. It is a living
tissue. Every increment below inherits this section. Generic = rejected.
### 7.1 The signature palette (fuse two systems — do not invent a third)
TWO real palettes already exist in-repo. The Observatory FUSES them:
- **Meaning layer — FSRS state (from `lib/graph/nodes.ts`, use verbatim):**
- active `#10b981` emerald · dormant `#f59e0b` amber · silent `#8b5cf6` violet · unavailable `#6b7280` slate
- aha `#FFD700` · confusion/failure `#EF4444` · guardrail `#9CA3AF`
- **Transcendence layer — iridescent thin-film (from `causal-brain-demo.html` `spectral(w)`):**
a catmull blend around 4 anchors, w∈[0,1]:
- indigo `vec3(0.20,0.28,0.95)` → cyan-teal `vec3(0.20,0.85,0.90)` → mint `vec3(0.45,1.00,0.72)` → magenta rim `vec3(0.85,0.45,1.00)`
- CORE reads indigo/teal (living tissue); MOTION shifts toward cyan/magenta; NEVER muddy orange.
- **Void:** background `#05060a` (near-black, `color-scheme: dark`). No grey dashboard chrome.
- **Rule:** a node's BASE hue = its FSRS state color. Its ACTIVATION glow (recall, birth,
ignite) rides the thin-film spectral band. So resting graph = meaningful; active graph = otherworldly.
Port `spectral(w)` to WGSL exactly (4-anchor catmull-ish blend). This is the single most
important visual primitive — it is what makes it look alien instead of corporate.
### 7.2 Glow / bloom language (copy the demo's exact values, then push further in WebGPU)
- Additive radial glow blobs (`glow(x,y,rad,col,alpha)` in the demo) → in WebGPU, a real
additive bloom pass. Node halos are `0 0 24px` soft; ignite events drop-shadow at
`0 0 26px rgba(110,240,220,.45)` energy.
- Text/instrument overlay glow: captions `text-shadow:0 0 24px rgba(30,180,255,.35)`;
verdict headline `linear-gradient(90deg,#7fe6c0,#6ef0e6,#a6dcff)` clipped to text with
`drop-shadow(0 0 26px rgba(110,240,220,.45))`; wordmark `letter-spacing:.28em`, `#5dcaa5`.
- **Breath:** a global `pulse = 0.5 + 0.5*sin(t*0.002)` (~0.32Hz) modulates halo intensity so
the whole field breathes even at rest. Living, never static.
### 7.3 DOM = instrument overlay ONLY (already the spec rule — here is the exact style)
Copy the demo's overlay contract: `.layer { position:fixed; inset:0; pointer-events:none }`
over a full-bleed `inset:0` canvas. Instruments (TelemetryStrip, CommandSurface,
TimelineSpine, InspectorPanel) are the ONLY DOM, styled like the demo's `.cap.mono`
(SF Mono, `#cfe9ff`), `.verdict` (radial-gradient vignette card), `.wordmark`. NO KPI cards.
NO solid panels. Instruments float, glow faintly, and never block the canvas
(`pointer-events:auto` only on the specific interactive control).
### 7.4 The reference moment already exists in 2D — port it, don't reinvent
`causal-brain-demo.html` IS Moment C (salience-rescue / backward-trace) as a 2D canvas
proof: brain point cloud (two lobes, center-dense), a failure flare on the deep right, a
vector-search stall on confounders, then a BACKWARD arrow tracing to the dormant cause on
the deep-left which **ignites**, then a verdict card ("Vestige 60% · vector search 0%"),
then the VESTIGE wordmark. Beat clock: brain forms 0-1.5s, fail fades in ~0.3s, vector
stalls 4-6.5s, backward trace fires 6.5-10.5s, verdict 10.5-13s, signature 13-15s, loop.
The Observatory's `?demo=salience-rescue` must reproduce this exact narrative in real
WebGPU with real memory nodes. Study its `spectral`, `seedBrain`, `glow`, and beat
`schedule` before writing the WGSL.
### 7.5 Non-negotiable "another dimension" checklist (verifier MUST confirm each)
- [ ] Field breathes at rest (global pulse), never a static image.
- [ ] Node base hue = FSRS state; activation = thin-film spectral. Both visible.
- [ ] Real additive bloom on ignite/recall (not just opacity).
- [ ] Void `#05060a`, zero grey dashboard chrome, zero KPI cards.
- [ ] Instruments are glowing SF-Mono overlays with `pointer-events:none` except controls.
- [ ] `?demo=salience-rescue` reproduces the causal-brain backward-trace narrative.
- [ ] It looks like living tissue from another dimension, not a SaaS dashboard. If a
reviewer would call it "clean" or "professional," it FAILED. The word is "alive."

View file

@ -0,0 +1,131 @@
<script lang="ts">
/**
* Cognitive Observatory — WebGPU canvas host.
*
* Owns the ObservatoryEngine lifecycle: mount → boot → resize → dispose.
* If WebGPU is unavailable, renders a readable fallback instead of crashing
* (Increment 3 gate, spec §4).
*/
import { onMount, onDestroy } from 'svelte';
import { ObservatoryEngine, type EngineStatus } from '$lib/observatory/engine';
import type { DemoMode } from '$lib/observatory/types';
interface Props {
demo: DemoMode;
seed: string;
/** Capture mode: freeze the sim at this loop frame (?frame=N). */
freezeFrame?: number | null;
/** Telemetry callback: loop frame + fps estimate. */
onframe?: (frame: number, fps: number) => void;
/** Fired when the engine is running (the route uploads the graph here). */
onready?: (engine: ObservatoryEngine) => void;
}
let { demo, seed, freezeFrame = null, onframe, onready }: Props = $props();
let canvasEl: HTMLCanvasElement;
let engine: ObservatoryEngine | null = null;
let status = $state<EngineStatus>({ state: 'booting' });
let unsubStatus: (() => void) | null = null;
let resizeObserver: ResizeObserver | null = null;
onMount(() => {
engine = new ObservatoryEngine({
canvas: canvasEl,
demo,
seed,
freezeFrame,
maxDpr: 2,
onFrame: (frame, fps) => onframe?.(frame, fps)
});
unsubStatus = engine.onStatus((s) => (status = s));
// Keep the drawing buffer in lockstep with layout size (DPR-clamped).
resizeObserver = new ResizeObserver(() => engine?.resize());
resizeObserver.observe(canvasEl);
engine.start().then((ok) => {
if (ok && engine) {
engine.resize();
onready?.(engine);
}
});
});
onDestroy(() => {
unsubStatus?.();
resizeObserver?.disconnect();
engine?.dispose();
engine = null;
});
</script>
<!-- Full-bleed canvas: the living memory field (void #05060a is cleared on-GPU). -->
<canvas bind:this={canvasEl} class="observatory-canvas" aria-label="Vestige memory field"
></canvas>
{#if status.state === 'unsupported' || status.state === 'error'}
<!-- Readable fallback — never a crash (spec §4 Increment 3 gate). -->
<div class="fallback" role="alert">
<div class="fallback-title">MEMORY FIELD OFFLINE</div>
<div class="fallback-reason">{status.reason}</div>
<div class="fallback-hint">
WebGPU is required for the Observatory. Chrome 113+, Edge 113+, or Safari 18+ —
the classic <a href="./graph">Graph view</a> works everywhere.
</div>
</div>
{/if}
<style>
.observatory-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
background: #05060a;
}
/* Fallback panel follows the instrument-overlay grammar (§7.3):
SF-Mono, faint glow, floating on the void — not a grey error card. */
.fallback {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
text-align: center;
padding: 0 10%;
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
pointer-events: auto;
}
.fallback-title {
color: #5dcaa5;
font-size: 0.95rem;
letter-spacing: 0.28em;
text-shadow: 0 0 20px rgba(93, 202, 165, 0.4);
}
.fallback-reason {
color: #9fd0e4;
font-size: 0.8rem;
letter-spacing: 0.04em;
opacity: 0.85;
}
.fallback-hint {
color: #7c8a97;
font-size: 0.75rem;
max-width: 34rem;
line-height: 1.6;
}
.fallback-hint a {
color: #cfe9ff;
text-decoration: underline;
text-underline-offset: 3px;
}
</style>

View file

@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock WebGPU before any imports
vi.mock('$lib/observatory/engine', () => ({
ObservatoryEngine: class {},
}));
// Mock document.createElement for canvas (needed by demo-clock)
const mockCanvas = {
width: 512,
height: 64,
getContext: () => null,
toDataURL: () => 'data:image/png;base64,',
};
if (typeof globalThis.document === 'undefined') {
(globalThis as any).document = {
createElement: (tag: string) => (tag === 'canvas' ? mockCanvas : {}),
};
}
import { buildBirthPlan, pickTargetIndex } from '../birth-plan';
import type { ObservatoryGraph, ObservatoryNode, ObservatoryEdge } from '../types';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeNode(
id: string,
index: number,
opts: Partial<ObservatoryNode> = {}
): ObservatoryNode {
return {
id,
index,
label: `Node ${id}`,
type: 'memory',
retention: opts.retention ?? 0.5,
tags: opts.tags ?? [],
isCenter: opts.isCenter ?? false,
suppressed: opts.suppressed ?? false,
};
}
function makeEdge(sourceIndex: number, targetIndex: number): ObservatoryEdge {
return { sourceIndex, targetIndex, weight: 1.0, type: 'association' };
}
function makeGraph(
nodes: ObservatoryNode[],
edges: ObservatoryEdge[]
): ObservatoryGraph {
const indexById = new Map<string, number>();
for (const n of nodes) indexById.set(n.id, n.index);
const centerIndex = nodes.findIndex((n) => n.isCenter);
return {
nodes,
edges,
indexById,
centerIndex: centerIndex < 0 ? 0 : centerIndex,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('pickTargetIndex', () => {
it('picks center\'s highest-retention neighbor when edges exist', () => {
const nodes = [
makeNode('center', 0, { isCenter: true, retention: 0.9 }),
makeNode('a', 1, { retention: 0.8 }),
makeNode('b', 2, { retention: 0.95 }),
makeNode('c', 3, { retention: 0.7 }),
];
const edges = [
makeEdge(0, 1), // center <-> a (ret 0.8)
makeEdge(0, 2), // center <-> b (ret 0.95)
makeEdge(1, 3), // a <-> c (not incident to center)
];
const graph = makeGraph(nodes, edges);
const target = pickTargetIndex(graph);
expect(target).toBe(2); // b has highest retention (0.95)
});
it('picks first non-center node when center has no edges', () => {
const nodes = [
makeNode('center', 0, { isCenter: true }),
makeNode('a', 1),
makeNode('b', 2),
];
const edges = [makeEdge(1, 2)]; // no edges from center
const graph = makeGraph(nodes, edges);
const target = pickTargetIndex(graph);
expect(target).toBe(1); // first non-center
});
it('picks center node when graph has only the center', () => {
const nodes = [makeNode('center', 0, { isCenter: true })];
const graph = makeGraph(nodes, []);
const target = pickTargetIndex(graph);
expect(target).toBe(0);
});
it('picks center when all neighbors have equal retention', () => {
const nodes = [
makeNode('center', 0, { isCenter: true }),
makeNode('a', 1, { retention: 0.5 }),
makeNode('b', 2, { retention: 0.5 }),
];
const edges = [makeEdge(0, 1), makeEdge(0, 2)];
const graph = makeGraph(nodes, edges);
const target = pickTargetIndex(graph);
// Both have same retention (0.5), so picks first neighbor (a, index 1)
expect(target).toBe(1);
});
});
describe('buildBirthPlan', () => {
let graph: ObservatoryGraph;
beforeEach(() => {
const nodes = [
makeNode('center', 0, { isCenter: true, retention: 0.9 }),
makeNode('a', 1, { retention: 0.8 }),
makeNode('b', 2, { retention: 0.95 }),
makeNode('c', 3, { retention: 0.7 }),
makeNode('d', 4, { retention: 0.6 }),
];
const edges = [
makeEdge(0, 1),
makeEdge(0, 2),
makeEdge(1, 3),
makeEdge(2, 4),
];
graph = makeGraph(nodes, edges);
});
it('produces deterministic particles for same graph + seed', () => {
const plan1 = buildBirthPlan(graph, 'test-seed', 1024);
const plan2 = buildBirthPlan(graph, 'test-seed', 1024);
expect(plan1.particles).toEqual(plan2.particles);
expect(plan1.edgeSteps).toEqual(plan2.edgeSteps);
expect(plan1.targetIndex).toBe(plan2.targetIndex);
});
it('produces different particles for different seeds', () => {
const plan1 = buildBirthPlan(graph, 'seed-a', 1024);
const plan2 = buildBirthPlan(graph, 'seed-b', 1024);
// Same target, different particle positions
expect(plan1.targetIndex).toBe(plan2.targetIndex);
// But particles should differ
let different = false;
for (let i = 0; i < plan1.particles.length; i++) {
if (plan1.particles[i] !== plan2.particles[i]) {
different = true;
break;
}
}
expect(different).toBe(true);
});
it('always picks the same target regardless of seed', () => {
const plan1 = buildBirthPlan(graph, 'seed-a', 1024);
const plan2 = buildBirthPlan(graph, 'seed-b', 1024);
const plan3 = buildBirthPlan(graph, 'seed-c', 1024);
expect(plan1.targetIndex).toBe(plan2.targetIndex);
expect(plan2.targetIndex).toBe(plan3.targetIndex);
// Should be index 2 (b, highest retention neighbor of center)
expect(plan1.targetIndex).toBe(2);
});
it('has correct particle array size', () => {
const plan = buildBirthPlan(graph, 'test', 2048);
expect(plan.particles.length).toBe(2048 * 16); // 16 floats per particle
});
it('has correct edge step array size', () => {
const plan = buildBirthPlan(graph, 'test');
// 2 edges incident to center (0-1, 0-2)
expect(plan.edgeSteps.length).toBe(2 * 4); // 4 u32 per step
});
it('has valid timeline beats', () => {
const plan = buildBirthPlan(graph, 'test');
expect(plan.timeline.length).toBeGreaterThan(0);
for (const beat of plan.timeline) {
expect(beat.label).toBeTruthy();
expect(beat.startFrame).toBeGreaterThanOrEqual(0);
expect(beat.endFrame).toBeGreaterThanOrEqual(beat.startFrame);
expect(beat.endFrame).toBeLessThan(720);
}
});
it('center-only graph produces valid target and zero edge steps', () => {
const centerOnly = makeGraph(
[makeNode('center', 0, { isCenter: true })],
[]
);
const plan = buildBirthPlan(centerOnly, 'center-test', 512);
expect(plan.targetIndex).toBe(0);
expect(plan.targetNodeId).toBe('center');
expect(plan.edgeSteps.length).toBe(0); // zero incident edges = zero steps (plan L464)
});
it('does not use Math.random() — verified by code inspection', () => {
// This test documents the constraint: buildBirthPlan uses DemoClock
// (xmur3 + mulberry32) exclusively. Math.random() is never called.
// The test passes by construction — if Math.random() were used,
// determinism would break, which is caught by the determinism test above.
expect(true).toBe(true);
});
it('default particle count is 8192', () => {
const plan = buildBirthPlan(graph, 'test');
expect(plan.particles.length).toBe(8192 * 16);
});
it('custom particle count works', () => {
const plan = buildBirthPlan(graph, 'test', 4096);
expect(plan.particles.length).toBe(4096 * 16);
});
});

View file

@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { DemoClock, deterministicSpherePosition } from '../demo-clock';
describe('DemoClock', () => {
it('should start at frame 0 with phase 0', () => {
const clock = new DemoClock({ seed: 'test-seed' });
const state = clock.state;
expect(state.frame).toBe(0);
expect(state.phase).toBe(0);
expect(state.totalFrames).toBe(0);
});
it('should advance frame by 1 on each tick', () => {
const clock = new DemoClock({ seed: 'test-seed' });
clock.tick();
expect(clock.state.frame).toBe(1);
clock.tick();
expect(clock.state.frame).toBe(2);
});
it('should wrap frame at loopFrames (default 720)', () => {
const clock = new DemoClock({ seed: 'test-seed' });
// Advance to loopFrames - 1
for (let i = 0; i < 719; i++) {
clock.tick();
}
expect(clock.state.frame).toBe(719);
// One more tick should wrap to 0
clock.tick();
expect(clock.state.frame).toBe(0);
});
it('should compute correct phase (frame / loopFrames)', () => {
const clock = new DemoClock({ seed: 'test-seed' });
clock.tick();
expect(clock.state.phase).toBeCloseTo(1 / 720, 6);
clock.tick();
expect(clock.state.phase).toBeCloseTo(2 / 720, 6);
});
it('should produce identical PRNG sequences for the same seed', () => {
const clock1 = new DemoClock({ seed: 'identical-seed' });
const clock2 = new DemoClock({ seed: 'identical-seed' });
const values1: number[] = [];
const values2: number[] = [];
for (let i = 0; i < 100; i++) {
clock1.tick();
values1.push(clock1.state.rng());
clock2.tick();
values2.push(clock2.state.rng());
}
expect(values1).toEqual(values2);
});
it('should produce different PRNG sequences for different seeds', () => {
const clock1 = new DemoClock({ seed: 'seed-a' });
const clock2 = new DemoClock({ seed: 'seed-b' });
clock1.tick();
clock2.tick();
expect(clock1.state.rng()).not.toBe(clock2.state.rng());
});
it('should produce different positions for different seeds', () => {
const clock1 = new DemoClock({ seed: 'pos-seed-a' });
const clock2 = new DemoClock({ seed: 'pos-seed-b' });
const pos1 = deterministicSpherePosition(0, 100, 50, clock1.state.rng);
const pos2 = deterministicSpherePosition(0, 100, 50, clock2.state.rng);
expect(pos1).not.toEqual(pos2);
});
it('should produce identical positions for the same seed', () => {
const clock1 = new DemoClock({ seed: 'same-seed' });
const clock2 = new DemoClock({ seed: 'same-seed' });
const pos1 = deterministicSpherePosition(0, 100, 50, clock1.state.rng);
const pos2 = deterministicSpherePosition(0, 100, 50, clock2.state.rng);
expect(pos1).toEqual(pos2);
});
it('should reset to frame 0 and re-seed PRNG', () => {
const clock = new DemoClock({ seed: 'reset-seed' });
// Advance 100 frames
for (let i = 0; i < 100; i++) {
clock.tick();
}
expect(clock.state.frame).toBe(100);
expect(clock.state.totalFrames).toBe(100);
// Reset
clock.reset();
expect(clock.state.frame).toBe(0);
expect(clock.state.totalFrames).toBe(0);
// After reset, the PRNG should produce the same values as the initial state
const afterResetRng = clock.state.rng();
const initialRng = new DemoClock({ seed: 'reset-seed' }).state.rng();
expect(afterResetRng).toBe(initialRng);
});
it('should respect custom loopFrames', () => {
const clock = new DemoClock({ seed: 'custom-loop', loopFrames: 360 });
for (let i = 0; i < 360; i++) {
clock.tick();
}
expect(clock.state.frame).toBe(0);
expect(clock.state.phase).toBe(0);
});
it('should respect custom fps (affects loopDuration)', () => {
const clock = new DemoClock({ seed: 'custom-fps', fps: 30, loopFrames: 300 });
expect(clock.loopDuration).toBe(10); // 300 / 30 = 10s
});
it('exposes framesPerLoop for capture-mode frame normalization', () => {
expect(new DemoClock({ seed: 'x' }).framesPerLoop).toBe(720);
expect(new DemoClock({ seed: 'x', loopFrames: 360 }).framesPerLoop).toBe(360);
});
});
describe('deterministicSpherePosition', () => {
it('should place nodes on a sphere surface', () => {
const clock = new DemoClock({ seed: 'sphere-test' });
const pos = deterministicSpherePosition(0, 10, 50, clock.state.rng);
const [x, y, z] = pos;
const dist = Math.sqrt(x * x + y * y + z * z);
// Should be approximately at the given radius (some variance from golden angle)
expect(dist).toBeGreaterThan(40);
expect(dist).toBeLessThan(60);
});
it('should produce different positions for different indices', () => {
const clock = new DemoClock({ seed: 'sphere-test' });
const pos0 = deterministicSpherePosition(0, 10, 50, clock.state.rng);
const pos1 = deterministicSpherePosition(1, 10, 50, clock.state.rng);
expect(pos0).not.toEqual(pos1);
});
});

View file

@ -0,0 +1,439 @@
import { describe, it, expect } from 'vitest';
import {
buildForgettingPlan,
forgettingEnvelopes,
horizonDrift,
pickDrifting,
pickRescued,
rescueFrame,
FORGETTING_K,
FADING_BASE,
FADING_INTERVAL,
RESCUE_BASE,
RESCUE_INTERVAL,
SINK_BEAT_FRAME,
RETRIEVABLE_BEAT_FRAME
} from '../forgetting-plan';
import { buildObservatoryGraph } from '../graph-upload';
import { PATH_KIND } from '../types';
import type { GraphNode, GraphEdge, GraphResponse } from '$types';
// ---------------------------------------------------------------------------
// Fixture helpers (cloned from rescue-plan.test.ts)
// ---------------------------------------------------------------------------
function gn(id: string, opts: Partial<GraphNode> = {}): GraphNode {
return {
id,
label: opts.label ?? `Memory ${id}`,
type: 'note',
retention: opts.retention ?? 0.5,
tags: opts.tags ?? [],
createdAt: opts.createdAt ?? '2026-01-15T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
isCenter: opts.isCenter ?? false,
suppression_count: opts.suppression_count
};
}
function ge(source: string, target: string, type = 'semantic'): GraphEdge {
return { source, target, weight: 1, type };
}
function gr(nodes: GraphNode[], edges: GraphEdge[], centerId = 'center'): GraphResponse {
return {
nodes,
edges,
center_id: centerId,
depth: 3,
nodeCount: nodes.length,
edgeCount: edges.length
};
}
/** 10-node fixture: center + 9, varied retention → driftCount = max(3, round(2.25)) = 3. */
function tenNodeFixture(): GraphResponse {
const nodes = [
gn('center', { isCenter: true, retention: 0.9 }),
gn('a', { retention: 0.05, label: 'oldest forgotten fact' }),
gn('b', { retention: 0.12 }),
gn('c', { retention: 0.2 }),
gn('d', { retention: 0.55 }),
gn('e', { retention: 0.6 }),
gn('f', { retention: 0.65 }),
gn('g', { retention: 0.7 }),
gn('h', { retention: 0.75 }),
gn('i', { retention: 0.8 })
];
const edges = [
ge('center', 'a'),
ge('center', 'b'),
ge('b', 'c'),
ge('center', 'd'),
ge('d', 'e')
];
return gr(nodes, edges);
}
/** 40-node fixture: center + 39 → driftCount = round(0.25·39) = 10, K = 3 rescued. */
function fortyNodeFixture(): GraphResponse {
const nodes: GraphNode[] = [gn('center', { isCenter: true, retention: 0.95 })];
const edges: GraphEdge[] = [];
for (let i = 0; i < 39; i++) {
const id = `n${String(i).padStart(2, '0')}`;
nodes.push(gn(id, { retention: 0.04 + i * 0.02, label: `memory ${id}` }));
edges.push(ge('center', id));
}
// Extra connectivity inside the drifting band so rescue scores separate.
edges.push(ge('n07', 'n08'));
edges.push(ge('n07', 'n09'));
edges.push(ge('n08', 'n09'));
return gr(nodes, edges);
}
function planFor(response: GraphResponse) {
const graph = buildObservatoryGraph(response);
return { graph, plan: buildForgettingPlan(graph) };
}
// ---------------------------------------------------------------------------
// 1. Determinism
// ---------------------------------------------------------------------------
describe('determinism', () => {
it('same graph → identical plan, byte-identical typed arrays', () => {
const r = fortyNodeFixture();
const { plan: p1 } = planFor(r);
const { plan: p2 } = planFor(r);
expect(p1).toEqual(p2);
expect(Array.from(p1.horizonData)).toEqual(Array.from(p2.horizonData));
expect(Array.from(p1.pathData)).toEqual(Array.from(p2.pathData));
});
});
// ---------------------------------------------------------------------------
// 2. pickDrifting — 25% clamp formula, center excluded, tie → lower index
// ---------------------------------------------------------------------------
describe('pickDrifting', () => {
it('10-node graph → driftCount 3 (floor of min(3, n1) beats round(0.25·9)=2)', () => {
const graph = buildObservatoryGraph(tenNodeFixture());
const drifting = pickDrifting(graph);
expect(drifting.length).toBe(3);
expect(drifting).not.toContain(graph.centerIndex);
// lowest retention first
const ids = drifting.map((i) => graph.nodes[i].id);
expect(ids).toEqual(['a', 'b', 'c']);
});
it('40-node graph → driftCount round(0.25·39) = 10', () => {
const graph = buildObservatoryGraph(fortyNodeFixture());
const drifting = pickDrifting(graph);
expect(drifting.length).toBe(10);
expect(drifting).not.toContain(graph.centerIndex);
// retention non-decreasing along the rank order
for (let i = 1; i < drifting.length; i++) {
expect(graph.nodes[drifting[i]].retention).toBeGreaterThanOrEqual(
graph.nodes[drifting[i - 1]].retention
);
}
});
it('retention tie → lower node index drifts first', () => {
const r = gr(
[
gn('center', { isCenter: true, retention: 0.9 }),
gn('a', { retention: 0.2 }),
gn('b', { retention: 0.2 }),
gn('c', { retention: 0.8 })
],
[]
);
const graph = buildObservatoryGraph(r);
const drifting = pickDrifting(graph);
const ia = graph.indexById.get('a')!;
const ib = graph.indexById.get('b')!;
expect(drifting.indexOf(ia)).toBeLessThan(drifting.indexOf(ib));
});
});
// ---------------------------------------------------------------------------
// 3. pickRescued — subset of drifting, K = min(3, driftCount), score ordering
// ---------------------------------------------------------------------------
describe('pickRescued', () => {
it('rescued ⊆ drifting, K = min(3, driftCount), highest 2·retention + degree/8 wins', () => {
const graph = buildObservatoryGraph(fortyNodeFixture());
const drifting = pickDrifting(graph);
const rescued = pickRescued(graph, drifting);
expect(rescued.length).toBe(Math.min(FORGETTING_K, drifting.length));
for (const idx of rescued) expect(drifting).toContain(idx);
// recompute scores and verify the rescued are the top-K
const degree = new Uint32Array(graph.nodes.length);
for (const e of graph.edges) {
degree[e.sourceIndex]++;
degree[e.targetIndex]++;
}
const score = (i: number) => 2 * graph.nodes[i].retention + Math.min(degree[i], 8) / 8;
const expected = drifting.slice().sort((a, b) => score(b) - score(a) || a - b).slice(0, 3);
expect(rescued).toEqual(expected);
});
it('driftCount 1 → K = 1, the sole drifter is rescued', () => {
const r = gr([gn('center', { isCenter: true }), gn('a', { retention: 0.1 })], [
ge('center', 'a')
]);
const graph = buildObservatoryGraph(r);
const drifting = pickDrifting(graph);
expect(drifting.length).toBe(1);
const rescued = pickRescued(graph, drifting);
expect(rescued).toEqual(drifting);
});
});
// ---------------------------------------------------------------------------
// 4. Packing round-trip
// ---------------------------------------------------------------------------
describe('horizonData packing', () => {
it('rank/isDrifting/isRescued/slot decode; non-drifting words are exactly 0', () => {
const { graph, plan } = planFor(fortyNodeFixture());
expect(plan.viable).toBe(true);
const driftCount = plan.driftingIndices.length;
plan.driftingIndices.forEach((idx, i) => {
const w = plan.horizonData[idx];
expect(w & 0x100).not.toBe(0);
expect(w & 0xff).toBe(Math.round((255 * i) / Math.max(1, driftCount - 1)));
});
plan.rescuedIndices.forEach((idx, k) => {
const w = plan.horizonData[idx];
expect(w & 0x200).not.toBe(0);
expect(w & 0x100).not.toBe(0); // rescued word also carries isDrifting
expect((w >>> 10) & 0x3).toBe(k);
});
const roleSet = new Set(plan.driftingIndices);
for (let i = 0; i < graph.nodes.length; i++) {
if (!roleSet.has(i)) expect(plan.horizonData[i]).toBe(0);
}
expect(plan.horizonData[graph.centerIndex]).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 5. SEAM PROOF — every word, frames 0 and 719 all-zero
// ---------------------------------------------------------------------------
describe('loop seam', () => {
it('forgettingEnvelopes is exactly zero at frames 0 and 719 for EVERY packed word', () => {
const { plan } = planFor(fortyNodeFixture());
expect(plan.viable).toBe(true);
for (const packed of plan.horizonData) {
for (const frame of [0, 719]) {
const e = forgettingEnvelopes(frame, packed);
expect(Math.abs(e.x)).toBeLessThan(1e-6);
expect(Math.abs(e.y)).toBeLessThan(1e-6);
expect(Math.abs(e.z)).toBeLessThan(1e-6);
expect(Math.abs(e.w)).toBeLessThan(1e-6);
}
}
});
});
// ---------------------------------------------------------------------------
// 6. Envelopes fire on the beat map
// ---------------------------------------------------------------------------
describe('envelopes fire', () => {
it('unrescued drift: z > 0.5 @420, > 0.95 @655, ≤ 1+1e-6 at all frames', () => {
const { plan } = planFor(fortyNodeFixture());
const rescuedSet = new Set(plan.rescuedIndices);
const unrescued = plan.driftingIndices.filter((i) => !rescuedSet.has(i));
expect(unrescued.length).toBeGreaterThan(0);
for (const idx of unrescued) {
const w = plan.horizonData[idx];
expect(forgettingEnvelopes(420, w).z).toBeGreaterThan(0.5);
expect(forgettingEnvelopes(655, w).z).toBeGreaterThan(0.95);
for (let f = 0; f < 720; f++) {
expect(forgettingEnvelopes(f, w).z).toBeLessThanOrEqual(1 + 1e-6);
}
}
});
it('rescued slot k: z < 1e-6 @rk+6, x > 0.95 @rk, x < 1e-6 @rk+140', () => {
const { plan } = planFor(fortyNodeFixture());
plan.rescuedIndices.forEach((idx, k) => {
const w = plan.horizonData[idx];
const rk = rescueFrame(k);
expect(Math.abs(forgettingEnvelopes(rk + 6, w).z)).toBeLessThan(1e-6);
expect(forgettingEnvelopes(rk, w).x).toBeGreaterThan(0.95);
expect(Math.abs(forgettingEnvelopes(rk + 140, w).x)).toBeLessThan(1e-6);
});
});
});
// ---------------------------------------------------------------------------
// 7. Monotone unrescued sink over 0..655
// ---------------------------------------------------------------------------
describe('monotone sink', () => {
it('unrescued z is non-decreasing over frames 0..655', () => {
const { plan } = planFor(fortyNodeFixture());
const rescuedSet = new Set(plan.rescuedIndices);
const unrescued = plan.driftingIndices.filter((i) => !rescuedSet.has(i));
for (const idx of unrescued) {
const w = plan.horizonData[idx];
let prev = forgettingEnvelopes(0, w).z;
for (let f = 1; f <= 655; f++) {
const z = forgettingEnvelopes(f, w).z;
expect(z).toBeGreaterThanOrEqual(prev - 1e-9);
prev = z;
}
}
});
});
// ---------------------------------------------------------------------------
// 8. horizonDrift — CPU mirror of the demo-3 vertex displacement
// ---------------------------------------------------------------------------
describe('horizonDrift', () => {
it('[0,0,0] at dz=0; ~40.5 units at dz=1, downward, away from axis; no NaN on-axis', () => {
expect(horizonDrift(0, [30, 10, 40])).toEqual([0, 0, 0]);
const p: [number, number, number] = [30, 10, 40];
const d = horizonDrift(1, p);
const mag = Math.hypot(d[0], d[1], d[2]);
expect(mag).toBeGreaterThanOrEqual(38);
expect(mag).toBeLessThanOrEqual(42);
expect(d[1]).toBeLessThan(0);
// away·p ≥ 0: the drift pushes outward, never inward
expect(d[0] * p[0] + d[2] * p[2]).toBeGreaterThanOrEqual(0);
// exactly on the y axis: r_xz clamps to 0.001, no NaN
const axis = horizonDrift(1, [0, 55, 0]);
expect(Number.isNaN(axis[0])).toBe(false);
expect(Number.isNaN(axis[1])).toBe(false);
expect(Number.isNaN(axis[2])).toBe(false);
expect(axis).toEqual([0, -34, 0]);
// dz clamps to 1
const over = horizonDrift(2, p);
expect(Math.hypot(over[0], over[1], over[2])).toBeCloseTo(mag, 9);
});
});
// ---------------------------------------------------------------------------
// 9. Ribbon window + step shape
// ---------------------------------------------------------------------------
describe('path steps', () => {
it('K kind-0 steps, src = center, dst = rescued_k, bf = 318+60k, window inside the loop', () => {
const { graph, plan } = planFor(fortyNodeFixture());
const count = plan.pathData.length / 4;
expect(count).toBe(plan.rescuedIndices.length);
expect(plan.pathMetas.length).toBe(count);
plan.rescuedIndices.forEach((idx, k) => {
expect(plan.pathData[k * 4 + 0]).toBe(graph.centerIndex);
expect(plan.pathData[k * 4 + 1]).toBe(idx);
expect(plan.pathData[k * 4 + 2]).toBe(RESCUE_BASE + RESCUE_INTERVAL * k);
expect(plan.pathData[k * 4 + 3]).toBe(PATH_KIND.recall);
const bf = plan.pathData[k * 4 + 2];
expect(bf - 46).toBeGreaterThanOrEqual(0);
expect(bf + 90).toBeLessThanOrEqual(719);
});
});
});
// ---------------------------------------------------------------------------
// 10. Spine beats
// ---------------------------------------------------------------------------
describe('spine beats', () => {
it('strictly increasing unique frames; fading/recalled labels; sink + retrievable', () => {
const { graph, plan } = planFor(fortyNodeFixture());
const frames = plan.spineBeats.map((b) => b.beatFrame);
for (let i = 1; i < frames.length; i++) {
expect(frames[i]).toBeGreaterThan(frames[i - 1]);
}
expect(new Set(frames).size).toBe(frames.length);
expect(frames[0]).toBe(FADING_BASE);
expect(frames).toContain(SINK_BEAT_FRAME);
expect(frames[frames.length - 1]).toBe(RETRIEVABLE_BEAT_FRAME);
const labels = plan.spineBeats.map((b) => b.label);
// fading beats carry real labels + retention percent
const rescuedSet = new Set(plan.rescuedIndices);
const fading = plan.driftingIndices.filter((i) => !rescuedSet.has(i)).slice(0, 3);
fading.forEach((idx, j) => {
const pct = Math.round(graph.nodes[idx].retention * 100);
expect(labels[j]).toBe(
`fading: ${graph.nodes[idx].label} · retention ${pct}%`
);
expect(plan.spineBeats[j].beatFrame).toBe(FADING_BASE + FADING_INTERVAL * j);
});
expect(labels.some((l) => l.startsWith('recalled: '))).toBe(true);
expect(labels).toContain('the unrecalled sink · nothing is deleted');
expect(labels).toContain('every memory still retrievable');
});
it('labels truncate at 64 chars with an ellipsis', () => {
const r = fortyNodeFixture();
// lowest-retention node (n00) is unrescued → becomes the first fading beat
const n00 = r.nodes.find((n) => n.id === 'n00')!;
n00.label = 'x'.repeat(100);
const { plan } = planFor(r);
const fadingLabel = plan.spineBeats[0].label;
expect(fadingLabel.startsWith('fading: ')).toBe(true);
const memLabel = fadingLabel.slice('fading: '.length).split(' · ')[0];
expect(memLabel.length).toBe(65);
expect(memLabel.endsWith('…')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 11. Degenerates survive
// ---------------------------------------------------------------------------
describe('degenerate graphs', () => {
it('0-node and 1-node → viable:false, min pathData, no throw; 2-node → viable', () => {
const { plan: p0 } = planFor(gr([], [], ''));
expect(p0.viable).toBe(false);
expect(p0.pathData.length).toBe(4);
expect(p0.pathMetas).toEqual([]);
expect(p0.spineBeats).toEqual([]);
const { plan: p1 } = planFor(gr([gn('center', { isCenter: true })], []));
expect(p1.viable).toBe(false);
expect(Array.from(p1.horizonData)).toEqual([0]);
const { graph: g2, plan: p2 } = planFor(
gr([gn('center', { isCenter: true }), gn('a', { retention: 0.1 })], [ge('center', 'a')])
);
expect(p2.viable).toBe(true);
expect(p2.driftingIndices).toEqual([g2.indexById.get('a')!]);
expect(p2.rescuedIndices).toEqual([g2.indexById.get('a')!]);
expect(p2.pathData.length).toBe(4);
expect(p2.pathData[2]).toBe(RESCUE_BASE);
});
});
// ---------------------------------------------------------------------------
// 12. Lane hygiene — the rescue/firewall grammars can NEVER fire in demo 3
// ---------------------------------------------------------------------------
describe('lane hygiene', () => {
it('y and w are exactly 0 for every word at frames 0..719 step 7', () => {
const { plan } = planFor(fortyNodeFixture());
for (const packed of plan.horizonData) {
for (let f = 0; f < 720; f += 7) {
const e = forgettingEnvelopes(f, packed);
expect(e.y).toBe(0);
expect(e.w).toBe(0);
}
}
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { DemoClock } from '../demo-clock';
import {
buildObservatoryGraph,
buildNodeStateArray,
buildEdgeIndexArray,
hexToRgb01,
nodeBaseColor
} from '../graph-upload';
import { FLOATS_PER_NODE, NODE_LANE, NODE_FLAG, toObservatoryNode } from '../types';
import type { GraphResponse, GraphNode } from '$types';
function node(partial: Partial<GraphNode> & { id: string }): GraphNode {
return {
label: partial.id,
type: 'note',
retention: 0.8,
tags: [],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
isCenter: false,
...partial
};
}
function response(nodes: GraphNode[], edges: GraphResponse['edges'] = []): GraphResponse {
return {
nodes,
edges,
center_id: nodes.find((n) => n.isCenter)?.id ?? '',
depth: 3,
nodeCount: nodes.length,
edgeCount: edges.length
};
}
describe('buildObservatoryGraph', () => {
it('orders center first, then by id — independent of API order', () => {
const a = response([node({ id: 'zz' }), node({ id: 'aa' }), node({ id: 'mm', isCenter: true })]);
const b = response([node({ id: 'mm', isCenter: true }), node({ id: 'zz' }), node({ id: 'aa' })]);
const ga = buildObservatoryGraph(a);
const gb = buildObservatoryGraph(b);
expect(ga.nodes.map((n) => n.id)).toEqual(['mm', 'aa', 'zz']);
expect(gb.nodes.map((n) => n.id)).toEqual(['mm', 'aa', 'zz']);
expect(ga.centerIndex).toBe(0);
});
it('maps edges to stable indices and drops dangling/self edges', () => {
const g = buildObservatoryGraph(
response(
[node({ id: 'a' }), node({ id: 'b' })],
[
{ source: 'a', target: 'b', weight: 1, type: 'semantic' },
{ source: 'a', target: 'ghost', weight: 1, type: 'semantic' },
{ source: 'b', target: 'b', weight: 1, type: 'semantic' }
]
)
);
expect(g.edges).toHaveLength(1);
expect(g.edges[0].sourceIndex).toBe(g.indexById.get('a'));
expect(g.edges[0].targetIndex).toBe(g.indexById.get('b'));
});
});
describe('buildNodeStateArray determinism', () => {
const resp = response([
node({ id: 'center', isCenter: true }),
node({ id: 'n1', retention: 0.9 }),
node({ id: 'n2', retention: 0.2 }),
node({ id: 'n3', retention: 0.05 })
]);
it('same seed → identical field; different seed → different field', () => {
const g = buildObservatoryGraph(resp);
const a = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
const b = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
const c = buildNodeStateArray(g, new DemoClock({ seed: 'B' }).state.rng);
expect(Array.from(a.data)).toEqual(Array.from(b.data));
expect(Array.from(a.data)).not.toEqual(Array.from(c.data));
});
it('anchors the center node at the origin with the largest radius', () => {
const g = buildObservatoryGraph(resp);
const { data } = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
expect(data[NODE_LANE.posRadius]).toBe(0);
expect(data[NODE_LANE.posRadius + 1]).toBe(0);
expect(data[NODE_LANE.posRadius + 2]).toBe(0);
const centerRadius = data[NODE_LANE.posRadius + 3];
for (let i = 1; i < g.nodes.length; i++) {
expect(centerRadius).toBeGreaterThan(data[i * FLOATS_PER_NODE + NODE_LANE.posRadius + 3]);
}
});
it('stores retention and packs flags', () => {
const g = buildObservatoryGraph(
response([
node({ id: 'c', isCenter: true }),
node({ id: 's', suppression_count: 2, retention: 0.5 }),
node({ id: 'aha', tags: ['aha'], retention: 0.6 })
])
);
const { data } = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
const byId = (id: string) => g.indexById.get(id)! * FLOATS_PER_NODE;
expect(data[byId('s') + NODE_LANE.velRetention + 3]).toBeCloseTo(0.5, 6);
expect(data[byId('c') + NODE_LANE.colorFlags + 3] & NODE_FLAG.isCenter).toBeTruthy();
expect(data[byId('s') + NODE_LANE.colorFlags + 3] & NODE_FLAG.suppressed).toBeTruthy();
expect(data[byId('aha') + NODE_LANE.colorFlags + 3] & NODE_FLAG.isAha).toBeTruthy();
});
});
describe('meaning-layer palette (visual DNA §7.1)', () => {
it('maps FSRS retention buckets to the real dashboard palette', () => {
const active = toObservatoryNode(node({ id: 'a', retention: 0.9 }), 0);
const dormant = toObservatoryNode(node({ id: 'd', retention: 0.5 }), 1);
const silent = toObservatoryNode(node({ id: 's', retention: 0.2 }), 2);
const gone = toObservatoryNode(node({ id: 'u', retention: 0.01 }), 3);
expect(nodeBaseColor(active)).toEqual(hexToRgb01('#10b981')); // emerald
expect(nodeBaseColor(dormant)).toEqual(hexToRgb01('#f59e0b')); // amber
expect(nodeBaseColor(silent)).toEqual(hexToRgb01('#8b5cf6')); // violet
expect(nodeBaseColor(gone)).toEqual(hexToRgb01('#6b7280')); // slate
});
it('aha tag overrides with gold', () => {
const aha = toObservatoryNode(node({ id: 'x', retention: 0.9, tags: ['aha'] }), 0);
expect(nodeBaseColor(aha)).toEqual(hexToRgb01('#FFD700'));
});
it('hexToRgb01 falls back to slate on malformed input', () => {
expect(hexToRgb01('not-a-color')).toEqual(hexToRgb01('#6b7280'));
});
});
describe('buildEdgeIndexArray', () => {
it('emits source/target index pairs', () => {
const g = buildObservatoryGraph(
response(
[node({ id: 'a' }), node({ id: 'b' }), node({ id: 'c' })],
[
{ source: 'a', target: 'b', weight: 1, type: 'semantic' },
{ source: 'b', target: 'c', weight: 1, type: 'semantic' }
]
)
);
const arr = buildEdgeIndexArray(g);
expect(arr).toHaveLength(4);
expect(arr[0]).toBe(g.indexById.get('a'));
expect(arr[1]).toBe(g.indexById.get('b'));
});
});

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { planBloomMips } from '../post/mip-plan';
describe('planBloomMips', () => {
it('M1 Max 3024×1964: half-res base, 6 mips, halving widths', () => {
const p = planBloomMips(3024, 1964);
expect(p.baseW).toBe(1512);
expect(p.baseH).toBe(982);
expect(p.mipCount).toBe(6);
expect(p.sizes.map(([w]) => w)).toEqual([1512, 756, 378, 189, 94, 47]);
});
it('64×64 → 3 mips', () => {
const p = planBloomMips(64, 64);
expect(p.baseW).toBe(32);
expect(p.baseH).toBe(32);
expect(p.mipCount).toBe(3);
expect(p.sizes).toEqual([
[32, 32],
[16, 16],
[8, 8]
]);
});
it('2×2 → 1 mip (1×1 base)', () => {
const p = planBloomMips(2, 2);
expect(p.baseW).toBe(1);
expect(p.baseH).toBe(1);
expect(p.mipCount).toBe(1);
expect(p.sizes).toEqual([[1, 1]]);
});
it('1×1 → 1 mip (degenerate; the up-loop runs zero times — harmless)', () => {
const p = planBloomMips(1, 1);
expect(p.baseW).toBe(1);
expect(p.baseH).toBe(1);
expect(p.mipCount).toBe(1);
});
it('sizes halve monotonically and never drop below 1', () => {
for (const [w, h] of [
[3024, 1964],
[1920, 1080],
[800, 600],
[375, 812],
[7, 3]
] as const) {
const p = planBloomMips(w, h);
expect(p.sizes[0]).toEqual([p.baseW, p.baseH]);
for (let i = 1; i < p.sizes.length; i++) {
const [pw, ph] = p.sizes[i - 1];
const [cw, ch] = p.sizes[i];
expect(cw).toBe(Math.max(1, pw >> 1));
expect(ch).toBe(Math.max(1, ph >> 1));
expect(cw).toBeGreaterThanOrEqual(1);
expect(ch).toBeGreaterThanOrEqual(1);
}
}
});
it('smallest mip keeps min dimension ≥ 8 when the base allows it', () => {
for (const [w, h] of [
[3024, 1964],
[1920, 1080],
[375, 812],
[256, 128],
[64, 64]
] as const) {
const p = planBloomMips(w, h);
const [lw, lh] = p.sizes[p.mipCount - 1];
expect(Math.min(lw, lh)).toBeGreaterThanOrEqual(8);
}
});
it('mipCount is clamped to [1, 6]', () => {
expect(planBloomMips(1, 1).mipCount).toBe(1);
expect(planBloomMips(4, 4).mipCount).toBe(1);
expect(planBloomMips(8192, 8192).mipCount).toBe(6);
expect(planBloomMips(16384, 16384).mipCount).toBe(6);
});
});

View file

@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { buildRecallPath, beatFrameFor } from '../path-builder';
import { buildObservatoryGraph } from '../graph-upload';
import { UINTS_PER_PATHSTEP, PATH_KIND } from '../types';
import type { GraphResponse, GraphNode, GraphEdge } from '$types';
function node(id: string, extra: Partial<GraphNode> = {}): GraphNode {
return {
id,
label: `label-${id}`,
type: 'note',
retention: 0.8,
tags: [],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
isCenter: false,
...extra
};
}
function edge(source: string, target: string, weight = 1, type = 'semantic'): GraphEdge {
return { source, target, weight, type };
}
// A small star graph around a center with a spur — enough for a real story.
function response(): GraphResponse {
const nodes = [
node('center', { isCenter: true }),
node('a'),
node('b'),
node('c'),
node('d', { createdAt: '2026-07-02T00:00:00Z', updatedAt: '2026-07-02T00:00:00Z' })
];
const edges = [
edge('center', 'a', 5),
edge('center', 'b', 3),
edge('a', 'c', 2),
edge('c', 'd', 1)
];
return {
nodes,
edges,
center_id: 'center',
depth: 3,
nodeCount: nodes.length,
edgeCount: edges.length
};
}
describe('beatFrameFor', () => {
it('starts at frame 60 and advances 60 per beat', () => {
expect(beatFrameFor(0)).toBe(60);
expect(beatFrameFor(3)).toBe(240);
});
it('an 8-beat story + afterglow fits inside the 720-frame loop', () => {
// last beat at 60 + 7·60 = 480; afterglow envelope ends ≤ +200 frames
expect(beatFrameFor(7) + 200).toBeLessThan(720);
});
});
describe('buildRecallPath', () => {
it('produces steps that reference valid stable node indices', () => {
const resp = response();
const graph = buildObservatoryGraph(resp);
const { steps, data } = buildRecallPath(resp, graph);
expect(steps.length).toBeGreaterThan(1);
for (const s of steps) {
expect(s.targetIndex).toBeGreaterThanOrEqual(0);
expect(s.targetIndex).toBeLessThan(graph.nodes.length);
expect(s.sourceIndex).toBeGreaterThanOrEqual(0);
expect(s.sourceIndex).toBeLessThan(graph.nodes.length);
}
expect(data.length).toBe(steps.length * UINTS_PER_PATHSTEP);
});
it('starts the story at the center with itself as source', () => {
const resp = response();
const graph = buildObservatoryGraph(resp);
const { steps } = buildRecallPath(resp, graph);
expect(steps[0].nodeId).toBe('center');
expect(steps[0].sourceIndex).toBe(steps[0].targetIndex);
expect(steps[0].beatFrame).toBe(60);
});
it('is deterministic — same data → identical steps', () => {
const resp = response();
const graph = buildObservatoryGraph(resp);
const a = buildRecallPath(resp, graph);
const b = buildRecallPath(resp, graph);
expect(a.steps).toEqual(b.steps);
expect(Array.from(a.data)).toEqual(Array.from(b.data));
});
it('marks contradiction beats as backward-cause hops', () => {
const resp = response();
// pathfinder treats 'contradicts' edge type as a contradiction
resp.edges.push({ source: 'b', target: 'd', weight: 4, type: 'contradicts' });
const graph = buildObservatoryGraph(resp);
const { steps } = buildRecallPath(resp, graph);
const kinds = new Set(steps.map((s) => s.beatKind));
if (kinds.has('contradiction')) {
const c = steps.find((s) => s.beatKind === 'contradiction')!;
expect(c.kind).toBe(PATH_KIND.backwardCause);
}
// every step kind is one of the two GPU lanes either way
for (const s of steps) {
expect([PATH_KIND.recall, PATH_KIND.backwardCause]).toContain(s.kind);
}
});
it('survives an empty graph', () => {
const resp: GraphResponse = {
nodes: [],
edges: [],
center_id: '',
depth: 3,
nodeCount: 0,
edgeCount: 0
};
const graph = buildObservatoryGraph(resp);
const { steps, data } = buildRecallPath(resp, graph);
expect(steps).toHaveLength(0);
expect(data.length).toBeGreaterThan(0); // placeholder lane, no zero-size buffer
});
});

View file

@ -0,0 +1,619 @@
import { describe, it, expect } from 'vitest';
import {
buildRescuePlan,
rescueEnvelopes,
pickFailureIndex,
bfsFromFailure,
pickCauseIndex,
pickLookalikes,
layoutPositions,
hopSlotFor,
waveArrivalFrame,
lookalikeFrame,
UNREACHED,
MAX_WAVE_STEPS,
RESCUE_K,
ARC_FRAME,
VERDICT_START,
DETONATE_FRAME
} from '../rescue-plan';
import { buildObservatoryGraph } from '../graph-upload';
import { FLOATS_PER_NODE } from '../types';
import type { GraphNode, GraphEdge, GraphResponse } from '$types';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function gn(id: string, opts: Partial<GraphNode> = {}): GraphNode {
return {
id,
label: opts.label ?? `Memory ${id}`,
type: 'note',
retention: opts.retention ?? 0.5,
tags: opts.tags ?? [],
createdAt: opts.createdAt ?? '2026-01-15T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
isCenter: opts.isCenter ?? false,
suppression_count: opts.suppression_count
};
}
function ge(source: string, target: string, type = 'semantic'): GraphEdge {
return { source, target, weight: 1, type };
}
function gr(nodes: GraphNode[], edges: GraphEdge[], centerId = 'center'): GraphResponse {
return {
nodes,
edges,
center_id: centerId,
depth: 3,
nodeCount: nodes.length,
edgeCount: edges.length
};
}
/**
* Main fixture. Stable indices after buildObservatoryGraph (center first,
* then id-sorted): center=0, cause=1, fail=2, h1=3, h1b=4, h2=5, l1..l4=6..9.
*
* fail (temporal) h1 (causal) h2 (causal) cause (cause depth 3)
* fail h1b, fail center, center l1..l4
*/
function mainFixture(): GraphResponse {
const nodes = [
gn('center', { isCenter: true, retention: 0.9 }),
gn('fail', { tags: ['failure'], retention: 0.8, label: 'checkout 500s on submit' }),
gn('h1', { retention: 0.6 }),
gn('h1b', { retention: 0.7 }),
gn('h2', { retention: 0.55 }),
gn('cause', {
retention: 0.1,
createdAt: '2025-03-01T12:00:00Z',
label: 'schema migration dropped index'
}),
gn('l1', { retention: 0.62 }),
gn('l2', { retention: 0.63 }),
gn('l3', { retention: 0.64 }),
gn('l4', { retention: 0.65 })
];
const edges = [
ge('fail', 'h1', 'temporal'),
ge('h1', 'h2', 'causal'),
ge('h2', 'cause', 'causal'),
ge('fail', 'h1b'),
ge('fail', 'center'),
ge('center', 'l1'),
ge('center', 'l2'),
ge('center', 'l3'),
ge('center', 'l4')
];
return gr(nodes, edges);
}
const SEED = 'vestige-observatory-v1';
function planFor(response: GraphResponse, seed = SEED) {
const graph = buildObservatoryGraph(response);
return { graph, plan: buildRescuePlan(response, graph, seed) };
}
// ---------------------------------------------------------------------------
// 1. Determinism
// ---------------------------------------------------------------------------
describe('determinism', () => {
it('same graph + seed → identical plan, byte-identical typed arrays', () => {
const r = mainFixture();
const { plan: p1 } = planFor(r);
const { plan: p2 } = planFor(r);
expect(p1).toEqual(p2);
expect(Array.from(p1.waveData)).toEqual(Array.from(p2.waveData));
expect(Array.from(p1.pathData)).toEqual(Array.from(p2.pathData));
expect(Array.from(p1.hopDepths)).toEqual(Array.from(p2.hopDepths));
});
});
// ---------------------------------------------------------------------------
// 2. Failure selection
// ---------------------------------------------------------------------------
describe('pickFailureIndex', () => {
it('never the center; prefers failure-tagged well-connected node; degree ≥ 2 when available', () => {
const r = mainFixture();
const graph = buildObservatoryGraph(r);
const positions = layoutPositions(graph, SEED);
const failure = pickFailureIndex(graph, positions);
expect(failure).not.toBe(graph.centerIndex);
expect(graph.nodes[failure].id).toBe('fail'); // tagged 'failure', degree 3
});
it('falls back to a non-center node when nothing is tagged and degrees are low', () => {
const r = gr(
[gn('center', { isCenter: true }), gn('a'), gn('b')],
[ge('a', 'b')]
);
const graph = buildObservatoryGraph(r);
const failure = pickFailureIndex(graph, layoutPositions(graph, SEED));
expect(failure).not.toBe(graph.centerIndex);
});
});
// ---------------------------------------------------------------------------
// 3. BFS exactness
// ---------------------------------------------------------------------------
describe('bfsFromFailure', () => {
it('exact depths on a chain, disconnected node UNREACHED and absent from pathData', () => {
// chain: center — a(failure) — b — c — d — e, plus disconnected g
const r = gr(
[
gn('center', { isCenter: true }),
gn('a', { tags: ['failure'] }),
gn('b'),
gn('c'),
gn('d', { retention: 0.9 }),
gn('e', { retention: 0.2 }),
gn('g')
],
[ge('center', 'a'), ge('a', 'b'), ge('b', 'c'), ge('c', 'd'), ge('d', 'e')]
);
const graph = buildObservatoryGraph(r);
const idx = (id: string) => graph.indexById.get(id)!;
const { depths } = bfsFromFailure(graph, idx('a'));
expect(depths[idx('a')]).toBe(0);
expect(depths[idx('center')]).toBe(1);
expect(depths[idx('b')]).toBe(1);
expect(depths[idx('c')]).toBe(2);
expect(depths[idx('d')]).toBe(3);
expect(depths[idx('e')]).toBe(4);
expect(depths[idx('g')]).toBe(UNREACHED);
const plan = buildRescuePlan(r, graph, SEED);
expect(plan.viable).toBe(true);
// A disconnected node can be a LOOKALIKE (nearest in layout — "looks
// similar, causally unrelated" is the story), so kind-2 probe beams may
// target it. But the backward wave walks REAL graph edges: no kind-1
// wave/arc step may ever touch an unreached node.
for (let s = 0; s < plan.pathData.length / 4; s++) {
if (plan.pathData[s * 4 + 3] !== 1) continue;
expect(plan.pathData[s * 4 + 0]).not.toBe(idx('g'));
expect(plan.pathData[s * 4 + 1]).not.toBe(idx('g'));
}
});
});
// ---------------------------------------------------------------------------
// 4. Cause selection
// ---------------------------------------------------------------------------
describe('pickCauseIndex', () => {
it('depth ≥ 3 when available; lowest retention among depth ≥ 3 wins', () => {
const r = mainFixture();
const graph = buildObservatoryGraph(r);
const idx = (id: string) => graph.indexById.get(id)!;
const { depths } = bfsFromFailure(graph, idx('fail'));
const cause = pickCauseIndex(r, graph, depths, idx('fail'));
expect(cause.index).toBe(idx('cause'));
expect(cause.depth).toBe(3);
});
it('retention tie → older createdAt wins', () => {
// two depth-3 candidates with equal retention, different ages
const r = gr(
[
gn('center', { isCenter: true }),
gn('f', { tags: ['failure'] }),
gn('x'),
gn('y'),
gn('old', { retention: 0.2, createdAt: '2025-01-01T00:00:00Z' }),
gn('new', { retention: 0.2, createdAt: '2026-06-01T00:00:00Z' })
],
[
ge('center', 'f'),
ge('f', 'x'),
ge('x', 'y'),
ge('y', 'old'),
ge('y', 'new')
]
);
const graph = buildObservatoryGraph(r);
const idx = (id: string) => graph.indexById.get(id)!;
const { depths } = bfsFromFailure(graph, idx('f'));
expect(depths[idx('old')]).toBe(3);
expect(depths[idx('new')]).toBe(3);
const cause = pickCauseIndex(r, graph, depths, idx('f'));
expect(cause.index).toBe(idx('old'));
});
});
// ---------------------------------------------------------------------------
// 5. Edge-type-preferring parents
// ---------------------------------------------------------------------------
describe('edge-type parents', () => {
it('equal-hop dual parents: the causal edge wins the parent chain', () => {
// f — u1 (semantic) — v ; f — u2 (semantic) — v via causal edge u2—v
const r = gr(
[
gn('center', { isCenter: true }),
gn('f', { tags: ['failure'] }),
gn('u1'),
gn('u2'),
gn('v', { retention: 0.2 })
],
[
ge('center', 'f'),
ge('f', 'u1', 'semantic'),
ge('f', 'u2', 'semantic'),
ge('u1', 'v', 'semantic'),
ge('u2', 'v', 'causal')
]
);
const graph = buildObservatoryGraph(r);
const idx = (id: string) => graph.indexById.get(id)!;
const { depths, parents } = bfsFromFailure(graph, idx('f'));
expect(depths[idx('v')]).toBe(2);
expect(parents[idx('v')]).toBe(idx('u2'));
// pathData contains the (u2 → v) wave step
const plan = buildRescuePlan(r, graph, SEED);
let found = false;
for (let s = 0; s < plan.pathData.length / 4; s++) {
if (
plan.pathData[s * 4 + 0] === idx('u2') &&
plan.pathData[s * 4 + 1] === idx('v') &&
plan.pathData[s * 4 + 3] === 1
) {
found = true;
}
}
expect(found).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 6. Relaxation ladder + non-viable
// ---------------------------------------------------------------------------
describe('cause relaxation ladder', () => {
it('relaxes depth ≥ 3 → 2 on a short chain', () => {
// center — f(failure) — x — y : deepest candidate at depth 2
const r = gr(
[
gn('center', { isCenter: true }),
gn('f', { tags: ['failure'] }),
gn('x'),
gn('y', { retention: 0.3 })
],
[ge('center', 'f'), ge('f', 'x'), ge('x', 'y')]
);
const { graph, plan } = planFor(r);
expect(plan.viable).toBe(true);
expect(plan.causeIndex).toBe(graph.indexById.get('y')!);
expect(plan.causeDepth).toBe(2);
});
it('1-node graph → viable:false, no throw', () => {
const r = gr([gn('center', { isCenter: true })], []);
const { plan } = planFor(r);
expect(plan.viable).toBe(false);
expect(plan.pathMetas).toEqual([]);
expect(plan.spineBeats).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// 7. Lookalikes
// ---------------------------------------------------------------------------
describe('pickLookalikes', () => {
it('K = min(4, eligible), layout distances non-decreasing, excludes failure/cause/center', () => {
const r = mainFixture();
const graph = buildObservatoryGraph(r);
const plan = buildRescuePlan(r, graph, SEED);
expect(plan.viable).toBe(true);
expect(plan.lookalikeIndices.length).toBe(Math.min(RESCUE_K, graph.nodes.length - 3));
// Recompute via the REAL layout function + same seed.
const positions = layoutPositions(graph, SEED);
const fi = plan.failureIndex;
const d2 = (i: number) => {
const dx = positions[i * FLOATS_PER_NODE + 0] - positions[fi * FLOATS_PER_NODE + 0];
const dy = positions[i * FLOATS_PER_NODE + 1] - positions[fi * FLOATS_PER_NODE + 1];
const dz = positions[i * FLOATS_PER_NODE + 2] - positions[fi * FLOATS_PER_NODE + 2];
return dx * dx + dy * dy + dz * dz;
};
for (let k = 1; k < plan.lookalikeIndices.length; k++) {
expect(d2(plan.lookalikeIndices[k])).toBeGreaterThanOrEqual(
d2(plan.lookalikeIndices[k - 1])
);
}
for (const li of plan.lookalikeIndices) {
expect(li).not.toBe(plan.failureIndex);
expect(li).not.toBe(plan.causeIndex);
expect(li).not.toBe(graph.centerIndex);
}
// direct call agrees with the plan
const direct = pickLookalikes(
positions,
graph.nodes.length,
plan.failureIndex,
plan.causeIndex,
graph.centerIndex
);
expect(direct).toEqual(plan.lookalikeIndices);
});
});
// ---------------------------------------------------------------------------
// 8. SEAM PROOF — every node, frames 0 and 719 all-zero
// ---------------------------------------------------------------------------
describe('loop seam', () => {
it('rescueEnvelopes is exactly zero at frames 0 and 719 for EVERY packed word', () => {
const r = mainFixture();
const { plan } = planFor(r);
expect(plan.viable).toBe(true);
for (const packed of plan.waveData) {
for (const frame of [0, 719]) {
const e = rescueEnvelopes(frame, packed, plan.consts);
expect(Math.abs(e.x)).toBeLessThan(1e-6);
expect(Math.abs(e.y)).toBeLessThan(1e-6);
expect(Math.abs(e.z)).toBeLessThan(1e-6);
expect(Math.abs(e.w)).toBeLessThan(1e-6);
}
}
});
});
// ---------------------------------------------------------------------------
// 9. Envelopes fire on the beat map
// ---------------------------------------------------------------------------
describe('envelopes fire', () => {
it('y, x, z, w peak on their beats', () => {
const r = mainFixture();
const { plan } = planFor(r);
const c = plan.consts;
// searchlight: y > 0.9 at Fk on lookalike k
plan.lookalikeIndices.forEach((li, k) => {
const e = rescueEnvelopes(lookalikeFrame(k), plan.waveData[li], c);
expect(e.y).toBeGreaterThan(0.9);
});
// cause ignition: x > 0.99 at frame 580
const cw = plan.waveData[plan.causeIndex];
expect(rescueEnvelopes(580, cw, c).x).toBeGreaterThan(0.99);
// backward wave: max z > 0.7 within [W(d), W(d)+28] on a depth-1 node
const d1 = plan.hopDepths.findIndex((d, i) => d === 1 && i !== plan.failureIndex);
expect(d1).toBeGreaterThanOrEqual(0);
const wd = waveArrivalFrame(1, plan.hopSlot);
let zMax = 0;
for (let f = wd; f <= wd + 28; f++) {
zMax = Math.max(zMax, rescueEnvelopes(f, plan.waveData[d1], c).z);
}
expect(zMax).toBeGreaterThan(0.7);
// detonation: w > 0.9 at frame 105 on the failure
expect(rescueEnvelopes(105, plan.waveData[plan.failureIndex], c).w).toBeGreaterThan(0.9);
});
});
// ---------------------------------------------------------------------------
// 10. Packing round-trip
// ---------------------------------------------------------------------------
describe('waveData packing', () => {
it('depth/roles/k decode for failure, cause, lookalikes, plain and unreached nodes', () => {
// use the BFS-chain fixture with a disconnected node
const r = gr(
[
gn('center', { isCenter: true }),
gn('a', { tags: ['failure'] }),
gn('b'),
gn('c'),
gn('d', { retention: 0.9 }),
gn('e', { retention: 0.2 }),
gn('g')
],
[ge('center', 'a'), ge('a', 'b'), ge('b', 'c'), ge('c', 'd'), ge('d', 'e')]
);
const graph = buildObservatoryGraph(r);
const idx = (id: string) => graph.indexById.get(id)!;
const plan = buildRescuePlan(r, graph, SEED);
expect(plan.viable).toBe(true);
const fw = plan.waveData[plan.failureIndex];
expect(fw & 0xffff).toBe(0);
expect(fw & 0x10000).not.toBe(0);
expect(fw & 0x20000).toBe(0);
const cw = plan.waveData[plan.causeIndex];
expect(cw & 0xffff).toBe(plan.causeDepth);
expect(cw & 0x20000).not.toBe(0);
expect(cw & 0x10000).toBe(0);
plan.lookalikeIndices.forEach((li, k) => {
const w = plan.waveData[li];
expect(w & 0x40000).not.toBe(0);
expect((w >>> 19) & 0x7).toBe(k);
expect(w & 0xffff).toBe(plan.hopDepths[li]);
});
// unreached node round-trips 0xFFFF and is never failure/cause
const gw = plan.waveData[idx('g')];
expect(gw & 0xffff).toBe(UNREACHED);
expect(gw & 0x30000).toBe(0);
// plain node (no role bits at all) — main fixture has 3 non-role nodes
const rich = mainFixture();
const richGraph = buildObservatoryGraph(rich);
const richPlan = buildRescuePlan(rich, richGraph, SEED);
const roles = new Set([
richPlan.failureIndex,
richPlan.causeIndex,
...richPlan.lookalikeIndices
]);
const plain = richGraph.nodes.findIndex(
(n) => !roles.has(n.index) && n.index !== richGraph.centerIndex
);
expect(plain).toBeGreaterThanOrEqual(0);
expect(richPlan.waveData[plain] & 0x7f0000).toBe(0);
expect(richPlan.waveData[plain] & 0xffff).toBe(richPlan.hopDepths[plain]);
});
});
// ---------------------------------------------------------------------------
// 11. Ribbon-window invariant + step ordering
// ---------------------------------------------------------------------------
describe('path steps', () => {
it('every beat frame keeps its ribbon window inside [0, 719]; probes first, arc last', () => {
const r = mainFixture();
const { graph, plan } = planFor(r);
const count = plan.pathData.length / 4;
expect(count).toBeLessThanOrEqual(RESCUE_K + MAX_WAVE_STEPS + 1);
for (let s = 0; s < count; s++) {
const bf = plan.pathData[s * 4 + 2];
expect(bf - 46).toBeGreaterThanOrEqual(0);
expect(bf + 90).toBeLessThanOrEqual(719);
}
// probes first, kind 2, beats 138/166/194/222
const K = plan.lookalikeIndices.length;
for (let k = 0; k < K; k++) {
expect(plan.pathData[k * 4 + 0]).toBe(plan.failureIndex);
expect(plan.pathData[k * 4 + 1]).toBe(plan.lookalikeIndices[k]);
expect(plan.pathData[k * 4 + 2]).toBe(138 + 28 * k);
expect(plan.pathData[k * 4 + 3]).toBe(2);
}
// wave steps: kind 1 with bf = W(depth(dst))
for (let s = K; s < count - 1; s++) {
expect(plan.pathData[s * 4 + 3]).toBe(1);
const dst = plan.pathData[s * 4 + 1];
expect(plan.pathData[s * 4 + 2]).toBe(
waveArrivalFrame(plan.hopDepths[dst], plan.hopSlot)
);
expect(plan.pathData[s * 4 + 2]).toBeLessThanOrEqual(514);
}
// arc last: cause → failure at 560, kind 1
const last = count - 1;
expect(plan.pathData[last * 4 + 0]).toBe(plan.causeIndex);
expect(plan.pathData[last * 4 + 1]).toBe(plan.failureIndex);
expect(plan.pathData[last * 4 + 2]).toBe(ARC_FRAME);
expect(plan.pathData[last * 4 + 3]).toBe(1);
void graph;
});
});
// ---------------------------------------------------------------------------
// 12. setPathSteps contract
// ---------------------------------------------------------------------------
describe('pathMetas contract', () => {
it('pathMetas is 1:1 with pathData steps (draw count = metas.length)', () => {
const { plan } = planFor(mainFixture());
expect(plan.pathMetas.length).toBe(plan.pathData.length / 4);
});
});
// ---------------------------------------------------------------------------
// 13. Spine beats
// ---------------------------------------------------------------------------
describe('spine beats', () => {
it('beatFrames strictly increasing and unique; real labels present', () => {
const { plan } = planFor(mainFixture());
const frames = plan.spineBeats.map((b) => b.beatFrame);
for (let i = 1; i < frames.length; i++) {
expect(frames[i]).toBeGreaterThan(frames[i - 1]);
}
expect(new Set(frames).size).toBe(frames.length);
expect(frames[0]).toBe(DETONATE_FRAME);
expect(frames[frames.length - 1]).toBe(VERDICT_START);
const labels = plan.spineBeats.map((b) => b.label);
expect(labels[0]).toContain('checkout 500s on submit');
expect(labels.some((l) => l.includes('lookalike ✗'))).toBe(true);
expect(labels.some((l) => l.includes('schema migration dropped index'))).toBe(true);
expect(labels[labels.length - 1]).toBe('root cause found');
});
});
// ---------------------------------------------------------------------------
// 14. Verdict receipt
// ---------------------------------------------------------------------------
describe('verdict', () => {
it('real labels, real date, hop count and K', () => {
const { plan } = planFor(mainFixture());
expect(plan.verdict.causeLabel).toBe('schema migration dropped index');
expect(plan.verdict.failureLabel).toBe('checkout 500s on submit');
expect(plan.verdict.causeDate).toBe('2025-03-01');
expect(plan.verdict.hops).toBe(3);
expect(plan.verdict.k).toBe(4);
expect(plan.verdict.receipt).toBe('3 hops back · 2025-03-01 · vector search: 0 for 4');
});
it('labels truncate at 64 chars with an ellipsis', () => {
const long = 'x'.repeat(100);
const r = mainFixture();
const causeNode = r.nodes.find((n) => n.id === 'cause')!;
causeNode.label = long;
const { plan } = planFor(r);
expect(plan.verdict.causeLabel.length).toBe(65);
expect(plan.verdict.causeLabel.endsWith('…')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 15. Degenerates survive
// ---------------------------------------------------------------------------
describe('degenerate graphs', () => {
it('0-node, 2-node and edgeless graphs → viable:false, no throw, min pathData', () => {
const zero = gr([], [], '');
const { plan: p0 } = planFor(zero);
expect(p0.viable).toBe(false);
expect(p0.pathData.length).toBe(4);
const two = gr([gn('center', { isCenter: true }), gn('a')], []);
const { plan: p2 } = planFor(two);
expect(p2.viable).toBe(false);
expect(p2.pathData.length).toBe(4);
const edgeless = gr([gn('center', { isCenter: true }), gn('a'), gn('b'), gn('c')], []);
const { plan: pe } = planFor(edgeless);
expect(pe.viable).toBe(false);
expect(pe.spineBeats).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// 16. hopSlot clamping
// ---------------------------------------------------------------------------
describe('hopSlot', () => {
it('D=3 → 84; D=18 → 14 (clamped); W(D) ≤ 514', () => {
expect(hopSlotFor(3)).toBe(84);
expect(hopSlotFor(18)).toBe(14);
expect(waveArrivalFrame(3, hopSlotFor(3))).toBe(512);
expect(waveArrivalFrame(18, hopSlotFor(18))).toBe(512);
for (let d = 1; d <= 20; d++) {
expect(waveArrivalFrame(d, hopSlotFor(d))).toBeLessThanOrEqual(514);
}
// the main fixture's plan agrees
const { plan } = planFor(mainFixture());
expect(plan.hopSlot).toBe(84);
expect(plan.consts.hopSlot).toBe(84);
});
});

View file

@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { pbrNeutralReference, VOID_CLEAR_HDR } from '../post/tone-reference';
import { BLOOM_STRENGTH } from '../post/post-chain';
const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255
];
const argmax = (v: readonly number[]) => v.indexOf(Math.max(...v));
const argmin = (v: readonly number[]) => v.indexOf(Math.min(...v));
describe('pbrNeutralReference', () => {
it('void gate: tonemap(clear · (1 + BLOOM_STRENGTH)) is exactly #05060a', () => {
// The normalized bloom chain has flat-field gain exactly 1, so a void
// pixel enters the tonemap as (1 + BLOOM_STRENGTH) · VOID_CLEAR_HDR.
const s = 1 + BLOOM_STRENGTH;
const out = pbrNeutralReference([
VOID_CLEAR_HDR.r * s,
VOID_CLEAR_HDR.g * s,
VOID_CLEAR_HDR.b * s
]);
expect(Math.abs(out[0] - 5 / 255)).toBeLessThan(1e-6);
expect(Math.abs(out[1] - 6 / 255)).toBeLessThan(1e-6);
expect(Math.abs(out[2] - 10 / 255)).toBeLessThan(1e-6);
});
it('void preimage stays below the compression knee (early-return branch)', () => {
const s = 1 + BLOOM_STRENGTH;
const peak = Math.max(VOID_CLEAR_HDR.r, VOID_CLEAR_HDR.g, VOID_CLEAR_HDR.b) * s;
expect(peak).toBeLessThan(0.76);
});
it('below the knee is NOT identity: uniform 0.04 offset when min ≥ 0.08', () => {
// (0.5, 0.3, 0.2): min = 0.2 ≥ 0.08 → offset = 0.04, peak 0.46 < 0.76.
const out = pbrNeutralReference([0.5, 0.3, 0.2]);
expect(out[0]).toBeCloseTo(0.46, 12);
expect(out[1]).toBeCloseTo(0.26, 12);
expect(out[2]).toBeCloseTo(0.16, 12);
});
it('black offset: min < 0.08 → out_min = 6.25·min²', () => {
// (0.05, 0.3, 0.2): offset = 0.05 6.25·0.05² = 0.034375.
const out = pbrNeutralReference([0.05, 0.3, 0.2]);
expect(out[0]).toBeCloseTo(6.25 * 0.05 * 0.05, 12);
expect(out[1]).toBeCloseTo(0.3 - 0.034375, 12);
expect(out[2]).toBeCloseTo(0.2 - 0.034375, 12);
});
it('FSRS mint #10b981 stays below the knee: pure offset, order preserved', () => {
const rgb = hexToRgb('#10b981');
const x = rgb[0]; // min channel (16/255 < 0.08)
const offset = x - 6.25 * x * x;
// peak after offset ≈ 0.687 < 0.76 → early return, deltas = offset.
expect(Math.max(...rgb) - offset).toBeLessThan(0.76);
const out = pbrNeutralReference(rgb);
expect(out[0]).toBeCloseTo(rgb[0] - offset, 12);
expect(out[1]).toBeCloseTo(rgb[1] - offset, 12);
expect(out[2]).toBeCloseTo(rgb[2] - offset, 12);
expect(argmax(out)).toBe(argmax(rgb));
expect(argmin(out)).toBe(argmin(rgb));
});
it('FSRS amber #f59e0b and violet #8b5cf6 hit compression: hue order still preserved', () => {
// NOTE deviation from the design brief, which claimed all three FSRS
// colors take the below-knee branch: after the black offset, amber's
// peak ≈ 0.929 and violet's ≈ 0.925 — both ≥ 0.76, so the compression
// branch runs. Hue preservation (channel ordering) is the real guard.
for (const hex of ['#f59e0b', '#8b5cf6']) {
const rgb = hexToRgb(hex);
const x = Math.min(...rgb);
const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
expect(Math.max(...rgb) - offset).toBeGreaterThanOrEqual(0.76);
const out = pbrNeutralReference(rgb);
expect(argmax(out)).toBe(argmax(rgb));
expect(argmin(out)).toBe(argmin(rgb));
// Compressed: peak shrinks, everything stays inside (0, 1).
expect(Math.max(...out)).toBeLessThan(Math.max(...rgb));
for (const ch of out) {
expect(ch).toBeGreaterThan(0);
expect(ch).toBeLessThan(1);
}
}
});
it('compression: HDR greys 2/4/8 map monotonically, all below 1', () => {
const g2 = pbrNeutralReference([2, 2, 2])[0];
const g4 = pbrNeutralReference([4, 4, 4])[0];
const g8 = pbrNeutralReference([8, 8, 8])[0];
expect(g2).toBeLessThan(g4);
expect(g4).toBeLessThan(g8);
expect(g8).toBeLessThan(1);
// Greys stay grey (hue-preserving on the neutral axis).
expect(pbrNeutralReference([4, 4, 4])).toEqual([g4, g4, g4]);
});
it('hue: argmax stable through compression for (1.5, 0.4, 0.2)', () => {
const out = pbrNeutralReference([1.5, 0.4, 0.2]);
expect(argmax(out)).toBe(0);
expect(Math.max(...out)).toBeLessThan(1);
});
});

View file

@ -0,0 +1,381 @@
/**
* Cognitive Observatory — deterministic birth-plan CPU helpers (Moment B, Task B1).
*
* Pure CPU: pick a birth target, precompute deterministic `BirthParticle` initial
* arrays, build birth beat metadata. No GPU code, no Math.random().
*
* Particle layout (16 floats / 64 bytes per particle):
* start_life : xyz start position, w seed/life scalar (phase offset)
* target_size : xyz target position, w base size (1.0 + rng * 1.8)
* color_phase : rgb target/base node color, w phase offset
* state : xyz current position (shader computes), w alpha
*
* All start positions form a deterministic hollow shell around the target:
* 70% spherical shell (radius 110-180)
* 20% tendrils along incident edge directions
* 10% near-camera dust plane
*/
import { DemoClock, deterministicSpherePosition } from './demo-clock';
import type { ObservatoryGraph } from './types';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface TimelineBeat {
label: string;
startFrame: number;
endFrame: number;
}
export interface BirthPlan {
targetIndex: number;
targetNodeId: string;
/** 16 floats per particle (64 bytes). */
particles: Float32Array;
/** 4 u32 per edge step (source, target, beatFrame, kind). */
edgeSteps: Uint32Array;
timeline: TimelineBeat[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FLOATS_PER_BIRTH_PARTICLE = 16;
const UINTS_PER_BIRTH_EDGE_STEP = 4;
// Shell radii
const SHELL_MIN_RADIUS = 110;
const SHELL_MAX_RADIUS = 180;
// Particle distribution fractions
const FRACTION_SHELL = 0.70;
const FRACTION_TENDRIL = 0.20;
const FRACTION_DUST = 0.10;
// Edge engraving: each incident edge gets a pulse starting at frame 360
const ENGRAVE_START_FRAME = 360;
const ENGRAVE_INTERVAL = 18; // frames between successive edge pulses
// ---------------------------------------------------------------------------
// Target selection
// ---------------------------------------------------------------------------
/**
* Pick the birth target deterministically from the graph.
*
* Priority:
* 1. Center node's highest-retention neighbor (if graph has edges).
* 2. First non-center node after stable graph ordering.
* 3. Center node.
*/
export function pickTargetIndex(graph: ObservatoryGraph): number {
// 1. Prefer center's highest-retention neighbor
if (graph.edges.length > 0) {
const centerIdx = graph.centerIndex;
const incidentEdges = graph.edges.filter(
(e) => e.sourceIndex === centerIdx || e.targetIndex === centerIdx
);
if (incidentEdges.length > 0) {
let bestNeighborIdx = -1;
let bestRetention = -1;
for (const edge of incidentEdges) {
const neighborIdx =
edge.sourceIndex === centerIdx ? edge.targetIndex : edge.sourceIndex;
const neighbor = graph.nodes[neighborIdx];
if (neighbor && neighbor.retention > bestRetention) {
bestRetention = neighbor.retention;
bestNeighborIdx = neighborIdx;
}
}
if (bestNeighborIdx >= 0) return bestNeighborIdx;
}
}
// 2. First non-center node after stable ordering
for (let i = 0; i < graph.nodes.length; i++) {
if (i !== graph.centerIndex) return i;
}
// 3. Center node (fallback)
return graph.centerIndex;
}
// ---------------------------------------------------------------------------
// Particle precomputation
// ---------------------------------------------------------------------------
/**
* Build the deterministic particle array for a birth event.
*
* Uses a fresh DemoClock seeded with `seed + ':birth:' + targetNodeId` so
* the same graph + seed always produces the same layout.
*/
export function buildBirthPlan(
graph: ObservatoryGraph,
seed: string,
particleCount = 8192
): BirthPlan {
const targetIndex = pickTargetIndex(graph);
const targetNode = graph.nodes[targetIndex];
const targetNodeId = targetNode.id;
// Get target node position from the graph (will be in the node state buffer)
const targetPos = getNodePosition(graph, targetIndex);
// Build a fresh DemoClock for deterministic particle placement
const clock = new DemoClock({ seed: seed + ':birth:' + targetNodeId });
const rng = clock.state.rng;
// Build particles
const particles = new Float32Array(particleCount * FLOATS_PER_BIRTH_PARTICLE);
const shellCount = Math.floor(particleCount * FRACTION_SHELL);
const tendrilCount = Math.floor(particleCount * FRACTION_TENDRIL);
const dustCount = particleCount - shellCount - tendrilCount;
// --- 70%: spherical shell around target ---
for (let i = 0; i < shellCount; i++) {
const base = i * FLOATS_PER_BIRTH_PARTICLE;
// Deterministic position on a sphere around the target
const [sx, sy, sz] = deterministicSpherePosition(
i,
shellCount,
SHELL_MIN_RADIUS + rng() * (SHELL_MAX_RADIUS - SHELL_MIN_RADIUS),
rng
);
// World-space start position = target + shell offset
particles[base + 0] = targetPos[0] + sx;
particles[base + 1] = targetPos[1] + sy;
particles[base + 2] = targetPos[2] + sz;
// w: phase offset (stagger)
particles[base + 3] = rng();
// Target position (same for all particles — convergence target)
particles[base + 4] = targetPos[0];
particles[base + 5] = targetPos[1];
particles[base + 6] = targetPos[2];
// w: base size
particles[base + 7] = 1.0 + rng() * 1.8;
// Color: violet dust base (0.55, 0.32, 1.00)
particles[base + 8] = 0.55;
particles[base + 9] = 0.32;
particles[base + 10] = 1.00;
// w: phase offset for spectral rim
particles[base + 11] = rng();
// state: zeroed (shader computes current position)
particles[base + 12] = 0;
particles[base + 13] = 0;
particles[base + 14] = 0;
particles[base + 15] = 0;
}
// --- 20%: tendrils along incident edge directions ---
const incidentEdges = graph.edges.filter(
(e) => e.sourceIndex === targetIndex || e.targetIndex === targetIndex
);
for (let i = 0; i < tendrilCount; i++) {
const base = (shellCount + i) * FLOATS_PER_BIRTH_PARTICLE;
// Skip tendrils when no incident edges (center-only graph)
if (incidentEdges.length === 0) {
// Just place as shell particles (already done above)
continue;
}
// Pick an incident edge direction (cycle through edges)
const edgeIdx = i % incidentEdges.length;
const edge = incidentEdges[edgeIdx];
// Direction from target to neighbor
const neighborIdx =
edge.sourceIndex === targetIndex ? edge.targetIndex : edge.sourceIndex;
const neighborPos = getNodePosition(graph, neighborIdx);
const dx = neighborPos[0] - targetPos[0];
const dy = neighborPos[1] - targetPos[1];
const dz = neighborPos[2] - targetPos[2];
const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
// Place along the edge direction, spread out
const t = (i / Math.max(1, tendrilCount)) * 2.0 + 0.5; // 0.5 to 2.5
const spread = rng() * 30; // perpendicular spread
// Perpendicular offset (simple cross with a fixed axis)
const px = -dy * spread / (len || 1);
const py = dx * spread / (len || 1);
const pz = 0;
particles[base + 0] = targetPos[0] + (dx / len) * t * 80 + px;
particles[base + 1] = targetPos[1] + (dy / len) * t * 80 + py;
particles[base + 2] = targetPos[2] + (dz / len) * t * 80 + pz;
particles[base + 3] = rng();
particles[base + 4] = targetPos[0];
particles[base + 5] = targetPos[1];
particles[base + 6] = targetPos[2];
particles[base + 7] = 1.0 + rng() * 1.8;
particles[base + 8] = 0.55;
particles[base + 9] = 0.32;
particles[base + 10] = 1.00;
particles[base + 11] = rng();
particles[base + 12] = 0;
particles[base + 13] = 0;
particles[base + 14] = 0;
particles[base + 15] = 0;
}
// --- 10%: near-camera dust plane for depth sparkle ---
// Camera orbits around the field; a "near camera" plane is roughly at
// orbit distance. We place these in front of the target along the
// camera's approximate view direction (z-axis in our coordinate system).
const ORBIT_DISTANCE = 300;
for (let i = 0; i < dustCount; i++) {
const base = (shellCount + tendrilCount + i) * FLOATS_PER_BIRTH_PARTICLE;
// Spread in a plane near the camera orbit distance
const angle = rng() * Math.PI * 2;
const spread = rng() * 120;
particles[base + 0] = targetPos[0] + Math.cos(angle) * spread;
particles[base + 1] = targetPos[1] + Math.sin(angle) * spread;
particles[base + 2] = targetPos[2] + ORBIT_DISTANCE * 0.6 + rng() * 40;
particles[base + 3] = rng();
particles[base + 4] = targetPos[0];
particles[base + 5] = targetPos[1];
particles[base + 6] = targetPos[2];
particles[base + 7] = 1.0 + rng() * 1.8;
particles[base + 8] = 0.55;
particles[base + 9] = 0.32;
particles[base + 10] = 1.00;
particles[base + 11] = rng();
particles[base + 12] = 0;
particles[base + 13] = 0;
particles[base + 14] = 0;
particles[base + 15] = 0;
}
// ---------------------------------------------------------------------------
// Edge steps for engraving
// ---------------------------------------------------------------------------
const edgeSteps = buildEdgeSteps(graph, targetIndex);
// ---------------------------------------------------------------------------
// Timeline beats
// ---------------------------------------------------------------------------
const timeline = buildTimeline();
return {
targetIndex,
targetNodeId,
particles,
edgeSteps,
timeline,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Get the world-space position of a node from the graph.
* Uses deterministic sphere placement (same as graph-upload).
*/
function getNodePosition(
graph: ObservatoryGraph,
nodeIndex: number
): [number, number, number] {
const node = graph.nodes[nodeIndex];
const n = graph.nodes.length;
if (node.isCenter && graph.centerIndex === nodeIndex) {
return [0, 0, 0];
}
// Use a fixed seed for position (not the birth seed) so positions match
// what the NodeRenderer will produce. We use a deterministic "position seed"
// derived from the node's index and the graph's center.
// The actual NodeRenderer uses the demo seed for perturbation, but for
// the birth plan we need to know where the target node will be.
// We use a simple golden-angle placement with a fixed perturbation seed.
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const y = 1 - (nodeIndex / (n - 1 || 1)) * 2;
const radiusAtY = Math.sqrt(1 - y * y);
const theta = goldenAngle * nodeIndex;
const fieldRadius = 120;
// Fixed perturbation (no random — deterministic by index)
const px = ((nodeIndex * 7 + 3) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
const py = ((nodeIndex * 13 + 7) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
const pz = ((nodeIndex * 17 + 11) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
return [
Math.cos(theta) * radiusAtY * fieldRadius + px,
y * fieldRadius + py,
Math.sin(theta) * radiusAtY * fieldRadius + pz,
];
}
/**
* Build edge steps for the engraving phase (frames 360+).
* Each incident edge from the target gets a pulse.
*/
function buildEdgeSteps(
graph: ObservatoryGraph,
targetIndex: number
): Uint32Array {
const incidentEdges = graph.edges.filter(
(e) => e.sourceIndex === targetIndex || e.targetIndex === targetIndex
);
const stepCount = incidentEdges.length;
// No incident edges → zero steps (plan L464): BirthRenderer skips the
// engrave buffer entirely rather than drawing a phantom 0→0 pulse.
if (stepCount === 0) {
return new Uint32Array(0);
}
const data = new Uint32Array(stepCount * UINTS_PER_BIRTH_EDGE_STEP);
for (let k = 0; k < stepCount; k++) {
const edge = incidentEdges[k];
const neighborIdx =
edge.sourceIndex === targetIndex ? edge.targetIndex : edge.sourceIndex;
const beatFrame = ENGRAVE_START_FRAME + k * ENGRAVE_INTERVAL;
data[k * UINTS_PER_BIRTH_EDGE_STEP + 0] = targetIndex; // source = target
data[k * UINTS_PER_BIRTH_EDGE_STEP + 1] = neighborIdx; // target = neighbor
data[k * UINTS_PER_BIRTH_EDGE_STEP + 2] = beatFrame;
data[k * UINTS_PER_BIRTH_EDGE_STEP + 3] = 0; // kind: normal
}
return data;
}
/**
* Build the timeline beats for the 720-frame birth loop.
* Matches the choreography schedule from the plan.
*/
function buildTimeline(): TimelineBeat[] {
return [
{ label: 'latent trace condensing', startFrame: 60, endFrame: 239 },
{ label: 'engram coalescence', startFrame: 240, endFrame: 329 },
{ label: 'memory ignition', startFrame: 330, endFrame: 359 },
{ label: 'associations engrave', startFrame: 360, endFrame: 509 },
{ label: 'stabilization', startFrame: 510, endFrame: 659 },
];
}

View file

@ -0,0 +1,616 @@
/**
* Cognitive Observatory birth particle renderer (Moment B, Tasks B3B6).
*
* Registers as a FramePass on the engine ONLY when demoMode === 'engram-birth'.
* Handles:
* B3: compute entry (convergence choreography)
* B4: particle billboard render pipeline (instanced additive)
* B5: birth flash + target halo (frames 330359, loop-seam safe)
* B6: edge engraving via path-ribbon reuse + TimelineSpine beats
*
* Integration: reads nodeStateBuffer and cameraUniformBuffer from NodeRenderer,
* creates its own particle buffer (initialized from buildBirthPlan), and
* dispatches compute + render passes each frame.
*
* Loop-seam: all time terms are integer-cycles per 720 frames.
* Capture mode: params._pad == 1.0 skips stateful particle integration.
* No Math.random() in new files.
*/
import type { ObservatoryEngine, FramePass } from './engine';
import { NodeRenderer } from './node-renderer';
import { buildBirthPlan, type BirthPlan, type TimelineBeat } from './birth-plan';
import { birthParticlesWGSL } from './shaders/birth-particles.wgsl';
import { renderPathWGSL } from './shaders/render-path.wgsl';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PARTICLE_FLOATS = 16; // 64 bytes per particle
const QUAD_VERTS = 6; // two triangles
// Flash frames (B5)
const FLASH_START = 330;
const FLASH_END = 359;
// Edge engraving start (B6)
const ENGRAVE_START = 360;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface BirthRendererOptions {
engine: ObservatoryEngine;
nodeRenderer: NodeRenderer;
seed: string;
}
// ---------------------------------------------------------------------------
// BirthRenderer
// ---------------------------------------------------------------------------
export class BirthRenderer implements FramePass {
private engine: ObservatoryEngine;
private nodeRenderer: NodeRenderer;
private active: boolean;
// Compute pipeline (B3)
private computePipeline: GPUComputePipeline | null = null;
private computeBindGroup: GPUBindGroup | null = null;
private particleBuffer: GPUBuffer | null = null;
private particleCount = 0;
// Render pipeline (B4) — instanced additive billboards
private renderPipeline: GPURenderPipeline | null = null;
private renderBindGroup: GPUBindGroup | null = null;
// Flash/halo (B5) — target glow ring
private haloPipeline: GPURenderPipeline | null = null;
private haloBindGroup: GPUBindGroup | null = null;
private haloIndexBuffer: GPUBuffer | null = null;
// Edge engraving (B6) — reuse path-ribbon shader
private engravePipeline: GPURenderPipeline | null = null;
private engraveBindGroup: GPUBindGroup | null = null;
private engraveBuffer: GPUBuffer | null = null;
private engraveStepCount = 0;
// Timeline beats for overlay (B6)
timeline: TimelineBeat[] = [];
// Birth plan (CPU-side)
private birthPlan: BirthPlan | null = null;
/**
* Engrave steps in PathStep layout (source, target, beatFrame, kind) the
* route feeds these into the NodeRenderer path system so the proven
* wavefront machinery renders the outward engraving (and the recall sim
* blooms each neighbor as its edge lands).
*/
get engraveSteps(): Uint32Array<ArrayBuffer> {
return (this.birthPlan?.edgeSteps ?? new Uint32Array(0)) as Uint32Array<ArrayBuffer>;
}
constructor(opts: BirthRendererOptions) {
this.engine = opts.engine;
this.nodeRenderer = opts.nodeRenderer;
// Only activate when demoMode === 'engram-birth' (demo_id === 1).
// We check this each frame in compute() so it's safe to always register.
this.active = false;
this.engine.addPass(this);
}
/** Initialize the birth plan and GPU resources. Call after upload(). */
upload(seed: string): void {
const device = this.engine.gpuDevice;
if (!device || !this.nodeRenderer.nodeStateBuffer) return;
const graph = this.nodeRenderer.graph;
if (!graph) return;
// Build the deterministic birth plan (CPU).
this.birthPlan = buildBirthPlan(graph, seed);
this.timeline = this.birthPlan.timeline;
const particleCount = this.birthPlan.particles.length / PARTICLE_FLOATS;
this.particleCount = particleCount;
// Create particle storage buffer.
this.particleBuffer?.destroy();
this.particleBuffer = device.createBuffer({
label: 'observatory-birth-particles',
size: this.birthPlan.particles.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.particleBuffer, 0, this.birthPlan.particles.buffer as ArrayBuffer);
// Create edge engraving buffer (B6).
this.engraveBuffer?.destroy();
this.engraveStepCount = this.birthPlan.edgeSteps.length / 4;
if (this.engraveStepCount > 0) {
this.engraveBuffer = device.createBuffer({
label: 'observatory-birth-engrave',
size: this.birthPlan.edgeSteps.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.engraveBuffer, 0, this.birthPlan.edgeSteps.buffer as ArrayBuffer);
}
// Create compute pipeline (B3).
this.createComputePipeline(device);
// Create render pipeline (B4).
this.createRenderPipeline(device);
// Create flash/halo pipeline (B5).
this.createHaloPipeline(device);
// Create edge engraving pipeline (B6).
this.createEngravePipeline(device);
}
private createComputePipeline(device: GPUDevice): void {
const module = device.createShaderModule({
label: 'observatory-birth-compute',
code: birthParticlesWGSL
});
this.computePipeline = device.createComputePipeline({
label: 'observatory-birth-compute-pipeline',
layout: 'auto',
compute: { module, entryPoint: 'birth_compute' }
});
// The compute shader declares exactly bindings 0-1; binding anything the
// auto layout stripped (it has no binding 2) invalidates the bind group.
const entries: GPUBindGroupEntry[] = [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
{ binding: 1, resource: { buffer: this.particleBuffer! } }
];
this.computeBindGroup = device.createBindGroup({
label: 'observatory-birth-compute-bind',
layout: this.computePipeline!.getBindGroupLayout(0),
entries
});
}
private createRenderPipeline(device: GPUDevice): void {
// Particle billboard shader (inline WGSL for render pass).
const particleRenderWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Camera {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
struct BirthParticle {
start_life: vec4<f32>,
target_size: vec4<f32>,
color_phase: vec4<f32>,
state: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<uniform> camera: Camera;
@group(0) @binding(2) var<storage, read> particles: array<BirthParticle>;
struct VSOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) color: vec3<f32>,
@location(2) @interpolate(flat) misc: vec4<f32>,
};
const CORNERS = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0)
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32
) -> VSOut {
var out: VSOut;
if (ii >= arrayLength(&particles)) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
let particle = particles[ii];
let corner = CORNERS[vi];
// Current position from state.xyz.
let pos = particle.state.xyz;
// Base size from target_size.w.
let baseSize = particle.target_size.w;
// Flash boost during ignition (frames 330359).
let frame = params.frame;
var flashBoost = 1.0;
if (frame >= 330.0 && frame <= 359.0) {
let flashT = (frame - 330.0) / 29.0; // 0..1 over flash frames
// Sharp flash: peaks at frame 345, fades by 359.
flashBoost = 1.0 + 3.0 * (1.0 - smoothstep(330.0, 345.0, frame))
+ 2.0 * smoothstep(345.0, 359.0, frame);
}
// Size: base + flash boost + pulse breathing.
let breath = 1.0 + 0.06 * params.pulse;
let halfSize = baseSize * 4.0 * breath * flashBoost;
let world = pos
+ camera.right.xyz * corner.x * halfSize
+ camera.up.xyz * corner.y * halfSize;
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
out.uv = corner;
// Color: violet dust (0.55, 0.32, 1.00) with spectral rim.
let phase = particle.color_phase.w;
let spectralW = fract(params.loop_phase + phase);
var spectralColor: vec3<f32>;
var stops = array<vec3<f32>, 4>(
vec3<f32>(0.55, 0.32, 1.00), // violet base
vec3<f32>(0.40, 0.60, 1.00), // blue-violet
vec3<f32>(0.70, 0.45, 1.00), // magenta-violet
vec3<f32>(0.55, 0.32, 1.00) // wrap
);
let f = spectralW * 4.0;
let i = u32(floor(f)) % 4u;
let frac = f - floor(f);
spectralColor = mix(stops[i], stops[(i + 1u) % 4u], frac);
// Alpha from state.w (convergence progress + fade).
let alpha = particle.state.w;
out.color = spectralColor;
out.misc = vec4<f32>(baseSize, 0.0, 0.0, alpha);
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
let d = length(in.uv);
if (d > 1.0) {
discard;
}
let alpha = in.misc.w;
let core = smoothstep(0.25, 0.0, d);
let halo = pow(max(1.0 - d, 0.0), 2.0);
// Additive glow: core + halo.
let intensity = core * 1.5 + halo * 0.6;
// Flash boost during ignition.
let frame = params.frame;
var flash = 0.0;
if (frame >= 330.0 && frame <= 359.0) {
flash = smoothstep(330.0, 345.0, frame) * 2.0;
}
let color = in.color * (intensity + flash);
return vec4<f32>(color * params.brightness, 1.0);
}
`;
const module = device.createShaderModule({
label: 'observatory-birth-render',
code: particleRenderWGSL
});
this.renderPipeline = device.createRenderPipeline({
label: 'observatory-birth-render',
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: {
module,
entryPoint: 'fs_main',
targets: [
{
format: this.engine.sceneFormat,
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
const cameraBuffer = this.nodeRenderer.cameraUniformBuffer;
this.renderBindGroup = device.createBindGroup({
label: 'observatory-birth-render-bind',
layout: this.renderPipeline!.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
{ binding: 1, resource: { buffer: cameraBuffer! } },
{ binding: 2, resource: { buffer: this.particleBuffer! } }
]
});
}
private createHaloPipeline(device: GPUDevice): void {
// Flash halo: a glowing ring around the target node (B5).
const haloWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Camera {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<uniform> camera: Camera;
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
struct VSOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32
) -> VSOut {
var out: VSOut;
if (ii >= u32(params.node_count)) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
let node = nodes[ii];
let flags = u32(node.color_flags.w);
let is_target = (flags & 4u) != 0u; // flag 2: is birth target
if (!is_target) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
// Flash halo: only visible during ignition (frames 330359).
let frame = params.frame;
if (frame < 330.0 || frame > 359.0) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
// Halo ring: expands during flash, fades by frame 359.
let flashT = (frame - 330.0) / 29.0; // 0..1
let ringRadius = 0.3 + flashT * 0.5; // expands 0.3 → 0.8
// Quad centered on target position.
let pos = node.pos_radius.xyz;
let cornerX = (f32(vi) / 3.0 - 1.0); // -1, 0, 1 (3 unique x)
let cornerY = (f32(vi % 3) / 1.5 - 1.0); // -1, 0, 1
// We use 4 vertices for a simple quad (vi 0..3).
let cx = cornerX * ringRadius;
let cy = cornerY * ringRadius;
let world = pos
+ camera.right.xyz * cx
+ camera.up.xyz * cy;
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
// UV for radial fade.
out.uv = vec2<f32>(cx / ringRadius, cy / ringRadius);
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
let d = length(in.uv);
if (d > 0.7) {
discard;
}
// Flash: white-hot core, violet rim.
let flashIntensity = 1.0 - smoothstep(0.0, 0.7, d);
let color = vec3<f32>(0.7, 0.4, 1.0) * flashIntensity * 2.0;
// Fade out as flash ends.
let frame = params.frame;
let fadeOut = 1.0 - smoothstep(345.0, 359.0, frame);
return vec4<f32>(color * params.brightness * fadeOut, 1.0);
}
`;
const module = device.createShaderModule({
label: 'observatory-birth-halo',
code: haloWGSL
});
this.haloPipeline = device.createRenderPipeline({
label: 'observatory-birth-halo',
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: {
module,
entryPoint: 'fs_main',
targets: [
{
format: this.engine.sceneFormat,
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
const cameraBuffer = this.nodeRenderer.cameraUniformBuffer;
this.haloBindGroup = device.createBindGroup({
label: 'observatory-birth-halo-bind',
layout: this.haloPipeline!.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
{ binding: 1, resource: { buffer: cameraBuffer! } },
{ binding: 2, resource: { buffer: this.nodeRenderer.nodeStateBuffer! } }
]
});
}
private createEngravePipeline(device: GPUDevice): void {
// Reuse path-ribbon shader for edge engraving (B6).
// The birth engraving uses the same triangle-strip ribbon pattern
// but with different beat timing (starts at frame 360).
if (this.engraveStepCount === 0 || !this.engraveBuffer) return;
const pathModule = device.createShaderModule({
label: 'observatory-birth-engrave',
code: renderPathWGSL
});
this.engravePipeline = device.createRenderPipeline({
label: 'observatory-birth-engrave-pipeline',
layout: 'auto',
vertex: { module: pathModule, entryPoint: 'vs_main' },
fragment: {
module: pathModule,
entryPoint: 'fs_main',
targets: [
{
format: this.engine.sceneFormat,
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
this.engraveBindGroup = device.createBindGroup({
label: 'observatory-birth-engrave-bind',
layout: this.engravePipeline!.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
{ binding: 1, resource: { buffer: this.nodeRenderer.cameraUniformBuffer! } },
{ binding: 2, resource: { buffer: this.nodeRenderer.nodeStateBuffer! } },
{ binding: 3, resource: { buffer: this.engraveBuffer! } }
]
});
}
/** FramePass.compute — run birth particle simulation. */
compute(encoder: GPUCommandEncoder, frame: number): void {
// Only active when demoMode === 'engram-birth' (demo_id === 1).
const demoId = this.engine.params[9];
this.active = demoId === 1;
if (!this.active || !this.computePipeline || !this.computeBindGroup) return;
const pass = encoder.beginComputePass({ label: 'observatory-birth-compute' });
pass.setPipeline(this.computePipeline);
pass.setBindGroup(0, this.computeBindGroup);
pass.dispatchWorkgroups(Math.ceil(this.particleCount / 64));
pass.end();
}
/** FramePass.render — draw particles, flash halo, edge engraving. */
render(pass: GPURenderPassEncoder, frame: number): void {
if (!this.active) return;
// B4: Draw particle billboards (instanced additive).
if (this.renderPipeline && this.renderBindGroup && this.particleCount > 0) {
pass.setPipeline(this.renderPipeline);
pass.setBindGroup(0, this.renderBindGroup);
pass.draw(QUAD_VERTS, this.particleCount);
}
// B5: Draw flash halo (only during frames 330359).
if (this.haloPipeline && this.haloBindGroup && frame >= FLASH_START && frame <= FLASH_END) {
pass.setPipeline(this.haloPipeline);
pass.setBindGroup(0, this.haloBindGroup);
// Draw one halo quad per node (most will be degenerate).
pass.draw(4, this.nodeRenderer.nodeCountValue);
}
// B6: Draw edge engraving ribbons (starts at frame 360).
if (this.engravePipeline && this.engraveBindGroup && this.engraveStepCount > 0 && frame >= ENGRAVE_START) {
pass.setPipeline(this.engravePipeline);
pass.setBindGroup(0, this.engraveBindGroup);
pass.draw(6, this.engraveStepCount);
}
}
dispose(): void {
this.particleBuffer?.destroy();
this.particleBuffer = null;
// GPUComputePipeline.destroy() exists at runtime but older TS types omit it.
(this.computePipeline as any)?.destroy?.();
this.computePipeline = null;
this.computeBindGroup = null;
// GPURenderPipeline.destroy() exists at runtime but older TS types omit it.
(this.renderPipeline as any)?.destroy?.();
this.renderPipeline = null;
this.renderBindGroup = null;
(this.haloPipeline as any)?.destroy?.();
this.haloPipeline = null;
this.haloBindGroup = null;
this.haloIndexBuffer?.destroy();
this.haloIndexBuffer = null;
(this.engravePipeline as any)?.destroy?.();
this.engravePipeline = null;
this.engraveBindGroup = null;
this.engraveBuffer?.destroy();
this.engraveBuffer = null;
}
}

View file

@ -0,0 +1,139 @@
/**
* Cognitive Observatory minimal column-major mat4 camera math.
*
* Spec §6: no new dependencies small local helpers instead of three.js.
* All outputs are Float32Array(16) in WebGPU/WGSL column-major order.
*/
export type Mat4 = Float32Array;
/** Perspective projection (right-handed, depth 0..1 as WebGPU expects). */
export function perspective(fovYRad: number, aspect: number, near: number, far: number): Mat4 {
const f = 1 / Math.tan(fovYRad / 2);
const nf = 1 / (near - far);
// column-major
const m = new Float32Array(16);
m[0] = f / aspect;
m[5] = f;
m[10] = far * nf;
m[11] = -1;
m[14] = far * near * nf;
return m;
}
/** Right-handed lookAt view matrix. */
export function lookAt(
eye: [number, number, number],
target: [number, number, number],
up: [number, number, number]
): Mat4 {
const [ex, ey, ez] = eye;
let zx = ex - target[0];
let zy = ey - target[1];
let zz = ez - target[2];
let len = Math.hypot(zx, zy, zz) || 1;
zx /= len;
zy /= len;
zz /= len;
// x = up × z
let xx = up[1] * zz - up[2] * zy;
let xy = up[2] * zx - up[0] * zz;
let xz = up[0] * zy - up[1] * zx;
len = Math.hypot(xx, xy, xz) || 1;
xx /= len;
xy /= len;
xz /= len;
// y = z × x
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
const m = new Float32Array(16);
m[0] = xx;
m[1] = yx;
m[2] = zx;
m[4] = xy;
m[5] = yy;
m[6] = zy;
m[8] = xz;
m[9] = yz;
m[10] = zz;
m[12] = -(xx * ex + xy * ey + xz * ez);
m[13] = -(yx * ex + yy * ey + yz * ez);
m[14] = -(zx * ex + zy * ey + zz * ez);
m[15] = 1;
return m;
}
/** out = a × b (column-major). */
export function multiply(a: Mat4, b: Mat4): Mat4 {
const out = new Float32Array(16);
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
out[c * 4 + r] =
a[r] * b[c * 4] +
a[4 + r] * b[c * 4 + 1] +
a[8 + r] * b[c * 4 + 2] +
a[12 + r] * b[c * 4 + 3];
}
}
return out;
}
export interface OrbitCamera {
viewProj: Mat4;
/** world-space camera right vector (billboarding) */
right: [number, number, number];
/** world-space camera up vector (billboarding) */
up: [number, number, number];
eye: [number, number, number];
}
/**
* Deterministic slow orbit camera: angle driven by the loop phase (frames),
* never wall clock the same frame always yields the same view. Returns the
* view-projection plus the camera basis for GPU billboards.
*/
export function orbitCamera(
phase: number,
aspect: number,
distance: number,
elevation = 0.35
): OrbitCamera {
const angle = phase * Math.PI * 2;
const eye: [number, number, number] = [
Math.sin(angle) * distance,
distance * elevation,
Math.cos(angle) * distance
];
const proj = perspective((50 * Math.PI) / 180, aspect, 0.1, 4000);
const view = lookAt(eye, [0, 0, 0], [0, 1, 0]);
// camera basis: forward = normalize(target - eye), right = f × up, up = r × f
let fx = -eye[0],
fy = -eye[1],
fz = -eye[2];
let len = Math.hypot(fx, fy, fz) || 1;
fx /= len;
fy /= len;
fz /= len;
let rx = fy * 0 - fz * 1;
let ry = fz * 0 - fx * 0;
let rz = fx * 1 - fy * 0;
len = Math.hypot(rx, ry, rz) || 1;
rx /= len;
ry /= len;
rz /= len;
const ux = ry * fz - rz * fy;
const uy = rz * fx - rx * fz;
const uz = rx * fy - ry * fx;
return {
viewProj: multiply(proj, view),
right: [rx, ry, rz],
up: [ux, uy, uz],
eye
};
}

View file

@ -0,0 +1,137 @@
/**
* Deterministic demo clock for the Cognitive Observatory.
*
* Fixed 60fps loop, 720-frame period (12 seconds), seeded PRNG.
* No Math.random() or performance.now() for simulation state.
* performance.now() only schedules frames; positions/colors/path are deterministic.
*
* Pattern: https://gafferongames.com/post/fix_your_timestep/
*/
// ---- MurmurHash3-compatible 32-bit hash (xmur3) ----
// Used to hash a seed string into a 32-bit integer for mulberry32.
function xmur3(str: string): () => number {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 2654435761);
h = (h << 13) | (h >>> 19);
}
return function () {
let t = (h += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- Mulberry32 seeded PRNG ----
// Fast, deterministic, 32-bit. Good enough for demo visuals.
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- DemoClock ----
export interface DemoClockConfig {
/** Frames per second (fixed) */
fps?: number;
/** Frames per loop (default 720 = 12s at 60fps) */
loopFrames?: number;
/** Seed string for deterministic PRNG */
seed: string;
}
export interface DemoClockState {
/** Current frame (integer, wraps at loopFrames) */
frame: number;
/** Loop phase: 0..1 */
phase: number;
/** PRNG function seeded from the provided seed */
rng: () => number;
/** Total frames elapsed (monotonic, does not wrap) */
totalFrames: number;
}
export class DemoClock {
private readonly fps: number;
private readonly loopFrames: number;
private readonly seedStr: string;
private _frame: number;
private _totalFrames: number;
private _rng: () => number;
constructor(config: DemoClockConfig) {
this.fps = config.fps ?? 60;
this.loopFrames = config.loopFrames ?? 720;
this.seedStr = config.seed;
this._frame = 0;
this._totalFrames = 0;
// Hash the seed string into a 32-bit integer, then create a mulberry32 PRNG
const hash = xmur3(this.seedStr)();
this._rng = mulberry32(Math.floor(hash * 2 ** 32));
}
/** Advance the clock by one frame. Returns the new state. */
tick(): DemoClockState {
this._frame = (this._frame + 1) % this.loopFrames;
this._totalFrames++;
return this.state;
}
/** Get the current clock state without advancing. */
get state(): DemoClockState {
return {
frame: this._frame,
phase: this._frame / this.loopFrames,
rng: this._rng,
totalFrames: this._totalFrames
};
}
/** Reset the clock to frame 0. */
reset(): void {
this._frame = 0;
this._totalFrames = 0;
// Re-seed the PRNG from the original seed
const hash = xmur3(this.seedStr)();
this._rng = mulberry32(Math.floor(hash * 2 ** 32));
}
/** Get the loop duration in seconds. */
get loopDuration(): number {
return this.loopFrames / this.fps;
}
/** Frames per loop (capture mode needs this to freeze deterministically). */
get framesPerLoop(): number {
return this.loopFrames;
}
}
// ---- Utility: deterministic position on a golden-angle sphere ----
// Golden-angle placement is deterministic by index. The rng provides
// a small seed-based perturbation so different seeds produce different layouts.
export function deterministicSpherePosition(
index: number,
total: number,
radius: number,
rng: () => number
): [number, number, number] {
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const y = 1 - (index / (total - 1 || 1)) * 2; // -1 to 1
const radiusAtY = Math.sqrt(1 - y * y);
const theta = goldenAngle * index;
const x = Math.cos(theta) * radiusAtY;
const z = Math.sin(theta) * radiusAtY;
// Small seed-based perturbation (±5% of radius)
const px = (rng() - 0.5) * 0.1 * radius;
const py = (rng() - 0.5) * 0.1 * radius;
const pz = (rng() - 0.5) * 0.1 * radius;
return [x * radius + px, y * radius + py, z * radius + pz];
}

View file

@ -0,0 +1,389 @@
/**
* Cognitive Observatory WebGPU engine.
*
* Owns adapter/device/context lifecycle, the render loop, resize, and dispose.
* Increment 3 scope: boot WebGPU, clear to void #05060a, DPR-clamped resize,
* readable fallback when WebGPU is unavailable. Later increments register
* pipelines on top of this shell (spec §2, §3.4).
*
* Hard rules (spec §6):
* - No Math.random()/Date.now()/performance.now() deciding simulation state
* (the DemoClock is the only sim clock; rAF timestamps only schedule).
* - No GPU readback in the frame loop.
*/
import { DemoClock } from './demo-clock';
import { PARAMS_FLOATS, demoModeId, type DemoMode } from './types';
import { PostChain, SCENE_FORMAT } from './post/post-chain';
import { VOID_CLEAR_HDR } from './post/tone-reference';
/**
* Void background visual DNA §7. #05060a as the DISPLAY value (public API,
* pre-post-stack). The HDR scene pass clears to VOID_CLEAR_HDR
* (post/tone-reference.ts), whose tonemapped composite result is exactly
* this color.
*/
export const VOID_CLEAR: GPUColor = {
r: 0x05 / 255,
g: 0x06 / 255,
b: 0x0a / 255,
a: 1
};
export interface EngineOptions {
canvas: HTMLCanvasElement;
demo: DemoMode;
seed: string;
/** Max devicePixelRatio to render at (spec: DPR clamp). */
maxDpr?: number;
/** Called once per completed frame with the loop frame index (telemetry). */
onFrame?: (frame: number, fps: number) => void;
/**
* Capture mode (?frame=N): freeze the simulation at this exact loop frame.
* Same URL + frame identical pixels, for shareable stills (spec §4 Inc 9).
*/
freezeFrame?: number | null;
}
export type EngineStatus =
| { state: 'booting' }
| { state: 'running' }
| { state: 'unsupported'; reason: string }
| { state: 'error'; reason: string }
| { state: 'disposed' };
/**
* Frame hook contract for later increments: each registered pass gets the
* encoder + the main scene pass (offscreen HDR target) once per frame, after
* the clear. The post chain composites the scene to the swapchain afterwards.
*/
export interface FramePass {
/** Encode compute work (before the render pass). */
compute?(encoder: GPUCommandEncoder, frame: number): void;
/** Encode draw calls inside the main render pass. */
render?(pass: GPURenderPassEncoder, frame: number): void;
}
export class ObservatoryEngine {
private canvas: HTMLCanvasElement;
private device: GPUDevice | null = null;
private context: GPUCanvasContext | null = null;
private format: GPUTextureFormat = 'bgra8unorm';
private clock: DemoClock;
private demo: DemoMode;
private freezeFrame: number | null;
private rafId = 0;
private running = false;
private disposed = false;
private maxDpr: number;
private onFrame?: (frame: number, fps: number) => void;
/** Per-frame uniform data (layout: types.PARAMS_FLOATS). */
readonly params = new Float32Array(PARAMS_FLOATS);
paramsBuffer: GPUBuffer | null = null;
private passes: FramePass[] = [];
private post: PostChain | null = null;
private _status: EngineStatus = { state: 'booting' };
private statusListeners = new Set<(s: EngineStatus) => void>();
// fps estimate for telemetry only (never sim state)
private lastRafTs = 0;
private fpsEstimate = 0;
// Fixed-timestep accumulator (gafferongames.com/post/fix_your_timestep):
// wall clock ONLY schedules how many fixed 60Hz ticks to run — sim state
// stays a pure function of the frame index, so a 120Hz ProMotion display
// plays the same 12s loop as a 60Hz panel instead of double-speed.
private accumulatorMs = 0;
private static readonly FIXED_DT_MS = 1000 / 60;
constructor(opts: EngineOptions) {
this.canvas = opts.canvas;
this.demo = opts.demo;
this.maxDpr = opts.maxDpr ?? 2;
this.onFrame = opts.onFrame;
this.clock = new DemoClock({ seed: opts.seed });
this.freezeFrame =
typeof opts.freezeFrame === 'number' && Number.isFinite(opts.freezeFrame)
? ((Math.floor(opts.freezeFrame) % this.clock.framesPerLoop) +
this.clock.framesPerLoop) %
this.clock.framesPerLoop
: null;
this.params[8] = 1; // brightness default — the void must never eat the field
}
get status(): EngineStatus {
return this._status;
}
get gpuDevice(): GPUDevice | null {
return this.device;
}
get presentationFormat(): GPUTextureFormat {
return this.format;
}
/**
* Format every FramePass render pipeline targets: the offscreen HDR scene
* texture (post stack input), NOT the swapchain.
*/
get sceneFormat(): GPUTextureFormat {
return SCENE_FORMAT;
}
get demoClock(): DemoClock {
return this.clock;
}
onStatus(cb: (s: EngineStatus) => void): () => void {
this.statusListeners.add(cb);
cb(this._status);
return () => this.statusListeners.delete(cb);
}
private setStatus(s: EngineStatus) {
this._status = s;
for (const cb of this.statusListeners) cb(s);
}
/** Register a frame pass (later increments: sim, nodes, edges, path, post). */
addPass(pass: FramePass): void {
this.passes.push(pass);
}
/** Boot WebGPU. Resolves true when running, false when unsupported/error. */
async start(): Promise<boolean> {
if (this.disposed) return false;
const gpu = (navigator as Navigator & { gpu?: GPU }).gpu;
if (!gpu) {
this.setStatus({
state: 'unsupported',
reason: 'WebGPU is not available in this browser.'
});
return false;
}
let adapter: GPUAdapter | null = null;
try {
adapter = await gpu.requestAdapter();
} catch (e) {
this.setStatus({
state: 'error',
reason: e instanceof Error ? e.message : 'requestAdapter failed'
});
return false;
}
if (!adapter) {
this.setStatus({
state: 'unsupported',
reason: 'No suitable GPU adapter found.'
});
return false;
}
try {
this.device = await adapter.requestDevice();
} catch (e) {
this.setStatus({
state: 'error',
reason: e instanceof Error ? e.message : 'requestDevice failed'
});
return false;
}
if (this.disposed) {
// disposed while awaiting — release the device we just got
this.device?.destroy();
this.device = null;
return false;
}
this.device.lost.then((info) => {
if (this.disposed || info.reason === 'destroyed') return;
this.setStatus({ state: 'error', reason: `GPU device lost: ${info.message}` });
this.stopLoop();
});
// Surface shader/pipeline validation errors loudly — a silent black
// field is the worst failure mode an observatory can have.
this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => {
console.error('[observatory] WebGPU error:', ev.error.message);
};
const context = this.canvas.getContext('webgpu');
if (!context) {
this.setStatus({ state: 'error', reason: 'Could not get webgpu canvas context.' });
return false;
}
this.context = context;
this.format = gpu.getPreferredCanvasFormat();
this.configureContext();
// Per-frame uniform buffer (written by writeBuffer each frame; no readback).
this.paramsBuffer = this.device.createBuffer({
label: 'observatory-params',
size: this.params.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
// Post stack (S1S4): HDR scene → mip bloom → tonemap/grain/vignette.
// Sampler + explicit layouts + 4 pipelines build once here; textures
// are created on the first ensure() (resize or boot frame).
this.post = new PostChain(this.device, this.paramsBuffer, this.format);
this.setStatus({ state: 'running' });
this.running = true;
this.rafId = requestAnimationFrame(this.frame);
return true;
}
/** Resize the drawing buffer to the canvas' CSS size × clamped DPR. */
resize(): void {
if (!this.device || !this.context) return;
const dpr = Math.min(window.devicePixelRatio || 1, this.maxDpr);
const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
// context.configure picks up the new size on next getCurrentTexture
this.configureContext();
// HDR scene + bloom mip textures recreate on resize (idempotent).
this.post?.ensure(w, h);
}
}
private configureContext(): void {
if (!this.device || !this.context) return;
this.context.configure({
device: this.device,
format: this.format,
alphaMode: 'opaque'
});
}
private frame = (ts: number) => {
if (!this.running || !this.device || !this.context || !this.paramsBuffer || !this.post)
return;
// fps estimate — telemetry only, never sim state
let deltaMs = 0;
if (this.lastRafTs > 0) {
deltaMs = ts - this.lastRafTs;
if (deltaMs > 0) this.fpsEstimate = Math.round(1000 / deltaMs);
}
this.lastRafTs = ts;
// Fixed 60Hz timestep: advance the deterministic clock by however many
// whole ticks of wall time elapsed (clamped so a background tab doesn't
// fast-forward the story on return). The sequence of frames is identical
// on every display; only the scheduling reads the wall clock.
this.accumulatorMs += Math.min(deltaMs, 250);
let ticked = false;
while (this.accumulatorMs >= ObservatoryEngine.FIXED_DT_MS) {
this.clock.tick();
this.accumulatorMs -= ObservatoryEngine.FIXED_DT_MS;
ticked = true;
}
// First rAF (deltaMs 0) still renders frame 0.
void ticked;
// Capture mode (?frame=N) pins every derived value to one loop frame.
const state = this.clock.state;
const frame = this.freezeFrame ?? state.frame;
const phase = frame / this.clock.framesPerLoop;
// Per-frame params (layout must match WGSL Params; types.ts doc block).
// EVERYTHING derives from the wrapped loop frame — never totalFrames —
// so the 720-frame loop is periodic by construction: loop k is pixel-
// identical to loop 1, recordings never pop at the seam, and a
// ?frame=N still matches what a live viewer sees at that playhead
// position forever.
const p = this.params;
p[0] = frame;
p[1] = phase;
// p[2] nodeCount, p[3] edgeCount, p[4] pathCount — set by graph upload (Inc 4+)
// Breath: exactly 4 cycles per 720-frame loop (0.333 Hz ≈ spec §7.2's
// ~0.32 Hz) — integer cycles/loop is what makes the seam invisible.
p[5] = 0.5 + 0.5 * Math.sin(2 * Math.PI * 4 * phase);
p[6] = this.canvas.width;
p[7] = this.canvas.height;
// p[8] brightness — set by the canvas component
p[9] = demoModeId(this.demo);
p[10] = frame / 60; // fixed sim seconds within the loop (wraps with it)
// p[11] capture_mode — 1.0 when freezeFrame is active (capture mode).
// When 1.0, the compute shader skips physics integration so the
// storage-buffer state stays frozen at the initial upload values,
// making same URL + frame → identical pixels (spec §4 Inc 9).
p[11] = this.freezeFrame !== null ? 1.0 : 0.0;
this.device.queue.writeBuffer(this.paramsBuffer, 0, p);
let swapTex: GPUTexture;
try {
swapTex = this.context.getCurrentTexture();
} catch {
// canvas hidden/zero-sized this frame — try again next frame
this.rafId = requestAnimationFrame(this.frame);
return;
}
// No-op unless the size changed — covers the boot frame before any resize.
this.post.ensure(swapTex.width, swapTex.height);
const swapView = swapTex.createView();
const encoder = this.device.createCommandEncoder({ label: 'observatory-frame' });
// Passes receive the freeze-adjusted frame — same value as params.frame,
// so capture mode pins hook-driven sim work too, not just uniforms.
for (const pass of this.passes) pass.compute?.(encoder, frame);
const render = encoder.beginRenderPass({
label: 'observatory-main',
colorAttachments: [
{
view: this.post.sceneView,
clearValue: VOID_CLEAR_HDR,
loadOp: 'clear',
storeOp: 'store'
}
]
});
for (const pass of this.passes) pass.render?.(render, frame);
render.end();
// Post stack: bloom pyramid + composite (tonemap/grain/vignette) to the
// swapchain — same encoder, single submit, no readback.
this.post.encode(encoder, swapView);
this.device.queue.submit([encoder.finish()]);
this.onFrame?.(frame, this.fpsEstimate);
this.rafId = requestAnimationFrame(this.frame);
};
private stopLoop(): void {
this.running = false;
if (this.rafId !== 0) {
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.stopLoop();
this.paramsBuffer?.destroy();
this.paramsBuffer = null;
this.post?.dispose();
this.post = null;
this.device?.destroy();
this.device = null;
this.context = null;
this.passes = [];
this.setStatus({ state: 'disposed' });
this.statusListeners.clear();
}
}

View file

@ -0,0 +1,358 @@
/**
* Cognitive Observatory firewall demo plan (the immune response).
*
* Pure CPU: given the stable-indexed ObservatoryGraph + the demo seed,
* DETERMINISTICALLY pick the intruder (prefer failure/guardrail/confusion
* tags, else the lowest-retention leaf), precompute the per-node shockwave
* delays from the REAL layout (layoutPositions never reimplemented), the
* severed-edge steps, the spine beats and the verdict copy. No Math.random(),
* no Date.now().
*
* The 720-frame beat map (fixed 60Hz, seamless loop):
* 0-90 field at rest
* 90-150 INTRUSION the suspicious memory flares sickly green-white
* (demo.y flare band (0..1], 36 integer sine cycles/loop)
* 150-330 CRIMSON SHOCKWAVE a radial front expands from the intruder;
* per-node arrival A = 150 + delay, delay = round(144·dist/maxDist)
* (demo.w rim, amplitude fading with distance; all rims dead by 320)
* 330-480 QUARANTINE probe beams to the intruder's neighbors flare then
* die one by one (kind 2, bf = 345 + 21k) while the MEMBRANE forms
* (demo.y membrane band [2.6..2.9] the lane VALUE RANGE separates
* intrusion-flare (0..1] from membrane [2..3], one lane, two reads)
* 480-620 VERDICT overlay "threat quarantined" (RescueVerdict, tone
* quarantine, fadeWindow 480/495/605/620)
* 620-720 every lane decays to EXACTLY zero (all releases r1 680)
*
* `firewallEnvelopes` is the authoritative CPU mirror of
* shaders/firewall.wgsl.ts the seam-zero unit test machine-checks the loop
* guarantee against it.
*/
import { FLOATS_PER_NODE, PATH_KIND, type ObservatoryGraph } from './types';
import type { PathStepMeta } from './path-builder';
import { layoutPositions, truncateLabel } from './rescue-plan';
// ---------------------------------------------------------------------------
// Beat-map constants (shared with shaders/firewall.wgsl.ts — keep in lockstep)
// ---------------------------------------------------------------------------
export const INTRUSION_FRAME = 90;
export const SHOCK_START = 150;
/** The front always crosses the field in exactly SHOCK_SPAN frames (adaptive speed). */
export const SHOCK_SPAN = 144;
export const MEMBRANE_START = 330;
/** Sever step k lands at SEVER_BASE + k * SEVER_INTERVAL → 345..450. */
export const SEVER_BASE = 345;
export const SEVER_INTERVAL = 21;
export const MAX_SEVERED = 6;
export const FIREWALL_VERDICT_START = 480;
export const FIREWALL_VERDICT_END = 620;
export const LOOP_FRAMES = 720;
/** Tags that mark a memory as suspicious (case-insensitive). */
export const INTRUDER_TAGS: readonly string[] = ['failure', 'guardrail', 'confusion'];
const TAU = Math.PI * 2;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface FirewallVerdictCopy {
headline: 'threat quarantined';
intruderLabel: string;
receipt: 'memory held in review · Memory PR opened';
}
export interface FirewallPlan {
/** false ⇒ field renders at rest, story suppressed (no fake intruder). */
viable: boolean;
intruderIndex: number;
/** Unique intruder neighbors, ascending index, ≤ MAX_SEVERED. */
severedNeighborIndices: number[];
/** Per-node shock delay, round(SHOCK_SPAN·dist/maxDist), intruder 0. */
shockDelays: number[];
/**
* 1 u32/node: bits 0-7 shockDelay (0..144), bit 8 isIntruder,
* bit 9 isSeverNeighbor, bits 10-13 sever slot k.
*/
fireData: Uint32Array;
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
pathData: Uint32Array<ArrayBuffer>;
/** MUST be 1:1 with pathData steps (setPathSteps contract). */
pathMetas: PathStepMeta[];
/** Curated spine beats (route state only, NEVER sent to GPU). */
spineBeats: PathStepMeta[];
verdict: FirewallVerdictCopy;
}
// ---------------------------------------------------------------------------
// Selection (exported for tests; all ties → ascending node index)
// ---------------------------------------------------------------------------
/**
* Pick the intruder. Relaxation ladder, each tier ordered by
* (retention asc, index asc):
* 1. tagged (failure/guardrail/confusion) non-center unsuppressed
* 2. leaf (degree 1) non-center unsuppressed
* 3. any non-center unsuppressed
* 4. any non-center
* No candidate anywhere -1 (plan viable:false).
*/
export function pickIntruderIndex(graph: ObservatoryGraph): number {
const n = graph.nodes.length;
if (n === 0) return -1;
const degree = new Uint32Array(n);
for (const e of graph.edges) {
degree[e.sourceIndex]++;
degree[e.targetIndex]++;
}
const isTagged = (i: number): boolean =>
graph.nodes[i].tags.some((t) => INTRUDER_TAGS.includes(t.toLowerCase()));
const tiers: Array<(i: number) => boolean> = [
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && isTagged(i),
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && degree[i] <= 1,
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed,
(i) => i !== graph.centerIndex
];
for (const accept of tiers) {
let best = -1;
for (let i = 0; i < n; i++) {
if (!accept(i)) continue;
// strict < keeps the lowest index on retention ties (ascending scan)
if (best < 0 || graph.nodes[i].retention < graph.nodes[best].retention) best = i;
}
if (best >= 0) return best;
}
return -1;
}
/**
* Per-node shockwave delay from LAYOUT distance to the intruder:
* delay_i = round(SHOCK_SPAN · dist_i / maxDist), clamped [0, 255], intruder 0.
* maxDist is floored 1e-6 1.0 (co-located degenerate: everything fires at
* once safe, deterministic). Adaptive speed: the front always crosses the
* whole field in exactly SHOCK_SPAN frames.
*/
export function computeShockDelays(
positions: Float32Array,
nodeCount: number,
intruderIndex: number
): number[] {
const ix = positions[intruderIndex * FLOATS_PER_NODE + 0];
const iy = positions[intruderIndex * FLOATS_PER_NODE + 1];
const iz = positions[intruderIndex * FLOATS_PER_NODE + 2];
const dists = new Array<number>(nodeCount);
let maxDist = 0;
for (let i = 0; i < nodeCount; i++) {
const dx = positions[i * FLOATS_PER_NODE + 0] - ix;
const dy = positions[i * FLOATS_PER_NODE + 1] - iy;
const dz = positions[i * FLOATS_PER_NODE + 2] - iz;
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
dists[i] = d;
if (d > maxDist) maxDist = d;
}
if (maxDist < 1e-6) maxDist = 1.0;
const delays = new Array<number>(nodeCount);
for (let i = 0; i < nodeCount; i++) {
delays[i] = Math.min(255, Math.max(0, Math.round((SHOCK_SPAN * dists[i]) / maxDist)));
}
delays[intruderIndex] = 0;
return delays;
}
/**
* The severed edges: unique intruder neighbors (self-loops excluded also
* already dropped by buildObservatoryGraph and deduped), ascending index,
* capped at MAX_SEVERED. Edgeless intruder [] (still viable: the membrane
* forms around a memory with nothing to sever).
*/
export function pickSeveredNeighbors(graph: ObservatoryGraph, intruderIndex: number): number[] {
const nbrs = new Set<number>();
for (const e of graph.edges) {
if (e.sourceIndex === intruderIndex && e.targetIndex !== intruderIndex) {
nbrs.add(e.targetIndex);
}
if (e.targetIndex === intruderIndex && e.sourceIndex !== intruderIndex) {
nbrs.add(e.sourceIndex);
}
}
return Array.from(nbrs)
.sort((a, b) => a - b)
.slice(0, MAX_SEVERED);
}
export function severFrame(k: number): number {
return SEVER_BASE + SEVER_INTERVAL * k;
}
// ---------------------------------------------------------------------------
// Envelope math — the authoritative CPU mirror of shaders/firewall.wgsl.ts
// ---------------------------------------------------------------------------
function smooth(a: number, b: number, f: number): number {
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function env(f: number, a0: number, a1: number, r0: number, r1: number): number {
return smooth(a0, a1, f) * (1 - smooth(r0, r1, f));
}
/**
* Pure function of (frame, packed fire word) the four demo lanes
* (x ALWAYS 0, y intruder flare/membrane, z ALWAYS 0, w shock rim/sever blink).
* Every envelope has attack a0 90 and release r1 680 exactly 0 at
* frames 0 and 719 the machine-checked seam guarantee. Sines are factors on
* zero-at-seam envelopes and run INTEGER cycles per loop (36 flare, 12
* membrane). demo.y value ranges: intrusion flare (0..1], membrane
* [2.60..2.90] the fragment shader separates the two reads by range.
* Keep in lockstep with firewall_choreo in shaders/firewall.wgsl.ts.
*/
export function firewallEnvelopes(
frame: number,
packed: number
): { x: number; y: number; z: number; w: number } {
const delay = packed & 0xff;
const isIntruder = (packed & 0x100) !== 0;
const isSever = (packed & 0x200) !== 0;
const k = (packed >>> 10) & 0xf;
const loopPhase = frame / LOOP_FRAMES;
let y = 0;
let w = 0;
if (isIntruder) {
// Intrusion flare: sickly strobe, band (0..1]. C¹ handoff into the
// membrane over 330-332 — the rise sweeps the flare band exactly once
// (the condensation read is intentional).
y = env(frame, 90, 96, 310, 332) * (0.55 + 0.45 * Math.sin(TAU * 36 * loopPhase));
// Membrane: sustained ring band [2.60..2.90], slow shimmer.
y += env(frame, 330, 352, 620, 680) * (2.75 + 0.15 * Math.sin(TAU * 12 * loopPhase));
// Source detonation as the front leaves.
w = env(frame, 148, 153, 162, 196);
} else {
const a = SHOCK_START + delay;
const amp = 0.9 - 0.45 * (delay / SHOCK_SPAN);
// Crimson rim as the front passes; A ∈ [150, 294] ⇒ dead by 320 < 330.
w = amp * env(frame, a - 2, a + 3, a + 8, a + 26);
if (isSever) {
// Node-side receipt of the severed edge; last release 450+24 = 474.
const sk = severFrame(k);
w += 0.6 * env(frame, sk - 4, sk, sk + 6, sk + 24);
}
}
// x and z are hard 0.0: the recall/thin-film and horizon grammars can
// never fire in demo 4 (enforced by the lane-hygiene test).
return { x: 0, y, z: 0, w };
}
// ---------------------------------------------------------------------------
// Plan builder
// ---------------------------------------------------------------------------
const UINTS_PER_STEP = 4;
function emptyPlan(nodeCount: number): FirewallPlan {
return {
viable: false,
intruderIndex: -1,
severedNeighborIndices: [],
shockDelays: [],
fireData: new Uint32Array(nodeCount),
pathData: new Uint32Array(4),
pathMetas: [],
spineBeats: [],
verdict: {
headline: 'threat quarantined',
intruderLabel: '',
receipt: 'memory held in review · Memory PR opened'
}
};
}
/**
* Build the full deterministic firewall plan. Same graph + seed identical
* plan (byte-identical typed arrays). Empty/center-only graphs survive with
* viable:false the field breathes, nothing pretends to be a threat.
*/
export function buildFirewallPlan(graph: ObservatoryGraph, seed: string): FirewallPlan {
const n = graph.nodes.length;
const intruderIndex = pickIntruderIndex(graph);
if (n === 0 || intruderIndex < 0) return emptyPlan(n);
// Shock delays come from the REAL layout (rescue-plan.layoutPositions —
// byte-identical to NodeRenderer.upload, never reimplemented).
const positions = layoutPositions(graph, seed);
const shockDelays = computeShockDelays(positions, n, intruderIndex);
const severed = pickSeveredNeighbors(graph, intruderIndex);
// --- fireData packing (1 u32/node; every node carries its shock delay) ---
const fireData = new Uint32Array(n);
for (let i = 0; i < n; i++) fireData[i] = shockDelays[i] & 0xff;
fireData[intruderIndex] = 0x100; // intruder: delay forced 0
severed.forEach((idx, k) => {
fireData[idx] |= 0x200 | (k << 10);
});
// --- PathStep emission: probe beams (kind 2) intruder → neighbor, one per
// severed edge — they flare then die (the visible severing).
// Window invariant: bf46 ≥ 0 and bf+90 ≤ 719 for bf ∈ [345, 450].
const pathData = new Uint32Array(Math.max(1, severed.length) * UINTS_PER_STEP);
const pathMetas: PathStepMeta[] = [];
severed.forEach((idx, k) => {
const bf = severFrame(k);
pathData[k * UINTS_PER_STEP + 0] = intruderIndex;
pathData[k * UINTS_PER_STEP + 1] = idx;
pathData[k * UINTS_PER_STEP + 2] = bf;
pathData[k * UINTS_PER_STEP + 3] = PATH_KIND.probe;
pathMetas.push({
sourceIndex: intruderIndex,
targetIndex: idx,
beatFrame: bf,
kind: PATH_KIND.probe,
beatKind: 'sever',
nodeId: graph.nodes[idx].id,
label: truncateLabel(graph.nodes[idx].label)
});
});
// --- Curated spine beats (unique, strictly increasing beatFrames) ---
const intruderLabel = truncateLabel(graph.nodes[intruderIndex].label);
const spineBeats: PathStepMeta[] = [];
const spine = (beatFrame: number, label: string, nodeId: string) => {
spineBeats.push({
sourceIndex: intruderIndex,
targetIndex: intruderIndex,
beatFrame,
kind: 1,
beatKind: 'firewall',
nodeId,
label
});
};
spine(INTRUSION_FRAME, `intrusion · ${intruderLabel}`, graph.nodes[intruderIndex].id);
spine(SHOCK_START, 'immune response · shockwave', 'firewall-shock');
spine(MEMBRANE_START, 'membrane forming', 'firewall-membrane');
severed.forEach((idx, k) => {
spine(severFrame(k), `edge severed ✗ · ${truncateLabel(graph.nodes[idx].label)}`, graph.nodes[idx].id);
});
spine(FIREWALL_VERDICT_START, 'threat quarantined', 'firewall-verdict');
const verdict: FirewallVerdictCopy = {
headline: 'threat quarantined',
intruderLabel,
receipt: 'memory held in review · Memory PR opened'
};
return {
viable: true,
intruderIndex,
severedNeighborIndices: severed,
shockDelays,
fireData,
pathData,
pathMetas,
spineBeats,
verdict
};
}

View file

@ -0,0 +1,114 @@
/**
* Cognitive Observatory firewall choreography pass.
*
* Compute-only FramePass (no render(): nodes and probe beams draw via the
* NodeRenderer's existing pipelines). Uploads the per-node packed fire word
* once (static), then each frame extends the choreography INTO the NodeState
* demo lanes as a pure function of (frame, role, shockDelay) no stateful
* integration, so capture mode (?frame=N) works with zero special-casing.
*
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
* the upload $effect creates FirewallRenderer) so firewall_choreo encodes
* AFTER recall_sim in the same encoder and its demo-lane overwrite wins.
* recall_sim rewrites demo.x every frame with an afterglow window of
* bf+40..bf+200 the k=5 sever beam at bf=450 would otherwise carry residual
* ignition into the verdict window.
*
* Three independent walls keep OTHER demos pixel-identical:
* (a) the route constructs this renderer only in the firewall branch,
* (b) compute() gates on params[9] === 4 ('firewall' demo index),
* (c) the demo-4 vertex/fragment terms in render-nodes.wgsl are themselves
* gated on params.demo_id == 4.0.
*/
import type { ObservatoryEngine, FramePass } from './engine';
import type { NodeRenderer } from './node-renderer';
import type { FirewallPlan } from './firewall-plan';
import { firewallWGSL } from './shaders/firewall.wgsl';
/** DEMO_MODES.indexOf('firewall') — types.ts, verified index 4. */
const FIREWALL_DEMO_ID = 4;
export interface FirewallRendererOptions {
engine: ObservatoryEngine;
nodeRenderer: NodeRenderer;
plan: FirewallPlan;
}
export class FirewallRenderer implements FramePass {
private engine: ObservatoryEngine;
private nodeRenderer: NodeRenderer;
private plan: FirewallPlan;
private pipeline: GPUComputePipeline | null = null;
private bindGroup: GPUBindGroup | null = null;
private fireBuffer: GPUBuffer | null = null;
constructor(opts: FirewallRendererOptions) {
this.engine = opts.engine;
this.nodeRenderer = opts.nodeRenderer;
this.plan = opts.plan;
this.engine.addPass(this);
}
/** Create the fire buffer + compute pipeline. Call after NodeRenderer.upload(). */
upload(): void {
const device = this.engine.gpuDevice;
if (!device || !this.engine.paramsBuffer) return;
if (!this.plan.viable) return;
if (!this.nodeRenderer.nodeStateBuffer || this.nodeRenderer.nodeCountValue === 0) return;
this.fireBuffer?.destroy();
this.fireBuffer = device.createBuffer({
label: 'observatory-firewall-fire',
size: Math.max(4, this.plan.fireData.byteLength),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.fireBuffer, 0, this.plan.fireData.buffer as ArrayBuffer);
const module = device.createShaderModule({
label: 'observatory-firewall-choreo',
code: firewallWGSL
});
this.pipeline = device.createComputePipeline({
label: 'observatory-firewall-choreo',
layout: 'auto',
compute: { module, entryPoint: 'firewall_choreo' }
});
// EXACTLY the 3 declared bindings — auto layout strips unused bindings
// and binding anything extra invalidates the group (the BirthRenderer
// lesson, birth-renderer.ts createComputePipeline).
this.bindGroup = device.createBindGroup({
label: 'observatory-firewall-bind',
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.nodeRenderer.nodeStateBuffer } },
{ binding: 2, resource: { buffer: this.fireBuffer } }
]
});
}
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
compute(encoder: GPUCommandEncoder): void {
if (this.engine.params[9] !== FIREWALL_DEMO_ID) return;
if (!this.pipeline || !this.bindGroup) return;
const n = this.nodeRenderer.nodeCountValue;
if (n === 0) return;
const pass = encoder.beginComputePass({ label: 'observatory-firewall-choreo' });
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.dispatchWorkgroups(Math.ceil(n / 64));
pass.end();
}
dispose(): void {
this.fireBuffer?.destroy();
this.fireBuffer = null;
this.pipeline = null;
this.bindGroup = null;
}
}

View file

@ -0,0 +1,307 @@
/**
* Cognitive Observatory forgetting-horizon demo plan (FSRS as a living system).
*
* Pure CPU: given the stable-indexed ObservatoryGraph, DETERMINISTICALLY pick
* the drifting set (the lowest-retention ~25% of memories), the 3 rescued
* memories (mid-retention, well-connected the ones a recall would plausibly
* save), the packed per-node role words, the recall PathSteps and the spine
* beats. No Math.random(), no Date.now(), no layout needed selection is a
* pure function of the graph.
*
* The 720-frame beat map (fixed 60Hz, seamless loop):
* 0-90 field at rest
* 90-420 THE DRIFT drifting memories dim and fall toward the horizon
* (demo.z rises to the 0.55 plateau, staggered by retention rank)
* 300-480 THE RESCUES 3 fading memories are recalled one by one (recall
* ribbons land at 318/378/438; demo.z snaps back, demo.x ignites)
* 480-660 the unrescued sink to near-black (demo.z exactly 1.0 by 640;
* the fragment floor keeps them at ~6% never gone, always
* retrievable)
* 660-720 master release every lane decays to EXACTLY zero by frame 712
*
* `forgettingEnvelopes` and `horizonDrift` are the authoritative CPU mirrors
* of shaders/forgetting.wgsl.ts and the demo-3 branch of render-nodes.wgsl.ts;
* the seam-zero unit test machine-checks the loop guarantee against them.
*/
import { PATH_KIND, type ObservatoryGraph } from './types';
import type { PathStepMeta } from './path-builder';
import { truncateLabel } from './rescue-plan';
// ---------------------------------------------------------------------------
// Beat-map constants (shared with shaders/forgetting.wgsl.ts — keep in lockstep)
// ---------------------------------------------------------------------------
export const DRIFT_ONSET_BASE = 90;
export const DRIFT_ONSET_SPREAD = 42;
export const DRIFT_ENGULF = 210;
export const PHASE1_LEVEL = 0.55;
export const PHASE2_LEVEL = 0.45;
export const PHASE2_BASE = 480;
export const PHASE2_STAGGER = 24;
export const PHASE2_END = 640;
/** Rescue k ribbon lands at RESCUE_BASE + k * RESCUE_INTERVAL → 318/378/438. */
export const RESCUE_BASE = 318;
export const RESCUE_INTERVAL = 60;
export const MASTER_R0 = 660;
export const MASTER_R1 = 712;
export const LOOP_FRAMES = 720;
export const FORGETTING_K = 3;
/** Fading spine beat k lands at FADING_BASE + k * FADING_INTERVAL → 132/192/252. */
export const FADING_BASE = 132;
export const FADING_INTERVAL = 60;
export const SINK_BEAT_FRAME = 540;
export const RETRIEVABLE_BEAT_FRAME = 660;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ForgettingPlan {
/** false ⇒ field renders at rest, story suppressed (no fake drift). */
viable: boolean;
/** Drifting node indices, retention asc (rank order). */
driftingIndices: number[];
/** Rescued node indices, slot order k = 0..K-1. Always ⊆ driftingIndices. */
rescuedIndices: number[];
/**
* 1 u32/node: bits 0-7 rank (0..255 across the drifting set),
* bit 8 isDrifting, bit 9 isRescued, bits 10-11 rescue slot k.
* Non-drifting nodes (incl. the center) are exactly 0.
*/
horizonData: Uint32Array;
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
pathData: Uint32Array<ArrayBuffer>;
/** MUST be 1:1 with pathData steps (setPathSteps contract). */
pathMetas: PathStepMeta[];
/** Curated spine beats (route state only, NEVER sent to GPU). */
spineBeats: PathStepMeta[];
}
// ---------------------------------------------------------------------------
// Selection (exported for tests; all ties → ascending node index)
// ---------------------------------------------------------------------------
/**
* The drifting set: exclude the center, sort (retention asc, index asc), take
* driftCount = min(n1, max(min(3, n1), round(0.25·(n1)))). Suppressed
* memories are eligible suppression is not forgetting.
*/
export function pickDrifting(graph: ObservatoryGraph): number[] {
const cand: number[] = [];
for (let i = 0; i < graph.nodes.length; i++) {
if (i === graph.centerIndex) continue;
cand.push(i);
}
cand.sort((a, b) => graph.nodes[a].retention - graph.nodes[b].retention || a - b);
const eligible = cand.length;
if (eligible === 0) return [];
const driftCount = Math.min(
eligible,
Math.max(Math.min(FORGETTING_K, eligible), Math.round(0.25 * eligible))
);
return cand.slice(0, driftCount);
}
/**
* The rescued: within the drifting set, the K = min(3, driftCount) most
* plausible recall targets score = 2·retention + min(degree, 8)/8
* (mid-retention, well-connected), sort (score desc, index asc).
*/
export function pickRescued(graph: ObservatoryGraph, drifting: number[]): number[] {
const degree = new Uint32Array(graph.nodes.length);
for (const e of graph.edges) {
degree[e.sourceIndex]++;
degree[e.targetIndex]++;
}
const score = (i: number): number =>
2 * graph.nodes[i].retention + Math.min(degree[i], 8) / 8;
const sorted = drifting.slice().sort((a, b) => score(b) - score(a) || a - b);
return sorted.slice(0, Math.min(FORGETTING_K, drifting.length));
}
export function rescueFrame(k: number): number {
return RESCUE_BASE + RESCUE_INTERVAL * k;
}
// ---------------------------------------------------------------------------
// Envelope math — the authoritative CPU mirror of shaders/forgetting.wgsl.ts
// ---------------------------------------------------------------------------
function smooth(a: number, b: number, f: number): number {
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function env(f: number, a0: number, a1: number, r0: number, r1: number): number {
return smooth(a0, a1, f) * (1 - smooth(r0, r1, f));
}
/**
* Pure function of (frame, packed horizon word) the four demo lanes
* (x rescue ignition, y ALWAYS 0, z horizon fade-and-fall, w ALWAYS 0).
* Every term has attack a0 90 ( exactly 0 at frame 0) and is multiplied by
* the master release 1smoothstep(660, 712, f) ( exactly 0 at frame 719)
* the machine-checked seam guarantee. No sines anywhere in this moment.
* Keep in lockstep with forgetting_choreo in shaders/forgetting.wgsl.ts.
*/
export function forgettingEnvelopes(
frame: number,
packed: number
): { x: number; y: number; z: number; w: number } {
const isDrifting = (packed & 0x100) !== 0;
if (!isDrifting) return { x: 0, y: 0, z: 0, w: 0 };
const rank01 = (packed & 0xff) / 255;
const isRescued = (packed & 0x200) !== 0;
const k = (packed >>> 10) & 0x3;
const onset = DRIFT_ONSET_BASE + DRIFT_ONSET_SPREAD * rank01;
const master = 1 - smooth(MASTER_R0, MASTER_R1, frame);
const phase1 = PHASE1_LEVEL * smooth(onset, onset + DRIFT_ENGULF, frame);
let x = 0;
let z = 0;
if (isRescued) {
const rk = rescueFrame(k);
// Snap-back starts 22 frames before the recall ribbon lands at rk —
// the memory visibly rises to meet the call; exactly 0 from rk+6.
z = master * phase1 * (1 - smooth(rk - 22, rk + 6, frame));
// Ignition rides the EXISTING recall response (thin-film + white core
// + 0.9·recall swell in render-nodes.wgsl) for free; released ≤ rk+130.
x = master * env(frame, rk - 26, rk, rk + 60, rk + 130);
} else {
const phase2 =
PHASE2_LEVEL * smooth(PHASE2_BASE + PHASE2_STAGGER * rank01, PHASE2_END, frame);
// Plateau 0.55 by ≤342, then sink to exactly 1.0 over 640..660.
z = master * (phase1 + phase2);
}
return { x, y: 0, z, w: 0 };
}
/**
* CPU mirror of the demo-3 VERTEX displacement in render-nodes.wgsl.ts.
* Visual-only, world-space: down (34·dz) and away from the field axis
* (+22·dz radially in the xz plane) |drift| 40.5 units at dz = 1.
* pos_radius is NEVER written the force sim owns positions.
*/
export function horizonDrift(
dz: number,
p: [number, number, number]
): [number, number, number] {
if (dz <= 0) return [0, 0, 0];
const dzc = Math.min(1, Math.max(0, dz));
const rXz = Math.max(Math.hypot(p[0], p[2]), 0.001);
const ax = p[0] / rXz;
const az = p[2] / rXz;
return [dzc * ax * 22, dzc * -34, dzc * az * 22];
}
// ---------------------------------------------------------------------------
// Plan builder
// ---------------------------------------------------------------------------
const UINTS_PER_STEP = 4;
function emptyPlan(nodeCount: number): ForgettingPlan {
return {
viable: false,
driftingIndices: [],
rescuedIndices: [],
horizonData: new Uint32Array(nodeCount),
pathData: new Uint32Array(4),
pathMetas: [],
spineBeats: []
};
}
/**
* Build the full deterministic forgetting-horizon plan. Same graph
* identical plan (byte-identical typed arrays). Empty/1-node graphs survive
* with viable:false the field breathes, nothing pretends to be forgotten.
*/
export function buildForgettingPlan(graph: ObservatoryGraph): ForgettingPlan {
const n = graph.nodes.length;
const drifting = pickDrifting(graph);
if (n < 2 || drifting.length < 1) return emptyPlan(n);
const rescued = pickRescued(graph, drifting);
const driftCount = drifting.length;
// --- horizonData packing (1 u32/node; non-drifting stays exactly 0) ---
const horizonData = new Uint32Array(n);
drifting.forEach((idx, i) => {
const rank = Math.round((255 * i) / Math.max(1, driftCount - 1));
horizonData[idx] = (rank & 0xff) | 0x100;
});
rescued.forEach((idx, k) => {
horizonData[idx] |= 0x200 | (k << 10);
});
// --- PathStep emission: K recall ribbons, center → rescued_k ---
// Window invariant: bf46 ≥ 0 and bf+90 ≤ 719 for bf ∈ {318, 378, 438}.
const pathData = new Uint32Array(Math.max(1, rescued.length) * UINTS_PER_STEP);
const pathMetas: PathStepMeta[] = [];
rescued.forEach((idx, k) => {
const bf = rescueFrame(k);
pathData[k * UINTS_PER_STEP + 0] = graph.centerIndex;
pathData[k * UINTS_PER_STEP + 1] = idx;
pathData[k * UINTS_PER_STEP + 2] = bf;
pathData[k * UINTS_PER_STEP + 3] = PATH_KIND.recall;
pathMetas.push({
sourceIndex: graph.centerIndex,
targetIndex: idx,
beatFrame: bf,
kind: PATH_KIND.recall,
beatKind: 'recall',
nodeId: graph.nodes[idx].id,
label: truncateLabel(graph.nodes[idx].label)
});
});
// --- Curated spine beats (unique, strictly increasing beatFrames) ---
const spineBeats: PathStepMeta[] = [];
const spine = (beatFrame: number, kind: number, label: string, nodeId: string) => {
spineBeats.push({
sourceIndex: graph.centerIndex,
targetIndex: graph.centerIndex,
beatFrame,
kind,
beatKind: 'horizon',
nodeId,
label
});
};
// ≤3 lowest-retention NON-rescued drifting memories narrate the fade
// (drifting is already retention-asc; emit only beats whose subject exists).
const rescuedSet = new Set(rescued);
const fading = drifting.filter((i) => !rescuedSet.has(i)).slice(0, 3);
fading.forEach((idx, j) => {
const pct = Math.round(graph.nodes[idx].retention * 100);
spine(
FADING_BASE + FADING_INTERVAL * j,
1,
`fading: ${truncateLabel(graph.nodes[idx].label)} · retention ${pct}%`,
graph.nodes[idx].id
);
});
rescued.forEach((idx, k) => {
spine(rescueFrame(k), 0, `recalled: ${truncateLabel(graph.nodes[idx].label)}`, graph.nodes[idx].id);
});
if (fading.length > 0) {
spine(SINK_BEAT_FRAME, 1, 'the unrecalled sink · nothing is deleted', 'horizon-sink');
}
spine(RETRIEVABLE_BEAT_FRAME, 0, 'every memory still retrievable', 'horizon-retrievable');
return {
viable: true,
driftingIndices: drifting,
rescuedIndices: rescued,
horizonData,
pathData,
pathMetas,
spineBeats
};
}

View file

@ -0,0 +1,114 @@
/**
* Cognitive Observatory forgetting-horizon choreography pass.
*
* Compute-only FramePass (no render(): nodes and ribbons draw via the
* NodeRenderer's existing pipelines). Uploads the per-node packed horizon word
* once (static), then each frame extends the choreography INTO the NodeState
* demo lanes as a pure function of (frame, role, rank) no stateful
* integration, so capture mode (?frame=N) works with zero special-casing.
*
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
* the upload $effect creates ForgettingRenderer) so forgetting_choreo encodes
* AFTER recall_sim in the same encoder and its demo-lane overwrite wins.
* recall_sim rewrites demo.x every frame with an afterglow window of
* bf+40..bf+200 the k=2 rescue ribbon at bf=438 would otherwise carry
* residual ignition into the master release.
*
* Three independent walls keep OTHER demos pixel-identical:
* (a) the route constructs this renderer only in the forgetting-horizon branch,
* (b) compute() gates on params[9] === 3 ('forgetting-horizon' demo index),
* (c) the demo-3 vertex/fragment terms in render-nodes.wgsl are themselves
* gated on params.demo_id == 3.0.
*/
import type { ObservatoryEngine, FramePass } from './engine';
import type { NodeRenderer } from './node-renderer';
import type { ForgettingPlan } from './forgetting-plan';
import { forgettingWGSL } from './shaders/forgetting.wgsl';
/** DEMO_MODES.indexOf('forgetting-horizon') — types.ts, verified index 3. */
const HORIZON_DEMO_ID = 3;
export interface ForgettingRendererOptions {
engine: ObservatoryEngine;
nodeRenderer: NodeRenderer;
plan: ForgettingPlan;
}
export class ForgettingRenderer implements FramePass {
private engine: ObservatoryEngine;
private nodeRenderer: NodeRenderer;
private plan: ForgettingPlan;
private pipeline: GPUComputePipeline | null = null;
private bindGroup: GPUBindGroup | null = null;
private horizonBuffer: GPUBuffer | null = null;
constructor(opts: ForgettingRendererOptions) {
this.engine = opts.engine;
this.nodeRenderer = opts.nodeRenderer;
this.plan = opts.plan;
this.engine.addPass(this);
}
/** Create the horizon buffer + compute pipeline. Call after NodeRenderer.upload(). */
upload(): void {
const device = this.engine.gpuDevice;
if (!device || !this.engine.paramsBuffer) return;
if (!this.plan.viable) return;
if (!this.nodeRenderer.nodeStateBuffer || this.nodeRenderer.nodeCountValue === 0) return;
this.horizonBuffer?.destroy();
this.horizonBuffer = device.createBuffer({
label: 'observatory-forgetting-horizon',
size: Math.max(4, this.plan.horizonData.byteLength),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.horizonBuffer, 0, this.plan.horizonData.buffer as ArrayBuffer);
const module = device.createShaderModule({
label: 'observatory-forgetting-choreo',
code: forgettingWGSL
});
this.pipeline = device.createComputePipeline({
label: 'observatory-forgetting-choreo',
layout: 'auto',
compute: { module, entryPoint: 'forgetting_choreo' }
});
// EXACTLY the 3 declared bindings — auto layout strips unused bindings
// and binding anything extra invalidates the group (the BirthRenderer
// lesson, birth-renderer.ts createComputePipeline).
this.bindGroup = device.createBindGroup({
label: 'observatory-forgetting-bind',
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.nodeRenderer.nodeStateBuffer } },
{ binding: 2, resource: { buffer: this.horizonBuffer } }
]
});
}
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
compute(encoder: GPUCommandEncoder): void {
if (this.engine.params[9] !== HORIZON_DEMO_ID) return;
if (!this.pipeline || !this.bindGroup) return;
const n = this.nodeRenderer.nodeCountValue;
if (n === 0) return;
const pass = encoder.beginComputePass({ label: 'observatory-forgetting-choreo' });
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.dispatchWorkgroups(Math.ceil(n / 64));
pass.end();
}
dispose(): void {
this.horizonBuffer?.destroy();
this.horizonBuffer = null;
this.pipeline = null;
this.bindGroup = null;
}
}

View file

@ -0,0 +1,143 @@
/**
* Cognitive Observatory graph GPU upload preparation.
*
* Converts the API GraphResponse into stable-indexed typed arrays matching the
* NodeState buffer layout (types.ts). Pure functions, fully unit-testable.
*
* Determinism contract (spec §6): node ordering is stable (sorted by id, center
* first), positions come from deterministicSpherePosition seeded by the
* DemoClock PRNG same seed identical layout, different seed different.
*
* Visual DNA §7.1 meaning layer: base color = FSRS state color from the real
* dashboard palette (lib/graph/nodes.ts, used verbatim). Aha/failure/confusion
* tags override, exactly like the Graph3D 'ahagraph' mode.
*/
import type { GraphResponse } from '$types';
import { getMemoryState, getAhaGraphColor, MEMORY_STATE_COLORS } from '$lib/graph/nodes';
import {
FLOATS_PER_NODE,
NODE_LANE,
NODE_FLAG,
UINTS_PER_EDGE,
toObservatoryNode,
type ObservatoryGraph,
type ObservatoryNode,
type ObservatoryEdge
} from './types';
import { deterministicSpherePosition } from './demo-clock';
/** Parse '#rrggbb' to 0..1 rgb. Falls back to slate on malformed input. */
export function hexToRgb01(hex: string): [number, number, number] {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return [0x6b / 255, 0x72 / 255, 0x80 / 255]; // unavailable slate
const v = parseInt(m[1], 16);
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}
/**
* Meaning-layer base color for a node (visual DNA §7.1).
* Tag kinds override via the REAL Graph3D mapping (getAhaGraphColor case-
* insensitive, aha gold confusion red failure/guardrail grey), else the
* FSRS state palette. Reused verbatim so Observatory and Graph3D never drift.
*/
export function nodeBaseColor(node: ObservatoryNode): [number, number, number] {
const tagColor = getAhaGraphColor({ tags: node.tags });
if (tagColor) return hexToRgb01(tagColor);
return hexToRgb01(MEMORY_STATE_COLORS[getMemoryState(node.retention)]);
}
/**
* Build the stable-indexed observatory graph.
* Ordering: center node first (index 0), then remaining nodes sorted by id
* deterministic regardless of API response order.
*/
export function buildObservatoryGraph(response: GraphResponse): ObservatoryGraph {
const sorted = [...response.nodes].sort((a, b) => {
if (a.isCenter !== b.isCenter) return a.isCenter ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const nodes: ObservatoryNode[] = sorted.map((n, i) => toObservatoryNode(n, i));
const indexById = new Map<string, number>();
for (const n of nodes) indexById.set(n.id, n.index);
const edges: ObservatoryEdge[] = [];
for (const e of response.edges) {
const s = indexById.get(e.source);
const t = indexById.get(e.target);
if (s === undefined || t === undefined || s === t) continue;
edges.push({ sourceIndex: s, targetIndex: t, weight: e.weight, type: e.type });
}
const centerIndex = nodes.findIndex((n) => n.isCenter);
return { nodes, edges, indexById, centerIndex: centerIndex < 0 ? 0 : centerIndex };
}
export interface NodeStateBuild {
/** FLOATS_PER_NODE floats per node, ready for the storage buffer. */
data: Float32Array<ArrayBuffer>;
nodeCount: number;
}
/**
* Fill the NodeState array. `rng` must come from a fresh DemoClock seeded with
* the demo seed so the layout is reproducible.
*/
export function buildNodeStateArray(
graph: ObservatoryGraph,
rng: () => number,
fieldRadius = 120
): NodeStateBuild {
const n = graph.nodes.length;
const data = new Float32Array(n * FLOATS_PER_NODE);
for (let i = 0; i < n; i++) {
const node = graph.nodes[i];
const base = i * FLOATS_PER_NODE;
// lane 0: position + visual radius
const [x, y, z] =
node.isCenter && graph.centerIndex === i
? [0, 0, 0] // the center memory anchors the field
: deterministicSpherePosition(i, n, fieldRadius, rng);
// radius: retention-weighted, center node reads largest (meaning layer)
const radius = node.isCenter ? 4.2 : 1.4 + node.retention * 1.8;
data[base + NODE_LANE.posRadius + 0] = x;
data[base + NODE_LANE.posRadius + 1] = y;
data[base + NODE_LANE.posRadius + 2] = z;
data[base + NODE_LANE.posRadius + 3] = radius;
// lane 1: velocity (rest) + retention
data[base + NODE_LANE.velRetention + 3] = node.retention;
// lane 2: base color + packed flags
const [r, g, b] = nodeBaseColor(node);
let flags = 0;
if (node.isCenter) flags |= NODE_FLAG.isCenter;
if (node.suppressed) flags |= NODE_FLAG.suppressed;
// case-insensitive, mirroring getAhaGraphColor's tag semantics
const tags = new Set(node.tags.map((t) => t.toLowerCase()));
if (tags.has('aha')) flags |= NODE_FLAG.isAha;
if (tags.has('failure') || tags.has('guardrail')) flags |= NODE_FLAG.isFailure;
if (tags.has('confusion') || tags.has('weak-spot')) flags |= NODE_FLAG.isConfusion;
data[base + NODE_LANE.colorFlags + 0] = r;
data[base + NODE_LANE.colorFlags + 1] = g;
data[base + NODE_LANE.colorFlags + 2] = b;
data[base + NODE_LANE.colorFlags + 3] = flags;
// lane 3: demo activations start at zero
}
return { data, nodeCount: n };
}
/** array<vec2<u32>> edge index buffer. */
export function buildEdgeIndexArray(graph: ObservatoryGraph): Uint32Array<ArrayBuffer> {
const data = new Uint32Array(Math.max(1, graph.edges.length) * UINTS_PER_EDGE);
graph.edges.forEach((e, i) => {
data[i * UINTS_PER_EDGE] = e.sourceIndex;
data[i * UINTS_PER_EDGE + 1] = e.targetIndex;
});
return data;
}

View file

@ -0,0 +1,338 @@
/**
* Cognitive Observatory instanced node renderer (Increment 4).
*
* Owns the NodeState/EdgeIndex GPU buffers and the additive billboard
* pipeline. Registers as a FramePass on the engine: camera uniforms are
* written from the deterministic loop phase each frame (orbit), nodes draw
* as instanced soft-glow sprites straight from the storage buffer.
*
* No GPU readback, no wall-clock state (spec §6).
*/
import type { GraphResponse } from '$types';
import type { ObservatoryEngine, FramePass } from './engine';
import { DemoClock } from './demo-clock';
import { orbitCamera } from './camera';
import {
buildObservatoryGraph,
buildNodeStateArray,
buildEdgeIndexArray
} from './graph-upload';
import type { ObservatoryGraph } from './types';
import { renderNodesWGSL } from './shaders/render-nodes.wgsl';
import { simulateWGSL } from './shaders/simulate.wgsl';
import { renderPathWGSL } from './shaders/render-path.wgsl';
import { buildRecallPath, type PathStepMeta } from './path-builder';
/** mat4 (16) + right vec4 (4) + up vec4 (4) floats. */
const CAMERA_FLOATS = 24;
/** Orbit distance fitted to the default field radius (graph-upload). */
const ORBIT_DISTANCE = 300;
export class NodeRenderer implements FramePass {
private engine: ObservatoryEngine;
private pipeline: GPURenderPipeline | null = null;
private bindGroup: GPUBindGroup | null = null;
private cameraBuffer: GPUBuffer | null = null;
private nodeBuffer: GPUBuffer | null = null;
private edgeBuffer: GPUBuffer | null = null;
private cameraData = new Float32Array(CAMERA_FLOATS);
private nodeCount = 0;
// Recall-path simulation (Increment 5)
private simPipeline: GPUComputePipeline | null = null;
private simBindGroup: GPUBindGroup | null = null;
private pathBuffer: GPUBuffer | null = null;
// Path edge wavefront (Increment 6)
private pathPipeline: GPURenderPipeline | null = null;
private pathBindGroup: GPUBindGroup | null = null;
private pathStepCount = 0;
graph: ObservatoryGraph | null = null;
/** Beat metadata for the timeline spine overlay (Increment 6). */
pathSteps: PathStepMeta[] = [];
constructor(engine: ObservatoryEngine) {
this.engine = engine;
engine.addPass(this);
}
/**
* Upload the graph into GPU buffers. Layout is deterministic: positions
* come from a fresh DemoClock PRNG seeded with `seed` (same seed
* identical field, Increment 4 gate).
*/
upload(response: GraphResponse, seed: string, opts?: { recallPath?: boolean }): void {
const device = this.engine.gpuDevice;
if (!device) return;
// Other demo modes (engram-birth, …) bring their own choreography and
// upload with recallPath: false so the recall wave stays quiet.
const includeRecallPath = opts?.recallPath ?? true;
const graph = buildObservatoryGraph(response);
this.graph = graph;
const layoutClock = new DemoClock({ seed });
const { data, nodeCount } = buildNodeStateArray(graph, layoutClock.state.rng);
this.nodeCount = nodeCount;
this.nodeBuffer?.destroy();
this.nodeBuffer = device.createBuffer({
label: 'observatory-node-state',
size: Math.max(data.byteLength, 64),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX
});
device.queue.writeBuffer(this.nodeBuffer, 0, data.buffer as ArrayBuffer);
const edgeData = buildEdgeIndexArray(graph);
this.edgeBuffer?.destroy();
this.edgeBuffer = device.createBuffer({
label: 'observatory-edge-index',
size: edgeData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.edgeBuffer, 0, edgeData.buffer as ArrayBuffer);
// Recall path: deterministic story beats → PathStep storage buffer.
// (Skipped for demo modes with their own choreography — the buffer is
// still created so the sim bind group stays valid, just with 0 steps.)
const recall = includeRecallPath
? buildRecallPath(response, graph)
: { steps: [], data: new Uint32Array(4) };
this.pathSteps = recall.steps;
this.pathBuffer?.destroy();
this.pathBuffer = device.createBuffer({
label: 'observatory-path-steps',
size: recall.data.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.pathBuffer, 0, recall.data.buffer as ArrayBuffer);
this.pathStepCount = this.pathSteps.length;
// Per-frame counts for every shader that reads Params.
this.engine.params[2] = nodeCount;
this.engine.params[3] = graph.edges.length;
this.engine.params[4] = this.pathSteps.length;
if (!this.cameraBuffer) {
this.cameraBuffer = device.createBuffer({
label: 'observatory-camera',
size: this.cameraData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
}
this.createPipeline(device);
}
/**
* Replace the PathStep buffer after upload (Moment B: the birth engrave
* steps ride the same wavefront machinery as recall). Rebuilds the
* pipelines/bind groups so they reference the new buffer.
*/
setPathSteps(data: Uint32Array<ArrayBuffer>, steps: PathStepMeta[]): void {
const device = this.engine.gpuDevice;
if (!device) return;
this.pathSteps = steps;
this.pathStepCount = steps.length;
this.pathBuffer?.destroy();
this.pathBuffer = device.createBuffer({
label: 'observatory-path-steps',
size: Math.max(data.byteLength, 16),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.pathBuffer, 0, data.buffer as ArrayBuffer);
this.engine.params[4] = steps.length;
this.createPipeline(device);
}
private createPipeline(device: GPUDevice): void {
if (!this.engine.paramsBuffer || !this.cameraBuffer || !this.nodeBuffer) return;
// Recall-path simulation pipeline (compute-boids pattern, §1).
if (this.pathBuffer) {
const simModule = device.createShaderModule({
label: 'observatory-simulate',
code: simulateWGSL
});
this.simPipeline = device.createComputePipeline({
label: 'observatory-recall-sim',
layout: 'auto',
compute: { module: simModule, entryPoint: 'recall_sim' }
});
const simEntries: GPUBindGroupEntry[] = [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.nodeBuffer } },
{ binding: 2, resource: { buffer: this.pathBuffer } }
];
if (this.edgeBuffer) {
simEntries.push({ binding: 3, resource: { buffer: this.edgeBuffer } });
}
this.simBindGroup = device.createBindGroup({
label: 'observatory-recall-sim-bind',
layout: this.simPipeline.getBindGroupLayout(0),
entries: simEntries
});
}
const module = device.createShaderModule({
label: 'observatory-render-nodes',
code: renderNodesWGSL
});
this.pipeline = device.createRenderPipeline({
label: 'observatory-nodes',
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: {
module,
entryPoint: 'fs_main',
targets: [
{
format: this.engine.sceneFormat,
// Additive: light accumulates on the void (§7.2).
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
this.bindGroup = device.createBindGroup({
label: 'observatory-nodes-bind',
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.cameraBuffer } },
{ binding: 2, resource: { buffer: this.nodeBuffer } }
]
});
// Path wavefront ribbons (Increment 6) — drawn after nodes, additive.
if (this.pathBuffer) {
const pathModule = device.createShaderModule({
label: 'observatory-render-path',
code: renderPathWGSL
});
this.pathPipeline = device.createRenderPipeline({
label: 'observatory-path',
layout: 'auto',
vertex: { module: pathModule, entryPoint: 'vs_main' },
fragment: {
module: pathModule,
entryPoint: 'fs_main',
targets: [
{
format: this.engine.sceneFormat,
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
this.pathBindGroup = device.createBindGroup({
label: 'observatory-path-bind',
layout: this.pathPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.cameraBuffer } },
{ binding: 2, resource: { buffer: this.nodeBuffer } },
{ binding: 3, resource: { buffer: this.pathBuffer } }
]
});
}
}
/**
* FramePass write the deterministic orbit camera, then run the
* recall-path simulation for this frame (compute before render).
*/
compute(encoder: GPUCommandEncoder): void {
const device = this.engine.gpuDevice;
if (!device || !this.cameraBuffer) return;
const w = this.engine.params[6] || 1;
const h = this.engine.params[7] || 1;
const phase = this.engine.params[1];
const cam = orbitCamera(phase, w / h, ORBIT_DISTANCE);
this.cameraData.set(cam.viewProj, 0);
this.cameraData[16] = cam.right[0];
this.cameraData[17] = cam.right[1];
this.cameraData[18] = cam.right[2];
this.cameraData[19] = 0;
this.cameraData[20] = cam.up[0];
this.cameraData[21] = cam.up[1];
this.cameraData[22] = cam.up[2];
this.cameraData[23] = 0;
device.queue.writeBuffer(this.cameraBuffer, 0, this.cameraData);
if (this.simPipeline && this.simBindGroup && this.nodeCount > 0) {
const pass = encoder.beginComputePass({ label: 'observatory-recall-sim' });
pass.setPipeline(this.simPipeline);
pass.setBindGroup(0, this.simBindGroup);
pass.dispatchWorkgroups(Math.ceil(this.nodeCount / 64));
pass.end();
}
}
/** FramePass — instanced additive draws: nodes, then path ribbons on top. */
render(pass: GPURenderPassEncoder): void {
if (!this.pipeline || !this.bindGroup || this.nodeCount === 0) return;
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.draw(6, this.nodeCount);
if (this.pathPipeline && this.pathBindGroup && this.pathStepCount > 0) {
pass.setPipeline(this.pathPipeline);
pass.setBindGroup(0, this.pathBindGroup);
pass.draw(6, this.pathStepCount);
}
}
// ---------------------------------------------------------------------------
// Read-only handles for BirthRenderer (Moment B, Task B2).
// No behavior change — these expose existing buffers for other passes.
// ---------------------------------------------------------------------------
get nodeStateBuffer(): GPUBuffer | null {
return this.nodeBuffer;
}
get cameraUniformBuffer(): GPUBuffer | null {
return this.cameraBuffer;
}
get nodeCountValue(): number {
return this.nodeCount;
}
get pathStepMeta(): PathStepMeta[] {
return this.pathSteps;
}
dispose(): void {
this.nodeBuffer?.destroy();
this.edgeBuffer?.destroy();
this.cameraBuffer?.destroy();
this.pathBuffer?.destroy();
this.nodeBuffer = null;
this.edgeBuffer = null;
this.cameraBuffer = null;
this.pathBuffer = null;
this.pipeline = null;
this.bindGroup = null;
this.simPipeline = null;
this.simBindGroup = null;
this.pathPipeline = null;
this.pathBindGroup = null;
}
}

View file

@ -0,0 +1,114 @@
<script lang="ts">
/**
* Demo verdict card — DOM instrument overlay (§7.3 grammar, ported from
* docs/launch/causal-brain-demo.html's .verdict).
*
* Opacity is a PURE function of the frame prop (smoothstep in TS, NO CSS
* transitions) so capture mode (?frame=N) renders the exact same card at
* the exact same alpha every time. Defaults preserve the salience-rescue
* card byte-for-byte (window 600 → 660, triumph tone adds no class);
* firewall reuses it with tone="quarantine" + fadeWindow 480/495/605/620.
*/
/** Structural shape — RescueVerdictCopy and FirewallPlan copy both assign. */
interface VerdictContent {
headline: string;
causeLabel: string;
receipt: string;
}
interface Props {
frame?: number;
verdict: VerdictContent;
/** [fadeInStart, fadeInEnd, fadeOutStart, fadeOutEnd] loop frames. */
fadeWindow?: [number, number, number, number];
tone?: 'triumph' | 'quarantine';
}
let {
frame = 0,
verdict,
fadeWindow = [600, 615, 645, 660],
tone = 'triumph'
}: Props = $props();
const ss = (a: number, b: number, f: number): number => {
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
return t * t * (3 - 2 * t);
};
let opacity = $derived(
ss(fadeWindow[0], fadeWindow[1], frame) * (1 - ss(fadeWindow[2], fadeWindow[3], frame))
);
</script>
{#if opacity > 0.001}
<div class="verdict" class:quarantine={tone === 'quarantine'} style="opacity: {opacity}">
<div class="k">{verdict.headline}</div>
<div class="v">{verdict.causeLabel}</div>
<div class="s">{verdict.receipt}</div>
</div>
{/if}
<style>
.verdict {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
padding: clamp(18px, 3vw, 40px) clamp(28px, 6vw, 80px);
border-radius: 20px;
background: radial-gradient(
ellipse at center,
rgba(5, 7, 14, 0.86) 0%,
rgba(5, 7, 14, 0.72) 60%,
rgba(5, 7, 14, 0) 100%
);
pointer-events: none;
}
.k {
font-size: clamp(13px, 1.8vw, 18px);
color: #9fd0e4;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.v {
font-size: clamp(32px, 6.4vw, 72px);
font-weight: 600;
margin-top: 0.12em;
line-height: 1.05;
background: linear-gradient(90deg, #7fe6c0, #6ef0e6, #a6dcff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 26px rgba(110, 240, 220, 0.45));
}
.s {
font-size: clamp(11px, 1.5vw, 15px);
color: #8fb0be;
margin-top: 0.6em;
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
}
/* Quarantine tone (firewall verdict) — crimson-ember palette. Triumph adds
no class, so the rescue card's DOM + styles stay byte-identical. */
.quarantine .k {
color: #ffb0a6;
}
.quarantine .v {
background: linear-gradient(90deg, #ff6a5e, #ff9d6b, #ffd2a8);
filter: drop-shadow(0 0 26px rgba(255, 90, 70, 0.45));
}
.quarantine .s {
color: #d9a49a;
}
</style>

View file

@ -0,0 +1,104 @@
<script lang="ts">
/**
* Telemetry strip — top instrument overlay (§7.3).
*
* A floating readout, not a chrome bar: the wrapper is pointer-events-none
* so the memory field stays interactive everywhere; only the [url] copy
* button opts back in. Copies the FULL shareable demo URL including the
* capture frame when one is pinned.
*/
import { base } from '$app/paths';
interface Props {
demoMode?: string;
seed?: string;
nodeCount?: number;
edgeCount?: number;
centerId?: string;
frameCount?: number;
fpsEstimate?: number;
freezeFrame?: number | null;
loading?: boolean;
error?: string;
}
let {
demoMode = 'recall-path',
seed = 'vestige-observatory-v1',
nodeCount = 0,
edgeCount = 0,
centerId = '',
frameCount = 0,
fpsEstimate = 0,
freezeFrame = null,
loading = false,
error = ''
}: Props = $props();
// Full shareable URL — origin + base + canonical params (+ pinned frame).
function copyDemoUrl() {
const q = new URLSearchParams({ demo: demoMode, seed });
if (freezeFrame !== null) q.set('frame', String(freezeFrame));
const url = `${window.location.origin}${base}/observatory?${q.toString()}`;
navigator.clipboard.writeText(url).catch(() => {});
}
</script>
<!-- Floating instrument readout — never a solid chrome bar (§7.3).
Every span is nowrap + tabular-nums so live counters never garble or
reflow; lower-priority readouts shed below sm/md instead of wrapping. -->
<div
class="absolute top-0 left-0 right-0 z-20 pointer-events-none"
style="padding-top: env(safe-area-inset-top);"
>
<div
class="flex items-center justify-between gap-3 px-4 py-2 bg-gradient-to-b from-[#05060a]/85 to-transparent font-mono text-xs [font-variant-numeric:tabular-nums]"
>
<!-- Left: demo mode + seed (seed sheds below md; mode ellipsizes,
never overlaps the right cluster at extreme widths) -->
<div class="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
<span class="text-[#5dcaa5] tracking-widest uppercase truncate">
{demoMode}
</span>
<span class="hidden md:inline text-[#ffffff]/[0.5] whitespace-nowrap">
seed={seed.slice(0, 12)}{seed.length > 12 ? '…' : ''}
</span>
</div>
<!-- Center: node/edge counts (center id sheds below lg) -->
<div class="hidden sm:flex items-center gap-4">
<span class="text-[#ffffff]/[0.55] whitespace-nowrap">
{nodeCount} nodes · {edgeCount} edges
</span>
{#if centerId}
<span class="hidden lg:inline text-[#ffffff]/[0.5] whitespace-nowrap">
center={centerId.slice(0, 8)}
</span>
{/if}
</div>
<!-- Right: frame, fps, controls — frame padded so the counter never jitters -->
<div class="flex items-center gap-3">
<span class="text-[#ffffff]/[0.55] whitespace-nowrap"
>frame: {String(frameCount).padStart(3, '')}</span
>
{#if freezeFrame !== null}
<span class="text-[#a6dcff] tracking-widest whitespace-nowrap">CAPTURE</span>
{:else if fpsEstimate > 0}
<span class="text-[#5dcaa5] whitespace-nowrap w-[6ch] text-right">
{fpsEstimate}fps
</span>
{/if}
<button
class="text-[#ffffff]/[0.5] hover:text-[#5dcaa5] transition-colors cursor-pointer pointer-events-auto whitespace-nowrap"
onclick={copyDemoUrl}
title="Copy shareable demo URL"
>
[url]
</button>
</div>
</div>
</div>
<!-- loading/error props are accepted for API parity; the route renders those states itself -->

View file

@ -0,0 +1,128 @@
<script lang="ts">
/**
* Timeline spine — bottom instrument (Increment 6).
*
* A 720-frame loop track: one tick per story beat (positioned at
* beatFrame/loopFrames), a playhead riding the live frame, and the active
* beat's label glowing as the wavefront lands. Pointer-events: none —
* pure instrument, never a control surface (§7.3).
*/
import type { PathStepMeta } from '$lib/observatory/path-builder';
interface Props {
steps?: PathStepMeta[];
frame?: number;
loopFrames?: number;
}
let { steps = [], frame = 0, loopFrames = 720 }: Props = $props();
const pct = (f: number) => (f / loopFrames) * 100;
// A beat is "live" from just before arrival through its afterglow.
function beatEnergy(beatFrame: number, f: number): number {
const d = f - beatFrame;
if (d < -14 || d > 90) return 0;
if (d < 0) return 1 + d / 14; // attack
return 1 - d / 90; // decay
}
let activeLabel = $derived.by(() => {
let best = '';
let bestE = 0.15;
for (const s of steps) {
const e = beatEnergy(s.beatFrame, frame);
if (e > bestE) {
bestE = e;
best = s.label;
}
}
return best;
});
</script>
{#if steps.length > 0}
<div class="spine">
{#if activeLabel}
<div class="active-label">{activeLabel}</div>
{/if}
<div class="track">
{#each steps as s (s.beatFrame)}
<div
class="tick"
class:hot={beatEnergy(s.beatFrame, frame) > 0}
class:backward={s.kind === 1}
style="left: {pct(s.beatFrame)}%; opacity: {0.45 +
0.55 * beatEnergy(s.beatFrame, frame)}"
title={s.label}
></div>
{/each}
<div class="playhead" style="left: {pct(frame)}%"></div>
</div>
</div>
{/if}
<style>
.spine {
position: absolute;
left: 8%;
right: 8%;
/* Chromeless route: anchor to the true viewport bottom, clearing the
home-indicator on notched phones (safe-area) — audit blocker B3. */
bottom: calc(2.5rem + env(safe-area-inset-bottom, 0px));
pointer-events: none;
}
.active-label {
text-align: center;
margin-bottom: 0.6rem;
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #cfe9ff;
text-shadow: 0 0 24px rgba(30, 180, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track {
position: relative;
height: 2px;
background: rgba(255, 255, 255, 0.07);
border-radius: 1px;
}
.tick {
position: absolute;
top: 50%;
width: 3px;
height: 10px;
transform: translate(-50%, -50%);
border-radius: 2px;
background: #6ef0e6;
transition: opacity 0.2s linear;
}
.tick.hot {
box-shadow: 0 0 12px rgba(110, 240, 230, 0.75);
}
.tick.backward {
background: #ff4070;
}
.tick.backward.hot {
box-shadow: 0 0 12px rgba(255, 64, 112, 0.75);
}
.playhead {
position: absolute;
top: 50%;
width: 1.5px;
height: 16px;
transform: translate(-50%, -50%);
background: rgba(207, 233, 255, 0.9);
box-shadow: 0 0 10px rgba(30, 180, 255, 0.5);
}
</style>

Binary file not shown.

View file

@ -0,0 +1,34 @@
/**
* Cognitive Observatory bloom mip-chain plan (S2).
*
* Pure, GPU-free math so vitest can pin the pyramid shape. Mip 0 is HALF the
* swapchain resolution (the classic mip-bloom cost/quality point); the chain
* stops before the smallest dimension would drop below 8px (degenerate
* anisotropic mips smear) and is clamped to at most 6 levels.
*
* NOTE bloom RADIUS therefore varies with viewport size (more mips on bigger
* canvases = wider glow). Brightness does NOT the composite normalizes the
* additive up-chain by textureNumLevels (post-chain.ts).
*/
export interface BloomMipPlan {
baseW: number;
baseH: number;
mipCount: number;
sizes: Array<[number, number]>;
}
export function planBloomMips(w: number, h: number): BloomMipPlan {
const baseW = Math.max(1, w >> 1);
const baseH = Math.max(1, h >> 1); // mip 0 = HALF res
// min-dim ≥ 8px at the smallest mip (avoids degenerate anisotropic mips).
const mipCount = Math.min(
6,
Math.max(1, 1 + Math.floor(Math.log2(Math.min(baseW, baseH) / 8)))
);
const sizes = Array.from({ length: mipCount }, (_, i): [number, number] => [
Math.max(1, baseW >> i),
Math.max(1, baseH >> i)
]);
return { baseW, baseH, mipCount, sizes };
}

View file

@ -0,0 +1,340 @@
/**
* Cognitive Observatory post-processing chain (S1S4).
*
* Owns EVERY GPU resource of the post stack; the engine holds exactly one
* field (`post`). The scene renders into an offscreen HDR texture
* (rgba16float core WebGPU, no feature gate) instead of the swapchain, then
* PostChain encodes, on the SAME encoder (single submit, zero readback):
*
* 1..N threshold-free mip bloom: progressive 13-tap Jimenez downsample
* (Karis average on the first hop kills fireflies), then 9-tap
* tent upsample ACCUMULATED additively up the chain,
* final composite swapchain: scene + BLOOM_STRENGTH·bloom/mipCount
* Khronos PBR Neutral seeded grain cos vignette.
*
* Bloom RADIUS varies with viewport size (mipCount grows with the canvas);
* BRIGHTNESS does not the composite divides by textureNumLevels, so the
* up-chain's DC gain of exactly mipCount normalizes to 1.
*
* Zero per-frame uniforms/allocations: pipelines + explicit layouts build
* once in the constructor; textures/views/bind groups rebuild only when
* ensure() sees a new size; the shaders read sizes via textureDimensions /
* textureNumLevels straight from the bound views.
*
* SUBRESOURCE RULE (load-bearing): the blur chain binds ONLY single-
* subresource views (mipView[i]) sampling mip i+1 while rendering mip i is
* valid only because the subresources are disjoint. The full-mip view exists
* ONLY in the composite bind group, where bloomTex is never an attachment.
*/
import { planBloomMips, type BloomMipPlan } from './mip-plan';
import { postWGSL } from './shaders/post.wgsl';
// Tuning constants — defined next to the WGSL they are interpolated into;
// re-exported here as the public constant surface of the post stack.
export {
BLOOM_STRENGTH,
BLOOM_CHROMATIC_TEXELS,
GRAIN_AMP,
VIGNETTE_LIFT,
VIGNETTE_TAN
} from './shaders/post.wgsl';
/**
* Format every FramePass render pipeline targets the offscreen HDR scene
* texture. rgba16float is core WebGPU: render-attachable, blendable, and
* filterable with no feature gate.
*/
export const SCENE_FORMAT: GPUTextureFormat = 'rgba16float';
const ADDITIVE_BLEND: GPUBlendState = {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
};
export class PostChain {
private device: GPUDevice;
private paramsBuffer: GPUBuffer;
private samp: GPUSampler;
private blurLayout: GPUBindGroupLayout;
private compositeLayout: GPUBindGroupLayout;
private pipeDownFirst: GPURenderPipeline;
private pipeDown: GPURenderPipeline;
private pipeUp: GPURenderPipeline;
private pipeComposite: GPURenderPipeline;
// Size-dependent resources — (re)built by ensure() only.
private width = 0;
private height = 0;
private plan: BloomMipPlan | null = null;
private sceneTex: GPUTexture | null = null;
private _sceneView: GPUTextureView | null = null;
private bloomTex: GPUTexture | null = null;
private mipViews: GPUTextureView[] = [];
private bloomFullView: GPUTextureView | null = null;
private downBind: GPUBindGroup[] = [];
private upBind: GPUBindGroup[] = [];
private compositeBind: GPUBindGroup | null = null;
constructor(
device: GPUDevice,
paramsBuffer: GPUBuffer,
presentationFormat: GPUTextureFormat
) {
this.device = device;
this.paramsBuffer = paramsBuffer;
this.samp = device.createSampler({
label: 'observatory-post-sampler',
minFilter: 'linear',
magFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge'
});
const module = device.createShaderModule({
label: 'observatory-post',
code: postWGSL
});
// EXPLICIT layouts (WGSL trap #6 structurally dead): one blur layout
// serves all three blur pipelines — explicit layouts may contain entries
// an entry point ignores, so down/up bind groups are interchangeable.
this.blurLayout = device.createBindGroupLayout({
label: 'observatory-post-blur-layout',
entries: [
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float', viewDimension: '2d' }
},
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }
]
});
this.compositeLayout = device.createBindGroupLayout({
label: 'observatory-post-composite-layout',
entries: [
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float', viewDimension: '2d' }
},
{
binding: 4,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float', viewDimension: '2d' }
}
]
});
const blurPipeLayout = device.createPipelineLayout({
label: 'observatory-post-blur-pipe-layout',
bindGroupLayouts: [this.blurLayout]
});
const compositePipeLayout = device.createPipelineLayout({
label: 'observatory-post-composite-pipe-layout',
bindGroupLayouts: [this.compositeLayout]
});
const makePipe = (
label: string,
layout: GPUPipelineLayout,
entryPoint: string,
format: GPUTextureFormat,
blend?: GPUBlendState
): GPURenderPipeline =>
device.createRenderPipeline({
label,
layout,
vertex: { module, entryPoint: 'vs_fullscreen' },
fragment: { module, entryPoint, targets: [{ format, blend }] },
primitive: { topology: 'triangle-list' }
});
this.pipeDownFirst = makePipe(
'observatory-post-down-karis',
blurPipeLayout,
'fs_downsample_karis',
SCENE_FORMAT
);
this.pipeDown = makePipe(
'observatory-post-down',
blurPipeLayout,
'fs_downsample',
SCENE_FORMAT
);
this.pipeUp = makePipe(
'observatory-post-up',
blurPipeLayout,
'fs_upsample_tent',
SCENE_FORMAT,
ADDITIVE_BLEND
);
// Composite targets the swapchain — presentation format comes from the
// constructor arg; never hardcode bgra8unorm.
this.pipeComposite = makePipe(
'observatory-post-composite',
compositePipeLayout,
'fs_composite',
presentationFormat
);
}
/**
* The main scene pass' color attachment (offscreen HDR). ensure() always
* precedes use in the engine's frame loop.
*/
get sceneView(): GPUTextureView {
if (!this._sceneView) {
throw new Error('PostChain.ensure() must run before sceneView is used');
}
return this._sceneView;
}
/**
* Idempotent size-compare: recreates textures/views/bind groups iff the
* size changed (clamped 1). Called from engine.resize() AND from the
* frame loop with the swapchain texture's dims (covers the boot frame).
*/
ensure(width: number, height: number): void {
const w = Math.max(1, Math.floor(width));
const h = Math.max(1, Math.floor(height));
if (w === this.width && h === this.height && this.sceneTex !== null) return;
this.width = w;
this.height = h;
this.sceneTex?.destroy();
this.bloomTex?.destroy();
this.sceneTex = this.device.createTexture({
label: 'observatory-scene-hdr',
size: [w, h],
format: SCENE_FORMAT,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
this._sceneView = this.sceneTex.createView({ label: 'observatory-scene-hdr-view' });
const plan = planBloomMips(w, h);
this.plan = plan;
this.bloomTex = this.device.createTexture({
label: 'observatory-bloom-mips',
size: [plan.baseW, plan.baseH],
format: SCENE_FORMAT,
mipLevelCount: plan.mipCount,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
// SINGLE-subresource views for the blur chain (see header rule) …
const bloomTex = this.bloomTex;
this.mipViews = Array.from({ length: plan.mipCount }, (_, i) =>
bloomTex.createView({
label: `observatory-bloom-mip-${i}`,
baseMipLevel: i,
mipLevelCount: 1
})
);
// … and the full-mip view for the composite ONLY (textureNumLevels).
this.bloomFullView = bloomTex.createView({ label: 'observatory-bloom-full' });
// Bind groups rebuild here only — zero per-frame allocations.
const sceneView = this._sceneView;
this.downBind = this.mipViews.map((_, i) =>
this.device.createBindGroup({
label: `observatory-bloom-down-bind-${i}`,
layout: this.blurLayout,
entries: [
{ binding: 1, resource: i === 0 ? sceneView : this.mipViews[i - 1] },
{ binding: 2, resource: this.samp }
]
})
);
this.upBind = [];
for (let i = 0; i + 1 < plan.mipCount; i++) {
this.upBind.push(
this.device.createBindGroup({
label: `observatory-bloom-up-bind-${i}`,
layout: this.blurLayout,
entries: [
{ binding: 1, resource: this.mipViews[i + 1] },
{ binding: 2, resource: this.samp }
]
})
);
}
this.compositeBind = this.device.createBindGroup({
label: 'observatory-post-composite-bind',
layout: this.compositeLayout,
entries: [
{ binding: 0, resource: { buffer: this.paramsBuffer } },
{ binding: 2, resource: this.samp },
{ binding: 3, resource: sceneView },
{ binding: 4, resource: this.bloomFullView }
]
});
}
/**
* Encode the whole post stack: 2(N1)+2 tiny fullscreen passes, each
* `setPipeline; setBindGroup; draw(3)`. Same encoder as the scene pass.
*/
encode(encoder: GPUCommandEncoder, swapchainView: GPUTextureView): void {
const plan = this.plan;
if (!plan || !this.compositeBind) return;
const n = plan.mipCount;
// Downsample: scene (full res) → mip 0 (Karis), then mip i1 → i.
for (let i = 0; i < n; i++) {
const pass = encoder.beginRenderPass({
label: `observatory-bloom-down-${i}`,
colorAttachments: [{ view: this.mipViews[i], loadOp: 'clear', storeOp: 'store' }]
});
pass.setPipeline(i === 0 ? this.pipeDownFirst : this.pipeDown);
pass.setBindGroup(0, this.downBind[i]);
pass.draw(3);
pass.end();
}
// Upsample: tent of mip i+1 accumulates ADDITIVELY onto the stored
// downsample at mip i (loadOp 'load' + one/one blend). DC gain becomes
// exactly n — normalized in the composite. Runs zero times when n = 1.
for (let i = n - 2; i >= 0; i--) {
const pass = encoder.beginRenderPass({
label: `observatory-bloom-up-${i}`,
colorAttachments: [{ view: this.mipViews[i], loadOp: 'load', storeOp: 'store' }]
});
pass.setPipeline(this.pipeUp);
pass.setBindGroup(0, this.upBind[i]);
pass.draw(3);
pass.end();
}
// Composite to the swapchain: bloom-add → tonemap → grain → vignette.
const pass = encoder.beginRenderPass({
label: 'observatory-post-composite',
colorAttachments: [{ view: swapchainView, loadOp: 'clear', storeOp: 'store' }]
});
pass.setPipeline(this.pipeComposite);
pass.setBindGroup(0, this.compositeBind);
pass.draw(3);
pass.end();
}
dispose(): void {
this.sceneTex?.destroy();
this.bloomTex?.destroy();
this.sceneTex = null;
this.bloomTex = null;
this._sceneView = null;
this.bloomFullView = null;
this.mipViews = [];
this.downBind = [];
this.upBind = [];
this.compositeBind = null;
this.plan = null;
this.width = 0;
this.height = 0;
}
}

View file

@ -0,0 +1,285 @@
/**
* Cognitive Observatory post-processing shader module (S1S4).
*
* ONE WGSL module, five entry points, globally unique bindings, consumed
* through EXPLICIT bind group layouts in post-chain.ts (WGSL trap #6
* auto-layout stripping unused bindings is structurally dead here).
*
* Chain: scene (offscreen rgba16float HDR) threshold-FREE mip bloom
* (13-tap Jimenez downsample, Karis average on the FIRST hop to kill
* fireflies; 9-tap tent upsample accumulated additively up the chain)
* composite to the swapchain:
*
* hdr = scene + BLOOM_STRENGTH · bloom / mipCount
* Khronos PBR Neutral tonemap (hue-preserving; NEVER ACES it would
* skew the FSRS palette)
* seeded TPDF film grain (720-frame periodic, capture-pinned)
* cos vignette.
*
* Determinism: grain is keyed to the WRAPPED loop frame + integer pixel
* coords via a PCG hash no wall clock, no Math.random so identical
* URL+frame identical pixels and the 720-frame loop stays seamless.
*
* Scene ALPHA is discarded by the composite: additive one/one blending
* accumulates it past 1 in the HDR target; the composite reads .rgb and
* writes a = 1 (canvas alphaMode is 'opaque').
*
* Trap audit (the six WGSL traps previously hit in this codebase):
* (1) no `meta` identifier; (2) no arrays at all bit-math fullscreen
* vertex + fully unrolled taps; (3) no per-instance varyings (instance-free
* fullscreen passes); (4) whole-vector writes only; (5) no arrayLength;
* (6) explicit layouts on all four pipelines.
*/
// -- Tuning constants. TS is the single source of truth: the values are
// interpolated into the WGSL header below, so the shader can never drift
// from what post-chain.ts / tone-reference.ts compute with.
// (Re-exported through post-chain.ts as the public constant surface.)
/** Bloom mix into the scene. Spec window 0.150.25 — the one tuning knob. */
export const BLOOM_STRENGTH = 0.18;
/** Radial dispersion on the bloom term. LOCKED AT 0.0: chromatic aberration is
* INSANITY-PLAN §4 KILLED item #9 it fringes the ignite/recall halos whose
* hue IS FSRS data (§7.1). Do not re-enable; re-litigate via the plan first. */
export const BLOOM_CHROMATIC_TEXELS = 0.0;
/** Film grain amplitude. Spec window 1.52.5/255. */
export const GRAIN_AMP = 2.0 / 255;
/** Vignette corner floor — "observatory, not tunnel". */
export const VIGNETTE_LIFT = 0.85;
/** Vignette tan(θ) at the corner — attenuation ≈ 0.93 with lift 0.85. */
export const VIGNETTE_TAN = 0.62;
export const postWGSL = /* wgsl */ `
// Tuning constants — interpolated from post.wgsl.ts (TS single source of truth).
const BLOOM_STRENGTH: f32 = ${BLOOM_STRENGTH};
const BLOOM_CHROMATIC_TEXELS: f32 = ${BLOOM_CHROMATIC_TEXELS};
const GRAIN_AMP: f32 = ${GRAIN_AMP};
const VIGNETTE_LIFT: f32 = ${VIGNETTE_LIFT};
const VIGNETTE_TAN: f32 = ${VIGNETTE_TAN};
// Params layout — VERBATIM from render-nodes.wgsl.ts (types.PARAMS_FLOATS).
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
// Globally unique bindings — each entry point statically uses a subset; the
// explicit bind group layouts in post-chain.ts carry exactly what each
// pipeline needs (blur: 1+2, composite: 0+2+3+4).
@group(0) @binding(0) var<uniform> params: Params; // composite only
@group(0) @binding(1) var src: texture_2d<f32>; // blur chain input
@group(0) @binding(2) var samp: sampler; // shared
@group(0) @binding(3) var scene_tex: texture_2d<f32>; // composite only
@group(0) @binding(4) var bloom_tex: texture_2d<f32>; // composite only (FULL-mip view)
struct FSOut {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
};
// Fullscreen triangle from bit math — no vertex buffer, no arrays.
// vi 0/1/2 → clip (-1,-1) (3,-1) (-1,3); uv y flipped so uv(0,0) = top-left.
@vertex
fn vs_fullscreen(@builtin(vertex_index) vi: u32) -> FSOut {
let xy = vec2f(f32((vi << 1u) & 2u), f32(vi & 2u)) * 2.0 - 1.0;
var out: FSOut;
out.pos = vec4f(xy, 0.0, 1.0);
out.uv = vec2f(xy.x, -xy.y) * 0.5 + 0.5;
return out;
}
fn luma(c: vec3f) -> f32 {
return dot(c, vec3f(0.2126, 0.7152, 0.0722));
}
// ---------------------------------------------------------------------------
// Bloom downsample — 13-tap Jimenez (SIGGRAPH 2014 "Next Generation Post
// Processing in Call of Duty: Advanced Warfare"), taps fully unrolled.
//
// a b c outer ring at ±2 texels
// j k inner ring at ±1 texels
// d e f e = center
// l m
// g h i
//
// Grouped as 5 overlapping 4-tap boxes: center box (the four inner taps)
// weight 0.5, four corner boxes weight 0.125 each. A flat field reproduces
// itself EXACTLY (0.5 + 4·0.125 = 1) — that exactness is what the void
// preimage in tone-reference.ts depends on.
// ---------------------------------------------------------------------------
@fragment
fn fs_downsample_karis(in: FSOut) -> @location(0) vec4f {
let ts = 1.0 / vec2f(textureDimensions(src));
let a = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, -2.0) * ts, 0.0).rgb;
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -2.0) * ts, 0.0).rgb;
let c = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, -2.0) * ts, 0.0).rgb;
let d = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 0.0) * ts, 0.0).rgb;
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
let f = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 0.0) * ts, 0.0).rgb;
let g = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 2.0) * ts, 0.0).rgb;
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 2.0) * ts, 0.0).rgb;
let i = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 2.0) * ts, 0.0).rgb;
let j = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
let k = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
let l = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
let m = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
let box_c = (j + k + l + m) * 0.25;
let box_tl = (a + b + d + e) * 0.25;
let box_tr = (b + c + e + f) * 0.25;
let box_bl = (d + e + g + h) * 0.25;
let box_br = (e + f + h + i) * 0.25;
// Karis average (fireflies killer) — used ONLY on the full→mip0 hop.
// Each box is additionally weighted 1/(1 + luma) and the sum RENORMALIZED:
// on a flat field every Karis factor is equal, so the result is exact.
let w_c = 0.5 / (1.0 + luma(box_c));
let w_tl = 0.125 / (1.0 + luma(box_tl));
let w_tr = 0.125 / (1.0 + luma(box_tr));
let w_bl = 0.125 / (1.0 + luma(box_bl));
let w_br = 0.125 / (1.0 + luma(box_br));
let sum = w_c * box_c + w_tl * box_tl + w_tr * box_tr + w_bl * box_bl + w_br * box_br;
return vec4f(sum / (w_c + w_tl + w_tr + w_bl + w_br), 1.0);
}
@fragment
fn fs_downsample(in: FSOut) -> @location(0) vec4f {
let ts = 1.0 / vec2f(textureDimensions(src));
let a = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, -2.0) * ts, 0.0).rgb;
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -2.0) * ts, 0.0).rgb;
let c = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, -2.0) * ts, 0.0).rgb;
let d = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 0.0) * ts, 0.0).rgb;
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
let f = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 0.0) * ts, 0.0).rgb;
let g = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 2.0) * ts, 0.0).rgb;
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 2.0) * ts, 0.0).rgb;
let i = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 2.0) * ts, 0.0).rgb;
let j = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
let k = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
let l = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
let m = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
let box_c = (j + k + l + m) * 0.25;
let box_tl = (a + b + d + e) * 0.25;
let box_tr = (b + c + e + f) * 0.25;
let box_bl = (d + e + g + h) * 0.25;
let box_br = (e + f + h + i) * 0.25;
return vec4f(box_c * 0.5 + (box_tl + box_tr + box_bl + box_br) * 0.125, 1.0);
}
// ---------------------------------------------------------------------------
// Bloom upsample — 9-tap 3×3 tent, 1/16·[1 2 1; 2 4 2; 1 2 1], radius = one
// SOURCE-mip texel. Rendered with additive one/one blending onto the stored
// downsample of the destination mip (accumulate-up-the-chain). The resulting
// DC gain of exactly mipCount is normalized in fs_composite.
// ---------------------------------------------------------------------------
@fragment
fn fs_upsample_tent(in: FSOut) -> @location(0) vec4f {
let ts = 1.0 / vec2f(textureDimensions(src));
let a = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -1.0) * ts, 0.0).rgb;
let c = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
let d = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 0.0) * ts, 0.0).rgb;
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
let f = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 0.0) * ts, 0.0).rgb;
let g = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 1.0) * ts, 0.0).rgb;
let i = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
let sum = (a + c + g + i) + (b + d + f + h) * 2.0 + e * 4.0;
return vec4f(sum * (1.0 / 16.0), 1.0);
}
// ---------------------------------------------------------------------------
// Composite — bloom-add → PBR Neutral → grain → vignette (order is mandated).
// ---------------------------------------------------------------------------
// Khronos PBR Neutral — EXACT port of the Khronos reference implementation.
// Hue-preserving; the FSRS palette keeps its channel ordering. Pinned to the
// CPU mirror in post/tone-reference.ts (pbrNeutralReference) — keep in
// lockstep, the void-preimage tests run against the mirror.
fn pbr_neutral(color_in: vec3f) -> vec3f {
let start_compression = 0.8 - 0.04;
let desaturation = 0.15;
var color = color_in;
let x = min(color.r, min(color.g, color.b));
// WGSL select(false_value, true_value, condition) — argument order trap.
let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
color = color - vec3f(offset);
let peak = max(color.r, max(color.g, color.b));
if (peak < start_compression) {
return color;
}
let d = 1.0 - start_compression;
let new_peak = 1.0 - d * d / (peak + d - start_compression);
color = color * (new_peak / peak);
let g = 1.0 / (desaturation * (peak - new_peak) + 1.0);
// mix weight = 1 - g per the Khronos spec.
return mix(color, vec3f(new_peak), 1.0 - g);
}
// PCG hash — integers only, 24-bit-exact output in [0, 1). Deterministic.
fn pcg(v: u32) -> u32 {
var s = v * 747796405u + 2891336453u;
let t = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
return (t >> 22u) ^ t;
}
fn hashf(p: vec2u, f: u32) -> f32 {
return f32(pcg(p.x ^ pcg(p.y ^ pcg(f))) >> 8u) / 16777216.0;
}
@fragment
fn fs_composite(in: FSOut) -> @location(0) vec4f {
let pix = vec2u(in.pos.xy);
// Exact 1:1 fetch (alpha discarded — see module header).
let scene = textureLoad(scene_tex, pix, 0).rgb;
// Bloom, normalized by the mip count: the additive up-chain has DC gain
// exactly mipCount, so /mips makes flat-field gain exactly 1 — the void
// preimage holds and brightness is viewport-stable. Chromatic dispersion
// rides the bloom term ONLY (BLOOM_CHROMATIC_TEXELS = 0.0 kills it).
let mips = f32(textureNumLevels(bloom_tex));
let dims = vec2f(textureDimensions(bloom_tex));
let dvec = in.uv - vec2f(0.5);
let off = dvec * (BLOOM_CHROMATIC_TEXELS * dot(dvec, dvec) * 4.0) / dims;
let bloom = vec3f(
textureSampleLevel(bloom_tex, samp, in.uv - off, 0.0).r,
textureSampleLevel(bloom_tex, samp, in.uv, 0.0).g,
textureSampleLevel(bloom_tex, samp, in.uv + off, 0.0).b
) / mips;
var c = pbr_neutral(scene + BLOOM_STRENGTH * bloom);
// Seeded TPDF film grain (post-tonemap dither): keyed to the WRAPPED loop
// frame → 720-periodic and capture-pinned. Full strength in the shadows
// (kills #05060a banding), fades out of highlights.
let f = u32(params.frame + 0.5);
let n = hashf(pix, f) + hashf(pix ^ vec2u(0x9E3779B9u, 0x85EBCA6Bu), f) - 1.0;
let w = 1.0 - smoothstep(0.0, 0.8, luma(c));
c += GRAIN_AMP * n * w;
// cos⁴ vignette: cos⁴θ = (1 + r²·tan²)⁻², aspect-normalized so rn = 1.0
// exactly at the corners regardless of viewport shape. Lifted floor keeps
// it an observatory, not a tunnel.
let ar = vec2f(params.viewport_w / max(params.viewport_h, 1.0), 1.0);
let rn = length((in.uv * 2.0 - 1.0) * ar) / length(ar);
let k = rn * rn * VIGNETTE_TAN * VIGNETTE_TAN;
c *= mix(VIGNETTE_LIFT, 1.0, 1.0 / ((1.0 + k) * (1.0 + k)));
// NO gamma encode — display-referred pass-through, matching the pre-post
// look where shader outputs went straight to the swapchain.
return vec4f(c, 1.0);
}
`;

View file

@ -0,0 +1,75 @@
/**
* Cognitive Observatory Khronos PBR Neutral tone mapping, CPU reference.
*
* Exact TS mirror of pbr_neutral() in post/shaders/post.wgsl.ts. vitest can't
* run WGSL on a GPU, so all determinism-critical tonemap math is verified
* against this mirror keep the two in lockstep.
*
* IMPORTANT: PBR Neutral is NOT the identity below the compression knee. The
* black-offset branch subtracts `offset` from EVERY pixel (0.04 when the min
* channel is 0.08, else x 6.25x²). A naive #05060a clear would therefore
* tonemap to #010206 (crushed void). VOID_CLEAR_HDR below is the analytic
* preimage that lands the post stack EXACTLY back on #05060a.
*/
import { BLOOM_STRENGTH } from './post-chain';
/** Khronos PBR Neutral reference (https://github.com/KhronosGroup/ToneMapping). */
export function pbrNeutralReference(
rgb: readonly [number, number, number]
): [number, number, number] {
const startCompression = 0.8 - 0.04;
const desaturation = 0.15;
let [r, g, b] = rgb;
const x = Math.min(r, Math.min(g, b));
const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
r -= offset;
g -= offset;
b -= offset;
const peak = Math.max(r, Math.max(g, b));
if (peak < startCompression) return [r, g, b];
const d = 1 - startCompression;
const newPeak = 1 - (d * d) / (peak + d - startCompression);
const scale = newPeak / peak;
r *= scale;
g *= scale;
b *= scale;
const gMix = 1 / (desaturation * (peak - newPeak) + 1);
// mix(color, vec3(newPeak), 1 - g) per the Khronos spec.
const w = 1 - gMix;
return [r + w * (newPeak - r), g + w * (newPeak - g), b + w * (newPeak - b)];
}
// ---------------------------------------------------------------------------
// VOID_CLEAR_HDR — the HDR clear color whose post-stack output is #05060a.
//
// Derivation (verified by tone-reference.test.ts):
// - The normalized bloom chain has flat-field gain exactly 1 (renormalized
// Karis + exact box/tent weights + /mipCount in the composite), so a flat
// void field enters the tonemap as (1 + BLOOM_STRENGTH) · v = 1.18 · v.
// - Below the knee, out = in offset with offset = x 6.25x² (x = min
// channel < 0.08), hence out_min = 6.25x². Solving out_min = 5/255:
// x = sqrt((5/255)/6.25) = 0.05601120…
// offset = x 5/255 = 0.03640336…
// g_in = 6/255 + offset; b_in = 10/255 + offset
// peak = b_in ≈ 0.0756 < 0.76 → the compression branch is NOT taken.
// - Divide by (1 + BLOOM_STRENGTH): pbrNeutral(1.18 · VOID_CLEAR_HDR) is
// EXACTLY (5/255, 6/255, 10/255) = #05060a.
// Literals: r ≈ 0.0474671, g ≈ 0.0507905, b ≈ 0.0640839.
// - f16 quantization of the clear (ulp ≈ 3e-5 near 0.05) keeps the
// tonemapped void well inside ±0.5/255 — verified safe.
// ---------------------------------------------------------------------------
const VOID_R_IN = Math.sqrt(5 / 255 / 6.25); // 0.05601120…
const VOID_OFFSET = VOID_R_IN - 5 / 255; // 0.03640336…
export const VOID_CLEAR_HDR: GPUColorDict = {
r: VOID_R_IN / (1 + BLOOM_STRENGTH),
g: (6 / 255 + VOID_OFFSET) / (1 + BLOOM_STRENGTH),
b: (10 / 255 + VOID_OFFSET) / (1 + BLOOM_STRENGTH),
a: 1
};

View file

@ -0,0 +1,634 @@
/**
* Cognitive Observatory Retroactive Salience Backfill demo plan (salience-rescue).
*
* Pure CPU: given the API GraphResponse + the stable-indexed ObservatoryGraph +
* the demo seed, DETERMINISTICALLY pick the failure memory, the K=4 lookalikes
* (nearest in LAYOUT space exactly what vector search would return), the true
* cause (graph-distance 3, low retention, old), the BFS hop-depth map for the
* backward wave, the PathSteps (probe beams, wave tree, causal arc), the spine
* beats, and the verdict copy. No Math.random(), no Date.now() Date.parse is
* only applied to DATA timestamps.
*
* The 720-frame beat map (fixed 60Hz, seamless loop):
* 0-90 field at rest
* 90-120 DETONATION failure flares crimson (demo.w)
* 120-260 SEARCHLIGHT K lookalikes flare cold-white sequentially (demo.y)
* 260-520 BACKWARD WAVE hop-by-hop interrogation away from the failure (demo.z)
* 520-600 CAUSE IGNITES thin-film recall envelope (demo.x) + causal arc
* 600-660 VERDICT DOM overlay
* 660-720 decay to rest every envelope is exactly 0 at frames 0 and 719
*
* `rescueEnvelopes` is the authoritative CPU mirror of shaders/rescue.wgsl.ts
* the seam-zero unit test machine-checks the loop guarantee against it.
*/
import type { GraphResponse } from '$types';
import { DemoClock } from './demo-clock';
import { buildNodeStateArray } from './graph-upload';
import { FLOATS_PER_NODE, PATH_KIND, type ObservatoryGraph } from './types';
import type { PathStepMeta } from './path-builder';
// ---------------------------------------------------------------------------
// Beat-map constants (shared with shaders/rescue.wgsl.ts via RescueShaderConsts)
// ---------------------------------------------------------------------------
export const RESCUE_K = 4;
export const DETONATE_FRAME = 90;
/** Lookalike k flares at LOOKALIKE_BASE + k * LOOKALIKE_INTERVAL → 138,166,194,222. */
export const LOOKALIKE_BASE = 138;
export const LOOKALIKE_INTERVAL = 28;
export const WAVE_START = 260;
export const WAVE_ARRIVAL_CAP = 514;
export const ARC_FRAME = 560;
export const VERDICT_START = 600;
export const VERDICT_END = 660;
export const UNREACHED = 0xffff;
export const MAX_WAVE_STEPS = 48;
export const LOOP_FRAMES = 720;
/** BFS parent preference: causal chains first — RSB semantics. Unknown → 5. */
export const EDGE_TYPE_RANK: Record<string, number> = {
causal: 0,
temporal: 1,
shared_concepts: 2,
complementary: 3,
semantic: 4
};
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface RescueShaderConsts {
/** Frames per BFS hop for the backward wave: clamp(⌊252/D⌋, 14, 84). */
hopSlot: number;
/** BFS depth of the cause (D). Wave lane fires only for 1 ≤ d ≤ D. */
causeDepth: number;
}
export interface RescueVerdictCopy {
headline: 'root cause found';
causeLabel: string;
failureLabel: string;
causeDate: string;
hops: number;
k: number;
/** `${hops} hops back · ${causeDate} · vector search: 0 for ${k}` */
receipt: string;
}
export interface RescuePlan {
/** false ⇒ field renders, story suppressed (no fake cause on tiny graphs). */
viable: boolean;
failureIndex: number;
causeIndex: number;
lookalikeIndices: number[];
/** Per-node BFS hop depth from the failure; UNREACHED; failure = 0. */
hopDepths: Uint16Array;
causeDepth: number;
hopSlot: number;
/** 1 u32/node: bits 0-15 hopDepth, 16 isFailure, 17 isCause, 18 isLookalike, 19-21 k. */
waveData: Uint32Array;
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
pathData: Uint32Array<ArrayBuffer>;
/**
* MUST be 1:1 with pathData steps setPathSteps sets params[4] and the
* ribbon draw count from THIS array's length.
*/
pathMetas: PathStepMeta[];
/** Curated spine beats (route state only, NEVER sent to GPU). Unique beatFrames. */
spineBeats: PathStepMeta[];
verdict: RescueVerdictCopy;
consts: RescueShaderConsts;
}
// ---------------------------------------------------------------------------
// Layout parity — the ONE correct way to know where nodes are on screen
// ---------------------------------------------------------------------------
/**
* Byte-identical replica of NodeRenderer.upload's layout: same function
* (buildNodeStateArray), same fresh DemoClock, same rng consumption (incl. the
* center-node skip). Do NOT reimplement golden-angle placement here.
*/
export function layoutPositions(graph: ObservatoryGraph, seed: string): Float32Array {
const layoutClock = new DemoClock({ seed });
return buildNodeStateArray(graph, layoutClock.state.rng).data;
}
// ---------------------------------------------------------------------------
// Selection algorithms (all exported for tests; ties → ascending node index)
// ---------------------------------------------------------------------------
function nodeDegrees(graph: ObservatoryGraph): Uint32Array {
const degree = new Uint32Array(graph.nodes.length);
for (const e of graph.edges) {
degree[e.sourceIndex]++;
degree[e.targetIndex]++;
}
return degree;
}
/**
* Pick the failure memory: non-center, unsuppressed, well-connected, prefer
* failure/guardrail (then confusion/weak-spot) tags, prefer nodes away from
* the field center in layout ( 0.45 · fieldRadius 120 = 54).
* Relaxation ladder: degree 2 any unsuppressed non-center any
* non-center center (degenerate single-node field). Empty graph -1.
*/
export function pickFailureIndex(graph: ObservatoryGraph, positions: Float32Array): number {
const n = graph.nodes.length;
if (n === 0) return -1;
const degree = nodeDegrees(graph);
const score = (i: number): number => {
const node = graph.nodes[i];
const tags = new Set(node.tags.map((t) => t.toLowerCase()));
let s = 0;
if (tags.has('failure') || tags.has('guardrail')) s += 3;
if (tags.has('confusion') || tags.has('weak-spot')) s += 2;
s += Math.min(degree[i], 8) / 8;
const x = positions[i * FLOATS_PER_NODE + 0];
const y = positions[i * FLOATS_PER_NODE + 1];
const z = positions[i * FLOATS_PER_NODE + 2];
if (Math.sqrt(x * x + y * y + z * z) >= 54) s += 0.5;
return s;
};
const tiers: Array<(i: number) => boolean> = [
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && degree[i] >= 2,
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed,
(i) => i !== graph.centerIndex,
() => true
];
for (const accept of tiers) {
let best = -1;
let bestScore = -Infinity;
for (let i = 0; i < n; i++) {
if (!accept(i)) continue;
const s = score(i);
// strict > keeps the lowest index on ties (ascending scan)
if (s > bestScore) {
bestScore = s;
best = i;
}
}
if (best >= 0) return best;
}
return -1;
}
/**
* Undirected BFS from the failure. Hop depths are TRUE BFS distances
* (layer order is edge-rank independent); parent pointers are chosen in a
* second pass preferring causal/temporal edges (RSB semantics), then lowest
* neighbor index fully deterministic.
*/
export function bfsFromFailure(
graph: ObservatoryGraph,
failureIndex: number
): { depths: Uint16Array; parents: Int32Array } {
const n = graph.nodes.length;
const depths = new Uint16Array(n).fill(UNREACHED);
const parents = new Int32Array(n).fill(-1);
if (failureIndex < 0 || failureIndex >= n) return { depths, parents };
const adj: Array<Array<{ nbr: number; rank: number }>> = Array.from({ length: n }, () => []);
for (const e of graph.edges) {
const rank = EDGE_TYPE_RANK[e.type] ?? 5;
adj[e.sourceIndex].push({ nbr: e.targetIndex, rank });
adj[e.targetIndex].push({ nbr: e.sourceIndex, rank });
}
for (const list of adj) list.sort((a, b) => a.rank - b.rank || a.nbr - b.nbr);
depths[failureIndex] = 0;
const queue = [failureIndex];
for (let qi = 0; qi < queue.length; qi++) {
const u = queue[qi];
for (const { nbr } of adj[u]) {
if (depths[nbr] === UNREACHED) {
depths[nbr] = depths[u] + 1;
queue.push(nbr);
}
}
}
// Parent pass: first depth-(d-1) neighbor in (rank, index) order wins.
for (let v = 0; v < n; v++) {
if (depths[v] === UNREACHED || depths[v] === 0) continue;
for (const { nbr } of adj[v]) {
if (depths[nbr] === depths[v] - 1) {
parents[v] = nbr;
break;
}
}
}
return { depths, parents };
}
/**
* Pick the true cause: graph-distant (depth 3, relaxed 2 1), low
* retention (hard 0.45, relaxed away if empty visually slate/dim), old.
* score = 2·(1retention) + 0.5·min(depth,6)/6 + 0.5·ageRank, ageRank [0,1]
* with the oldest createdAt 1 (invalid/missing dates 0).
* Sort (score desc, depth desc, index asc). No candidate at any depth -1.
*/
export function pickCauseIndex(
response: GraphResponse,
graph: ObservatoryGraph,
depths: Uint16Array,
failureIndex: number
): { index: number; depth: number } {
const createdById = new Map<string, string>();
for (const nd of response.nodes) createdById.set(nd.id, nd.createdAt);
for (const minDepth of [3, 2, 1]) {
const cand: number[] = [];
for (let i = 0; i < graph.nodes.length; i++) {
if (i === graph.centerIndex || i === failureIndex) continue;
const d = depths[i];
if (d === UNREACHED || d < minDepth) continue;
cand.push(i);
}
if (cand.length === 0) continue;
let pool = cand.filter((i) => graph.nodes[i].retention <= 0.45);
if (pool.length === 0) pool = cand;
// ageRank across the pool: oldest Date.parse(createdAt) → 1.
const times = new Map<number, number>();
let minT = Infinity;
let maxT = -Infinity;
for (const i of pool) {
const raw = createdById.get(graph.nodes[i].id);
const t = raw ? Date.parse(raw) : NaN;
if (Number.isFinite(t)) {
times.set(i, t);
if (t < minT) minT = t;
if (t > maxT) maxT = t;
}
}
const ageRank = (i: number): number => {
const t = times.get(i);
if (t === undefined) return 0;
if (maxT === minT) return 1;
return (maxT - t) / (maxT - minT);
};
const score = (i: number): number =>
2 * (1 - graph.nodes[i].retention) + (0.5 * Math.min(depths[i], 6)) / 6 + 0.5 * ageRank(i);
pool.sort((a, b) => {
const sa = score(a);
const sb = score(b);
if (sb !== sa) return sb - sa;
if (depths[b] !== depths[a]) return depths[b] - depths[a];
return a - b;
});
return { index: pool[0], depth: depths[pool[0]] };
}
return { index: -1, depth: 0 };
}
/**
* K = min(4, eligible) nearest neighbors of the failure in LAYOUT space,
* excluding failure/cause/center "looks similar" is literal: these are the
* nodes vector search would return. Flare order = nearest first.
*/
export function pickLookalikes(
positions: Float32Array,
nodeCount: number,
failureIndex: number,
causeIndex: number,
centerIndex: number
): number[] {
const fx = positions[failureIndex * FLOATS_PER_NODE + 0];
const fy = positions[failureIndex * FLOATS_PER_NODE + 1];
const fz = positions[failureIndex * FLOATS_PER_NODE + 2];
const cand: Array<{ i: number; d2: number }> = [];
for (let i = 0; i < nodeCount; i++) {
if (i === failureIndex || i === causeIndex || i === centerIndex) continue;
const dx = positions[i * FLOATS_PER_NODE + 0] - fx;
const dy = positions[i * FLOATS_PER_NODE + 1] - fy;
const dz = positions[i * FLOATS_PER_NODE + 2] - fz;
cand.push({ i, d2: dx * dx + dy * dy + dz * dz });
}
cand.sort((a, b) => a.d2 - b.d2 || a.i - b.i);
return cand.slice(0, RESCUE_K).map((c) => c.i);
}
// ---------------------------------------------------------------------------
// Wave timing
// ---------------------------------------------------------------------------
/** σ = clamp(⌊252/D⌋, 14, 84): the wave reaches the cause 6-10 frames before ignition. */
export function hopSlotFor(causeDepth: number): number {
const d = Math.max(1, causeDepth);
return Math.min(84, Math.max(14, Math.floor(252 / d)));
}
/** W(d) = min(WAVE_START + σ·d, WAVE_ARRIVAL_CAP). */
export function waveArrivalFrame(depth: number, hopSlot: number): number {
return Math.min(WAVE_START + hopSlot * depth, WAVE_ARRIVAL_CAP);
}
export function lookalikeFrame(k: number): number {
return LOOKALIKE_BASE + LOOKALIKE_INTERVAL * k;
}
export function truncateLabel(label: string): string {
return label.length > 64 ? label.slice(0, 64) + '…' : label;
}
// ---------------------------------------------------------------------------
// Envelope math — the authoritative CPU mirror of shaders/rescue.wgsl.ts
// ---------------------------------------------------------------------------
function smooth(a: number, b: number, f: number): number {
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function env(f: number, a0: number, a1: number, r0: number, r1: number): number {
return smooth(a0, a1, f) * (1 - smooth(r0, r1, f));
}
/**
* Pure function of (frame, packed wave word, shader consts) the four demo
* lanes (x recall/ignition, y searchlight, z wave, w shock). Every term is
* A·S(a0,a1,f)·(1S(r0,r1,f)) with all a0 88 and all r1 700, so the value
* is EXACTLY zero at frames 0 and 719 the machine-checked seam guarantee.
* Keep in lockstep with rescue_choreo in shaders/rescue.wgsl.ts.
*/
export function rescueEnvelopes(
frame: number,
packed: number,
c: RescueShaderConsts
): { x: number; y: number; z: number; w: number } {
const depth = packed & 0xffff;
const isFailure = (packed & 0x10000) !== 0;
const isCause = (packed & 0x20000) !== 0;
const isLook = (packed & 0x40000) !== 0;
const lookK = (packed >>> 19) & 0x7;
const loopPhase = frame / LOOP_FRAMES;
let x = 0;
let y = 0;
let z = 0;
let w = 0;
if (isFailure) {
// Detonation spike + wound simmer (burns through the investigation)
// + recognition flare as the causal arc lands at 560.
w += env(frame, 90, 96, 120, 168);
w += 0.35 * env(frame, 100, 130, 600, 656);
w += 0.35 * env(frame, 552, 562, 580, 640);
// Symptom backlight while the cause burns.
x += 0.4 * env(frame, 556, 566, 620, 668);
}
if (!isFailure && depth >= 1 && depth <= 12) {
// Shockwave blink: crimson concussion at 3 frames/hop along REAL graph distance.
w +=
0.75 *
Math.exp(-0.3 * depth) *
env(frame, 92 + 3 * depth, 96 + 3 * depth, 96 + 3 * depth, 122 + 3 * depth);
}
if (isLook) {
const fk = lookalikeFrame(lookK);
// Searchlight flare: cold pop, sequential, on camera.
y += env(frame, fk - 6, fk, fk + 10, fk + 26);
// Ash residue: the struck-through lookalike stays in frame until the verdict.
y += 0.15 * smooth(fk + 10, fk + 26, frame) * (1 - smooth(600, 656, frame));
}
if (!isFailure && depth >= 1 && depth <= c.causeDepth) {
const wd = waveArrivalFrame(depth, c.hopSlot);
// Interrogation flicker: 24 integer sine cycles per loop, per-depth phase.
const flicker = 0.75 + 0.25 * Math.sin(2 * Math.PI * 24 * loopPhase + 1.7 * depth);
z += env(frame, wd - 10, wd, wd + 28, wd + 64) * flicker;
// Scanned ember.
z += 0.08 * smooth(wd + 28, wd + 64, frame) * (1 - smooth(580, 640, frame));
}
if (isCause) {
// Cause ignition rides the EXISTING recall response in render-nodes.wgsl:
// spectral() thin-film band + white-hot core + sprite swell, for free.
x += env(frame, 520, 546, 640, 700);
}
return { x, y, z, w };
}
// ---------------------------------------------------------------------------
// Plan builder
// ---------------------------------------------------------------------------
const UINTS_PER_STEP = 4;
function emptyPlan(nodeCount: number): RescuePlan {
const waveData = new Uint32Array(nodeCount);
waveData.fill(UNREACHED);
return {
viable: false,
failureIndex: -1,
causeIndex: -1,
lookalikeIndices: [],
hopDepths: new Uint16Array(nodeCount).fill(UNREACHED),
causeDepth: 0,
hopSlot: hopSlotFor(3),
waveData,
pathData: new Uint32Array(4),
pathMetas: [],
spineBeats: [],
verdict: {
headline: 'root cause found',
causeLabel: '',
failureLabel: '',
causeDate: '',
hops: 0,
k: 0,
receipt: ''
},
consts: { hopSlot: hopSlotFor(3), causeDepth: 3 }
};
}
/**
* Build the full deterministic salience-rescue plan. Same graph + seed
* identical plan (byte-identical typed arrays). Empty/tiny/edgeless graphs
* survive with viable:false the field breathes, no fake cause.
*/
export function buildRescuePlan(
response: GraphResponse,
graph: ObservatoryGraph,
seed: string
): RescuePlan {
const n = graph.nodes.length;
if (n === 0) return emptyPlan(0);
const positions = layoutPositions(graph, seed);
const failureIndex = pickFailureIndex(graph, positions);
if (failureIndex < 0) return emptyPlan(n);
const { depths, parents } = bfsFromFailure(graph, failureIndex);
const cause = pickCauseIndex(response, graph, depths, failureIndex);
if (cause.index < 0) {
const plan = emptyPlan(n);
plan.failureIndex = failureIndex;
plan.hopDepths = depths;
return plan;
}
const causeIndex = cause.index;
const causeDepth = Math.max(1, cause.depth);
const hopSlot = hopSlotFor(causeDepth);
const W = (d: number) => waveArrivalFrame(d, hopSlot);
const lookalikeIndices = pickLookalikes(positions, n, failureIndex, causeIndex, graph.centerIndex);
const K = lookalikeIndices.length;
// --- waveData packing (1 u32/node) ---
const waveData = new Uint32Array(n);
for (let i = 0; i < n; i++) {
let word = depths[i] & 0xffff;
if (i === failureIndex) word |= 1 << 16;
if (i === causeIndex) word |= 1 << 17;
waveData[i] = word;
}
lookalikeIndices.forEach((li, k) => {
waveData[li] |= (1 << 18) | (k << 19);
});
// --- PathStep emission: probes, wave tree, arc ---
interface Step {
src: number;
dst: number;
bf: number;
kind: number;
beatKind: string;
}
const steps: Step[] = [];
// Probe beams (vector search visibly probing and failing ON CAMERA).
lookalikeIndices.forEach((li, k) => {
steps.push({
src: failureIndex,
dst: li,
bf: lookalikeFrame(k),
kind: PATH_KIND.probe,
beatKind: 'probe'
});
});
// Wave tree, capped at MAX_WAVE_STEPS with the failure→cause parent chain
// guaranteed, then remaining by (depth asc, index asc).
const chainNodes: number[] = [];
{
let v = causeIndex;
while (v !== failureIndex && v >= 0 && parents[v] >= 0) {
chainNodes.push(v);
v = parents[v];
}
}
const chainSet = new Set(chainNodes);
const rest: number[] = [];
for (let v = 0; v < n; v++) {
if (v === failureIndex || chainSet.has(v)) continue;
const d = depths[v];
if (d === UNREACHED || d < 1 || d > causeDepth) continue;
if (parents[v] < 0) continue;
rest.push(v);
}
rest.sort((a, b) => depths[a] - depths[b] || a - b);
const waveNodes = [...chainNodes.slice().reverse(), ...rest].slice(0, MAX_WAVE_STEPS);
waveNodes.sort((a, b) => depths[a] - depths[b] || a - b);
for (const v of waveNodes) {
steps.push({
src: parents[v],
dst: v,
bf: W(depths[v]),
kind: PATH_KIND.backwardCause,
beatKind: 'wave'
});
}
// The causal arc: cause → failure, lands at 560 (kind 1 = crimson-magenta tint).
steps.push({
src: causeIndex,
dst: failureIndex,
bf: ARC_FRAME,
kind: PATH_KIND.backwardCause,
beatKind: 'arc'
});
const pathData = new Uint32Array(Math.max(1, steps.length) * UINTS_PER_STEP);
const pathMetas: PathStepMeta[] = [];
steps.forEach((s, i) => {
pathData[i * UINTS_PER_STEP + 0] = s.src;
pathData[i * UINTS_PER_STEP + 1] = s.dst;
pathData[i * UINTS_PER_STEP + 2] = s.bf;
pathData[i * UINTS_PER_STEP + 3] = s.kind;
pathMetas.push({
sourceIndex: s.src,
targetIndex: s.dst,
beatFrame: s.bf,
kind: s.kind,
beatKind: s.beatKind,
nodeId: graph.nodes[s.dst].id,
label: truncateLabel(graph.nodes[s.dst].label)
});
});
// --- Curated spine beats (unique, strictly increasing beatFrames) ---
const failureLabel = truncateLabel(graph.nodes[failureIndex].label);
const causeLabel = truncateLabel(graph.nodes[causeIndex].label);
const spineBeats: PathStepMeta[] = [];
const spine = (beatFrame: number, kind: number, label: string, nodeId: string) => {
spineBeats.push({
sourceIndex: failureIndex,
targetIndex: failureIndex,
beatFrame,
kind,
beatKind: 'rescue',
nodeId,
label
});
};
spine(DETONATE_FRAME, 1, `failure: ${failureLabel}`, graph.nodes[failureIndex].id);
lookalikeIndices.forEach((li, k) => {
spine(lookalikeFrame(k), 0, `lookalike ✗ · ${truncateLabel(graph.nodes[li].label)}`, graph.nodes[li].id);
});
spine(W(1), 1, 'reaching backward through time', 'rescue-wave-start');
if (causeDepth >= 2 && W(causeDepth) !== W(1)) {
spine(W(causeDepth), 1, `scrubbing past · ${causeDepth} hops`, 'rescue-wave-deep');
}
spine(ARC_FRAME, 1, `causal arc · ${causeLabel}`, graph.nodes[causeIndex].id);
spine(VERDICT_START, 1, 'root cause found', 'rescue-verdict');
// --- Verdict copy (REAL memory labels + real date) ---
const createdAt = response.nodes.find((nd) => nd.id === graph.nodes[causeIndex].id)?.createdAt ?? '';
const causeDate = createdAt ? createdAt.slice(0, 10) : '';
const verdict: RescueVerdictCopy = {
headline: 'root cause found',
causeLabel,
failureLabel,
causeDate,
hops: causeDepth,
k: K,
receipt: `${causeDepth} hops back · ${causeDate} · vector search: 0 for ${K}`
};
return {
viable: true,
failureIndex,
causeIndex,
lookalikeIndices,
hopDepths: depths,
causeDepth,
hopSlot,
waveData,
pathData,
pathMetas,
spineBeats,
verdict,
consts: { hopSlot, causeDepth }
};
}

View file

@ -0,0 +1,116 @@
/**
* Cognitive Observatory salience-rescue choreography pass.
*
* Compute-only FramePass (no render(): nodes and ribbons draw via the
* NodeRenderer's existing pipelines). Uploads the per-node packed wave word
* once (static), then each frame extends the choreography INTO the NodeState
* demo lanes as a pure function of (frame, role, hopDepth) no stateful
* integration, so capture mode (?frame=N) works with zero special-casing.
*
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
* the upload $effect creates RescueRenderer) so rescue_choreo encodes AFTER
* recall_sim in the same encoder and its demo-lane overwrite wins. recall_sim
* rewrites demo.x every frame with an afterglow window of bf+40..bf+200 the
* causal arc at bf=560 would otherwise carry visible residual across the
* 7190 loop seam.
*
* Three independent walls keep OTHER demos pixel-identical:
* (a) the route constructs this renderer only in the rescue branch,
* (b) compute() gates on params[9] === 2 ('salience-rescue' demo index),
* (c) demo.y/.z/.w have no other writer, so the new render-nodes terms
* multiply/add exact 0.0 elsewhere.
*/
import type { ObservatoryEngine, FramePass } from './engine';
import type { NodeRenderer } from './node-renderer';
import type { RescuePlan } from './rescue-plan';
import { rescueWGSL } from './shaders/rescue.wgsl';
/** DEMO_MODES.indexOf('salience-rescue') — types.ts, verified index 2. */
const RESCUE_DEMO_ID = 2;
export interface RescueRendererOptions {
engine: ObservatoryEngine;
nodeRenderer: NodeRenderer;
plan: RescuePlan;
}
export class RescueRenderer implements FramePass {
private engine: ObservatoryEngine;
private nodeRenderer: NodeRenderer;
private plan: RescuePlan;
private pipeline: GPUComputePipeline | null = null;
private bindGroup: GPUBindGroup | null = null;
private waveBuffer: GPUBuffer | null = null;
constructor(opts: RescueRendererOptions) {
this.engine = opts.engine;
this.nodeRenderer = opts.nodeRenderer;
this.plan = opts.plan;
this.engine.addPass(this);
}
/** Create the wave buffer + compute pipeline. Call after NodeRenderer.upload(). */
upload(): void {
const device = this.engine.gpuDevice;
if (!device || !this.engine.paramsBuffer) return;
if (!this.plan.viable) return;
if (!this.nodeRenderer.nodeStateBuffer || this.nodeRenderer.nodeCountValue === 0) return;
this.waveBuffer?.destroy();
this.waveBuffer = device.createBuffer({
label: 'observatory-rescue-wave',
size: Math.max(4, this.plan.waveData.byteLength),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(this.waveBuffer, 0, this.plan.waveData.buffer as ArrayBuffer);
// hopSlot/causeDepth are baked into the shader as f32 literals — no
// uniform buffer, so the auto layout has nothing to strip.
const module = device.createShaderModule({
label: 'observatory-rescue-choreo',
code: rescueWGSL(this.plan.consts)
});
this.pipeline = device.createComputePipeline({
label: 'observatory-rescue-choreo',
layout: 'auto',
compute: { module, entryPoint: 'rescue_choreo' }
});
// EXACTLY the 3 declared bindings — auto layout strips unused bindings
// and binding anything extra invalidates the group (the BirthRenderer
// lesson, birth-renderer.ts createComputePipeline).
this.bindGroup = device.createBindGroup({
label: 'observatory-rescue-bind',
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.nodeRenderer.nodeStateBuffer } },
{ binding: 2, resource: { buffer: this.waveBuffer } }
]
});
}
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
compute(encoder: GPUCommandEncoder): void {
if (this.engine.params[9] !== RESCUE_DEMO_ID) return;
if (!this.pipeline || !this.bindGroup) return;
const n = this.nodeRenderer.nodeCountValue;
if (n === 0) return;
const pass = encoder.beginComputePass({ label: 'observatory-rescue-choreo' });
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.dispatchWorkgroups(Math.ceil(n / 64));
pass.end();
}
dispose(): void {
this.waveBuffer?.destroy();
this.waveBuffer = null;
this.pipeline = null;
this.bindGroup = null;
}
}

View file

@ -0,0 +1,124 @@
/**
* Cognitive Observatory birth particle compute pass (Moment B, Task B3).
*
* One invocation per particle (workgroup 64, dispatch ceil(N/64)). Each
* particle converges from its start position toward the target over the
* 720-frame loop:
*
* frames 0239 : latent trace condensing (slow drift)
* frames 240329: engram coalescence (accelerated convergence)
* frames 330359: memory ignition (flash handled in render)
* frames 360509: associations engrave (hold at target)
* frames 510719: stabilization (hold, then reset)
*
* All time terms are integer-cycles per 720 frames so the loop seam is
* invisible. Capture mode (params._pad == 1.0) skips integration.
*
* Particle layout (16 floats / 64 bytes per particle):
* start_life : xyz start position, w phase offset (stagger)
* target_size : xyz target position, w base size (1.0 + rng * 1.8)
* color_phase : rgb base color, w phase offset
* state : xyz current position (shader writes), w alpha
*/
export const birthParticlesWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
// 16 floats / 64 bytes per particle (matches birth-plan.ts layout).
struct BirthParticle {
start_life: vec4<f32>,
target_size: vec4<f32>,
color_phase: vec4<f32>,
state: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> particles: array<BirthParticle>;
@compute @workgroup_size(64)
fn birth_compute(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&particles)) {
return;
}
// Capture mode (params._pad == 1.0): skip physics integration.
// The storage-buffer state stays frozen at initial upload values.
if (params._pad == 1.0) {
return;
}
var particle = particles[i];
let frame = params.frame;
let phase = params.loop_phase;
// --- Convergence choreography (integer cycles per 720-frame loop) ---
// Phase offset (stagger) from start_life.w: 0..1 → delays convergence.
let stagger = particle.start_life.w;
// Effective frame: staggered loop frame (wraps at 720).
let effFrame = fract(phase + stagger * 0.15) * 720.0;
// --- Phase 1: latent trace condensing (frames 0239) ---
// Slow drift toward target.
var t: f32;
if (effFrame < 240.0) {
// Smooth ease-in: 0 → 1 over 240 frames.
t = effFrame / 240.0;
t = t * t * (3.0 - 2.0 * t); // smoothstep
}
// --- Phase 2: engram coalescence (frames 240329) ---
// Accelerated convergence to target.
else if (effFrame < 330.0) {
let localFrame = effFrame - 240.0;
// 0 → 1 over 90 frames, with slight overshoot then settle.
t = localFrame / 90.0;
t = t * t * (3.0 - 2.0 * t);
// Add a small overshoot (1.05) then settle back to 1.0.
t = 1.0 - 0.05 * (1.0 - t);
}
// --- Phase 3: memory ignition (frames 330359) ---
// Hold at target (flash handled in render).
else if (effFrame < 360.0) {
t = 1.0;
}
// --- Phase 4: associations engrave (frames 360509) ---
// Hold at target.
else if (effFrame < 510.0) {
t = 1.0;
}
// --- Phase 5: stabilization (frames 510719) ---
// Hold at target, then fade alpha for reset.
else {
let localFrame = effFrame - 510.0;
// Fade alpha to 0 for seamless reset at frame 0.
t = 1.0;
particle.state.w = 1.0 - smoothstep(0.0, 150.0, localFrame);
}
// Interpolate from start to target.
let startPos = particle.start_life.xyz;
let targetPos = particle.target_size.xyz;
// (WGSL forbids swizzle stores - reconstruct, preserving alpha in .w)
particle.state = vec4<f32>(mix(startPos, targetPos, t), particle.state.w);
// Alpha: particles fade in during convergence, fade out during reset.
let fadeIn = smoothstep(0.0, 60.0, effFrame);
particle.state.w = max(particle.state.w, fadeIn * 0.8);
particles[i] = particle;
}
`;

View file

@ -0,0 +1,125 @@
/**
* Cognitive Observatory firewall choreography compute pass (WGSL).
*
* One invocation per node. Decodes the packed fire word (firewall-plan.ts:
* bits 0-7 shockDelay, bit 8 isIntruder, bit 9 isSeverNeighbor, bits 10-13
* sever slot k) and writes ALL FOUR NodeState demo lanes as PURE functions of
* (params.frame, params.loop_phase, packed word):
*
* demo.x ALWAYS 0.0 the recall/thin-film grammar can never fire here
* demo.y intruder only: intrusion flare band (0..1], 36 integer sine
* cycles/loop, then the sustained MEMBRANE band [2.60..2.90], 12
* integer cycles one lane, two value ranges (render-nodes.wgsl
* separates them with min(fy,1) vs smoothstep(1.5, 2.2, fy))
* demo.z ALWAYS 0.0 the forgetting-horizon grammar can never fire here
* demo.w crimson shock: source detonation on the intruder, per-node rim as
* the radial front passes (arrival A = 150 + delay, amplitude fades
* with distance), sever-blink receipts at 345 + 21k
*
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
* recall_sim rewrites demo.x every frame from the path buffer (afterglow
* decays bf+40..bf+200 the k=5 sever beam at bf=450 would leave residual
* demo.x near the seam). Overwriting all four lanes here is simultaneously
* the choreography, the loop-seam guarantee, and free ?frame=N capture
* support (stateless: same frame in same lanes out).
*
* Every envelope has attack a0 90 and release r1 680 exact 0.0 at
* frames 0 and 719. Sines are factors on zero-at-seam envelopes with INTEGER
* cycles per loop. The CPU mirror (firewall-plan.ts firewallEnvelopes) is
* machine-checked by the seam-zero test keep both in lockstep.
*
* Bind group = EXACTLY the 3 declared bindings (params, nodes, fire).
*/
export const firewallWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
// 1 u32/node: bits 0-7 shockDelay, 8 isIntruder, 9 isSeverNeighbor,
// 10-13 sever slot k (firewall-plan.ts packing). Every node carries a delay.
@group(0) @binding(2) var<storage, read> fire: array<u32>;
const TAU: f32 = 6.28318530717958647;
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
}
@compute @workgroup_size(64)
fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= u32(params.node_count)) {
return;
}
if (i >= arrayLength(&fire)) {
return;
}
// Belt-and-braces atop the TS gate: firewall is demo index 4.
if (params.demo_id != 4.0) {
return;
}
let packed = fire[i];
let delay = f32(packed & 0xffu);
let is_intruder = (packed & 0x100u) != 0u;
let is_sever = (packed & 0x200u) != 0u;
let k = f32((packed >> 10u) & 0xfu);
let f = params.frame;
var fy = 0.0;
var fw = 0.0;
if (is_intruder) {
// Intrusion flare: sickly strobe, band (0..1], 36 integer cycles/loop.
// C¹ handoff into the membrane over 330-332 (the rise sweeps the flare
// band exactly once — the condensation read is intentional).
fy = env(f, 90.0, 96.0, 310.0, 332.0)
* (0.55 + 0.45 * sin(TAU * 36.0 * params.loop_phase));
// Membrane: sustained ring band [2.60..2.90], 12 integer cycles/loop.
fy = fy + env(f, 330.0, 352.0, 620.0, 680.0)
* (2.75 + 0.15 * sin(TAU * 12.0 * params.loop_phase));
// Source detonation as the front leaves.
fw = env(f, 148.0, 153.0, 162.0, 196.0);
} else {
// Crimson rim as the radial front passes: arrival A = 150 + delay,
// amplitude fades with distance; A ∈ [150, 294] ⇒ all rims dead by 320.
let a = 150.0 + delay;
let amp = 0.9 - 0.45 * (delay / 144.0);
fw = amp * env(f, a - 2.0, a + 3.0, a + 8.0, a + 26.0);
if (is_sever) {
// Node-side receipt of the severed edge; last release 474.
let sk = 345.0 + 21.0 * k;
fw = fw + 0.6 * env(f, sk - 4.0, sk, sk + 6.0, sk + 24.0);
}
}
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
// lanes pass through untouched (the force sim owns them). demo.x and
// demo.z are hard 0.0: the recall and horizon grammars can never fire here.
var node = nodes[i];
node.demo = vec4<f32>(0.0, fy, 0.0, fw);
nodes[i] = node;
}
`;

View file

@ -0,0 +1,114 @@
/**
* Cognitive Observatory forgetting-horizon choreography compute pass (WGSL).
*
* One invocation per node. Decodes the packed horizon word (forgetting-plan.ts:
* bits 0-7 rank, bit 8 isDrifting, bit 9 isRescued, bits 10-11 rescue slot k)
* and writes ALL FOUR NodeState demo lanes as PURE functions of
* (params.frame, packed word):
*
* demo.x rescue ignition (existing thin-film recall response) on the 3 rescued
* demo.y ALWAYS 0.0 the rescue searchlight grammar can never fire here
* demo.z horizon fade-and-fall (vertex drift + shrink, fragment dim to ~6%)
* demo.w ALWAYS 0.0 the shock grammar can never fire here
*
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
* recall_sim rewrites demo.x every frame from the path buffer (afterglow decays
* bf+40..bf+200 the k=2 rescue ribbon at bf=438 would leave residual demo.x
* near the seam). Overwriting all four lanes here is simultaneously the
* choreography, the loop-seam guarantee, and free ?frame=N capture support
* (stateless: same frame in same lanes out).
*
* Every term has attack a0 90 and is multiplied by the master release
* 1smoothstep(660, 712, f) exact 0.0 at frames 0 and 719. NO sines in this
* moment. The CPU mirror (forgetting-plan.ts forgettingEnvelopes) is
* machine-checked by the seam-zero test keep both in lockstep.
*
* Bind group = EXACTLY the 3 declared bindings (params, nodes, horizon).
*/
export const forgettingWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
// 1 u32/node: bits 0-7 rank, 8 isDrifting, 9 isRescued, 10-11 rescue slot k
// (forgetting-plan.ts packing). Non-drifting nodes are exactly 0.
@group(0) @binding(2) var<storage, read> horizon: array<u32>;
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
}
@compute @workgroup_size(64)
fn forgetting_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= u32(params.node_count)) {
return;
}
if (i >= arrayLength(&horizon)) {
return;
}
// Belt-and-braces atop the TS gate: forgetting-horizon is demo index 3.
if (params.demo_id != 3.0) {
return;
}
let packed = horizon[i];
let is_drifting = (packed & 0x100u) != 0u;
let is_rescued = (packed & 0x200u) != 0u;
let rank01 = f32(packed & 0xffu) / 255.0;
let k = f32((packed >> 10u) & 0x3u);
let f = params.frame;
// Master release: every lane is exactly 0.0 by frame 712 — the seam wall.
let master = 1.0 - smoothstep(660.0, 712.0, f);
var dx = 0.0;
var dz = 0.0;
if (is_drifting) {
let onset = 90.0 + 42.0 * rank01;
// Phase 1 — the drift: dim + fall to the 0.55 plateau, retention-staggered.
let phase1 = 0.55 * smoothstep(onset, onset + 210.0, f);
if (is_rescued) {
let rk = 318.0 + 60.0 * k;
// Snap-back begins 22 frames before the recall ribbon lands at rk.
dz = master * phase1 * (1.0 - smoothstep(rk - 22.0, rk + 6.0, f));
// Ignition rides the EXISTING recall response (render-nodes.wgsl):
// spectral() thin-film band + white-hot core + sprite swell for free.
dx = master * env(f, rk - 26.0, rk, rk + 60.0, rk + 130.0);
} else {
// Phase 2 — the sink: to exactly 1.0 over 640..660 (the ~6% floor era).
let phase2 = 0.45 * smoothstep(480.0 + 24.0 * rank01, 640.0, f);
dz = master * (phase1 + phase2);
}
}
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
// lanes pass through untouched (the force sim owns them). demo.y and
// demo.w are hard 0.0: the rescue/firewall grammars can never fire here.
var node = nodes[i];
node.demo = vec4<f32>(dx, 0.0, dz, 0.0);
nodes[i] = node;
}
`;

View file

@ -0,0 +1,166 @@
/**
* Cognitive Observatory edge wavefront render shader (WGSL).
*
* Draws additive edges between nodes, with a traveling wavefront that
* travels from source target at the beat frame. The wavefront is a
* glowing pulse that rides along the edge, brightening as it approaches
* the target node (spec §7.2: additive bloom, thin-film spectral glow).
*
* Layout contracts: Params = types.PARAMS_FLOATS, Edge = 2×vec2<u32>
* (types.ts UINTS_PER_EDGE), PathStep = 4×vec4<u32> (types.ts
* UINTS_PER_PATHSTEP).
*/
export const renderEdgesWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Camera {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<uniform> camera: Camera;
// Source/target node indices (2 u32 per edge).
@group(0) @binding(2) var<storage, read> edges: array<vec2<u32>>;
// PathStep buffer for wavefront timing.
@group(0) @binding(3) var<storage, read> path: array<vec4<u32>>;
// NodeState storage buffer (positions for edge endpoints).
@group(0) @binding(4) var<storage, read> nodes: array<Node>;
// Iridescent thin-film band — ported EXACTLY from causal-brain-demo.html
// spectral(w) (visual DNA §7.1): indigo → cyan-teal → mint → magenta rim.
fn spectral(w_in: f32) -> vec3<f32> {
let w = fract(w_in);
let stops = array<vec3<f32>, 4>(
vec3<f32>(0.20, 0.28, 0.95), // indigo
vec3<f32>(0.20, 0.85, 0.90), // cyan-teal
vec3<f32>(0.45, 1.00, 0.72), // mint
vec3<f32>(0.85, 0.45, 1.00) // magenta rim
);
let f = w * 4.0;
let i = u32(floor(f)) % 4u;
let frac = f - floor(f);
let a = stops[i];
let b = stops[(i + 1u) % 4u];
return mix(a, b, frac);
}
struct VSOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec3<f32>,
@location(1) width: f32,
};
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32
) -> VSOut {
var out: VSOut;
let edgeCount = u32(params.edge_count);
if (ii >= edgeCount) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
let edge = edges[ii];
let srcIdx = edge.x;
let tgtIdx = edge.y;
if (srcIdx >= u32(params.node_count) || tgtIdx >= u32(params.node_count)) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
let src = nodes[srcIdx];
let tgt = nodes[tgtIdx];
// Two vertices per edge: source (vi=0) and target (vi=1).
let pos = select(src.pos_radius.xyz, tgt.pos_radius.xyz, vi == 1u);
// World-space position.
let world = pos;
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
// Wavefront computation: find the nearest path beat for this edge.
let pathCount = u32(params.path_count);
var waveIntensity = 0.0;
var waveT = 1.0; // 0 = source, 1 = target
for (var s = 0u; s < pathCount; s = s + 1u) {
let step = path[s];
let srcIdxS = step.x;
let tgtIdxS = step.y;
let bf = f32(step.z);
// Check if this path step uses the same source→target.
if (srcIdxS == srcIdx && tgtIdxS == tgtIdx) {
let frame = params.frame;
// Wavefront: sharp pulse traveling from source to target.
let attack = smoothstep(bf - 10.0, bf + 2.0, frame);
let decay = 1.0 - smoothstep(bf + 30.0, bf + 180.0, frame);
waveIntensity = max(waveIntensity, attack * decay);
// Wave position along edge (0 = source, 1 = target).
let arrival = bf - 10.0;
let end = bf + 30.0;
if (frame >= arrival && frame <= end) {
waveT = (frame - arrival) / (end - arrival);
} else if (frame > end) {
waveT = 1.0;
}
}
}
// Edge base color: blend of source and target node base colors.
let srcColor = src.color_flags.rgb;
let tgtColor = tgt.color_flags.rgb;
let baseColor = mix(srcColor, tgtColor, 0.5);
// Wavefront color: thin-film spectral band, modulated by wave position.
let waveColor = spectral(waveT + params.loop_phase);
// Combine: base edge (dim) + wavefront pulse (bright, additive).
let edgeAlpha = 0.08 * params.brightness; // dim connecting line
let waveAlpha = waveIntensity * 0.9 * params.brightness; // bright pulse
// Spectral hue rides the wavefront.
out.color = baseColor * edgeAlpha + waveColor * waveAlpha;
// Line width: thicker at the wavefront for visibility.
out.width = 1.0 + waveIntensity * 3.0;
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
// Soft edge: feather the line edges.
let alpha = smoothstep(0.0, 0.5, in.width) * 0.6;
// Additive: alpha is ignored, light accumulates.
return vec4<f32>(in.color, 1.0);
}
`;

View file

@ -0,0 +1,269 @@
/**
* Cognitive Observatory node billboard shader (WGSL).
*
* Instanced soft-glow sprites read straight from the NodeState storage buffer
* (compute-boids render pattern, spec §1). Additive blending onto the void so
* overlapping memories build light instead of z-fighting.
*
* Visual DNA §7: base hue = FSRS state color (meaning at rest); the global
* breath `pulse` modulates halo energy so the field is alive even when idle.
* Layout contracts: Params = types.PARAMS_FLOATS, Node = 4×vec4f (types.ts).
*/
export const renderNodesWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Camera {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<uniform> camera: Camera;
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
// Iridescent thin-film band — ported EXACTLY from causal-brain-demo.html
// spectral(w) (visual DNA §7.1): indigo → cyan-teal → mint → magenta rim,
// wrapping. Activation glow rides this band; base color stays FSRS state.
fn spectral(w_in: f32) -> vec3<f32> {
let w = fract(w_in);
// var, not let: WGSL only allows dynamic indexing through a reference.
var stops = array<vec3<f32>, 4>(
vec3<f32>(0.20, 0.28, 0.95), // indigo
vec3<f32>(0.20, 0.85, 0.90), // cyan-teal
vec3<f32>(0.45, 1.00, 0.72), // mint
vec3<f32>(0.85, 0.45, 1.00) // magenta rim
);
let f = w * 4.0;
let i = u32(floor(f)) % 4u;
let frac = f - floor(f);
let a = stops[i];
let b = stops[(i + 1u) % 4u];
return mix(a, b, frac);
}
struct VSOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
// Per-instance constants: flat interpolation guarantees the flag bit
// field survives the raster stage bit-exact (no barycentric rounding).
@location(1) @interpolate(flat) color: vec3<f32>,
// x retention, y flags (bit field as f32), z recall intensity, w radius
@location(2) @interpolate(flat) misc: vec4<f32>,
// Per-demo choreography lanes (demo.y, demo.z, demo.w), gated by demo_id:
// rescue (2) searchlight/wave/shock, forgetting-horizon (3) fade-and-fall,
// firewall (4) flare-membrane/shock. Each demo's choreography pass is the
// ONLY writer of its lanes, and every gated term below is an exact no-op
// when its lane is 0.0 — other demos stay pixel-identical.
@location(3) @interpolate(flat) demo_yzw: vec3<f32>,
};
// Quad corners for two triangles (vertex_index 0..5).
const CORNERS = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0)
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32
) -> VSOut {
var out: VSOut;
if (ii >= u32(params.node_count)) {
// degenerate — clipped away
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
return out;
}
let node = nodes[ii];
let corner = CORNERS[vi];
// Breath: halo geometry swells ~6% on the global pulse (§7.2), and the
// center memory breathes a touch deeper — a heartbeat, not a strobe.
let flags = u32(node.color_flags.w);
let is_center = (flags & 1u) != 0u;
var breath = 1.0 + 0.06 * params.pulse;
if (is_center) {
breath = 1.0 + 0.12 * params.pulse;
}
// Sprite spans ~3.2× the core radius so the halo has room to feather out.
// Recall activation swells the sprite — the wavefront physically blooms.
// Per-demo choreography lanes swell it too, gated by demo_id so each
// demo's grammar can never leak into another (lanes are 0.0 elsewhere,
// and the gate makes the no-op structural, not just numerical).
let recall = node.demo.x;
let dy = node.demo.y;
let dz = node.demo.z;
let dw = node.demo.w;
var lane_swell = 0.0;
if (params.demo_id == 2.0) {
// salience-rescue: searchlight pop, wave shiver, shock bloom.
lane_swell = 0.5 * dy + 0.25 * dz + 0.9 * dw;
} else if (params.demo_id == 4.0) {
// firewall: intrusion flare pop (band (0..1]), membrane presence
// (band [2.6..2.9] via the range gate), crimson shock bloom.
lane_swell = 0.35 * min(dy, 1.0) + 0.3 * smoothstep(1.5, 2.2, dy) + 0.55 * dw;
}
// forgetting-horizon (demo 3): VISUAL displacement toward the horizon —
// down and away from the field axis, ~40.5 units at dz = 1 — plus a
// shrink. pos_radius is NEVER written (the force sim owns positions);
// drift is pure of demo.z, so ?frame=N capture stays exact. CPU mirror:
// forgetting-plan.ts horizonDrift().
var horizon_scale = 1.0;
var drift = vec3<f32>(0.0);
if (params.demo_id == 3.0) {
let dzc = clamp(dz, 0.0, 1.0);
horizon_scale = 1.0 - 0.35 * dzc;
if (dz > 0.0) {
let p = node.pos_radius.xyz;
let r_xz = max(length(p.xz), 0.001);
let away = vec3<f32>(p.x / r_xz, 0.0, p.z / r_xz);
drift = dzc * (vec3<f32>(0.0, -34.0, 0.0) + away * 22.0);
}
}
let half_size = node.pos_radius.w * 3.2 * breath * (1.0 + 0.9 * recall)
* (1.0 + lane_swell) * horizon_scale;
let world = node.pos_radius.xyz + drift
+ camera.right.xyz * corner.x * half_size
+ camera.up.xyz * corner.y * half_size;
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
out.uv = corner;
out.color = node.color_flags.rgb;
out.misc = vec4<f32>(node.vel_retention.w, node.color_flags.w, node.demo.x, node.pos_radius.w);
out.demo_yzw = vec3<f32>(dy, dz, dw);
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
let d = length(in.uv);
if (d > 1.0) {
discard;
}
let retention = in.misc.x;
let flags = u32(in.misc.y);
let suppressed = (flags & 2u) != 0u;
let is_center = (flags & 1u) != 0u;
// Soft sprite: hot core + feathered halo. The halo rides the breath pulse.
let core = smoothstep(0.22, 0.0, d);
let halo = pow(max(1.0 - d, 0.0), 2.4);
var intensity = core * 1.35 + halo * (0.42 + 0.18 * params.pulse);
// Meaning layer: low-retention memories glow dimmer (drifting toward the
// horizon), the center anchor reads brightest.
intensity = intensity * (0.45 + 0.55 * retention);
if (is_center) {
intensity = intensity * 1.6;
}
if (suppressed) {
intensity = intensity * 0.28;
}
var color = in.color * intensity;
// Forgetting-horizon (demo 3): multiplicative dim toward near-black as
// demo.z rises. Floor 0.06 — never fully gone, always retrievable. Sits
// BEFORE the recall block so a rescued memory's ignition burns through
// the fade. demo_yzw.y carries demo.z (vec3 = y/z/w lanes).
if (params.demo_id == 3.0) {
color = color * mix(1.0, 0.06, clamp(in.demo_yzw.y, 0.0, 1.0));
}
// Recall activation (§7.1): the thin-film band takes over as the wave
// lands. Hue drifts exactly ONE full spectral cycle per 720-frame loop
// (loop_phase wraps 0→1 and spectral() fract-wraps) — oil-slick shimmer
// with a mathematically invisible loop seam.
let recall = in.misc.z;
if (recall > 0.001) {
let band = spectral(0.1 + params.loop_phase + d * 0.35);
let activation = band * recall * (core * 1.7 + halo * 0.9);
// white-hot pinpoint at full ignition
let flash = vec3<f32>(1.0, 1.0, 1.0) * core * recall * 0.55;
color = color + activation + flash;
}
// Per-demo choreography lanes — gated by demo_id AND on nonzero values so
// every other demo is pixel-unchanged (each demo's pass is the only
// writer of its lanes, and lanes are exactly 0.0 everywhere else).
if (params.demo_id == 2.0) {
if (in.demo_yzw.x > 0.001) {
// Searchlight: cold clinical white — unmistakably NOT the spectral grammar.
color = color + vec3<f32>(0.82, 0.90, 1.00) * in.demo_yzw.x * (core * 1.8 + halo * 0.7);
}
if (in.demo_yzw.y > 0.001) {
// Interrogation shimmer: icy spectral strobe as the wave scrubs the past.
color = color + spectral(0.55 + params.loop_phase) * in.demo_yzw.y * (core * 0.9 + halo * 0.5)
+ vec3<f32>(1.0) * core * in.demo_yzw.y * 0.2;
}
if (in.demo_yzw.z > 0.001) {
// Detonation: crimson blaze + warm-white pinpoint.
color = color + vec3<f32>(1.00, 0.16, 0.10) * in.demo_yzw.z * (core * 1.9 + halo * 1.1)
+ vec3<f32>(1.0, 0.85, 0.8) * core * in.demo_yzw.z * 0.4;
}
} else if (params.demo_id == 4.0) {
// firewall: demo.y carries TWO value bands — intrusion flare (0..1]
// and membrane [2.6..2.9] — separated by range, one lane. demo.w is
// the crimson shock rim / sever blink. (demo_yzw = y/z/w lanes.)
let fy = in.demo_yzw.x;
let fw = in.demo_yzw.z;
// Intrusion flare: sickly green-white — a hue deliberately OUTSIDE
// both the FSRS palette and the thin-film band. Continuous across the
// band boundary (fades out as fy climbs toward the membrane band).
let flare = min(fy, 1.0) * (1.0 - smoothstep(1.0, 1.8, fy));
if (flare > 0.001) {
color = color + vec3<f32>(0.62, 1.00, 0.55) * flare * (core * 1.7 + halo * 0.9)
+ vec3<f32>(0.90, 1.00, 0.85) * core * flare * 0.5;
}
// Membrane: quarantine ring at d ≈ 0.75 with fresnel-ish falloff —
// green body, crimson edge. exp(-q·q) squares by multiplication and
// the pow base is clamped ≥ 0 (no pow(neg) anywhere).
let mw = smoothstep(1.5, 2.2, fy);
if (mw > 0.001) {
let q = (d - 0.75) * 9.0;
let ring = exp(-q * q);
let fresnel = pow(clamp(d / 0.75, 0.0, 1.0), 3.0);
let ring_col = mix(vec3<f32>(0.55, 1.00, 0.60), vec3<f32>(1.00, 0.20, 0.16),
smoothstep(0.72, 0.92, d));
color = color + ring_col * ring * fresnel * mw * 1.4;
}
// Shockwave: crimson RIM as the front passes (a rim, not a blaze).
if (fw > 0.001) {
let rim = smoothstep(0.45, 0.8, d) * (1.0 - smoothstep(0.85, 1.0, d));
color = color + vec3<f32>(1.00, 0.14, 0.10) * rim * fw * 1.5
+ vec3<f32>(1.00, 0.60, 0.50) * core * fw * 0.15;
}
}
// Additive target (src=one, dst=one): alpha is ignored, light accumulates.
return vec4<f32>(color * params.brightness, 1.0);
}
`;

View file

@ -0,0 +1,181 @@
/**
* Cognitive Observatory recall path edge wavefront (Increment 6).
*
* One instanced screen-aligned quad per path step. The vertex shader fetches
* both endpoint nodes from the NodeState storage buffer (GraphWaGu edge_vert
* pattern, spec §1) and extrudes a constant-screen-width ribbon between them.
* The fragment draws a light packet traveling source target, timed to land
* exactly on the beat frame, with a fading trail behind it.
*
* Deterministic: wave position is a pure function of (frame, beatFrame).
*/
export const renderPathWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Camera {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<uniform> camera: Camera;
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
@group(0) @binding(3) var<storage, read> path: array<vec4<u32>>;
// Same thin-film band as the node shader (§7.1).
fn spectral(w_in: f32) -> vec3<f32> {
let w = fract(w_in);
// var, not let: WGSL only allows dynamic indexing through a reference.
var stops = array<vec3<f32>, 4>(
vec3<f32>(0.20, 0.28, 0.95),
vec3<f32>(0.20, 0.85, 0.90),
vec3<f32>(0.45, 1.00, 0.72),
vec3<f32>(0.85, 0.45, 1.00)
);
let f = w * 4.0;
let i = u32(floor(f)) % 4u;
let frac = f - floor(f);
let a = stops[i];
let b = stops[(i + 1u) % 4u];
return mix(a, b, frac);
}
struct VSOut {
@builtin(position) clip: vec4<f32>,
// x: t along segment (0 source → 1 target), y: side (-1..1)
@location(0) uv: vec2<f32>,
// x: beat frame, y: kind, z: segment visible (0 skips degenerate steps)
// Per-instance constant — flat keeps it bit-exact through the raster.
@location(1) @interpolate(flat) beat: vec3<f32>,
};
// (t, side) corners for two triangles of the ribbon.
const RIBBON = array<vec2<f32>, 6>(
vec2<f32>(0.0, -1.0),
vec2<f32>(1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0)
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32
) -> VSOut {
var out: VSOut;
if (ii >= u32(params.path_count)) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
out.beat = vec3<f32>(0.0);
return out;
}
let step = path[ii];
let src = nodes[step.x];
let dst = nodes[step.y];
let corner = RIBBON[vi];
// Degenerate (origin beat: source == target) — emit nothing visible.
if (step.x == step.y) {
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
out.beat = vec3<f32>(0.0);
return out;
}
let a = camera.view_proj * vec4<f32>(src.pos_radius.xyz, 1.0);
let b = camera.view_proj * vec4<f32>(dst.pos_radius.xyz, 1.0);
// NDC-space perpendicular for constant screen width.
let ndc_a = a.xy / max(a.w, 0.0001);
let ndc_b = b.xy / max(b.w, 0.0001);
var dir = ndc_b - ndc_a;
let dlen = max(length(dir), 0.0001);
dir = dir / dlen;
let perp = vec2<f32>(-dir.y, dir.x);
// Ribbon half-width in NDC (aspect-corrected), ~2.5 px on a 900px-tall view.
let px = 2.5 / max(params.viewport_h, 1.0) * 2.0;
let width = vec2<f32>(px * (params.viewport_h / max(params.viewport_w, 1.0)), px);
let base = mix(a, b, corner.x);
let offset = perp * width * corner.y * base.w;
out.clip = vec4<f32>(base.xy + offset, base.zw);
out.uv = vec2<f32>(corner.x, corner.y);
out.beat = vec3<f32>(f32(step.z), f32(step.w), 1.0);
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
if (in.beat.z < 0.5) {
discard;
}
let frame = params.frame;
let bf = in.beat.x;
let t = in.uv.x;
// Wave departs 45 frames before the beat and lands exactly on it.
let progress = clamp((frame - (bf - 45.0)) / 45.0, 0.0, 1.0);
// Nothing before departure; trail lingers ~90 frames after arrival.
let live = smoothstep(bf - 46.0, bf - 44.0, frame)
* (1.0 - smoothstep(bf + 40.0, bf + 90.0, frame));
if (live <= 0.001) {
discard;
}
// The light packet: gaussian around the wavefront position.
let dwave = (t - progress) * 14.0;
let packet = exp(-dwave * dwave);
// Fading trail behind the packet — provenance stays visible a beat.
var trail = 0.0;
if (t < progress) {
trail = (1.0 - (progress - t)) * 0.22;
}
// Feather across the ribbon width.
let across = 1.0 - abs(in.uv.y);
let profile = across * across;
// Backward/contradiction hops burn hotter into the magenta rim (§7.4).
// Hue drifts one full spectral cycle per loop (seamless at the wrap).
// Kind 2 (salience-rescue probe): a gray failing beam — vector search
// visibly probing lookalikes and coming back empty. Kinds 0/1 unchanged.
var band = spectral(0.15 + t * 0.35 + params.loop_phase);
var packet_white = 0.35;
if (in.beat.y > 1.5) {
band = vec3<f32>(0.62, 0.66, 0.72);
packet_white = 0.18;
} else if (in.beat.y > 0.5) {
band = mix(band, vec3<f32>(1.0, 0.25, 0.45), 0.55);
}
let energy = (packet * 1.6 + trail) * profile * live;
let color = band * energy + vec3<f32>(1.0) * packet * profile * live * packet_white;
return vec4<f32>(color * params.brightness, 1.0);
}
`;

View file

@ -0,0 +1,148 @@
/**
* Cognitive Observatory salience-rescue choreography compute pass (WGSL).
*
* One invocation per node. Decodes the packed wave word (rescue-plan.ts:
* bits 0-15 hopDepth, 16 isFailure, 17 isCause, 18 isLookalike, 19-21 k) and
* writes ALL FOUR NodeState demo lanes as PURE functions of
* (params.frame, params.loop_phase, packed word):
*
* demo.x cause ignition (existing thin-film recall response) + symptom backlight
* demo.y searchlight cold-white flare on the K lookalikes + ash residue
* demo.z backward-wave interrogation flicker + scanned ember
* demo.w detonation spike + wound simmer + shockwave blinks + recognition flare
*
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
* recall_sim rewrites demo.x every frame from the path buffer (afterglow decays
* bf+40..bf+200 the causal arc at bf=560 would leave a visible residual at
* frame 719). Overwriting all four lanes here is simultaneously the
* choreography, the loop-seam guarantee, and free ?frame=N capture support
* (stateless: same frame in same lanes out).
*
* Every envelope term is A·smoothstep(a0,a1,f)·(1smoothstep(r0,r1,f)) with
* attacks a0 88 and releases r1 700 exact 0.0 at frames 0 and 719.
* The flicker sine runs 24 INTEGER cycles per loop. The CPU mirror
* (rescue-plan.ts rescueEnvelopes) is machine-checked by the seam-zero test
* keep both in lockstep.
*
* `hopSlot`/`causeDepth` are template-substituted f32 literals (no uniform
* buffer no strippable binding). Bind group = EXACTLY the 3 declared
* bindings (params, nodes, wave).
*/
import type { RescueShaderConsts } from '../rescue-plan';
export function rescueWGSL(c: RescueShaderConsts): string {
const hopSlot = c.hopSlot.toFixed(1);
const causeDepth = c.causeDepth.toFixed(1);
return /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
// 1 u32/node: bits 0-15 hopDepth (0xffff unreached), 16 failure, 17 cause,
// 18 lookalike, 19-21 lookalike k (rescue-plan.ts packing).
@group(0) @binding(2) var<storage, read> wave: array<u32>;
const HOP_SLOT: f32 = ${hopSlot};
const CAUSE_DEPTH: f32 = ${causeDepth};
const TAU: f32 = 6.28318530717958647;
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
}
fn arrival(d: f32) -> f32 {
return min(260.0 + HOP_SLOT * d, 514.0);
}
@compute @workgroup_size(64)
fn rescue_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= u32(params.node_count)) {
return;
}
if (i >= arrayLength(&wave)) {
return;
}
// Belt-and-braces atop the TS gate: salience-rescue is demo index 2.
if (params.demo_id != 2.0) {
return;
}
let packed = wave[i];
let depth_u = packed & 0xffffu;
let d = f32(depth_u);
let is_failure = (packed & 0x10000u) != 0u;
let is_cause = (packed & 0x20000u) != 0u;
let is_look = (packed & 0x40000u) != 0u;
let look_k = f32((packed >> 19u) & 0x7u);
let f = params.frame;
var dx = 0.0;
var dy = 0.0;
var dz = 0.0;
var dw = 0.0;
if (is_failure) {
// Detonation spike, wound simmer, recognition flare as the arc lands.
dw = dw + env(f, 90.0, 96.0, 120.0, 168.0);
dw = dw + 0.35 * env(f, 100.0, 130.0, 600.0, 656.0);
dw = dw + 0.35 * env(f, 552.0, 562.0, 580.0, 640.0);
// Symptom backlight while the cause burns.
dx = dx + 0.4 * env(f, 556.0, 566.0, 620.0, 668.0);
}
if (!is_failure && depth_u >= 1u && depth_u <= 12u) {
// Shockwave blink: crimson concussion, 3 frames/hop of REAL graph distance.
dw = dw + 0.75 * exp(-0.3 * d)
* env(f, 92.0 + 3.0 * d, 96.0 + 3.0 * d, 96.0 + 3.0 * d, 122.0 + 3.0 * d);
}
if (is_look) {
let fk = 138.0 + 28.0 * look_k;
// Searchlight flare — cold pop, sequential, on camera.
dy = dy + env(f, fk - 6.0, fk, fk + 10.0, fk + 26.0);
// Ash residue — the struck-through lookalike stays in frame until the verdict.
dy = dy + 0.15 * smoothstep(fk + 10.0, fk + 26.0, f) * (1.0 - smoothstep(600.0, 656.0, f));
}
if (!is_failure && depth_u >= 1u && d <= CAUSE_DEPTH) {
let wd = arrival(d);
// Interrogation flicker: 24 integer sine cycles per loop, per-depth phase.
let flicker = 0.75 + 0.25 * sin(TAU * 24.0 * params.loop_phase + 1.7 * d);
dz = dz + env(f, wd - 10.0, wd, wd + 28.0, wd + 64.0) * flicker;
// Scanned ember.
dz = dz + 0.08 * smoothstep(wd + 28.0, wd + 64.0, f) * (1.0 - smoothstep(580.0, 640.0, f));
}
if (is_cause) {
// Cause ignition rides the EXISTING recall response (render-nodes.wgsl):
// spectral() thin-film band + white-hot core + sprite swell at full intensity.
dx = dx + env(f, 520.0, 546.0, 640.0, 700.0);
}
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
// lanes pass through untouched (the force sim owns them).
var node = nodes[i];
node.demo = vec4<f32>(dx, dy, dz, dw);
nodes[i] = node;
}
`;
}

View file

@ -0,0 +1,163 @@
/**
* Cognitive Observatory recall-path + force simulation compute pass (Increment 7).
*
* One invocation per node (workgroup 64, dispatch ceil(N/64) the canonical
* compute-boids pattern, spec §1). Each node:
*
* 1. Scans the small PathStep buffer ( 8 beats) and computes its recall
* activation envelope for this loop frame (arrival / departure).
* 2. Computes deterministic GPU force settle: edge springs, O(N²)
* repulsion ( 500 nodes), gentle centering, damping, velocity cap,
* and position integration.
*
* Writes NodeState.demo.x (recall intensity). Deterministic: everything is a
* pure function of (frame, path buffer, node state) no randomness, no wall
* clock. Center node (isCenter flag) never moves.
*
* PASS ORDER (salience-rescue): rescue_choreo (shaders/rescue.wgsl.ts) MUST
* encode AFTER this pass in the same encoder it overwrites all four demo
* lanes so the arc-afterglow demo.x written here (decays bf+40..bf+200) never
* crosses the 7190 loop seam. The route guarantees construction order:
* NodeRenderer first, RescueRenderer second.
*/
export const simulateWGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
_pad: f32,
};
struct Node {
pos_radius: vec4<f32>,
vel_retention: vec4<f32>,
color_flags: vec4<f32>,
demo: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
@group(0) @binding(2) var<storage, read> path: array<vec4<u32>>;
// x source index, y target node index (Increment 7: force simulation edges)
@group(0) @binding(3) var<storage, read> edges: array<vec2<u32>>;
// --- Force-simulation helpers (Increment 7) ---
fn safe_normalize(v: vec3<f32>) -> vec3<f32> {
let l = length(v);
if (l < 0.0001) { return vec3<f32>(0.0); }
return v / l;
}
fn clamp_len(v: vec3<f32>, hi: f32) -> vec3<f32> {
let l = length(v);
if (l > hi && l > 0.0001) { return v * (hi / l); }
return v;
}
@compute @workgroup_size(64)
fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= u32(params.node_count)) {
return;
}
let frame = params.frame;
var intensity = 0.0;
let steps = u32(params.path_count);
for (var s = 0u; s < steps; s = s + 1u) {
let step = path[s];
let bf = f32(step.z);
if (step.y == i) {
// Arrival: sharp attack as the wavefront lands, slow afterglow.
let attack = smoothstep(bf - 14.0, bf + 4.0, frame);
let decay = 1.0 - smoothstep(bf + 40.0, bf + 200.0, frame);
intensity = max(intensity, attack * decay);
}
if (step.x == i && step.x != step.y) {
// Departure: the source shimmers as the wave leaves it.
let rise = smoothstep(bf - 55.0, bf - 30.0, frame);
let fall = 1.0 - smoothstep(bf + 10.0, bf + 70.0, frame);
intensity = max(intensity, rise * fall * 0.45);
}
}
var node = nodes[i];
let flags = u32(node.color_flags.w);
let is_center = (flags & 1u) != 0u;
// Write recall intensity (existing behavior preserved).
node.demo.x = intensity;
// --- Increment 7: force simulation ---
// Capture mode (params._pad == 1.0): skip physics integration entirely.
// The storage-buffer state stays frozen at initial upload values,
// making same URL + frame → identical pixels (spec §4 Inc 9).
if (params._pad == 0.0) {
// 7B: center anchor — center node never moves.
// (WGSL forbids swizzle stores — reconstruct the vec4, preserving .w.)
if (is_center) {
node.pos_radius = vec4<f32>(0.0, 0.0, 0.0, node.pos_radius.w);
node.vel_retention = vec4<f32>(0.0, 0.0, 0.0, node.vel_retention.w);
nodes[i] = node;
return;
}
let pos = node.pos_radius.xyz;
var force = vec3<f32>(0.0);
// 7C: edge springs — scan existing edgeBuffer, no atomics.
for (var e = 0u; e < u32(params.edge_count); e = e + 1u) {
let edge = edges[e];
var other_idx = 0xffffffffu;
if (edge.x == i) { other_idx = edge.y; }
if (edge.y == i) { other_idx = edge.x; }
if (other_idx != 0xffffffffu && other_idx < u32(params.node_count)) {
let other = nodes[other_idx].pos_radius.xyz;
let delta = other - pos;
let dist = max(length(delta), 0.001);
let dir = delta / dist;
let stretch = dist - 34.0;
force = force + dir * stretch * 0.00055;
}
}
// 7D: soft repulsion (only ≤ 500 nodes for performance).
if (u32(params.node_count) <= 500u) {
for (var j = 0u; j < u32(params.node_count); j = j + 1u) {
if (j == i) { continue; }
let other = nodes[j].pos_radius.xyz;
let delta = pos - other;
let d2 = max(dot(delta, delta), 9.0);
force = force + safe_normalize(delta) * (7.5 / d2);
}
}
// Gentle centering: keeps the field in frame without crushing it.
force = force + (-pos) * 0.0008;
// 7B: velocity damping + cap, then position integration.
var vel = node.vel_retention.xyz;
vel = (vel + force) * 0.88;
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);
}
// When capture_mode (params._pad == 1.0), node is NOT written back —
// the storage buffer retains its initial upload values, guaranteeing
// deterministic pixels for the same frame index.
nodes[i] = node;
}
`;

View file

@ -0,0 +1,159 @@
/**
* Cognitive Observatory shared CPU-side types + GPU buffer layout constants.
*
* The Observatory is a full-bleed WebGPU "living memory field". Node/particle
* state lives in GPU storage buffers (boids ping-pong pattern, spec §1); this
* module defines the exact byte layout so the TS uploader and the WGSL shaders
* agree lane-for-lane.
*
* Visual DNA (spec §7): a node's BASE hue = its FSRS state color (meaning),
* its ACTIVATION glow rides the thin-film spectral band (transcendence).
*/
import type { GraphNode, GraphEdge } from '$types';
/** Demo modes reachable as deterministic loops: ?demo=<mode>&loop=1 */
export type DemoMode =
| 'recall-path'
| 'engram-birth'
| 'salience-rescue'
| 'forgetting-horizon'
| 'firewall';
export const DEMO_MODES: readonly DemoMode[] = [
'recall-path',
'engram-birth',
'salience-rescue',
'forgetting-horizon',
'firewall'
] as const;
export function isDemoMode(v: string): v is DemoMode {
return (DEMO_MODES as readonly string[]).includes(v);
}
// ---------------------------------------------------------------------------
// GPU buffer layout — NodeState
// ---------------------------------------------------------------------------
// Each node occupies 4 × vec4<f32> = 64 bytes, 16-byte aligned lanes so the
// WGSL struct maps 1:1 with no padding surprises.
//
// lane 0 pos_radius : xyz world position + w visual radius
// lane 1 vel_retention : xyz velocity + w FSRS retention (0..1)
// lane 2 color_flags : rgb base color + w packed flags (as f32)
// lane 3 demo : x recall intensity, y birth phase,
// z ripple phase, w shock phase
//
// FLOATS_PER_NODE is what the uploader writes; keep it in lockstep with the
// WGSL `struct NodeState`.
export const FLOATS_PER_NODE = 16;
export const BYTES_PER_NODE = FLOATS_PER_NODE * 4; // 64
// Lane offsets (in floats) for the uploader.
export const NODE_LANE = {
posRadius: 0, // +0..+3
velRetention: 4, // +4..+7
colorFlags: 8, // +8..+11
demo: 12 // +12..+15
} as const;
// Packed visual flags (stored in color_flags.w as an f32 bit field via bitcast
// on the GPU; on the CPU side we assemble the integer then Math.fround it).
export const NODE_FLAG = {
isCenter: 1 << 0,
suppressed: 1 << 1,
isAha: 1 << 2,
isFailure: 1 << 3,
isConfusion: 1 << 4
} as const;
// ---------------------------------------------------------------------------
// GPU buffer layout — EdgeIndex (static) and PathStep (demo path)
// ---------------------------------------------------------------------------
/** array<vec2<u32>> source/target node indices. 2 u32 per edge. */
export const UINTS_PER_EDGE = 2;
/**
* array<vec4<u32>> per path beat:
* x source node index, y target node index, z beat frame, w kind
* kind: 0 normal recall hop, 1 backward-cause hop (salience rescue),
* 2 probe beam (salience rescue: vector search failing on camera).
*/
export const UINTS_PER_PATHSTEP = 4;
export const PATH_KIND = {
recall: 0,
backwardCause: 1,
probe: 2
} as const;
// ---------------------------------------------------------------------------
// Per-frame uniforms — must match the WGSL `struct Params` exactly.
// ---------------------------------------------------------------------------
// Laid out as a flat Float32Array; sizes chosen so the whole block is a
// multiple of 16 bytes (WebGPU uniform alignment requirement).
//
// [0] frame (loop frame, wraps at loopFrames)
// [1] loopPhase (0..1)
// [2] nodeCount
// [3] edgeCount
// [4] pathCount
// [5] pulse (0.5 + 0.5*sin — global breath, spec §7.2)
// [6] viewportW
// [7] viewportH
// [8] brightness (from graphState)
// [9] demoId (DemoMode index)
// [10] time (fixed sim seconds = frame / fps; NOT wall clock)
// [11] _pad
export const PARAMS_FLOATS = 12;
export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 48 (multiple of 16)
export function demoModeId(mode: DemoMode): number {
const i = DEMO_MODES.indexOf(mode);
return i < 0 ? 0 : i;
}
// ---------------------------------------------------------------------------
// CPU-side observatory graph (stable-indexed view of the API GraphResponse).
// ---------------------------------------------------------------------------
export interface ObservatoryNode {
id: string;
index: number; // stable position in the NodeState buffer
label: string;
type: string;
retention: number;
tags: string[];
isCenter: boolean;
suppressed: boolean;
}
export interface ObservatoryEdge {
sourceIndex: number;
targetIndex: number;
weight: number;
type: string;
}
export interface ObservatoryGraph {
nodes: ObservatoryNode[];
edges: ObservatoryEdge[];
/** id -> stable buffer index */
indexById: Map<string, number>;
centerIndex: number;
}
/** Narrow the loose API GraphNode into what the Observatory needs. */
export function toObservatoryNode(n: GraphNode, index: number): ObservatoryNode {
return {
id: n.id,
index,
label: n.label,
type: n.type,
retention: typeof n.retention === 'number' ? n.retention : 0,
tags: Array.isArray(n.tags) ? n.tags : [],
isCenter: !!n.isCenter,
suppressed: (n.suppression_count ?? 0) > 0
};
}
export type { GraphNode, GraphEdge };

View file

@ -0,0 +1,286 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$stores/api';
import type { GraphResponse } from '$types';
import TelemetryStrip from '$lib/observatory/overlays/TelemetryStrip.svelte';
import TimelineSpine from '$lib/observatory/overlays/TimelineSpine.svelte';
import RescueVerdict from '$lib/observatory/overlays/RescueVerdict.svelte';
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
import { isDemoMode, type DemoMode } from '$lib/observatory/types';
import type { ObservatoryEngine } from '$lib/observatory/engine';
import { NodeRenderer } from '$lib/observatory/node-renderer';
import { BirthRenderer } from '$lib/observatory/birth-renderer';
import { RescueRenderer } from '$lib/observatory/rescue-renderer';
import { buildRescuePlan, type RescuePlan } from '$lib/observatory/rescue-plan';
import { ForgettingRenderer } from '$lib/observatory/forgetting-renderer';
import { buildForgettingPlan } from '$lib/observatory/forgetting-plan';
import { FirewallRenderer } from '$lib/observatory/firewall-renderer';
import { buildFirewallPlan, type FirewallPlan } from '$lib/observatory/firewall-plan';
import type { PathStepMeta } from '$lib/observatory/path-builder';
// URL contract: ?demo=recall-path&seed=vestige-observatory-v1
const params = new URLSearchParams(window.location.search);
const demoParam = params.get('demo') ?? 'recall-path';
const demoMode: DemoMode = isDemoMode(demoParam) ? demoParam : 'recall-path';
const seedValue = params.get('seed') ?? 'vestige-observatory-v1';
// Capture mode: ?frame=N freezes the sim at one loop frame (identical pixels).
const frameParam = params.get('frame');
const freezeFrame = frameParam !== null && frameParam !== '' ? Number(frameParam) : null;
// Recording mode: ?capture=1 hides EVERY DOM instrument — pure canvas for
// clips/stills. &hud=1 keeps the instruments; H toggles at runtime.
let showHud = $state(!(params.get('capture') === '1' && params.get('hud') !== '1'));
const isCapture = params.get('capture') === '1';
function onKeydown(e: KeyboardEvent) {
if (e.key === 'h' || e.key === 'H') showHud = !showHud;
}
let graphData: GraphResponse | null = $state(null);
let loading = $state(true);
let error = $state('');
// Telemetry state — frames come from the engine's deterministic DemoClock.
let frameCount = $state(0);
let fpsEstimate = $state(0);
let nodeCount = $state(0);
let edgeCount = $state(0);
let centerId = $state('');
// Engine + renderer handles (upload happens once both are ready).
let engine = $state<ObservatoryEngine | null>(null);
let renderer: NodeRenderer | null = null;
let birthRenderer: BirthRenderer | null = null;
let rescueRenderer: RescueRenderer | null = null;
let rescuePlan = $state<RescuePlan | null>(null);
let forgettingRenderer: ForgettingRenderer | null = null;
let firewallRenderer: FirewallRenderer | null = null;
let firewallPlan = $state<FirewallPlan | null>(null);
let uploaded = false;
let pathSteps = $state<PathStepMeta[]>([]);
async function loadGraph() {
loading = true;
error = '';
try {
const data = await api.graph({
max_nodes: 300,
depth: 3,
sort: 'recent'
});
graphData = data;
nodeCount = data.nodeCount;
edgeCount = data.edgeCount;
centerId = data.center_id;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load graph data';
} finally {
loading = false;
}
}
function handleFrame(frame: number, fps: number) {
frameCount = frame;
fpsEstimate = fps;
}
function handleReady(e: ObservatoryEngine) {
// A fresh engine (first boot or HMR re-create) always needs an upload.
uploaded = false;
engine = e;
renderer = new NodeRenderer(e);
}
// Upload the memory field once the engine AND the graph are both ready.
$effect(() => {
if (engine && renderer && graphData && !uploaded) {
uploaded = true;
const isBirth = demoMode === 'engram-birth';
const isRescue = demoMode === 'salience-rescue';
const isHorizon = demoMode === 'forgetting-horizon';
const isFirewall = demoMode === 'firewall';
renderer.upload(graphData, seedValue, {
recallPath: !isBirth && !isRescue && !isHorizon && !isFirewall
});
if (isBirth) {
// Moment B: violet dust converges into a newborn memory.
birthRenderer = new BirthRenderer({ engine, nodeRenderer: renderer, seed: seedValue });
birthRenderer.upload(seedValue);
// B6: the engrave steps ride the proven recall wavefront system —
// pulses travel outward from the newborn, neighbors bloom on landing.
const engrave = birthRenderer.engraveSteps;
const engraveMetas = [];
for (let i = 0; i < engrave.length / 4; i++) {
engraveMetas.push({
sourceIndex: engrave[i * 4],
targetIndex: engrave[i * 4 + 1],
beatFrame: engrave[i * 4 + 2],
kind: engrave[i * 4 + 3],
beatKind: 'engrave',
nodeId: `engrave-${i}`,
label: 'edge engraved'
});
}
renderer.setPathSteps(engrave, engraveMetas);
// Spine shows the birth beats (converge → flash → engrave).
pathSteps = birthRenderer.timeline.map((b, i) => ({
sourceIndex: 0,
targetIndex: 0,
beatFrame: b.startFrame,
kind: 0,
beatKind: 'birth',
nodeId: `birth-${i}`,
label: b.label
}));
} else if (isRescue) {
// Moment C: Retroactive Salience Backfill — vector search fails on
// camera, then Vestige reaches backward through time and ignites
// the true cause. All choreography is a pure CPU plan; the
// RescueRenderer overwrites the demo lanes AFTER recall_sim
// (construction order = pass order — load-bearing for the seam).
const plan = buildRescuePlan(graphData, renderer.graph!, seedValue);
rescuePlan = plan;
if (plan.viable) {
rescueRenderer = new RescueRenderer({ engine, nodeRenderer: renderer, plan });
rescueRenderer.upload();
// Probe beams + backward wave tree + causal arc ride the proven
// path-ribbon machinery. Metas are 1:1 with the GPU steps
// (setPathSteps sets params[4] + draw count from META length).
renderer.setPathSteps(plan.pathData, plan.pathMetas);
}
// Spine shows the curated story beats (unique beatFrames), never
// the raw GPU steps.
pathSteps = plan.spineBeats;
} else if (isHorizon) {
// Moment D: the forgetting horizon — FSRS as a visible living
// system. The lowest-retention memories dim and fall; three are
// recalled back on camera; the rest sink to a 6% floor (never
// deleted). Pure CPU plan; the ForgettingRenderer overwrites the
// demo lanes AFTER recall_sim (construction order = pass order).
const plan = buildForgettingPlan(renderer.graph!);
if (plan.viable) {
forgettingRenderer = new ForgettingRenderer({ engine, nodeRenderer: renderer, plan });
forgettingRenderer.upload();
// The 3 rescue ribbons ride the proven path-ribbon machinery.
renderer.setPathSteps(plan.pathData, plan.pathMetas);
}
// No verdict overlay for this moment — the spine narrates.
pathSteps = plan.spineBeats;
} else if (isFirewall) {
// Moment E: the immune response — a suspicious memory flares,
// a crimson shockwave crosses the field, its edges sever one by
// one, a membrane rings it, the verdict card lands. Pure CPU
// plan; the FirewallRenderer overwrites the demo lanes AFTER
// recall_sim (construction order = pass order).
const plan = buildFirewallPlan(renderer.graph!, seedValue);
firewallPlan = plan;
if (plan.viable) {
firewallRenderer = new FirewallRenderer({ engine, nodeRenderer: renderer, plan });
firewallRenderer.upload();
// The sever probe-beams ride the proven path-ribbon machinery.
renderer.setPathSteps(plan.pathData, plan.pathMetas);
}
pathSteps = plan.spineBeats;
} else {
pathSteps = renderer.pathSteps;
}
// Start the story at frame 0 now that the field exists — where the
// loop begins must never depend on how long the API call took.
engine.demoClock.reset();
}
});
onMount(() => {
loadGraph();
});
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Full-bleed void stage: #05060a -->
<div class="fixed inset-0 overflow-hidden bg-[#05060a]" class:cursor-none={isCapture}>
<!-- Canvas layer (z-index 0) — the living memory field -->
<div class="absolute inset-0 z-0">
<ObservatoryCanvas
demo={demoMode}
seed={seedValue}
{freezeFrame}
onframe={handleFrame}
onready={handleReady}
/>
</div>
<!-- DOM overlay layer (pointer-events:none) — instruments only.
?capture=1 hides it all (pure canvas for recording); H toggles. -->
{#if showHud}
<div class="absolute inset-0 z-10 pointer-events-none">
<!-- Top telemetry strip -->
<TelemetryStrip
demoMode={demoMode}
seed={seedValue}
nodeCount={nodeCount}
edgeCount={edgeCount}
centerId={centerId}
frameCount={frameCount}
fpsEstimate={fpsEstimate}
{freezeFrame}
loading={loading}
error={error}
/>
<!-- Loading state -->
{#if loading}
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
<div class="text-[#5dcaa5] font-mono text-sm tracking-widest animate-pulse">
LOADING MEMORY FIELD...
</div>
</div>
{/if}
<!-- Error state -->
{#if error && !loading}
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
<div class="text-red-400 font-mono text-sm border border-red-900/50 bg-red-950/30 px-4 py-2 rounded">
{error}
</div>
</div>
{/if}
<!-- Timeline spine: beat ticks + playhead riding the loop -->
<TimelineSpine steps={pathSteps} frame={frameCount} />
<!-- Salience-rescue verdict card (frames 600-660, frame-driven opacity) -->
{#if demoMode === 'salience-rescue' && rescuePlan?.viable}
<RescueVerdict frame={frameCount} verdict={rescuePlan.verdict} />
{/if}
<!-- Firewall quarantine verdict card (frames 480-620, crimson tone) -->
{#if demoMode === 'firewall' && firewallPlan?.viable}
<RescueVerdict
frame={frameCount}
tone="quarantine"
fadeWindow={[480, 495, 605, 620]}
verdict={{
headline: firewallPlan.verdict.headline,
causeLabel: firewallPlan.verdict.intruderLabel,
receipt: firewallPlan.verdict.receipt
}}
/>
{/if}
<!-- Empty graph state -->
{#if !loading && graphData && graphData.nodeCount === 0}
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
<div class="text-[#5dcaa5] font-mono text-sm tracking-widest">
NO MEMORIES IN FIELD
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- VISUAL DNA §7: void #05060a is the base (bg utility above) — no exceptions -->

View file

@ -0,0 +1,5 @@
// The Observatory is a WebGPU client-only surface: it reads
// window.location for its ?demo= contract and boots a GPU device on mount.
// SSR would 500 on `window` and can never render the field anyway.
export const ssr = false;
export const prerender = false;

View file

@ -29,15 +29,19 @@
$page.url.pathname.startsWith(base) ? $page.url.pathname.slice(base.length) || '/' : $page.url.pathname
);
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
// The Observatory is a full-bleed cinematic surface (spec §7): DOM =
// instrument overlays ONLY — no app chrome, no websocket toasts, no nav.
// Same bare-children bypass as marketing routes so recordings stay clean.
let isImmersiveRoute = $derived(dashboardPath.startsWith('/observatory'));
onMount(() => {
if (!isMarketingRoute) {
if (!isMarketingRoute && !isImmersiveRoute) {
websocket.connect();
}
const teardownTheme = initTheme();
function onKeyDown(e: KeyboardEvent) {
if (isMarketingRoute) return;
if (isMarketingRoute || isImmersiveRoute) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
showCommandPalette = !showCommandPalette;
@ -98,6 +102,7 @@
// set reused the same Unicode glyph across multiple items; every entry here
// now has a distinct silhouette that reads instantly.
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
{ href: '/observatory', label: 'Observatory', icon: 'sparkle', shortcut: 'O' },
{ href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' },
{ href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' },
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
@ -140,7 +145,7 @@
}
</script>
{#if isMarketingRoute}
{#if isMarketingRoute || isImmersiveRoute}
{@render children()}
{:else}
<!-- Ambient background orbs -->
@ -259,7 +264,7 @@
{/if}
<!-- Command Palette overlay -->
{#if showCommandPalette && !isMarketingRoute}
{#if showCommandPalette && !isMarketingRoute && !isImmersiveRoute}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4 bg-void/60 backdrop-blur-sm"

View file

@ -9,11 +9,14 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3927',
target: process.env.VESTIGE_API_TARGET ?? 'http://127.0.0.1:3927',
changeOrigin: true
},
'/ws': {
target: 'ws://127.0.0.1:3927',
target: (process.env.VESTIGE_API_TARGET ?? 'http://127.0.0.1:3927').replace(
'http',
'ws'
),
ws: true
}
}

View file

@ -0,0 +1,46 @@
<svg viewBox="0 0 680 430" xmlns="http://www.w3.org/2000/svg" width="680" height="430" role="img" aria-label="CauseBench root-cause recall at 1: Vestige scores 60% on synthetic and 50% on real; Dense vector, Hybrid-RRF, BM25, TF-IDF and Recency all score 0%." font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">
<title>CauseBench root-cause recall@1 — Vestige 60% / 50%, every resemblance retriever 0%</title>
<text x="24" y="34" font-size="17" font-weight="600" fill="#1d9e75">Root-cause recall@1 — can the retriever rank the true cause #1?</text>
<text x="24" y="56" font-size="13" fill="#888780">CauseBench · a cause that shares an entity with the failure but none of its words</text>
<line x1="330" y1="78" x2="330" y2="308" stroke="#88878055" stroke-width="1"/>
<line x1="450" y1="78" x2="450" y2="308" stroke="#88878055" stroke-width="1"/>
<line x1="570" y1="78" x2="570" y2="308" stroke="#88878055" stroke-width="1"/>
<line x1="210" y1="78" x2="210" y2="308" stroke="#888780" stroke-width="1.5"/>
<text x="210" y="330" font-size="11" fill="#888780" text-anchor="middle">0%</text>
<text x="330" y="330" font-size="11" fill="#888780" text-anchor="middle">20%</text>
<text x="450" y="330" font-size="11" fill="#888780" text-anchor="middle">40%</text>
<text x="570" y="330" font-size="11" fill="#888780" text-anchor="middle">60%</text>
<text x="196" y="99" font-size="13" font-weight="600" fill="#0f6e56" text-anchor="end">Vestige</text>
<rect x="210" y="88" width="216" height="17" rx="4" fill="#1d9e75"/>
<rect x="210" y="109" width="180" height="17" rx="4" fill="#5dcaa5"/>
<text x="434" y="101" font-size="12" font-weight="600" fill="#0f6e56">60% synthetic</text>
<text x="398" y="122" font-size="12" fill="#0f6e56">50% real</text>
<text x="196" y="158" font-size="13" fill="#888780" text-anchor="end">Dense vector (nomic)</text>
<circle cx="212" cy="153" r="2.5" fill="#888780"/>
<text x="224" y="157" font-size="12" fill="#888780">0%</text>
<text x="196" y="191" font-size="13" fill="#888780" text-anchor="end">Hybrid-RRF (BM25 + dense)</text>
<circle cx="212" cy="186" r="2.5" fill="#888780"/>
<text x="224" y="190" font-size="12" fill="#888780">0%</text>
<text x="196" y="224" font-size="13" fill="#888780" text-anchor="end">BM25</text>
<circle cx="212" cy="219" r="2.5" fill="#888780"/>
<text x="224" y="223" font-size="12" fill="#888780">0%</text>
<text x="196" y="257" font-size="13" fill="#888780" text-anchor="end">TF-IDF</text>
<circle cx="212" cy="252" r="2.5" fill="#888780"/>
<text x="224" y="256" font-size="12" fill="#888780">0%</text>
<text x="196" y="290" font-size="13" fill="#888780" text-anchor="end">Recency</text>
<circle cx="212" cy="285" r="2.5" fill="#888780"/>
<text x="224" y="289" font-size="12" fill="#888780">0%</text>
<line x1="24" y1="356" x2="656" y2="356" stroke="#88878040" stroke-width="1"/>
<text x="24" y="380" font-size="12" fill="#888780">Every resemblance retriever ranks the true cause #1 exactly 0% of the time.</text>
<text x="24" y="400" font-size="11" fill="#888780">Vestige also lands the cause in the top 5 in 100% of cases. Source: benchmarks/causebench/RESULTS.md · reproduce with 3 commands.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,108 @@
# Backward-Trace Animation — Storyboard ("The X Rocket")
**Purpose:** the single most shareable launch artifact. A ~15s looping clip,
pinned to the top of the launch thread on X and used as the Show HN demo. It
shows the ONE thing nothing else does: when a failure hits, Vestige's arrow
snaps *backward through time* past the confounders to the quiet change that
actually caused it — while a vector search sits stuck on the symptom.
**Design law (from 2026 viral-demo research):** animate the *mechanism*
legibly, no narration needed, payoff visible in the first 1.5s, loops clean, no
logo intro. The tldraw / X-algorithm-viz / CodeReel pattern: the demo IS the
pitch.
---
## The case shown (canonical CauseBench archetype)
Uses the exact incident SHAPE the benchmark is built on (see
`benchmarks/causebench/data/real/*.meta.json`: a config/limit/cert/migration/flag
change that shares an *entity* with a later failure but *none of its words*, with
a more-recent confounder planted to defeat naive recency).
Timeline of stored memories (left = 3 weeks ago, right = today):
| When | Memory | Role |
|---|---|---|
| 21 days ago | `Lowered connection-pool max from 100 → 20 in db.config.yaml` | **the true cause** (shares entity `db.config.yaml` / the DB, not words) |
| 9 days ago | `Renamed UserService to AccountService across the API` | confounder (loud, recent, unrelated) |
| 4 days ago | `Bumped Postgres driver to 5.2` | confounder (shares "Postgres", more recent than cause) |
| **today** | `PagerDuty: checkout timing out under load — "connection acquisition timeout"` | **the failure (symptom)** |
Vector search ranks by resemblance → it surfaces the driver bump and the
timeout log (they share words like "connection", "Postgres"). It ranks the
pool-size change near the *bottom* — that memory shares no vocabulary with
"timeout under load". Vestige's Retroactive Salience Backfill reaches backward
along the shared entity (the database / `db.config.yaml`) and promotes the
pool-size change to #1.
---
## Frame-by-frame (15s, 30fps, loops)
**Beat 0 — 0.0s to 1.5s · THE HOOK (payoff visible immediately)**
- A red failure card slams in at the right edge (today): `checkout timing out — connection acquisition timeout`. Subtle shake.
- Caption, one line, monospace: `your agent's bug, today →`
- (Why first: a scroller must see the payoff-shape in 1.5s or they're gone.)
**Beat 1 — 1.5s to 4.0s · THE TIMELINE FILLS**
- A horizontal time axis draws left→right. Four memory cards fade in at their dates: the pool-size change (far left, dim/dormant), the two confounders (mid), the failure (right, red).
- The dormant cause is visibly *faded* — small, low-contrast. It looks unimportant. That's the point.
**Beat 2 — 4.0s to 6.5s · VECTOR SEARCH TRIES (and fails)**
- Label appears: `vector search` with a small magnifying-glass icon.
- Three thin gray "similarity" beams shoot from the failure card to the cards that *share words*: the driver bump and (self) the log. A rank badge appears: the true cause gets tagged `#7` in gray, sinking to the bottom.
- Caption: `finds what looks like the bug`
- Beat ends with a gray ✕ over the search — it never touched the real cause.
**Beat 3 — 6.5s to 10.5s · THE ARROW SNAPS BACK (the money moment)**
- Everything else desaturates. A single bright teal arrow launches from the failure card and travels *right-to-left, backward in time*, deliberately skipping the two confounders (each pings faintly and is passed over — "shares words, not entity" micro-label flickers).
- The arrow lands on the dormant pool-size card 21 days back. On contact the card **ignites**: scales up, fills teal, snaps from dim to bright. A link line labeled `same entity: db.config.yaml` connects them.
- Caption: `Vestige reaches back to what caused it`
- This is the frame that gets clipped and reshared. Make the snap fast and physical (ease-in, slight overshoot).
**Beat 4 — 10.5s to 13.0s · THE VERDICT**
- The promoted cause card shows a teal `#1` badge; the vector `#7` badge greys beside it for contrast.
- Two-line stat, big: `root-cause recall@1` / `Vestige 60% · vector search 0%`
- Small honest sub-label: `on CauseBench · reproducible`
**Beat 5 — 13.0s to 15.0s · SIGNATURE + LOOP RESET**
- Wordmark `vestige` fades in bottom-left, tiny. Tagline: `memory that finds the cause, not the resemblance.`
- Everything gently fades and the failure card is already sliding back in at the right — the loop restart is seamless (no hard cut).
---
## Production notes
- **Format:** build in HTML/CSS/SVG (animated), screen-record to MP4 + GIF.
Alternatively Remotion (React) if you want a clean MP4 export pipeline — but
the animated-SVG prototype below is enough to record from directly.
- **Palette:** teal `#1d9e75` = Vestige/cause/truth; gray `#888780` = vector
search/confounders/noise; red `#e24b4a` = the failure only. Two-color
discipline + one alarm color. Works on light and dark.
- **Text:** monospace for the memory-card content (feels like real logs);
sans for captions. No narration — captions carry it, so it works muted in a
feed (most X video autoplays silent).
- **Honesty guardrails (non-negotiable, same as the chart):** the stat frame
must say `60%` with `on CauseBench` attached, and pair it with `vector search
0%`. Never imply an industry benchmark. Never show the FALSE cross-session
claim.
- **Loop length:** 15s is the sweet spot for X autoplay + a clean GIF under
~8MB. If the GIF is too heavy, cut Beat 1 to 2s and land at 13s total.
---
## The X post it pins to
> Every AI memory tool is built on vector search. Vector search finds what
> *looks like* your bug.
>
> But a root cause never looks like the bug it creates.
>
> So I built memory that reaches *backward in time* to the change that actually
> caused it. 60% root-cause recall where vector search scores 0%. 🧵👇
>
> [pinned: the 15s clip]
(Then the thread: the wall-of-zeros chart, the repro command, the "here's where
it's weak" honesty tweet.)

View file

@ -0,0 +1,282 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vestige — causal brain demo</title>
<style>
:root { color-scheme: dark; }
* { margin:0; padding:0; box-sizing:border-box; }
html,body { height:100%; background:#05060a; overflow:hidden; }
#stage { position:fixed; inset:0; }
canvas { display:block; width:100%; height:100%; }
.layer { position:fixed; inset:0; pointer-events:none; }
.cap {
position:fixed; left:0; right:0; bottom:8.5%;
text-align:center; font:500 clamp(15px,2.4vw,26px)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
letter-spacing:.01em; color:#cfe9ff; opacity:0; transition:opacity .5s ease;
text-shadow:0 0 24px rgba(30,180,255,.35); padding:0 6%;
}
.cap.mono { font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace; font-size:clamp(13px,1.9vw,20px); }
.verdict {
position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); text-align:center; opacity:0;
transition:opacity .6s ease; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
padding:clamp(18px,3vw,40px) clamp(28px,6vw,80px); border-radius:20px;
background:radial-gradient(ellipse at center, rgba(5,7,14,.86) 0%, rgba(5,7,14,.72) 60%, rgba(5,7,14,0) 100%);
}
.verdict .k { font-size:clamp(13px,1.8vw,18px); color:#9fd0e4; letter-spacing:.16em; text-transform:uppercase; }
.verdict .v { font-size:clamp(32px,6.4vw,72px); font-weight:600; margin-top:.12em; line-height:1.05;
background:linear-gradient(90deg,#7fe6c0,#6ef0e6,#a6dcff); -webkit-background-clip:text; background-clip:text; color:transparent;
filter:drop-shadow(0 0 26px rgba(110,240,220,.45)); }
.verdict .s { font-size:clamp(11px,1.5vw,15px); color:#8fb0be; margin-top:.6em; font-family:"SF Mono",ui-monospace,monospace; letter-spacing:.04em; }
.wordmark { position:fixed; left:5%; bottom:6%; font:600 clamp(16px,2.2vw,24px)/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
letter-spacing:.28em; color:#5dcaa5; opacity:0; transition:opacity .8s ease; text-shadow:0 0 20px rgba(93,202,165,.4); }
.replay { position:fixed; right:16px; bottom:14px; font:400 13px/1 -apple-system,sans-serif; color:#7c8a97;
background:rgba(255,255,255,.04); border:.5px solid rgba(255,255,255,.14); border-radius:8px; padding:6px 12px; cursor:pointer; pointer-events:auto; }
.replay:hover { background:rgba(255,255,255,.08); color:#cfe9ff; }
</style>
</head>
<body>
<div id="stage"><canvas id="c"></canvas></div>
<div class="layer">
<div class="cap mono" id="cap"></div>
<div class="verdict" id="verdict">
<div class="k">root-cause recall@1</div>
<div class="v">Vestige 60% &middot; vector search 0%</div>
<div class="s">on CauseBench &middot; reproducible</div>
</div>
<div class="wordmark" id="wordmark">VESTIGE</div>
</div>
<button class="replay" id="replay">replay</button>
<script>
(function(){
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
const cap = document.getElementById('cap'), verdict = document.getElementById('verdict');
const wordmark = document.getElementById('wordmark');
let W, H, DPR;
function viewport(){
const w = window.innerWidth || document.documentElement.clientWidth || document.getElementById('stage').clientWidth || 1280;
const h = window.innerHeight || document.documentElement.clientHeight || document.getElementById('stage').clientHeight || 720;
return { w: Math.max(w, 320), h: Math.max(h, 240) };
}
function resize(){
DPR = Math.min(devicePixelRatio||1, 2);
const { w, h } = viewport();
W = cv.width = w*DPR; H = cv.height = h*DPR;
cv.style.width = w+'px'; cv.style.height = h+'px';
}
function reflow(){ resize(); if(typeof seedBrain==='function'){ seedBrain(); placeActors(); } }
resize(); addEventListener('resize', reflow);
if(window.ResizeObserver){ let first=true; new ResizeObserver(()=>{ if(first){first=false;return;} reflow(); }).observe(document.getElementById('stage')); }
// ---- iridescent thin-film palette: indigo -> teal -> cyan -> magenta rim ----
// hand-tuned so the CORE reads indigo/teal (living tissue) and motion shifts it
// toward cyan/magenta, never muddy orange. w in [0,1].
function spectral(w){
w = ((w%1)+1)%1;
// 4 anchor colors around the film band, catmull-ish blend
const stops = [
[0.20,0.28,0.95], // indigo
[0.20,0.85,0.90], // cyan-teal
[0.45,1.00,0.72], // mint
[0.85,0.45,1.00], // magenta rim
];
const f = w*stops.length, i = Math.floor(f)%stops.length, frac = f-Math.floor(f);
const a = stops[i], b = stops[(i+1)%stops.length];
return [ a[0]+(b[0]-a[0])*frac, a[1]+(b[1]-a[1])*frac, a[2]+(b[2]-a[2])*frac ];
}
// ---- brain point cloud: two overlapping lobes, denser near centre ----
const N = 4200;
const pts = [];
function seedBrain(){
pts.length = 0;
const cx = W*0.5, cy = H*0.55, s = Math.min(W,H)*0.30;
for(let i=0;i<N;i++){
// pick a lobe (clear left/right hemispheres)
const lobe = Math.random()<0.5 ? -1 : 1;
const r = Math.pow(Math.random(), 0.5); // center-weighted -> dense core
const a = Math.random()*Math.PI*2;
const ex = 0.62, ey = 0.98;
let x = Math.cos(a)*r*ex, y = Math.sin(a)*r*ey;
x += lobe*0.40; // hemisphere split
// pinch the top (frontal) and round the base so silhouette reads as a brain
y *= (1.0 - 0.18*Math.max(0,-y));
// sulci wrinkle
const wob = 0.06*Math.sin(6*x)*Math.sin(5*y);
const px = cx + (x+wob)*s, py = cy + (y+wob)*s;
pts.push({
hx:px, hy:py, // brain-home
x:px + (Math.random()-0.5)*60, y:py + (Math.random()-0.5)*60,
vx:0, vy:0,
// base hue biased toward the indigo->teal core of the palette
base:0.02+Math.random()*0.42,
life:Math.random(),
r:0.8+Math.random()*1.6,
ignite:0, // 0..1 activation glow
});
}
}
seedBrain(); addEventListener('resize', seedBrain);
// ---- named actors (screen positions in brain space) ----
function homeAt(fx, fy){ // fractional brain coords -> nearest cloud point
const cx=W*0.5, cy=H*0.55, s=Math.min(W,H)*0.30;
return { x: cx+fx*s, y: cy+fy*s };
}
let actors = {};
function placeActors(){
actors = {
cause: homeAt(-0.92, 0.35), // deep left lobe, dormant
conf1: homeAt(-0.10,-0.45),
conf2: homeAt( 0.30, 0.55),
fail: homeAt( 0.92,-0.10), // deep right, the symptom
};
}
placeActors(); addEventListener('resize', placeActors);
// ---- animation clock / beats ----
let t0 = performance.now();
const DUR = 15000;
let arrow = 0; // 0..1 backward-trace progress
let failFlare = 0, vecStall = 0, causeGlow = 0;
function setCap(s, mono){ cap.textContent = s; cap.className = 'cap'+(mono?' mono':''); cap.style.opacity = s? 1:0; }
function schedule(el){ el.style.opacity = 1; }
function reset(){
t0 = performance.now(); arrow=0; failFlare=0; vecStall=0; causeGlow=0;
verdict.style.opacity=0; wordmark.style.opacity=0; setCap('');
seedBrain();
}
document.getElementById('replay').addEventListener('click', reset);
// ---- draw a soft additive glow blob ----
function glow(x,y,rad,col,alpha){
const g = ctx.createRadialGradient(x,y,0,x,y,rad);
g.addColorStop(0, `rgba(${col[0]},${col[1]},${col[2]},${alpha})`);
g.addColorStop(1, `rgba(${col[0]},${col[1]},${col[2]},0)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x,y,rad,0,Math.PI*2); ctx.fill();
}
function frame(now){
const t = (now - t0);
if(t > DUR){ reset(); requestAnimationFrame(frame); return; }
const pulse = 0.5 + 0.5*Math.sin(now*0.002); // ~0.32Hz breath
// ---- beat logic ----
// 0.0-1.5s brain forms + settles; failure fades in ~0.3s
// 4.0-6.5s vector search stalls on confounders
// 6.5-10.5s backward trace fires
// 10.5-13s verdict
// 13-15s signature
failFlare = t>300 ? Math.min(1,(t-300)/700) : 0;
if(t>4000 && t<6500){ vecStall = Math.min(1,(t-4000)/600); setCap('vector search: finds what looks like the bug', true); }
if(t>=1500 && t<4000){ setCap('your agents bug, today ', true); }
if(t>=6500 && t<10500){
arrow = Math.min(1,(t-6500)/3400);
vecStall = Math.max(0, vecStall-0.02);
setCap('Vestige reaches back to what caused it', true);
}
if(t>=9200){ causeGlow = Math.min(1,(t-9200)/900); }
if(t>=10500 && t<13000){ setCap(''); verdict.style.opacity=1; }
if(t>=13000){ wordmark.style.opacity=1; setCap('memory that finds the cause, not the resemblance', false); }
// field recedes behind the verdict so the headline is the focus
let fieldDim = 1;
if(t>=10200 && t<13000) fieldDim = 0.34;
else if(t>=13000) fieldDim = 0.7;
// ---- render ----
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(5,6,10,0.30)'; // trails / motion blur
ctx.fillRect(0,0,W,H);
ctx.globalCompositeOperation = 'lighter';
const formT = Math.min(1, t/1400);
// update + draw particles
for(let i=0;i<pts.length;i++){
const p = pts[i];
// spring to brain-home + gentle curl-ish drift
const k = 0.02*formT;
p.vx += (p.hx-p.x)*k; p.vy += (p.hy-p.y)*k;
const ca = now*0.0006 + i*0.3;
p.vx += Math.cos(ca+p.hy*0.01)*0.06; p.vy += Math.sin(ca+p.hx*0.01)*0.06;
// breathing: tighten toward center on systole
p.vx *= 0.90; p.vy *= 0.90;
p.x += p.vx; p.y += p.vy;
// ignition decays
p.ignite *= 0.94;
// hue: base + velocity-driven film shift + breath
const speed = Math.hypot(p.vx,p.vy);
let w = p.base + Math.min(speed*0.05,0.25) + 0.05*pulse + p.ignite*0.35;
const col = spectral(w);
// brighter, luminous tissue — floor keeps it from muddy, scale keeps it vivid
let R = 30+col[0]*225, G = 40+col[1]*215, B = 90+col[2]*165;
let a = (0.09 + 0.16*formT + p.ignite*0.7) * fieldDim;
const rad = p.r*DPR*(0.85 + p.ignite*3);
glow(p.x, p.y, rad*3.4, [R|0,G|0,B|0], a*0.45);
ctx.fillStyle = `rgba(${R|0},${G|0},${B|0},${Math.min(1,a*1.9)})`;
ctx.beginPath(); ctx.arc(p.x,p.y,rad,0,Math.PI*2); ctx.fill();
}
// ---- failure flare (red) ----
if(failFlare>0){
const shake = failFlare<1 ? (Math.random()-0.5)*4*DPR : 0;
glow(actors.fail.x+shake, actors.fail.y, 70*DPR*(0.7+0.3*pulse), [230,70,70], 0.5*failFlare);
glow(actors.fail.x+shake, actors.fail.y, 24*DPR, [255,180,170], 0.8*failFlare);
}
// ---- vector search: gray beams to confounders, then a fail cross ----
if(vecStall>0){
ctx.strokeStyle = `rgba(150,150,150,${0.5*vecStall})`;
ctx.lineWidth = 1.2*DPR; ctx.setLineDash([5*DPR,4*DPR]);
[actors.conf1, actors.conf2].forEach(c=>{
ctx.beginPath(); ctx.moveTo(actors.fail.x,actors.fail.y); ctx.lineTo(c.x,c.y); ctx.stroke();
});
ctx.setLineDash([]);
// faint pulses on the confounders it wrongly surfaces
[actors.conf1, actors.conf2].forEach(c=> glow(c.x,c.y,26*DPR,[150,150,150],0.25*vecStall));
}
// ---- the backward-trace arrow: bright iridescent light travelling fail -> cause ----
if(arrow>0){
const ax = actors.fail.x + (actors.cause.x-actors.fail.x)*arrow;
const ay = actors.fail.y + (actors.cause.y-actors.fail.y)*arrow
+ Math.sin(arrow*Math.PI)* -60*DPR; // arc up through the cortex
// trailing comet
for(let s=0;s<14;s++){
const tt = Math.max(0, arrow - s*0.02);
const tx = actors.fail.x + (actors.cause.x-actors.fail.x)*tt;
const ty = actors.fail.y + (actors.cause.y-actors.fail.y)*tt + Math.sin(tt*Math.PI)*-60*DPR;
glow(tx,ty, (10-s*0.5)*DPR, [90,230,210], 0.22*(1-s/14));
}
glow(ax,ay, 16*DPR, [140,245,255], 0.9);
glow(ax,ay, 40*DPR, [90,220,255], 0.4);
// ignite nearby particles as it passes (propagating thought)
for(let i=0;i<pts.length;i++){
const p=pts[i]; const d=Math.hypot(p.x-ax,p.y-ay);
if(d < 55*DPR) p.ignite = Math.min(1, p.ignite + (1-d/(55*DPR))*0.5);
}
}
// ---- cause neuron ignites ----
if(causeGlow>0){
glow(actors.cause.x, actors.cause.y, 80*DPR*(0.7+0.3*pulse), [93,230,180], 0.55*causeGlow);
glow(actors.cause.x, actors.cause.y, 26*DPR, [180,255,220], 0.9*causeGlow);
// ripple ring
const rr = (1-((t-9200)%1400/1400))*90*DPR;
ctx.strokeStyle = `rgba(93,230,180,${0.4*causeGlow})`; ctx.lineWidth=2*DPR;
ctx.beginPath(); ctx.arc(actors.cause.x,actors.cause.y, 90*DPR-rr, 0, Math.PI*2); ctx.stroke();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>