From a046c620c856fad71cc227e0fb481d011215381a Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Wed, 8 Jul 2026 10:49:18 -0700 Subject: [PATCH] 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=&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) --- apps/dashboard/OBSERVATORY-SPEC.md | 627 +++++++++++++++++ .../lib/components/ObservatoryCanvas.svelte | 131 ++++ .../observatory/__tests__/birth-plan.test.ts | 227 +++++++ .../observatory/__tests__/demo-clock.test.ts | 136 ++++ .../__tests__/forgetting-plan.test.ts | 439 ++++++++++++ .../__tests__/graph-upload.test.ts | 149 ++++ .../observatory/__tests__/mip-plan.test.ts | 81 +++ .../__tests__/path-builder.test.ts | 127 ++++ .../observatory/__tests__/rescue-plan.test.ts | 619 +++++++++++++++++ .../__tests__/tone-reference.test.ts | 103 +++ .../src/lib/observatory/birth-plan.ts | 381 +++++++++++ .../src/lib/observatory/birth-renderer.ts | 616 +++++++++++++++++ apps/dashboard/src/lib/observatory/camera.ts | 139 ++++ .../src/lib/observatory/demo-clock.ts | 137 ++++ apps/dashboard/src/lib/observatory/engine.ts | 389 +++++++++++ .../src/lib/observatory/firewall-plan.ts | 358 ++++++++++ .../src/lib/observatory/firewall-renderer.ts | 114 ++++ .../src/lib/observatory/forgetting-plan.ts | 307 +++++++++ .../lib/observatory/forgetting-renderer.ts | 114 ++++ .../src/lib/observatory/graph-upload.ts | 143 ++++ .../src/lib/observatory/node-renderer.ts | 338 ++++++++++ .../observatory/overlays/RescueVerdict.svelte | 114 ++++ .../overlays/TelemetryStrip.svelte | 104 +++ .../observatory/overlays/TimelineSpine.svelte | 128 ++++ .../src/lib/observatory/path-builder.ts | Bin 0 -> 3309 bytes .../src/lib/observatory/post/mip-plan.ts | 34 + .../src/lib/observatory/post/post-chain.ts | 340 ++++++++++ .../lib/observatory/post/shaders/post.wgsl.ts | 285 ++++++++ .../lib/observatory/post/tone-reference.ts | 75 +++ .../src/lib/observatory/rescue-plan.ts | 634 ++++++++++++++++++ .../src/lib/observatory/rescue-renderer.ts | 116 ++++ .../shaders/birth-particles.wgsl.ts | 124 ++++ .../lib/observatory/shaders/firewall.wgsl.ts | 125 ++++ .../observatory/shaders/forgetting.wgsl.ts | 114 ++++ .../observatory/shaders/render-edges.wgsl.ts | 166 +++++ .../observatory/shaders/render-nodes.wgsl.ts | 269 ++++++++ .../observatory/shaders/render-path.wgsl.ts | 181 +++++ .../lib/observatory/shaders/rescue.wgsl.ts | 148 ++++ .../lib/observatory/shaders/simulate.wgsl.ts | 163 +++++ apps/dashboard/src/lib/observatory/types.ts | 159 +++++ .../src/routes/(app)/observatory/+page.svelte | 286 ++++++++ .../src/routes/(app)/observatory/+page.ts | 5 + apps/dashboard/src/routes/+layout.svelte | 13 +- apps/dashboard/vite.config.ts | 7 +- assets/causebench-recall-at-1.svg | 46 ++ .../backward-trace-animation-storyboard.md | 108 +++ docs/launch/causal-brain-demo.html | 282 ++++++++ 47 files changed, 9695 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/OBSERVATORY-SPEC.md create mode 100644 apps/dashboard/src/lib/components/ObservatoryCanvas.svelte create mode 100644 apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/forgetting-plan.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/mip-plan.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/path-builder.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts create mode 100644 apps/dashboard/src/lib/observatory/__tests__/tone-reference.test.ts create mode 100644 apps/dashboard/src/lib/observatory/birth-plan.ts create mode 100644 apps/dashboard/src/lib/observatory/birth-renderer.ts create mode 100644 apps/dashboard/src/lib/observatory/camera.ts create mode 100644 apps/dashboard/src/lib/observatory/demo-clock.ts create mode 100644 apps/dashboard/src/lib/observatory/engine.ts create mode 100644 apps/dashboard/src/lib/observatory/firewall-plan.ts create mode 100644 apps/dashboard/src/lib/observatory/firewall-renderer.ts create mode 100644 apps/dashboard/src/lib/observatory/forgetting-plan.ts create mode 100644 apps/dashboard/src/lib/observatory/forgetting-renderer.ts create mode 100644 apps/dashboard/src/lib/observatory/graph-upload.ts create mode 100644 apps/dashboard/src/lib/observatory/node-renderer.ts create mode 100644 apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte create mode 100644 apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte create mode 100644 apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte create mode 100644 apps/dashboard/src/lib/observatory/path-builder.ts create mode 100644 apps/dashboard/src/lib/observatory/post/mip-plan.ts create mode 100644 apps/dashboard/src/lib/observatory/post/post-chain.ts create mode 100644 apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/post/tone-reference.ts create mode 100644 apps/dashboard/src/lib/observatory/rescue-plan.ts create mode 100644 apps/dashboard/src/lib/observatory/rescue-renderer.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts create mode 100644 apps/dashboard/src/lib/observatory/types.ts create mode 100644 apps/dashboard/src/routes/(app)/observatory/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/observatory/+page.ts create mode 100644 assets/causebench-recall-at-1.svg create mode 100644 docs/launch/backward-trace-animation-storyboard.md create mode 100644 docs/launch/causal-brain-demo.html diff --git a/apps/dashboard/OBSERVATORY-SPEC.md b/apps/dashboard/OBSERVATORY-SPEC.md new file mode 100644 index 0000000..6177df9 --- /dev/null +++ b/apps/dashboard/OBSERVATORY-SPEC.md @@ -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` source/target indices. +- One `PathStep` storage buffer for demo path: `array` 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` becomes CPU index map + GPU `NodeState` buffers. +- `velocities: Map` 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 ``. +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`. +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 params: Params; +@group(0) @binding(1) var prevNodes: array; +@group(0) @binding(2) var nextNodes: array; +@group(0) @binding(3) var path: array; + +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." diff --git a/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte b/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte new file mode 100644 index 0000000..9eb8754 --- /dev/null +++ b/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte @@ -0,0 +1,131 @@ + + + + + +{#if status.state === 'unsupported' || status.state === 'error'} + + +{/if} + + diff --git a/apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts b/apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts new file mode 100644 index 0000000..03a9d08 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts @@ -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 { + 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(); + 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); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts b/apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts new file mode 100644 index 0000000..a1a5182 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts @@ -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); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/forgetting-plan.test.ts b/apps/dashboard/src/lib/observatory/__tests__/forgetting-plan.test.ts new file mode 100644 index 0000000..2f5afd3 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/forgetting-plan.test.ts @@ -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 { + 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, n−1) 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); + } + } + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts b/apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts new file mode 100644 index 0000000..f1e9c54 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts @@ -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 & { 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')); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/mip-plan.test.ts b/apps/dashboard/src/lib/observatory/__tests__/mip-plan.test.ts new file mode 100644 index 0000000..ee62e5b --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/mip-plan.test.ts @@ -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); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/path-builder.test.ts b/apps/dashboard/src/lib/observatory/__tests__/path-builder.test.ts new file mode 100644 index 0000000..8838911 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/path-builder.test.ts @@ -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 { + 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 + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts b/apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts new file mode 100644 index 0000000..3c2db1b --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/__tests__/tone-reference.test.ts b/apps/dashboard/src/lib/observatory/__tests__/tone-reference.test.ts new file mode 100644 index 0000000..7d6a89d --- /dev/null +++ b/apps/dashboard/src/lib/observatory/__tests__/tone-reference.test.ts @@ -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); + }); +}); diff --git a/apps/dashboard/src/lib/observatory/birth-plan.ts b/apps/dashboard/src/lib/observatory/birth-plan.ts new file mode 100644 index 0000000..5b65ce4 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/birth-plan.ts @@ -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 }, + ]; +} diff --git a/apps/dashboard/src/lib/observatory/birth-renderer.ts b/apps/dashboard/src/lib/observatory/birth-renderer.ts new file mode 100644 index 0000000..00560da --- /dev/null +++ b/apps/dashboard/src/lib/observatory/birth-renderer.ts @@ -0,0 +1,616 @@ +/** + * Cognitive Observatory — birth particle renderer (Moment B, Tasks B3–B6). + * + * 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 330–359, 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 { + return (this.birthPlan?.edgeSteps ?? new Uint32Array(0)) as Uint32Array; + } + + 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, + right: vec4, + up: vec4, +}; + +struct BirthParticle { + start_life: vec4, + target_size: vec4, + color_phase: vec4, + state: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var camera: Camera; +@group(0) @binding(2) var particles: array; + +struct VSOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, + @location(1) @interpolate(flat) color: vec3, + @location(2) @interpolate(flat) misc: vec4, +}; + +const CORNERS = array, 6>( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-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(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 330–359). + 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(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; + var stops = array, 4>( + vec3(0.55, 0.32, 1.00), // violet base + vec3(0.40, 0.60, 1.00), // blue-violet + vec3(0.70, 0.45, 1.00), // magenta-violet + vec3(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(baseSize, 0.0, 0.0, alpha); + return out; +} + +@fragment +fn fs_main(in: VSOut) -> @location(0) vec4 { + 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(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, + right: vec4, + up: vec4, +}; + +struct Node { + pos_radius: vec4, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var camera: Camera; +@group(0) @binding(2) var nodes: array; + +struct VSOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, +}; + +@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(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(0.0, 0.0, 2.0, 1.0); + return out; + } + + // Flash halo: only visible during ignition (frames 330–359). + let frame = params.frame; + if (frame < 330.0 || frame > 359.0) { + out.clip = vec4(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(world, 1.0); + + // UV for radial fade. + out.uv = vec2(cx / ringRadius, cy / ringRadius); + + return out; +} + +@fragment +fn fs_main(in: VSOut) -> @location(0) vec4 { + 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(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(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 330–359). + 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; + } +} diff --git a/apps/dashboard/src/lib/observatory/camera.ts b/apps/dashboard/src/lib/observatory/camera.ts new file mode 100644 index 0000000..5b77b6a --- /dev/null +++ b/apps/dashboard/src/lib/observatory/camera.ts @@ -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 + }; +} diff --git a/apps/dashboard/src/lib/observatory/demo-clock.ts b/apps/dashboard/src/lib/observatory/demo-clock.ts new file mode 100644 index 0000000..c02d38f --- /dev/null +++ b/apps/dashboard/src/lib/observatory/demo-clock.ts @@ -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]; +} diff --git a/apps/dashboard/src/lib/observatory/engine.ts b/apps/dashboard/src/lib/observatory/engine.ts new file mode 100644 index 0000000..eb44dda --- /dev/null +++ b/apps/dashboard/src/lib/observatory/engine.ts @@ -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 { + 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 (S1–S4): 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(); + } +} diff --git a/apps/dashboard/src/lib/observatory/firewall-plan.ts b/apps/dashboard/src/lib/observatory/firewall-plan.ts new file mode 100644 index 0000000..c47e327 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/firewall-plan.ts @@ -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; + /** 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(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(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(); + 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: bf−46 ≥ 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 + }; +} diff --git a/apps/dashboard/src/lib/observatory/firewall-renderer.ts b/apps/dashboard/src/lib/observatory/firewall-renderer.ts new file mode 100644 index 0000000..2a484a2 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/firewall-renderer.ts @@ -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; + } +} diff --git a/apps/dashboard/src/lib/observatory/forgetting-plan.ts b/apps/dashboard/src/lib/observatory/forgetting-plan.ts new file mode 100644 index 0000000..5b5aaf2 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/forgetting-plan.ts @@ -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; + /** 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(n−1, max(min(3, n−1), round(0.25·(n−1)))). 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 1−smoothstep(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: bf−46 ≥ 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 + }; +} diff --git a/apps/dashboard/src/lib/observatory/forgetting-renderer.ts b/apps/dashboard/src/lib/observatory/forgetting-renderer.ts new file mode 100644 index 0000000..eb5615e --- /dev/null +++ b/apps/dashboard/src/lib/observatory/forgetting-renderer.ts @@ -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; + } +} diff --git a/apps/dashboard/src/lib/observatory/graph-upload.ts b/apps/dashboard/src/lib/observatory/graph-upload.ts new file mode 100644 index 0000000..eb89401 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/graph-upload.ts @@ -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(); + 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; + 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> edge index buffer. */ +export function buildEdgeIndexArray(graph: ObservatoryGraph): Uint32Array { + 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; +} diff --git a/apps/dashboard/src/lib/observatory/node-renderer.ts b/apps/dashboard/src/lib/observatory/node-renderer.ts new file mode 100644 index 0000000..0839688 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/node-renderer.ts @@ -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, 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; + } +} diff --git a/apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte b/apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte new file mode 100644 index 0000000..489c3e7 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte @@ -0,0 +1,114 @@ + + +{#if opacity > 0.001} +
+
{verdict.headline}
+
{verdict.causeLabel}
+
{verdict.receipt}
+
+{/if} + + diff --git a/apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte b/apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte new file mode 100644 index 0000000..44fb4d9 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte @@ -0,0 +1,104 @@ + + + +
+
+ +
+ + {demoMode} + + +
+ + + + + +
+ frame: {String(frameCount).padStart(3, ' ')} + {#if freezeFrame !== null} + CAPTURE + {:else if fpsEstimate > 0} + + {fpsEstimate}fps + + {/if} + +
+
+
+ + + diff --git a/apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte b/apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte new file mode 100644 index 0000000..469fdc5 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte @@ -0,0 +1,128 @@ + + +{#if steps.length > 0} +
+ {#if activeLabel} +
{activeLabel}
+ {/if} +
+ {#each steps as s (s.beatFrame)} +
0} + class:backward={s.kind === 1} + style="left: {pct(s.beatFrame)}%; opacity: {0.45 + + 0.55 * beatEnergy(s.beatFrame, frame)}" + title={s.label} + >
+ {/each} +
+
+
+{/if} + + diff --git a/apps/dashboard/src/lib/observatory/path-builder.ts b/apps/dashboard/src/lib/observatory/path-builder.ts new file mode 100644 index 0000000000000000000000000000000000000000..e67c1887c7fe8fcd77e0a383cad6a74c5fe4c11c GIT binary patch literal 3309 zcmai0&2HO95bjlaia7)@Wy_T8v~D2(1pZ4ZBu)*-J{g9y6j##16v^(AwiUxbPX&4{ z`UnO32)*Y~@+AFcmXfGAO)sLj+?k#E=9_P}v%1=(RXWT@sVU5@rk^9H?X4;@JEhOR z{z+C3RFY7xiZOlu?GJi=dO@dnd0*(9qH;LYmV$mduzI4?f_`X)@W(FCbm_FCVyx*z zPuSg|N%cgDtD#9_HjH(l?Zl+U6=pzAPK~rG*v@9yeu@ivEpb3DkX=X9??Cy zth56S^H|@vX-b8%BhBvQ{iAn2v}&T;9N!(?;1dPi8DQfqZ_@@xR9F?8L1D7A6RE+? zow70E>l~;94zn;oVbrT)CS@&(;Rs*k4BG0_8ktZ3;29hN&ch@d+=QIrD~_SnM!2y7hU z8fI2YHGkafuKS>pEX$Fv12*s`br>X_sT8rbmR3O=#sAe|0O{v&|2bqjsJ{Ou)7`Uo0#v6BBiWE1iK*uEXs0l&kx@ z=)XIEe|>sX>BJ{tM=MiMZ@}Dl1dBn82#9jhN%rc=>&Vw zDg2wLNGGn|s9UX&OVMBtZ^S=}go+`sG>df~2hJD7dh!0)e46r^$OI>TP?{v>tU1i5 z5L5YllX7&MgMhTb9u-ds+!AUrXVpkoy5a+jRiS!x0d${l?pv#-JMw!V(zAzf*^%ol zfqr>~buN0Z0*V9MZX$3F>Xoss@SO%m#YbrZs6HT|(okhznc#1FupJlnMq}ew* z%b%VYG!?2pBOvRDnsU?8IhGlbN_o-#IvJ92wCr;P+3{yN$V#QSYcv27+KR})Kl_u< zTpQ?;(>kV$v$v#@J2iDEDq0m!4n_9)5bI-tMyk*vft?wd7WgrP7K92ICNB=XWvys8 z8`9>z`qYv(#g~b?KLGZQcIibEpf4OQ7~1(lQz!x-ZyeoeGa9ofv+_+nij-Ll34vh9 z`2uRiBO^-kco7|uWoxu4xt$UA~S zwJB=R?q0LeP@#!wha!Hwr1cHzku2@amUyMxY_!l!^+S&1(bGdTFMfFlQ@k?0Q3&h9 zvj>GZc(Oiz$N#+sR{x>*j~{T>%p&p@QOzQ%S>XTNqUs|YvO_n~EE~M;`B%vVt{)e< za5Lb5<)ISPVB}IWrjjlzmFHmSd*Rx|ZJKcmUgr>fO^vuBEJ>_$LBiJvtaP{W*pa4` zLGHptrz7YTnYFc64LVUEM2dj@#!JJ_Dwt|x1Wu(NLg=}^KbZF85O)@lAR$3N=JXq8 zNWqfT?k<(EfHdg1C07=cmYQ>_&d9C4?Zmu?(9Af);@MV~k7gH+x*0?}U|%v_31OFsK)Dq+gx+(r5; zOZT)DWH`SFgtJTAp~8h02`;{P0iK`f$LQR#tj}g?4Ha3hM(TVylm%oX48@?TkQasB zg;LzrM__QmTmbum4X~6MS8=BWWqvwyx$1rw2zZREaS*sR8I}Q|86R?KzG`}fa=EGj ztBN!l-`J07u;FcL3}YUA+h)_-FdR~@|F?MVEx3Z%%&5($=>zd$6); +} + +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 }; +} diff --git a/apps/dashboard/src/lib/observatory/post/post-chain.ts b/apps/dashboard/src/lib/observatory/post/post-chain.ts new file mode 100644 index 0000000..c5b1e63 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/post/post-chain.ts @@ -0,0 +1,340 @@ +/** + * Cognitive Observatory — post-processing chain (S1–S4). + * + * 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(N−1)+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 i−1 → 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; + } +} diff --git a/apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts b/apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts new file mode 100644 index 0000000..d3ecd97 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts @@ -0,0 +1,285 @@ +/** + * Cognitive Observatory — post-processing shader module (S1–S4). + * + * 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.15–0.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.5–2.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 params: Params; // composite only +@group(0) @binding(1) var src: texture_2d; // blur chain input +@group(0) @binding(2) var samp: sampler; // shared +@group(0) @binding(3) var scene_tex: texture_2d; // composite only +@group(0) @binding(4) var bloom_tex: texture_2d; // 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); +} +`; diff --git a/apps/dashboard/src/lib/observatory/post/tone-reference.ts b/apps/dashboard/src/lib/observatory/post/tone-reference.ts new file mode 100644 index 0000000..02e7649 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/post/tone-reference.ts @@ -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 +}; diff --git a/apps/dashboard/src/lib/observatory/rescue-plan.ts b/apps/dashboard/src/lib/observatory/rescue-plan.ts new file mode 100644 index 0000000..22f06ac --- /dev/null +++ b/apps/dashboard/src/lib/observatory/rescue-plan.ts @@ -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 = { + 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; + /** + * 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.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·(1−retention) + 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(); + 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(); + 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)·(1−S(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 } + }; +} diff --git a/apps/dashboard/src/lib/observatory/rescue-renderer.ts b/apps/dashboard/src/lib/observatory/rescue-renderer.ts new file mode 100644 index 0000000..f1fa818 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/rescue-renderer.ts @@ -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 + * 719→0 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; + } +} diff --git a/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts new file mode 100644 index 0000000..1bd405b --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/birth-particles.wgsl.ts @@ -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 0–239 : latent trace condensing (slow drift) + * frames 240–329: engram coalescence (accelerated convergence) + * frames 330–359: memory ignition (flash — handled in render) + * frames 360–509: associations engrave (hold at target) + * frames 510–719: 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, + target_size: vec4, + color_phase: vec4, + state: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var particles: array; + +@compute @workgroup_size(64) +fn birth_compute(@builtin(global_invocation_id) id: vec3) { + 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 0–239) --- + // 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 240–329) --- + // 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 330–359) --- + // Hold at target (flash handled in render). + else if (effFrame < 360.0) { + t = 1.0; + } + // --- Phase 4: associations engrave (frames 360–509) --- + // Hold at target. + else if (effFrame < 510.0) { + t = 1.0; + } + // --- Phase 5: stabilization (frames 510–719) --- + // 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(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; +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts new file mode 100644 index 0000000..18aae15 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts @@ -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, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var nodes: array; +// 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 fire: array; + +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) { + 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(0.0, fy, 0.0, fw); + nodes[i] = node; +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts new file mode 100644 index 0000000..32bd356 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts @@ -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 + * 1−smoothstep(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, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var nodes: array; +// 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 horizon: array; + +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) { + 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(dx, 0.0, dz, 0.0); + nodes[i] = node; +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts new file mode 100644 index 0000000..ab3bc32 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts @@ -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 + * (types.ts UINTS_PER_EDGE), PathStep = 4×vec4 (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, + right: vec4, + up: vec4, +}; + +struct Node { + pos_radius: vec4, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +// x source index, y target index, z beat frame, w kind (0 recall, 1 backward) +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var camera: Camera; +// Source/target node indices (2 u32 per edge). +@group(0) @binding(2) var edges: array>; +// PathStep buffer for wavefront timing. +@group(0) @binding(3) var path: array>; +// NodeState storage buffer (positions for edge endpoints). +@group(0) @binding(4) var nodes: array; + +// 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 { + let w = fract(w_in); + let stops = array, 4>( + vec3(0.20, 0.28, 0.95), // indigo + vec3(0.20, 0.85, 0.90), // cyan-teal + vec3(0.45, 1.00, 0.72), // mint + vec3(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, + @location(0) color: vec3, + @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(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(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(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 { + // 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(in.color, 1.0); +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts new file mode 100644 index 0000000..070e387 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts @@ -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, + right: vec4, + up: vec4, +}; + +struct Node { + pos_radius: vec4, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var camera: Camera; +@group(0) @binding(2) var nodes: array; + +// 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 { + let w = fract(w_in); + // var, not let: WGSL only allows dynamic indexing through a reference. + var stops = array, 4>( + vec3(0.20, 0.28, 0.95), // indigo + vec3(0.20, 0.85, 0.90), // cyan-teal + vec3(0.45, 1.00, 0.72), // mint + vec3(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, + @location(0) uv: vec2, + // 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, + // x retention, y flags (bit field as f32), z recall intensity, w radius + @location(2) @interpolate(flat) misc: vec4, + // 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, +}; + +// Quad corners for two triangles (vertex_index 0..5). +const CORNERS = array, 6>( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-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(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(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(p.x / r_xz, 0.0, p.z / r_xz); + drift = dzc * (vec3(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(world, 1.0); + out.uv = corner; + out.color = node.color_flags.rgb; + out.misc = vec4(node.vel_retention.w, node.color_flags.w, node.demo.x, node.pos_radius.w); + out.demo_yzw = vec3(dy, dz, dw); + return out; +} + +@fragment +fn fs_main(in: VSOut) -> @location(0) vec4 { + 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(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(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(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(1.00, 0.16, 0.10) * in.demo_yzw.z * (core * 1.9 + halo * 1.1) + + vec3(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(0.62, 1.00, 0.55) * flare * (core * 1.7 + halo * 0.9) + + vec3(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(0.55, 1.00, 0.60), vec3(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(1.00, 0.14, 0.10) * rim * fw * 1.5 + + vec3(1.00, 0.60, 0.50) * core * fw * 0.15; + } + } + + // Additive target (src=one, dst=one): alpha is ignored, light accumulates. + return vec4(color * params.brightness, 1.0); +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts new file mode 100644 index 0000000..6bff3cd --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts @@ -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, + right: vec4, + up: vec4, +}; + +struct Node { + pos_radius: vec4, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var camera: Camera; +@group(0) @binding(2) var nodes: array; +// x source index, y target index, z beat frame, w kind (0 recall, 1 backward) +@group(0) @binding(3) var path: array>; + +// Same thin-film band as the node shader (§7.1). +fn spectral(w_in: f32) -> vec3 { + let w = fract(w_in); + // var, not let: WGSL only allows dynamic indexing through a reference. + var stops = array, 4>( + vec3(0.20, 0.28, 0.95), + vec3(0.20, 0.85, 0.90), + vec3(0.45, 1.00, 0.72), + vec3(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, + // x: t along segment (0 source → 1 target), y: side (-1..1) + @location(0) uv: vec2, + // 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, +}; + +// (t, side) corners for two triangles of the ribbon. +const RIBBON = array, 6>( + vec2(0.0, -1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0), + vec2(0.0, -1.0), + vec2(1.0, 1.0), + vec2(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(0.0, 0.0, 2.0, 1.0); + out.beat = vec3(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(0.0, 0.0, 2.0, 1.0); + out.beat = vec3(0.0); + return out; + } + + let a = camera.view_proj * vec4(src.pos_radius.xyz, 1.0); + let b = camera.view_proj * vec4(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(-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(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(base.xy + offset, base.zw); + out.uv = vec2(corner.x, corner.y); + out.beat = vec3(f32(step.z), f32(step.w), 1.0); + return out; +} + +@fragment +fn fs_main(in: VSOut) -> @location(0) vec4 { + 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(0.62, 0.66, 0.72); + packet_white = 0.18; + } else if (in.beat.y > 0.5) { + band = mix(band, vec3(1.0, 0.25, 0.45), 0.55); + } + + let energy = (packet * 1.6 + trail) * profile * live; + let color = band * energy + vec3(1.0) * packet * profile * live * packet_white; + return vec4(color * params.brightness, 1.0); +} +`; diff --git a/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts new file mode 100644 index 0000000..ee2689d --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts @@ -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)·(1−smoothstep(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, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var nodes: array; +// 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 wave: array; + +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) { + 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(dx, dy, dz, dw); + nodes[i] = node; +} +`; +} diff --git a/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts new file mode 100644 index 0000000..f4e08a3 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts @@ -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 719→0 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, + vel_retention: vec4, + color_flags: vec4, + demo: vec4, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var nodes: array; +// x source index, y target index, z beat frame, w kind (0 recall, 1 backward) +@group(0) @binding(2) var path: array>; +// x source index, y target node index (Increment 7: force simulation edges) +@group(0) @binding(3) var edges: array>; + +// --- Force-simulation helpers (Increment 7) --- + +fn safe_normalize(v: vec3) -> vec3 { + let l = length(v); + if (l < 0.0001) { return vec3(0.0); } + return v / l; +} + +fn clamp_len(v: vec3, hi: f32) -> vec3 { + 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) { + 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(0.0, 0.0, 0.0, node.pos_radius.w); + node.vel_retention = vec4(0.0, 0.0, 0.0, node.vel_retention.w); + nodes[i] = node; + return; + } + + let pos = node.pos_radius.xyz; + var force = vec3(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(vel, node.vel_retention.w); + node.pos_radius = vec4(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; +} +`; diff --git a/apps/dashboard/src/lib/observatory/types.ts b/apps/dashboard/src/lib/observatory/types.ts new file mode 100644 index 0000000..b7cc119 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/types.ts @@ -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=&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 = 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> source/target node indices. 2 u32 per edge. */ +export const UINTS_PER_EDGE = 2; + +/** + * array> 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; + 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 }; diff --git a/apps/dashboard/src/routes/(app)/observatory/+page.svelte b/apps/dashboard/src/routes/(app)/observatory/+page.svelte new file mode 100644 index 0000000..0f50505 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/observatory/+page.svelte @@ -0,0 +1,286 @@ + + + + + +
+ +
+ +
+ + + {#if showHud} +
+ + + + + {#if loading} +
+
+ LOADING MEMORY FIELD... +
+
+ {/if} + + + {#if error && !loading} +
+
+ {error} +
+
+ {/if} + + + + + + {#if demoMode === 'salience-rescue' && rescuePlan?.viable} + + {/if} + + + {#if demoMode === 'firewall' && firewallPlan?.viable} + + {/if} + + + {#if !loading && graphData && graphData.nodeCount === 0} +
+
+ NO MEMORIES IN FIELD +
+
+ {/if} +
+ {/if} +
+ + diff --git a/apps/dashboard/src/routes/(app)/observatory/+page.ts b/apps/dashboard/src/routes/(app)/observatory/+page.ts new file mode 100644 index 0000000..dc93871 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/observatory/+page.ts @@ -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; diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 53f5a52..ac15307 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -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 @@ } -{#if isMarketingRoute} +{#if isMarketingRoute || isImmersiveRoute} {@render children()} {:else} @@ -259,7 +264,7 @@ {/if} -{#if showCommandPalette && !isMarketingRoute} +{#if showCommandPalette && !isMarketingRoute && !isImmersiveRoute}