mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-12 22:42:10 +02:00
feat(observatory): raw-WebGPU living cognitive field with 5 deterministic demos
The Observatory is a full-bleed, zero-library WebGPU surface that renders the memory graph as a living cognitive field. Five deterministic demo moments driven by a URL contract (?demo=<name>&seed=...&frame=N): recall-path, engram-birth, salience-rescue, forgetting-horizon, firewall. Capture mode (?frame=N) freezes the sim deterministically so the same URL produces identical pixels, the viral-clip primitive. Architecture: bare-metal WebGPU engine (no Three.js), seeded demo clock, per-demo plan + renderer modules, WGSL shaders (simulate, nodes, edges, path, birth particles, rescue, forgetting, firewall) plus a post-processing chain (tone mapping, MIP). DOM is instrument overlays only (telemetry strip, timeline spine, rescue verdict); the layout gives /observatory the same full-bleed bypass as marketing routes so recordings stay clean. Reads the real memory graph. Verified live: svelte-check 939 files 0 errors, 96 observatory unit tests green, all 5 demos load at 108-119fps with zero console errors against the live brain. Known follow-up: engram-birth capture-mode particle cluster needs a render fix pass before it is camera-ready; the other 4 demos are camera-ready. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
898bd336cc
commit
a046c620c8
47 changed files with 9695 additions and 6 deletions
627
apps/dashboard/OBSERVATORY-SPEC.md
Normal file
627
apps/dashboard/OBSERVATORY-SPEC.md
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
# Vestige Cognitive Observatory WebGPU Implementation Spec
|
||||
|
||||
Status: RESEARCH -> SPEC ONLY. Do not implement app code from this file until Sam explicitly asks.
|
||||
|
||||
Goal: add a new full-bleed Cognitive Observatory route that reuses the existing graph/cinema/effects substrate, then layers a GPU-resident WebGPU simulation path for viral demo moments. Priority #1 is `?demo=recall-path`: a deterministic loop where recall lights a causal path through real memory nodes.
|
||||
|
||||
Ground truth read before this spec:
|
||||
- `apps/dashboard/src/lib/components/Graph3D.svelte` currently owns the Three/WebGL scene, 60fps governor, `ForceSimulation`, `NodeManager`, `EdgeManager`, `ParticleSystem`, `EffectManager`, DreamMode, post-processing, and event mapping.
|
||||
- `apps/dashboard/src/lib/graph/force-sim.ts` is a 101-line CPU simulation with O(N^2) repulsion, edge attraction, damping, centering, and frame-count cooldown.
|
||||
- `apps/dashboard/src/lib/graph/scene.ts` is Three/WebGL `WebGLRenderer` + `EffectComposer`, not WebGPU.
|
||||
- `apps/dashboard/src/lib/graph/particles.ts` and `effects.ts` still use CPU-updated `THREE.BufferGeometry` attributes and `Math.random()` bursts.
|
||||
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts` is already deterministic and produces story beats/flow edges. Reuse this for recall paths.
|
||||
- `apps/dashboard/src/lib/graph/cinema/director.ts` is renderer-agnostic camera choreography. Reuse its path semantics, not its renderer.
|
||||
- `apps/dashboard/src/routes/(app)/graph/+page.svelte` has protected `MemoryCinema`; do not modify it. Add a separate route.
|
||||
|
||||
## 1. Exact WebGPU technique chosen per demo moment
|
||||
|
||||
### Shared baseline: GPU-resident node/particle state
|
||||
|
||||
Chosen technique: WebGPU Samples compute-boids ping-pong storage-buffer update + instanced sprite render.
|
||||
|
||||
Why: it is the cleanest canonical WebGPU pattern: node/particle state is in GPU buffers created with `GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE`; a compute pass writes next state; a render pass reads the current/next state as an instance vertex buffer and draws one tiny sprite mesh per node/particle. The sample uses `@compute @workgroup_size(64)` and dispatches `Math.ceil(numParticles / 64)` workgroups.
|
||||
|
||||
Sources:
|
||||
- https://webgpu.github.io/webgpu-samples/samples/computeBoids/
|
||||
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/main.ts
|
||||
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/updateSprites.wgsl
|
||||
- https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/sprite.wgsl
|
||||
- https://webgpufundamentals.org/webgpu/lessons/webgpu-storage-buffers.html
|
||||
- https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
|
||||
|
||||
Concrete pattern to use:
|
||||
- `NodeState` storage buffer, 16-byte aligned lanes:
|
||||
- `pos_radius: vec4f` = xyz + visual radius
|
||||
- `vel_retention: vec4f` = xyz velocity + FSRS retention
|
||||
- `color_flags: vec4f` = rgb + packed visual flags as f32 for MVP; move to u32 later if needed
|
||||
- `demo: vec4f` = path phase, birth phase, ripple phase, shock phase
|
||||
- Two ping-pong `GPUBuffer`s for simulation: previous read-only, next read-write.
|
||||
- One static `EdgeIndex` storage buffer: `array<vec2u>` source/target indices.
|
||||
- One `PathStep` storage buffer for demo path: `array<vec4u>` with source index, target index, beat frame, kind.
|
||||
- One uniform buffer per frame: frame index, fixed time, loop phase, node count, edge count, path count, viewport, camera matrices.
|
||||
- Compute workgroup size: start at 64 for broad compatibility; allow 128 only after profiling.
|
||||
- Every WGSL entry checks `if (id.x >= params.nodeCount) { return; }` because dispatch is rounded up.
|
||||
- CPU writes graph data once on route load, then only writes uniforms and demo controls. No per-frame readback.
|
||||
|
||||
### Moment A: `?demo=recall-path` — PATH lighting through nodes
|
||||
|
||||
Chosen technique: GPU path-wave scalar + edge glow render from the `PathStep` buffer.
|
||||
|
||||
Implementation:
|
||||
- Use existing `planCinemaPath(nodes, edges, centerId, maxBeats)` to get deterministic beats and `flowEdges`.
|
||||
- Convert each beat/edge to node indices and upload to `PathStep`.
|
||||
- Compute pass `recallPathPass` runs per node. For each node, scan the small path buffer (max 7-12 beats) and compute:
|
||||
- `beatT = smoothstep(beatFrame - 18, beatFrame + 18, frameInLoop)`
|
||||
- `decayT = 1.0 - smoothstep(beatFrame + 45, beatFrame + 180, frameInLoop)`
|
||||
- path intensity = max of beat arrival envelope for matching node index.
|
||||
- Edge render reads source/target node states and path steps. A second `PathEdgeInstance` buffer is optional for MVP; simpler first build draws only path edges as instanced line quads using the `PathStep` source/target index.
|
||||
- Color: cyan-white core with violet afterglow. Node `demo.x` = recall intensity. Edge alpha = wavefront envelope.
|
||||
- Render order: background -> non-path edges -> nodes -> path edges additive -> path node halos additive -> DOM overlay.
|
||||
|
||||
Sources:
|
||||
- WebGPU boids storage/render pattern above.
|
||||
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts` for deterministic story path and flow edges.
|
||||
- GraphWaGu edge vertex shader pattern, where the vertex shader fetches node positions from a storage buffer by source/target edge indices: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
|
||||
|
||||
### Moment B: node BORN — particle convergence into a node
|
||||
|
||||
Chosen technique: GPU particle attractor convergence with deterministic seed, not CPU `Math.random()` bursts.
|
||||
|
||||
Implementation:
|
||||
- Add a `BirthParticle` storage buffer sized for e.g. 16k particles shared across all births.
|
||||
- For each birth event or demo beat, assign a deterministic particle slice: start positions are generated from `hash(seed, particleIndex, nodeIndex)` on GPU or precomputed once on CPU with the same seeded PRNG.
|
||||
- Compute pass lerps particle position from shell/noise field toward target node position:
|
||||
- `p = mix(start, target + curlNoiseOffset, easeOutCubic(t))`
|
||||
- Fade from violet dust to node color; size shrinks on arrival.
|
||||
- For MVP, render as instanced billboards. Later upgrade to screen-space point splatting if density gets high.
|
||||
|
||||
Sources:
|
||||
- WebGPU Samples compute-boids for GPU particle update/render.
|
||||
- Particle Life WebGPU for finite-radius particle update discipline and atomics when interactions become local-neighborhood based: https://lisyarus.github.io/blog/posts/particle-life-simulation-in-browser-using-webgpu.html
|
||||
- Codrops WebGPU fluids on particle simulation and screen-space point-splatting as the real-time surface path: https://tympanus.net/codrops/2025/01/29/particles-progress-and-perseverance-a-journey-into-webgpu-fluids/
|
||||
|
||||
### Moment C: backward RIPPLE — radial distortion moving from effect to cause
|
||||
|
||||
Chosen technique: screen-space radial distortion post pass + node intensity ring.
|
||||
|
||||
Implementation:
|
||||
- Add a fullscreen post-process pipeline after node/edge render.
|
||||
- Uniform `Ripple { originClip: vec2f, radius: f32, width: f32, strength: f32, direction: f32 }`.
|
||||
- Fragment shader samples the scene texture with UV displaced along radial direction:
|
||||
- `d = distance(uv, originClip)`
|
||||
- `ring = smoothstep(radius + width, radius, d) * smoothstep(radius - width, radius, d)`
|
||||
- `uv2 = uv + normalize(uv - originClip) * ring * strength * direction`
|
||||
- For backward causal recall, `direction = -1` and radius contracts from the failure node toward the cause node, while `PathStep` beats are ordered effect -> cause.
|
||||
|
||||
Sources:
|
||||
- WebGPU Fundamentals render/post basics: https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
|
||||
- Codrops Shader.se article for scroll/timeline-driven WebGPU scene transitions and shader masking: https://tympanus.net/codrops/2026/05/19/80s-business-tech-seamless-scene-transitions-inside-shader-ses-scroll-driven-webgpu-pipeline/
|
||||
- Existing `apps/dashboard/src/lib/graph/effects.ts` already has CPU `createRippleWave`; reuse semantics, move rendering to post shader for Observatory.
|
||||
|
||||
### Moment D: DRIFT toward a horizon — depth fog and horizon attractor
|
||||
|
||||
Chosen technique: GPU drift vector + depth/alpha fog in vertex/fragment shader.
|
||||
|
||||
Implementation:
|
||||
- Add per-node `lifecycle` scalar from retention/state: active=near, dormant=mid, silent=far, unavailable=horizon.
|
||||
- Compute pass applies a weak drift acceleration toward `horizon = vec3(0, -20, -220)` for low-retention nodes; high-retention nodes resist drift.
|
||||
- Vertex shader writes depth/fog factor from camera-space z and lifecycle.
|
||||
- Fragment shader mixes node color toward deep blue/black and reduces alpha with `smoothstep(fogNear, fogFar, depth)`.
|
||||
|
||||
Sources:
|
||||
- Existing `scene.ts` uses Three `FogExp2`; Observatory should port the semantic to WGSL rather than rely on Three.
|
||||
- Codrops False Earth for large WebGPU procedural world using compute shaders + indirect drawing; use as proof that compute-generated scene density belongs on GPU: https://tympanus.net/codrops/2026/04/21/false-earth-from-webgl-limits-to-a-webgpu-driven-world/
|
||||
|
||||
### Moment E: crimson SHOCKWAVE + membrane — firewall / immune response
|
||||
|
||||
Chosen technique: expanding instanced ring/membrane mesh + fullscreen chromatic shock pass.
|
||||
|
||||
Implementation:
|
||||
- Keep an `EventPulse` uniform/storage ring buffer with origin, start frame, color, type.
|
||||
- Node pass reads pulses and adds red rim lighting when `abs(distance(worldPos, origin) - radius) < width`.
|
||||
- Membrane pass draws a camera-facing ring/quad sphere impostor with fresnel alpha, crimson core, black outer edge.
|
||||
- Post pass applies a one-frame high-strength radial distortion and red channel bias when shockfront crosses screen-space pixel.
|
||||
|
||||
Sources:
|
||||
- Existing `effects.ts:createShockwave` uses a CPU Three `RingGeometry`; reuse the visual grammar, move to WebGPU instanced membrane.
|
||||
- Codrops WebGPU fluid articles for atomics/storage buffers/compute simulation made practical in browser: https://tympanus.net/codrops/2025/02/26/webgpu-fluid-simulations-high-performance-real-time-rendering/
|
||||
- compute.toys gallery for WebGPU compute-shader idioms and GPU toy iteration: https://compute.toys/ and https://github.com/compute-toys/compute.toys
|
||||
|
||||
### GPU force-directed graph layout
|
||||
|
||||
Chosen technique for v1: GPU force layout with storage buffers, edge-local spring pass, approximate repulsion pass, and integrate pass. Start with exact O(N^2) repulsion for <=2k Observatory demo nodes; add Barnes-Hut / spatial bins before 10k+.
|
||||
|
||||
Why: existing `force-sim.ts` is O(N^2) CPU. The right migration is not to immediately rebuild all GraphWaGu complexity; it is to preserve Vestige's force semantics on GPU with the boids ping-pong pattern, then graduate repulsion acceleration once visual requirements exceed a few thousand nodes.
|
||||
|
||||
Required compute passes:
|
||||
1. `clearForcesPass`: one invocation per node, zero `force.xyz`.
|
||||
2. `edgeSpringPass`: one invocation per edge. Read source/target node positions, compute attraction, accumulate into source/target force. MVP avoids float atomics by writing one force contribution per edge endpoint to `EdgeForce` and reducing per node in pass 3; advanced path uses atomic fixed-point accumulation.
|
||||
3. `repulsionPass`: one invocation per node; loop all nodes for MVP. For 10k+, replace with GraphWaGu-style Barnes-Hut/Morton tree or Particle Life spatial bins.
|
||||
4. `integratePass`: one invocation per node; apply centering, damping, max velocity, retention/lifecycle drift, and ping-pong write to next node buffer.
|
||||
5. `boundsPass` optional: compute bounds for fit camera; do not block v1 on GPU readback.
|
||||
|
||||
What moves from `force-sim.ts`:
|
||||
- `positions: Map<string, THREE.Vector3>` becomes CPU index map + GPU `NodeState` buffers.
|
||||
- `velocities: Map<string, THREE.Vector3>` becomes `vel_retention.xyz` in `NodeState`.
|
||||
- `repulsionStrength`, `attractionStrength`, `dampening`, `alpha`, `maxSteps` become uniform fields.
|
||||
- `tick(edges)` becomes command encoding of the compute passes above.
|
||||
- `addNode/removeNode/reset` become buffer rebuilds for v1; optimize incremental updates later.
|
||||
|
||||
Sources:
|
||||
- GraphWaGu repo: https://github.com/harp-lab/GraphWaGu
|
||||
- GraphWaGu force-directed TS with compute pipelines/buffers: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/webgpu/force_directed.ts
|
||||
- GraphWaGu exact force WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forces.wgsl
|
||||
- GraphWaGu Barnes-Hut force WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forcesBH.wgsl
|
||||
- GraphWaGu apply-forces WGSL: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/apply_forces.wgsl
|
||||
- GraphWaGu Morton/tree/radix sort: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/create_tree.wgsl and https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/radix_sort.wgsl
|
||||
- cosmos.gl / Cosmograph GPU force graph: https://github.com/cosmograph-org/cosmos and https://cosmograph.app/
|
||||
- cosmos.gl README says computations and drawing occur on GPU and targets hundreds of thousands of points/links: https://raw.githubusercontent.com/cosmograph-org/cosmos/main/README.md
|
||||
|
||||
## 2. Precise files to add/change
|
||||
|
||||
Add docs/spec only in this run:
|
||||
- `apps/dashboard/OBSERVATORY-SPEC.md` — this file.
|
||||
|
||||
Implementation files for the Qwen coder later:
|
||||
|
||||
Create:
|
||||
- `apps/dashboard/src/routes/(app)/observatory/+page.svelte`
|
||||
- New route only. Do not touch protected `graph/+page.svelte` or `MemoryCinema` wiring.
|
||||
- Fetch graph data via existing `api.graph` pattern from graph route.
|
||||
- Parse `?demo=` and `?seed=` with `URLSearchParams`.
|
||||
- Render full-bleed `ObservatoryCanvas` plus DOM overlay.
|
||||
- `apps/dashboard/src/lib/components/ObservatoryCanvas.svelte`
|
||||
- Svelte wrapper with `onMount`/`onDestroy`, canvas bind, ResizeObserver, WebGPU lifecycle.
|
||||
- `apps/dashboard/src/lib/observatory/engine.ts`
|
||||
- Owns adapter/device/context, pipelines, buffers, render loop, resize, dispose.
|
||||
- `apps/dashboard/src/lib/observatory/types.ts`
|
||||
- CPU-side `ObservatoryNode`, `ObservatoryEdge`, `DemoMode`, `DemoClock`, buffer layout constants.
|
||||
- `apps/dashboard/src/lib/observatory/demo-clock.ts`
|
||||
- Deterministic fixed-step clock, seeded PRNG, loop-frame math.
|
||||
- `apps/dashboard/src/lib/observatory/graph-upload.ts`
|
||||
- Convert `GraphNode[]`/`GraphEdge[]` to stable node index map, typed arrays, path steps from `planCinemaPath`.
|
||||
- `apps/dashboard/src/lib/observatory/overlays/TelemetryStrip.svelte`
|
||||
- `apps/dashboard/src/lib/observatory/overlays/CommandSurface.svelte`
|
||||
- `apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte`
|
||||
- `apps/dashboard/src/lib/observatory/overlays/InspectorPanel.svelte`
|
||||
- `apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts`
|
||||
- `apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts`
|
||||
- `apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts`
|
||||
- `apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts`
|
||||
- `apps/dashboard/src/lib/observatory/shaders/post-ripple-shock.wgsl.ts`
|
||||
- `apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts`
|
||||
- `apps/dashboard/src/lib/observatory/__tests__/graph-upload.test.ts`
|
||||
|
||||
Extend, do not rewrite:
|
||||
- `apps/dashboard/src/lib/graph/cinema/pathfinder.ts`
|
||||
- Export or reuse `planCinemaPath` as-is. If extra metadata is needed, add small pure helper `pathToIndexSteps(path, nodeIndexById)` in observatory layer, not inside Cinema.
|
||||
- `apps/dashboard/src/lib/graph/nodes.ts`
|
||||
- Reuse `getNodeColor`, `getMemoryState`, `AHAGRAPH_COLORS`. No renderer logic added here.
|
||||
- `apps/dashboard/src/lib/graph/effects.ts`
|
||||
- Do not move existing Graph3D effects. Use it as visual reference only.
|
||||
- `apps/dashboard/src/lib/graph/force-sim.ts`
|
||||
- Leave CPU simulation intact for Graph3D. Add no WebGPU imports here. Observatory gets its own GPU sim module.
|
||||
|
||||
Do not change:
|
||||
- `apps/dashboard/src/routes/(app)/graph/+page.svelte` protected `MemoryCinema` block.
|
||||
- `apps/dashboard/src/lib/components/MemoryCinema.svelte` unless Sam explicitly asks.
|
||||
- `crates/`, `Cargo.toml`, backend APIs.
|
||||
|
||||
## 3. `?demo=recall-path` priority build instructions
|
||||
|
||||
### 3.1 Route/component structure
|
||||
|
||||
1. Create `routes/(app)/observatory/+page.svelte`.
|
||||
2. On mount:
|
||||
- Parse `demo = new URLSearchParams(window.location.search).get('demo') ?? 'recall-path'`.
|
||||
- Parse `seed = ...get('seed') ?? 'vestige-observatory-v1'`.
|
||||
- Fetch graph with `api.graph({ max_nodes: 300, depth: 3, sort: 'recent' })` for MVP.
|
||||
- Pass `nodes`, `edges`, `centerId`, `demo`, `seed` into `<ObservatoryCanvas />`.
|
||||
3. Render route as a full viewport stage:
|
||||
- parent `class="fixed inset-0 overflow-hidden bg-[#03040a]"`.
|
||||
- canvas layer absolute inset-0.
|
||||
- overlay layer absolute inset-0 pointer-events-none.
|
||||
- specific interactive controls use `pointer-events-auto`.
|
||||
|
||||
### 3.2 Deterministic demo clock
|
||||
|
||||
Create `demo-clock.ts`:
|
||||
- `const FPS = 60`.
|
||||
- `const LOOP_SECONDS = 12` for recall-path; `LOOP_FRAMES = 720`.
|
||||
- `frame` is an integer, not derived from `Date.now()`.
|
||||
- In normal interactive mode, rAF accumulates elapsed time and advances fixed steps. In demo mode, each rendered frame advances exactly one fixed frame, or uses `?frame=N` for capture.
|
||||
- `phase = (frame % LOOP_FRAMES) / LOOP_FRAMES`.
|
||||
- Use a tiny deterministic PRNG (`xmur3` seed hash + `mulberry32`) in local code. Do not add a dependency for seedrandom unless needed.
|
||||
- Ban `Math.random()` and `performance.now()` from simulation state. `performance.now()` may schedule frames only; it must not decide positions/colors/path.
|
||||
|
||||
Source rationale:
|
||||
- Fixed timestep pattern: https://gafferongames.com/post/fix_your_timestep/
|
||||
- URL query parsing: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
|
||||
- Svelte lifecycle: https://svelte.dev/docs/svelte/lifecycle-hooks
|
||||
|
||||
### 3.3 Graph upload
|
||||
|
||||
Create `graph-upload.ts`:
|
||||
1. Stable-sort nodes for deterministic indices:
|
||||
- center node first if `id === centerId`.
|
||||
- then descending `updatedAt || createdAt`.
|
||||
- tie-break by `id.localeCompare`.
|
||||
2. Build `nodeIndexById: Map<string, number>`.
|
||||
3. Create `Float32Array nodeData` with 16 floats per node for alignment and future-proofing:
|
||||
- 0..3: x, y, z, radius
|
||||
- 4..7: vx, vy, vz, retention
|
||||
- 8..11: r, g, b, flags
|
||||
- 12..15: pathIntensity, birthPhase, ripplePhase, shockPhase
|
||||
4. Initial positions:
|
||||
- Reuse the golden-angle sphere logic from `NodeManager.createNodes`, but replace randomness with deterministic seed.
|
||||
- Radius scales from node count: `baseR = clamp(24 + sqrt(n) * 1.2, 35, 140)`.
|
||||
5. Create `Uint32Array edgeData` with 2 u32 per edge, skipping edges whose node IDs are absent.
|
||||
6. Call `planCinemaPath(nodes, edges, centerId, 8)` and convert `flowEdges` to `PathStep[]`.
|
||||
7. Path timing:
|
||||
- beat 0 at frame 60.
|
||||
- each next beat +60 frames.
|
||||
- final 180 frames reserved for afterglow and loop reset.
|
||||
|
||||
### 3.4 WebGPU engine setup
|
||||
|
||||
Create `engine.ts`:
|
||||
1. Feature detect:
|
||||
- `if (!navigator.gpu) throw new Error('WebGPU unavailable')` and render a DOM fallback panel.
|
||||
2. Request adapter/device.
|
||||
3. Configure canvas:
|
||||
- `format = navigator.gpu.getPreferredCanvasFormat()`.
|
||||
- On resize, set `canvas.width = Math.min(clientWidth * devicePixelRatio, maxTextureDimension2D)` and same for height.
|
||||
- Reconfigure context and recreate depth/scene textures.
|
||||
4. Create buffers:
|
||||
- `nodeA`, `nodeB`: `STORAGE | VERTEX | COPY_DST`.
|
||||
- `edges`: `STORAGE | COPY_DST`.
|
||||
- `pathSteps`: `STORAGE | COPY_DST`.
|
||||
- `params`: `UNIFORM | COPY_DST`.
|
||||
- sprite vertex buffer for a 2-triangle quad or 3-vertex triangle impostor.
|
||||
5. Create pipelines:
|
||||
- `simulatePipeline` with read nodeA/write nodeB.
|
||||
- `renderEdgesPipeline` reads node current + edges.
|
||||
- `renderNodesPipeline` uses instance index to fetch node state and draw billboard.
|
||||
- `renderPathPipeline` draws path edges + additive halos.
|
||||
- `postPipeline` optional in increment 5.
|
||||
6. Frame loop command order for recall-path MVP:
|
||||
- write params uniform for integer frame.
|
||||
- compute simulate: nodeA -> nodeB.
|
||||
- render scene using nodeB.
|
||||
- swap nodeA/nodeB.
|
||||
|
||||
### 3.5 WGSL simulation for recall path
|
||||
|
||||
MVP `simulate.wgsl.ts`:
|
||||
- Inputs: params, previous nodes, next nodes, edges, path steps.
|
||||
- For each node:
|
||||
- start with previous `pos`, `vel`.
|
||||
- apply low-cost centering and edge spring only after the first MVP render works; first render may use static positions.
|
||||
- compute path intensity by scanning `pathSteps`.
|
||||
- compute horizon drift from retention.
|
||||
- write next node state.
|
||||
|
||||
Pseudo-WGSL shape for Qwen:
|
||||
|
||||
```wgsl
|
||||
struct NodeState {
|
||||
pos_radius: vec4f,
|
||||
vel_retention: vec4f,
|
||||
color_flags: vec4f,
|
||||
demo: vec4f,
|
||||
};
|
||||
struct Params {
|
||||
frame: u32,
|
||||
loopFrames: u32,
|
||||
nodeCount: u32,
|
||||
edgeCount: u32,
|
||||
pathCount: u32,
|
||||
dt: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
struct PathStep { source: u32, target: u32, beatFrame: u32, kind: u32 };
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> prevNodes: array<NodeState>;
|
||||
@group(0) @binding(2) var<storage, read_write> nextNodes: array<NodeState>;
|
||||
@group(0) @binding(3) var<storage, read> path: array<PathStep>;
|
||||
|
||||
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
|
||||
fn smoothPulse(frame: f32, beat: f32, attack: f32, release: f32) -> f32 {
|
||||
let a = smoothstep(beat - attack, beat, frame);
|
||||
let r = 1.0 - smoothstep(beat, beat + release, frame);
|
||||
return a * r;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3u) {
|
||||
let i = gid.x;
|
||||
if (i >= params.nodeCount) { return; }
|
||||
var node = prevNodes[i];
|
||||
let f = f32(params.frame % params.loopFrames);
|
||||
var recall = 0.0;
|
||||
for (var p = 0u; p < params.pathCount; p = p + 1u) {
|
||||
let step = path[p];
|
||||
if (step.source == i || step.target == i) {
|
||||
recall = max(recall, smoothPulse(f, f32(step.beatFrame), 18.0, 150.0));
|
||||
}
|
||||
}
|
||||
node.demo.x = recall;
|
||||
nextNodes[i] = node;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Render approach
|
||||
|
||||
Nodes:
|
||||
- Use instanced billboards. Vertex shader fetches `NodeState` by `@builtin(instance_index)`.
|
||||
- Billboard size = `radius * (1.0 + recallIntensity * 2.0)`.
|
||||
- Fragment shader radial alpha: `alpha = smoothstep(1.0, 0.0, length(localUv))`.
|
||||
- Color = `mix(baseColor, vec3(0.8, 0.95, 1.0), recallIntensity)`.
|
||||
- Additive glow pass can be same shader with bigger billboard and low alpha.
|
||||
|
||||
Edges:
|
||||
- MVP: draw edges as line-list if available; better: instanced thin quads from source to target in screen space.
|
||||
- For path edges, draw a separate additive path pass from `PathStep` only, with wavefront alpha.
|
||||
- GraphWaGu source pattern fetches nodes in vertex shader by edge endpoint index; use that for WebGPU: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
|
||||
|
||||
Path lighting:
|
||||
- At beat frame, target node blooms first.
|
||||
- During frames `beatFrame..beatFrame+45`, edge from source to target gets a traveling pulse:
|
||||
- `edgeT = saturate((frame - beatFrame) / 45)`.
|
||||
- Fragment local coordinate along edge `u`; alpha peak at `abs(u - edgeT) < 0.08`.
|
||||
- Previous path nodes keep afterglow for 2-3 seconds.
|
||||
|
||||
### 3.7 DOM overlay
|
||||
|
||||
Overlay zones:
|
||||
- Top telemetry strip: demo mode, seed, node/edge count, frame, fps estimate.
|
||||
- Left command surface: demo selector buttons (`recall-path`, `birth`, `ripple`, `drift`, `firewall`) and copy demo URL.
|
||||
- Bottom timeline spine: 720-frame loop with beat tick marks and current frame cursor.
|
||||
- Right inspector: selected beat/node summary.
|
||||
|
||||
CSS pattern:
|
||||
- Root stage: `position: fixed; inset: 0; isolation: isolate; overflow: hidden;`.
|
||||
- Canvas: `position:absolute; inset:0; width:100%; height:100%; display:block; z-index:0;`.
|
||||
- Overlay: `position:absolute; inset:0; z-index:10; pointer-events:none;`.
|
||||
- Interactive overlay children: `pointer-events:auto;`.
|
||||
- Avoid KPI cards. These are instruments: thin glass, monospaced labels, tick marks, command rail.
|
||||
|
||||
Sources:
|
||||
- MDN `pointer-events`: https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events
|
||||
- WebGPU canvas resizing: https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html
|
||||
- Svelte lifecycle hooks: https://svelte.dev/docs/svelte/lifecycle-hooks
|
||||
|
||||
## 4. Build task-list with verification increments
|
||||
|
||||
### Increment 1 — route shell and deterministic URL contract
|
||||
|
||||
Build:
|
||||
- Add `routes/(app)/observatory/+page.svelte`.
|
||||
- Add minimal overlay shell and fetch graph data.
|
||||
- Parse `?demo=recall-path&seed=...` and display them.
|
||||
|
||||
Verify:
|
||||
- Run `pnpm --filter @vestige/dashboard check`.
|
||||
- Open `/observatory?demo=recall-path&seed=test`.
|
||||
- See full-bleed dark stage, top telemetry strip, no KPI cards, no console errors.
|
||||
|
||||
### Increment 2 — deterministic clock tests
|
||||
|
||||
Build:
|
||||
- Add `demo-clock.ts` and tests.
|
||||
- Fixed 60fps loop, 720-frame period, seeded PRNG.
|
||||
|
||||
Verify:
|
||||
- `pnpm --filter @vestige/dashboard test -- demo-clock`
|
||||
- Same seed produces identical first 100 random values and frame phases.
|
||||
- Different seed changes generated positions.
|
||||
|
||||
### Increment 3 — WebGPU canvas boots and clears
|
||||
|
||||
Build:
|
||||
- Add `ObservatoryCanvas.svelte` and `engine.ts` minimal WebGPU init.
|
||||
- Canvas clears to near-black and resizes with DPR clamp.
|
||||
|
||||
Verify:
|
||||
- `/observatory?demo=recall-path` displays WebGPU canvas.
|
||||
- Resize browser; canvas remains sharp and fills screen.
|
||||
- If WebGPU unavailable, route shows a readable fallback instead of crashing.
|
||||
|
||||
### Increment 4 — upload graph and render static nodes
|
||||
|
||||
Build:
|
||||
- Add `graph-upload.ts` and tests.
|
||||
- Convert graph nodes into stable indexed `Float32Array`.
|
||||
- Render node billboards from storage buffer; no simulation yet.
|
||||
|
||||
Verify:
|
||||
- Test stable node ordering and edge filtering.
|
||||
- Browser shows 100-300 nodes in deterministic positions.
|
||||
- Reload same seed: identical node positions.
|
||||
|
||||
### Increment 5 — recall path buffer + node glow
|
||||
|
||||
Build:
|
||||
- Reuse `planCinemaPath` to create path steps.
|
||||
- Add compute pass that writes `demo.x` recall intensity.
|
||||
- Node shader blooms active path nodes.
|
||||
|
||||
Verify:
|
||||
- `/observatory?demo=recall-path&seed=A` loops every 12s.
|
||||
- The same sequence lights the same nodes after reload.
|
||||
- `?seed=B` changes layout but path timing remains stable.
|
||||
|
||||
### Increment 6 — path edge wavefront
|
||||
|
||||
Build:
|
||||
- Add render-path pipeline drawing additive edge wave traveling source -> target.
|
||||
- Timeline spine shows beat ticks matching wave arrival.
|
||||
|
||||
Verify:
|
||||
- Wavefront visibly travels along each path edge.
|
||||
- At beat arrival, the target node blooms.
|
||||
- Loop reset is seamless: final afterglow fades before frame 0.
|
||||
|
||||
### Increment 7 — low-cost GPU force sim
|
||||
|
||||
Build:
|
||||
- Add static edge attraction + centering + damping in compute.
|
||||
- Keep repulsion disabled or low-count O(N^2) only for <=500 nodes.
|
||||
|
||||
Verify:
|
||||
- Nodes subtly settle without CPU `ForceSimulation`.
|
||||
- Performance remains smooth at 300 nodes.
|
||||
- No CPU per-frame node position loops.
|
||||
|
||||
### Increment 8 — lifecycle effects one by one
|
||||
|
||||
Build/verify separately:
|
||||
- Born convergence particles: trigger in demo and verify deterministic convergence.
|
||||
- Backward ripple post pass: verify radial distortion travels effect -> cause.
|
||||
- Horizon drift/fog: silent/unavailable nodes visibly drift/fade toward horizon.
|
||||
- Firewall shockwave/membrane: crimson ring expands and fades without breaking path demo.
|
||||
|
||||
### Increment 9 — capture mode
|
||||
|
||||
Build:
|
||||
- Add `?capture=1` behavior: hide cursor, freeze random seed, optional `?frame=N` exact-frame render.
|
||||
- Add overlay copy button for canonical demo URL.
|
||||
|
||||
Verify:
|
||||
- Same URL + frame renders identical screenshot.
|
||||
- 12-second screen recording loops without discontinuity.
|
||||
|
||||
### Increment 10 — performance guardrails
|
||||
|
||||
Build:
|
||||
- Add telemetry: CPU frame ms estimate, GPU timestamp query only if supported, node count, buffer mode.
|
||||
- Cap default demo to 300 real nodes; support synthetic 10k only behind `?stress=10000`.
|
||||
|
||||
Verify:
|
||||
- No `mapAsync` in the frame loop except delayed optional profiling.
|
||||
- 300-node demo holds 60fps on Sam's M1 Max.
|
||||
- Stress mode degrades gracefully and can be disabled by removing query param.
|
||||
|
||||
## 5. Source index
|
||||
|
||||
WebGPU core patterns:
|
||||
- WebGPU Samples compute boids demo: https://webgpu.github.io/webgpu-samples/samples/computeBoids/
|
||||
- compute-boids main TS: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/main.ts
|
||||
- compute-boids update WGSL: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/updateSprites.wgsl
|
||||
- compute-boids sprite WGSL: https://raw.githubusercontent.com/webgpu/webgpu-samples/main/sample/computeBoids/sprite.wgsl
|
||||
- WebGPU storage buffers: https://webgpufundamentals.org/webgpu/lessons/webgpu-storage-buffers.html
|
||||
- WebGPU compute shaders: https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html
|
||||
- WebGPU canvas resize: https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html
|
||||
- MDN WebGPU API: https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API
|
||||
|
||||
Particle/lifecycle shader references:
|
||||
- Particle Life WebGPU article: https://lisyarus.github.io/blog/posts/particle-life-simulation-in-browser-using-webgpu.html
|
||||
- Particle Life demo: https://lisyarus.github.io/webgpu/particle-life.html
|
||||
- compute.toys: https://compute.toys/
|
||||
- compute.toys repo: https://github.com/compute-toys/compute.toys
|
||||
- Codrops WebGPU fluids: https://tympanus.net/codrops/2025/02/26/webgpu-fluid-simulations-high-performance-real-time-rendering/
|
||||
- Codrops WebGPU particles/fluids: https://tympanus.net/codrops/2025/01/29/particles-progress-and-perseverance-a-journey-into-webgpu-fluids/
|
||||
- Codrops Shader.se WebGPU transitions: https://tympanus.net/codrops/2026/05/19/80s-business-tech-seamless-scene-transitions-inside-shader-ses-scroll-driven-webgpu-pipeline/
|
||||
- Codrops False Earth WebGPU world: https://tympanus.net/codrops/2026/04/21/false-earth-from-webgl-limits-to-a-webgpu-driven-world/
|
||||
|
||||
GPU graph layout:
|
||||
- GraphWaGu repo: https://github.com/harp-lab/GraphWaGu
|
||||
- GraphWaGu force-directed TS: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/webgpu/force_directed.ts
|
||||
- GraphWaGu exact forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forces.wgsl
|
||||
- GraphWaGu Barnes-Hut forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/compute_forcesBH.wgsl
|
||||
- GraphWaGu apply forces: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/apply_forces.wgsl
|
||||
- GraphWaGu tree build: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/create_tree.wgsl
|
||||
- GraphWaGu radix sort: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/radix_sort.wgsl
|
||||
- GraphWaGu node vertex: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/node_vert.wgsl
|
||||
- GraphWaGu edge vertex: https://raw.githubusercontent.com/harp-lab/GraphWaGu/main/src/wgsl/edge_vert.wgsl
|
||||
- cosmos.gl repo: https://github.com/cosmograph-org/cosmos
|
||||
- cosmos.gl README: https://raw.githubusercontent.com/cosmograph-org/cosmos/main/README.md
|
||||
- Cosmograph product: https://cosmograph.app/
|
||||
|
||||
Svelte/DOM/determinism:
|
||||
- Svelte lifecycle hooks: https://svelte.dev/docs/svelte/lifecycle-hooks
|
||||
- MDN URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
|
||||
- MDN pointer-events: https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events
|
||||
- Fixed timestep: https://gafferongames.com/post/fix_your_timestep/
|
||||
- seedrandom reference if a dependency is later preferred over local PRNG: https://github.com/davidbau/seedrandom
|
||||
|
||||
## 6. Non-negotiable implementation constraints
|
||||
|
||||
- New Observatory route only. Do not mutate the protected Memory Cinema block in `graph/+page.svelte`.
|
||||
- Keep existing Three/WebGL `Graph3D` intact. Observatory is a parallel WebGPU renderer, not a replacement in v1.
|
||||
- Do not add dependencies for v1. Use browser WebGPU, Svelte, TypeScript, and existing `three` only if needed for matrix math; prefer small local math helpers.
|
||||
- No `Math.random()` in demo-visible state. No `Date.now()`/`performance.now()` deciding simulation state.
|
||||
- No GPU readback in the frame loop. Profiling readback must be delayed and optional.
|
||||
- Every increment must render something verifiable before moving to the next effect.
|
||||
|
||||
---
|
||||
|
||||
## 7. VISUAL DNA — "from another dimension" (MANDATORY aesthetic contract)
|
||||
|
||||
Sam's directive (Jul 3 2026): the Observatory must be **mind-boggling** and look like it
|
||||
**came from another dimension** — the same grammar as the WebGPU waitlist hero and
|
||||
`docs/launch/causal-brain-demo.html`. This is NOT a dashboard skin. It is a living
|
||||
tissue. Every increment below inherits this section. Generic = rejected.
|
||||
|
||||
### 7.1 The signature palette (fuse two systems — do not invent a third)
|
||||
|
||||
TWO real palettes already exist in-repo. The Observatory FUSES them:
|
||||
|
||||
- **Meaning layer — FSRS state (from `lib/graph/nodes.ts`, use verbatim):**
|
||||
- active `#10b981` emerald · dormant `#f59e0b` amber · silent `#8b5cf6` violet · unavailable `#6b7280` slate
|
||||
- aha `#FFD700` · confusion/failure `#EF4444` · guardrail `#9CA3AF`
|
||||
- **Transcendence layer — iridescent thin-film (from `causal-brain-demo.html` `spectral(w)`):**
|
||||
a catmull blend around 4 anchors, w∈[0,1]:
|
||||
- indigo `vec3(0.20,0.28,0.95)` → cyan-teal `vec3(0.20,0.85,0.90)` → mint `vec3(0.45,1.00,0.72)` → magenta rim `vec3(0.85,0.45,1.00)`
|
||||
- CORE reads indigo/teal (living tissue); MOTION shifts toward cyan/magenta; NEVER muddy orange.
|
||||
- **Void:** background `#05060a` (near-black, `color-scheme: dark`). No grey dashboard chrome.
|
||||
- **Rule:** a node's BASE hue = its FSRS state color. Its ACTIVATION glow (recall, birth,
|
||||
ignite) rides the thin-film spectral band. So resting graph = meaningful; active graph = otherworldly.
|
||||
|
||||
Port `spectral(w)` to WGSL exactly (4-anchor catmull-ish blend). This is the single most
|
||||
important visual primitive — it is what makes it look alien instead of corporate.
|
||||
|
||||
### 7.2 Glow / bloom language (copy the demo's exact values, then push further in WebGPU)
|
||||
|
||||
- Additive radial glow blobs (`glow(x,y,rad,col,alpha)` in the demo) → in WebGPU, a real
|
||||
additive bloom pass. Node halos are `0 0 24px` soft; ignite events drop-shadow at
|
||||
`0 0 26px rgba(110,240,220,.45)` energy.
|
||||
- Text/instrument overlay glow: captions `text-shadow:0 0 24px rgba(30,180,255,.35)`;
|
||||
verdict headline `linear-gradient(90deg,#7fe6c0,#6ef0e6,#a6dcff)` clipped to text with
|
||||
`drop-shadow(0 0 26px rgba(110,240,220,.45))`; wordmark `letter-spacing:.28em`, `#5dcaa5`.
|
||||
- **Breath:** a global `pulse = 0.5 + 0.5*sin(t*0.002)` (~0.32Hz) modulates halo intensity so
|
||||
the whole field breathes even at rest. Living, never static.
|
||||
|
||||
### 7.3 DOM = instrument overlay ONLY (already the spec rule — here is the exact style)
|
||||
|
||||
Copy the demo's overlay contract: `.layer { position:fixed; inset:0; pointer-events:none }`
|
||||
over a full-bleed `inset:0` canvas. Instruments (TelemetryStrip, CommandSurface,
|
||||
TimelineSpine, InspectorPanel) are the ONLY DOM, styled like the demo's `.cap.mono`
|
||||
(SF Mono, `#cfe9ff`), `.verdict` (radial-gradient vignette card), `.wordmark`. NO KPI cards.
|
||||
NO solid panels. Instruments float, glow faintly, and never block the canvas
|
||||
(`pointer-events:auto` only on the specific interactive control).
|
||||
|
||||
### 7.4 The reference moment already exists in 2D — port it, don't reinvent
|
||||
|
||||
`causal-brain-demo.html` IS Moment C (salience-rescue / backward-trace) as a 2D canvas
|
||||
proof: brain point cloud (two lobes, center-dense), a failure flare on the deep right, a
|
||||
vector-search stall on confounders, then a BACKWARD arrow tracing to the dormant cause on
|
||||
the deep-left which **ignites**, then a verdict card ("Vestige 60% · vector search 0%"),
|
||||
then the VESTIGE wordmark. Beat clock: brain forms 0-1.5s, fail fades in ~0.3s, vector
|
||||
stalls 4-6.5s, backward trace fires 6.5-10.5s, verdict 10.5-13s, signature 13-15s, loop.
|
||||
The Observatory's `?demo=salience-rescue` must reproduce this exact narrative in real
|
||||
WebGPU with real memory nodes. Study its `spectral`, `seedBrain`, `glow`, and beat
|
||||
`schedule` before writing the WGSL.
|
||||
|
||||
### 7.5 Non-negotiable "another dimension" checklist (verifier MUST confirm each)
|
||||
|
||||
- [ ] Field breathes at rest (global pulse), never a static image.
|
||||
- [ ] Node base hue = FSRS state; activation = thin-film spectral. Both visible.
|
||||
- [ ] Real additive bloom on ignite/recall (not just opacity).
|
||||
- [ ] Void `#05060a`, zero grey dashboard chrome, zero KPI cards.
|
||||
- [ ] Instruments are glowing SF-Mono overlays with `pointer-events:none` except controls.
|
||||
- [ ] `?demo=salience-rescue` reproduces the causal-brain backward-trace narrative.
|
||||
- [ ] It looks like living tissue from another dimension, not a SaaS dashboard. If a
|
||||
reviewer would call it "clean" or "professional," it FAILED. The word is "alive."
|
||||
131
apps/dashboard/src/lib/components/ObservatoryCanvas.svelte
Normal file
131
apps/dashboard/src/lib/components/ObservatoryCanvas.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Cognitive Observatory — WebGPU canvas host.
|
||||
*
|
||||
* Owns the ObservatoryEngine lifecycle: mount → boot → resize → dispose.
|
||||
* If WebGPU is unavailable, renders a readable fallback instead of crashing
|
||||
* (Increment 3 gate, spec §4).
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { ObservatoryEngine, type EngineStatus } from '$lib/observatory/engine';
|
||||
import type { DemoMode } from '$lib/observatory/types';
|
||||
|
||||
interface Props {
|
||||
demo: DemoMode;
|
||||
seed: string;
|
||||
/** Capture mode: freeze the sim at this loop frame (?frame=N). */
|
||||
freezeFrame?: number | null;
|
||||
/** Telemetry callback: loop frame + fps estimate. */
|
||||
onframe?: (frame: number, fps: number) => void;
|
||||
/** Fired when the engine is running (the route uploads the graph here). */
|
||||
onready?: (engine: ObservatoryEngine) => void;
|
||||
}
|
||||
|
||||
let { demo, seed, freezeFrame = null, onframe, onready }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let engine: ObservatoryEngine | null = null;
|
||||
let status = $state<EngineStatus>({ state: 'booting' });
|
||||
let unsubStatus: (() => void) | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMount(() => {
|
||||
engine = new ObservatoryEngine({
|
||||
canvas: canvasEl,
|
||||
demo,
|
||||
seed,
|
||||
freezeFrame,
|
||||
maxDpr: 2,
|
||||
onFrame: (frame, fps) => onframe?.(frame, fps)
|
||||
});
|
||||
unsubStatus = engine.onStatus((s) => (status = s));
|
||||
|
||||
// Keep the drawing buffer in lockstep with layout size (DPR-clamped).
|
||||
resizeObserver = new ResizeObserver(() => engine?.resize());
|
||||
resizeObserver.observe(canvasEl);
|
||||
|
||||
engine.start().then((ok) => {
|
||||
if (ok && engine) {
|
||||
engine.resize();
|
||||
onready?.(engine);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubStatus?.();
|
||||
resizeObserver?.disconnect();
|
||||
engine?.dispose();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Full-bleed canvas: the living memory field (void #05060a is cleared on-GPU). -->
|
||||
<canvas bind:this={canvasEl} class="observatory-canvas" aria-label="Vestige memory field"
|
||||
></canvas>
|
||||
|
||||
{#if status.state === 'unsupported' || status.state === 'error'}
|
||||
<!-- Readable fallback — never a crash (spec §4 Increment 3 gate). -->
|
||||
<div class="fallback" role="alert">
|
||||
<div class="fallback-title">MEMORY FIELD OFFLINE</div>
|
||||
<div class="fallback-reason">{status.reason}</div>
|
||||
<div class="fallback-hint">
|
||||
WebGPU is required for the Observatory. Chrome 113+, Edge 113+, or Safari 18+ —
|
||||
the classic <a href="./graph">Graph view</a> works everywhere.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.observatory-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #05060a;
|
||||
}
|
||||
|
||||
/* Fallback panel follows the instrument-overlay grammar (§7.3):
|
||||
SF-Mono, faint glow, floating on the void — not a grey error card. */
|
||||
.fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0 10%;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fallback-title {
|
||||
color: #5dcaa5;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-shadow: 0 0 20px rgba(93, 202, 165, 0.4);
|
||||
}
|
||||
|
||||
.fallback-reason {
|
||||
color: #9fd0e4;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.fallback-hint {
|
||||
color: #7c8a97;
|
||||
font-size: 0.75rem;
|
||||
max-width: 34rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fallback-hint a {
|
||||
color: #cfe9ff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
227
apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts
Normal file
227
apps/dashboard/src/lib/observatory/__tests__/birth-plan.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock WebGPU before any imports
|
||||
vi.mock('$lib/observatory/engine', () => ({
|
||||
ObservatoryEngine: class {},
|
||||
}));
|
||||
|
||||
// Mock document.createElement for canvas (needed by demo-clock)
|
||||
const mockCanvas = {
|
||||
width: 512,
|
||||
height: 64,
|
||||
getContext: () => null,
|
||||
toDataURL: () => 'data:image/png;base64,',
|
||||
};
|
||||
|
||||
if (typeof globalThis.document === 'undefined') {
|
||||
(globalThis as any).document = {
|
||||
createElement: (tag: string) => (tag === 'canvas' ? mockCanvas : {}),
|
||||
};
|
||||
}
|
||||
|
||||
import { buildBirthPlan, pickTargetIndex } from '../birth-plan';
|
||||
import type { ObservatoryGraph, ObservatoryNode, ObservatoryEdge } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeNode(
|
||||
id: string,
|
||||
index: number,
|
||||
opts: Partial<ObservatoryNode> = {}
|
||||
): ObservatoryNode {
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
label: `Node ${id}`,
|
||||
type: 'memory',
|
||||
retention: opts.retention ?? 0.5,
|
||||
tags: opts.tags ?? [],
|
||||
isCenter: opts.isCenter ?? false,
|
||||
suppressed: opts.suppressed ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEdge(sourceIndex: number, targetIndex: number): ObservatoryEdge {
|
||||
return { sourceIndex, targetIndex, weight: 1.0, type: 'association' };
|
||||
}
|
||||
|
||||
function makeGraph(
|
||||
nodes: ObservatoryNode[],
|
||||
edges: ObservatoryEdge[]
|
||||
): ObservatoryGraph {
|
||||
const indexById = new Map<string, number>();
|
||||
for (const n of nodes) indexById.set(n.id, n.index);
|
||||
const centerIndex = nodes.findIndex((n) => n.isCenter);
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
indexById,
|
||||
centerIndex: centerIndex < 0 ? 0 : centerIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pickTargetIndex', () => {
|
||||
it('picks center\'s highest-retention neighbor when edges exist', () => {
|
||||
const nodes = [
|
||||
makeNode('center', 0, { isCenter: true, retention: 0.9 }),
|
||||
makeNode('a', 1, { retention: 0.8 }),
|
||||
makeNode('b', 2, { retention: 0.95 }),
|
||||
makeNode('c', 3, { retention: 0.7 }),
|
||||
];
|
||||
const edges = [
|
||||
makeEdge(0, 1), // center <-> a (ret 0.8)
|
||||
makeEdge(0, 2), // center <-> b (ret 0.95)
|
||||
makeEdge(1, 3), // a <-> c (not incident to center)
|
||||
];
|
||||
const graph = makeGraph(nodes, edges);
|
||||
const target = pickTargetIndex(graph);
|
||||
expect(target).toBe(2); // b has highest retention (0.95)
|
||||
});
|
||||
|
||||
it('picks first non-center node when center has no edges', () => {
|
||||
const nodes = [
|
||||
makeNode('center', 0, { isCenter: true }),
|
||||
makeNode('a', 1),
|
||||
makeNode('b', 2),
|
||||
];
|
||||
const edges = [makeEdge(1, 2)]; // no edges from center
|
||||
const graph = makeGraph(nodes, edges);
|
||||
const target = pickTargetIndex(graph);
|
||||
expect(target).toBe(1); // first non-center
|
||||
});
|
||||
|
||||
it('picks center node when graph has only the center', () => {
|
||||
const nodes = [makeNode('center', 0, { isCenter: true })];
|
||||
const graph = makeGraph(nodes, []);
|
||||
const target = pickTargetIndex(graph);
|
||||
expect(target).toBe(0);
|
||||
});
|
||||
|
||||
it('picks center when all neighbors have equal retention', () => {
|
||||
const nodes = [
|
||||
makeNode('center', 0, { isCenter: true }),
|
||||
makeNode('a', 1, { retention: 0.5 }),
|
||||
makeNode('b', 2, { retention: 0.5 }),
|
||||
];
|
||||
const edges = [makeEdge(0, 1), makeEdge(0, 2)];
|
||||
const graph = makeGraph(nodes, edges);
|
||||
const target = pickTargetIndex(graph);
|
||||
// Both have same retention (0.5), so picks first neighbor (a, index 1)
|
||||
expect(target).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBirthPlan', () => {
|
||||
let graph: ObservatoryGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
const nodes = [
|
||||
makeNode('center', 0, { isCenter: true, retention: 0.9 }),
|
||||
makeNode('a', 1, { retention: 0.8 }),
|
||||
makeNode('b', 2, { retention: 0.95 }),
|
||||
makeNode('c', 3, { retention: 0.7 }),
|
||||
makeNode('d', 4, { retention: 0.6 }),
|
||||
];
|
||||
const edges = [
|
||||
makeEdge(0, 1),
|
||||
makeEdge(0, 2),
|
||||
makeEdge(1, 3),
|
||||
makeEdge(2, 4),
|
||||
];
|
||||
graph = makeGraph(nodes, edges);
|
||||
});
|
||||
|
||||
it('produces deterministic particles for same graph + seed', () => {
|
||||
const plan1 = buildBirthPlan(graph, 'test-seed', 1024);
|
||||
const plan2 = buildBirthPlan(graph, 'test-seed', 1024);
|
||||
|
||||
expect(plan1.particles).toEqual(plan2.particles);
|
||||
expect(plan1.edgeSteps).toEqual(plan2.edgeSteps);
|
||||
expect(plan1.targetIndex).toBe(plan2.targetIndex);
|
||||
});
|
||||
|
||||
it('produces different particles for different seeds', () => {
|
||||
const plan1 = buildBirthPlan(graph, 'seed-a', 1024);
|
||||
const plan2 = buildBirthPlan(graph, 'seed-b', 1024);
|
||||
|
||||
// Same target, different particle positions
|
||||
expect(plan1.targetIndex).toBe(plan2.targetIndex);
|
||||
// But particles should differ
|
||||
let different = false;
|
||||
for (let i = 0; i < plan1.particles.length; i++) {
|
||||
if (plan1.particles[i] !== plan2.particles[i]) {
|
||||
different = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(different).toBe(true);
|
||||
});
|
||||
|
||||
it('always picks the same target regardless of seed', () => {
|
||||
const plan1 = buildBirthPlan(graph, 'seed-a', 1024);
|
||||
const plan2 = buildBirthPlan(graph, 'seed-b', 1024);
|
||||
const plan3 = buildBirthPlan(graph, 'seed-c', 1024);
|
||||
|
||||
expect(plan1.targetIndex).toBe(plan2.targetIndex);
|
||||
expect(plan2.targetIndex).toBe(plan3.targetIndex);
|
||||
// Should be index 2 (b, highest retention neighbor of center)
|
||||
expect(plan1.targetIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('has correct particle array size', () => {
|
||||
const plan = buildBirthPlan(graph, 'test', 2048);
|
||||
expect(plan.particles.length).toBe(2048 * 16); // 16 floats per particle
|
||||
});
|
||||
|
||||
it('has correct edge step array size', () => {
|
||||
const plan = buildBirthPlan(graph, 'test');
|
||||
// 2 edges incident to center (0-1, 0-2)
|
||||
expect(plan.edgeSteps.length).toBe(2 * 4); // 4 u32 per step
|
||||
});
|
||||
|
||||
it('has valid timeline beats', () => {
|
||||
const plan = buildBirthPlan(graph, 'test');
|
||||
expect(plan.timeline.length).toBeGreaterThan(0);
|
||||
for (const beat of plan.timeline) {
|
||||
expect(beat.label).toBeTruthy();
|
||||
expect(beat.startFrame).toBeGreaterThanOrEqual(0);
|
||||
expect(beat.endFrame).toBeGreaterThanOrEqual(beat.startFrame);
|
||||
expect(beat.endFrame).toBeLessThan(720);
|
||||
}
|
||||
});
|
||||
|
||||
it('center-only graph produces valid target and zero edge steps', () => {
|
||||
const centerOnly = makeGraph(
|
||||
[makeNode('center', 0, { isCenter: true })],
|
||||
[]
|
||||
);
|
||||
const plan = buildBirthPlan(centerOnly, 'center-test', 512);
|
||||
expect(plan.targetIndex).toBe(0);
|
||||
expect(plan.targetNodeId).toBe('center');
|
||||
expect(plan.edgeSteps.length).toBe(0); // zero incident edges = zero steps (plan L464)
|
||||
});
|
||||
|
||||
it('does not use Math.random() — verified by code inspection', () => {
|
||||
// This test documents the constraint: buildBirthPlan uses DemoClock
|
||||
// (xmur3 + mulberry32) exclusively. Math.random() is never called.
|
||||
// The test passes by construction — if Math.random() were used,
|
||||
// determinism would break, which is caught by the determinism test above.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('default particle count is 8192', () => {
|
||||
const plan = buildBirthPlan(graph, 'test');
|
||||
expect(plan.particles.length).toBe(8192 * 16);
|
||||
});
|
||||
|
||||
it('custom particle count works', () => {
|
||||
const plan = buildBirthPlan(graph, 'test', 4096);
|
||||
expect(plan.particles.length).toBe(4096 * 16);
|
||||
});
|
||||
});
|
||||
136
apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts
Normal file
136
apps/dashboard/src/lib/observatory/__tests__/demo-clock.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DemoClock, deterministicSpherePosition } from '../demo-clock';
|
||||
|
||||
describe('DemoClock', () => {
|
||||
it('should start at frame 0 with phase 0', () => {
|
||||
const clock = new DemoClock({ seed: 'test-seed' });
|
||||
const state = clock.state;
|
||||
expect(state.frame).toBe(0);
|
||||
expect(state.phase).toBe(0);
|
||||
expect(state.totalFrames).toBe(0);
|
||||
});
|
||||
|
||||
it('should advance frame by 1 on each tick', () => {
|
||||
const clock = new DemoClock({ seed: 'test-seed' });
|
||||
clock.tick();
|
||||
expect(clock.state.frame).toBe(1);
|
||||
clock.tick();
|
||||
expect(clock.state.frame).toBe(2);
|
||||
});
|
||||
|
||||
it('should wrap frame at loopFrames (default 720)', () => {
|
||||
const clock = new DemoClock({ seed: 'test-seed' });
|
||||
// Advance to loopFrames - 1
|
||||
for (let i = 0; i < 719; i++) {
|
||||
clock.tick();
|
||||
}
|
||||
expect(clock.state.frame).toBe(719);
|
||||
// One more tick should wrap to 0
|
||||
clock.tick();
|
||||
expect(clock.state.frame).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute correct phase (frame / loopFrames)', () => {
|
||||
const clock = new DemoClock({ seed: 'test-seed' });
|
||||
clock.tick();
|
||||
expect(clock.state.phase).toBeCloseTo(1 / 720, 6);
|
||||
clock.tick();
|
||||
expect(clock.state.phase).toBeCloseTo(2 / 720, 6);
|
||||
});
|
||||
|
||||
it('should produce identical PRNG sequences for the same seed', () => {
|
||||
const clock1 = new DemoClock({ seed: 'identical-seed' });
|
||||
const clock2 = new DemoClock({ seed: 'identical-seed' });
|
||||
const values1: number[] = [];
|
||||
const values2: number[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
clock1.tick();
|
||||
values1.push(clock1.state.rng());
|
||||
clock2.tick();
|
||||
values2.push(clock2.state.rng());
|
||||
}
|
||||
expect(values1).toEqual(values2);
|
||||
});
|
||||
|
||||
it('should produce different PRNG sequences for different seeds', () => {
|
||||
const clock1 = new DemoClock({ seed: 'seed-a' });
|
||||
const clock2 = new DemoClock({ seed: 'seed-b' });
|
||||
clock1.tick();
|
||||
clock2.tick();
|
||||
expect(clock1.state.rng()).not.toBe(clock2.state.rng());
|
||||
});
|
||||
|
||||
it('should produce different positions for different seeds', () => {
|
||||
const clock1 = new DemoClock({ seed: 'pos-seed-a' });
|
||||
const clock2 = new DemoClock({ seed: 'pos-seed-b' });
|
||||
const pos1 = deterministicSpherePosition(0, 100, 50, clock1.state.rng);
|
||||
const pos2 = deterministicSpherePosition(0, 100, 50, clock2.state.rng);
|
||||
expect(pos1).not.toEqual(pos2);
|
||||
});
|
||||
|
||||
it('should produce identical positions for the same seed', () => {
|
||||
const clock1 = new DemoClock({ seed: 'same-seed' });
|
||||
const clock2 = new DemoClock({ seed: 'same-seed' });
|
||||
const pos1 = deterministicSpherePosition(0, 100, 50, clock1.state.rng);
|
||||
const pos2 = deterministicSpherePosition(0, 100, 50, clock2.state.rng);
|
||||
expect(pos1).toEqual(pos2);
|
||||
});
|
||||
|
||||
it('should reset to frame 0 and re-seed PRNG', () => {
|
||||
const clock = new DemoClock({ seed: 'reset-seed' });
|
||||
// Advance 100 frames
|
||||
for (let i = 0; i < 100; i++) {
|
||||
clock.tick();
|
||||
}
|
||||
expect(clock.state.frame).toBe(100);
|
||||
expect(clock.state.totalFrames).toBe(100);
|
||||
|
||||
// Reset
|
||||
clock.reset();
|
||||
expect(clock.state.frame).toBe(0);
|
||||
expect(clock.state.totalFrames).toBe(0);
|
||||
|
||||
// After reset, the PRNG should produce the same values as the initial state
|
||||
const afterResetRng = clock.state.rng();
|
||||
const initialRng = new DemoClock({ seed: 'reset-seed' }).state.rng();
|
||||
expect(afterResetRng).toBe(initialRng);
|
||||
});
|
||||
|
||||
it('should respect custom loopFrames', () => {
|
||||
const clock = new DemoClock({ seed: 'custom-loop', loopFrames: 360 });
|
||||
for (let i = 0; i < 360; i++) {
|
||||
clock.tick();
|
||||
}
|
||||
expect(clock.state.frame).toBe(0);
|
||||
expect(clock.state.phase).toBe(0);
|
||||
});
|
||||
|
||||
it('should respect custom fps (affects loopDuration)', () => {
|
||||
const clock = new DemoClock({ seed: 'custom-fps', fps: 30, loopFrames: 300 });
|
||||
expect(clock.loopDuration).toBe(10); // 300 / 30 = 10s
|
||||
});
|
||||
|
||||
it('exposes framesPerLoop for capture-mode frame normalization', () => {
|
||||
expect(new DemoClock({ seed: 'x' }).framesPerLoop).toBe(720);
|
||||
expect(new DemoClock({ seed: 'x', loopFrames: 360 }).framesPerLoop).toBe(360);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deterministicSpherePosition', () => {
|
||||
it('should place nodes on a sphere surface', () => {
|
||||
const clock = new DemoClock({ seed: 'sphere-test' });
|
||||
const pos = deterministicSpherePosition(0, 10, 50, clock.state.rng);
|
||||
const [x, y, z] = pos;
|
||||
const dist = Math.sqrt(x * x + y * y + z * z);
|
||||
// Should be approximately at the given radius (some variance from golden angle)
|
||||
expect(dist).toBeGreaterThan(40);
|
||||
expect(dist).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('should produce different positions for different indices', () => {
|
||||
const clock = new DemoClock({ seed: 'sphere-test' });
|
||||
const pos0 = deterministicSpherePosition(0, 10, 50, clock.state.rng);
|
||||
const pos1 = deterministicSpherePosition(1, 10, 50, clock.state.rng);
|
||||
expect(pos0).not.toEqual(pos1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
buildForgettingPlan,
|
||||
forgettingEnvelopes,
|
||||
horizonDrift,
|
||||
pickDrifting,
|
||||
pickRescued,
|
||||
rescueFrame,
|
||||
FORGETTING_K,
|
||||
FADING_BASE,
|
||||
FADING_INTERVAL,
|
||||
RESCUE_BASE,
|
||||
RESCUE_INTERVAL,
|
||||
SINK_BEAT_FRAME,
|
||||
RETRIEVABLE_BEAT_FRAME
|
||||
} from '../forgetting-plan';
|
||||
import { buildObservatoryGraph } from '../graph-upload';
|
||||
import { PATH_KIND } from '../types';
|
||||
import type { GraphNode, GraphEdge, GraphResponse } from '$types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers (cloned from rescue-plan.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function gn(id: string, opts: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: opts.label ?? `Memory ${id}`,
|
||||
type: 'note',
|
||||
retention: opts.retention ?? 0.5,
|
||||
tags: opts.tags ?? [],
|
||||
createdAt: opts.createdAt ?? '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
isCenter: opts.isCenter ?? false,
|
||||
suppression_count: opts.suppression_count
|
||||
};
|
||||
}
|
||||
|
||||
function ge(source: string, target: string, type = 'semantic'): GraphEdge {
|
||||
return { source, target, weight: 1, type };
|
||||
}
|
||||
|
||||
function gr(nodes: GraphNode[], edges: GraphEdge[], centerId = 'center'): GraphResponse {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
center_id: centerId,
|
||||
depth: 3,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length
|
||||
};
|
||||
}
|
||||
|
||||
/** 10-node fixture: center + 9, varied retention → driftCount = max(3, round(2.25)) = 3. */
|
||||
function tenNodeFixture(): GraphResponse {
|
||||
const nodes = [
|
||||
gn('center', { isCenter: true, retention: 0.9 }),
|
||||
gn('a', { retention: 0.05, label: 'oldest forgotten fact' }),
|
||||
gn('b', { retention: 0.12 }),
|
||||
gn('c', { retention: 0.2 }),
|
||||
gn('d', { retention: 0.55 }),
|
||||
gn('e', { retention: 0.6 }),
|
||||
gn('f', { retention: 0.65 }),
|
||||
gn('g', { retention: 0.7 }),
|
||||
gn('h', { retention: 0.75 }),
|
||||
gn('i', { retention: 0.8 })
|
||||
];
|
||||
const edges = [
|
||||
ge('center', 'a'),
|
||||
ge('center', 'b'),
|
||||
ge('b', 'c'),
|
||||
ge('center', 'd'),
|
||||
ge('d', 'e')
|
||||
];
|
||||
return gr(nodes, edges);
|
||||
}
|
||||
|
||||
/** 40-node fixture: center + 39 → driftCount = round(0.25·39) = 10, K = 3 rescued. */
|
||||
function fortyNodeFixture(): GraphResponse {
|
||||
const nodes: GraphNode[] = [gn('center', { isCenter: true, retention: 0.95 })];
|
||||
const edges: GraphEdge[] = [];
|
||||
for (let i = 0; i < 39; i++) {
|
||||
const id = `n${String(i).padStart(2, '0')}`;
|
||||
nodes.push(gn(id, { retention: 0.04 + i * 0.02, label: `memory ${id}` }));
|
||||
edges.push(ge('center', id));
|
||||
}
|
||||
// Extra connectivity inside the drifting band so rescue scores separate.
|
||||
edges.push(ge('n07', 'n08'));
|
||||
edges.push(ge('n07', 'n09'));
|
||||
edges.push(ge('n08', 'n09'));
|
||||
return gr(nodes, edges);
|
||||
}
|
||||
|
||||
function planFor(response: GraphResponse) {
|
||||
const graph = buildObservatoryGraph(response);
|
||||
return { graph, plan: buildForgettingPlan(graph) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('determinism', () => {
|
||||
it('same graph → identical plan, byte-identical typed arrays', () => {
|
||||
const r = fortyNodeFixture();
|
||||
const { plan: p1 } = planFor(r);
|
||||
const { plan: p2 } = planFor(r);
|
||||
expect(p1).toEqual(p2);
|
||||
expect(Array.from(p1.horizonData)).toEqual(Array.from(p2.horizonData));
|
||||
expect(Array.from(p1.pathData)).toEqual(Array.from(p2.pathData));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. pickDrifting — 25% clamp formula, center excluded, tie → lower index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pickDrifting', () => {
|
||||
it('10-node graph → driftCount 3 (floor of min(3, 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DemoClock } from '../demo-clock';
|
||||
import {
|
||||
buildObservatoryGraph,
|
||||
buildNodeStateArray,
|
||||
buildEdgeIndexArray,
|
||||
hexToRgb01,
|
||||
nodeBaseColor
|
||||
} from '../graph-upload';
|
||||
import { FLOATS_PER_NODE, NODE_LANE, NODE_FLAG, toObservatoryNode } from '../types';
|
||||
import type { GraphResponse, GraphNode } from '$types';
|
||||
|
||||
function node(partial: Partial<GraphNode> & { id: string }): GraphNode {
|
||||
return {
|
||||
label: partial.id,
|
||||
type: 'note',
|
||||
retention: 0.8,
|
||||
tags: [],
|
||||
createdAt: '2026-07-01T00:00:00Z',
|
||||
updatedAt: '2026-07-01T00:00:00Z',
|
||||
isCenter: false,
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
function response(nodes: GraphNode[], edges: GraphResponse['edges'] = []): GraphResponse {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
center_id: nodes.find((n) => n.isCenter)?.id ?? '',
|
||||
depth: 3,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildObservatoryGraph', () => {
|
||||
it('orders center first, then by id — independent of API order', () => {
|
||||
const a = response([node({ id: 'zz' }), node({ id: 'aa' }), node({ id: 'mm', isCenter: true })]);
|
||||
const b = response([node({ id: 'mm', isCenter: true }), node({ id: 'zz' }), node({ id: 'aa' })]);
|
||||
const ga = buildObservatoryGraph(a);
|
||||
const gb = buildObservatoryGraph(b);
|
||||
expect(ga.nodes.map((n) => n.id)).toEqual(['mm', 'aa', 'zz']);
|
||||
expect(gb.nodes.map((n) => n.id)).toEqual(['mm', 'aa', 'zz']);
|
||||
expect(ga.centerIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('maps edges to stable indices and drops dangling/self edges', () => {
|
||||
const g = buildObservatoryGraph(
|
||||
response(
|
||||
[node({ id: 'a' }), node({ id: 'b' })],
|
||||
[
|
||||
{ source: 'a', target: 'b', weight: 1, type: 'semantic' },
|
||||
{ source: 'a', target: 'ghost', weight: 1, type: 'semantic' },
|
||||
{ source: 'b', target: 'b', weight: 1, type: 'semantic' }
|
||||
]
|
||||
)
|
||||
);
|
||||
expect(g.edges).toHaveLength(1);
|
||||
expect(g.edges[0].sourceIndex).toBe(g.indexById.get('a'));
|
||||
expect(g.edges[0].targetIndex).toBe(g.indexById.get('b'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNodeStateArray determinism', () => {
|
||||
const resp = response([
|
||||
node({ id: 'center', isCenter: true }),
|
||||
node({ id: 'n1', retention: 0.9 }),
|
||||
node({ id: 'n2', retention: 0.2 }),
|
||||
node({ id: 'n3', retention: 0.05 })
|
||||
]);
|
||||
|
||||
it('same seed → identical field; different seed → different field', () => {
|
||||
const g = buildObservatoryGraph(resp);
|
||||
const a = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
|
||||
const b = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
|
||||
const c = buildNodeStateArray(g, new DemoClock({ seed: 'B' }).state.rng);
|
||||
expect(Array.from(a.data)).toEqual(Array.from(b.data));
|
||||
expect(Array.from(a.data)).not.toEqual(Array.from(c.data));
|
||||
});
|
||||
|
||||
it('anchors the center node at the origin with the largest radius', () => {
|
||||
const g = buildObservatoryGraph(resp);
|
||||
const { data } = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
|
||||
expect(data[NODE_LANE.posRadius]).toBe(0);
|
||||
expect(data[NODE_LANE.posRadius + 1]).toBe(0);
|
||||
expect(data[NODE_LANE.posRadius + 2]).toBe(0);
|
||||
const centerRadius = data[NODE_LANE.posRadius + 3];
|
||||
for (let i = 1; i < g.nodes.length; i++) {
|
||||
expect(centerRadius).toBeGreaterThan(data[i * FLOATS_PER_NODE + NODE_LANE.posRadius + 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('stores retention and packs flags', () => {
|
||||
const g = buildObservatoryGraph(
|
||||
response([
|
||||
node({ id: 'c', isCenter: true }),
|
||||
node({ id: 's', suppression_count: 2, retention: 0.5 }),
|
||||
node({ id: 'aha', tags: ['aha'], retention: 0.6 })
|
||||
])
|
||||
);
|
||||
const { data } = buildNodeStateArray(g, new DemoClock({ seed: 'A' }).state.rng);
|
||||
const byId = (id: string) => g.indexById.get(id)! * FLOATS_PER_NODE;
|
||||
expect(data[byId('s') + NODE_LANE.velRetention + 3]).toBeCloseTo(0.5, 6);
|
||||
expect(data[byId('c') + NODE_LANE.colorFlags + 3] & NODE_FLAG.isCenter).toBeTruthy();
|
||||
expect(data[byId('s') + NODE_LANE.colorFlags + 3] & NODE_FLAG.suppressed).toBeTruthy();
|
||||
expect(data[byId('aha') + NODE_LANE.colorFlags + 3] & NODE_FLAG.isAha).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('meaning-layer palette (visual DNA §7.1)', () => {
|
||||
it('maps FSRS retention buckets to the real dashboard palette', () => {
|
||||
const active = toObservatoryNode(node({ id: 'a', retention: 0.9 }), 0);
|
||||
const dormant = toObservatoryNode(node({ id: 'd', retention: 0.5 }), 1);
|
||||
const silent = toObservatoryNode(node({ id: 's', retention: 0.2 }), 2);
|
||||
const gone = toObservatoryNode(node({ id: 'u', retention: 0.01 }), 3);
|
||||
expect(nodeBaseColor(active)).toEqual(hexToRgb01('#10b981')); // emerald
|
||||
expect(nodeBaseColor(dormant)).toEqual(hexToRgb01('#f59e0b')); // amber
|
||||
expect(nodeBaseColor(silent)).toEqual(hexToRgb01('#8b5cf6')); // violet
|
||||
expect(nodeBaseColor(gone)).toEqual(hexToRgb01('#6b7280')); // slate
|
||||
});
|
||||
|
||||
it('aha tag overrides with gold', () => {
|
||||
const aha = toObservatoryNode(node({ id: 'x', retention: 0.9, tags: ['aha'] }), 0);
|
||||
expect(nodeBaseColor(aha)).toEqual(hexToRgb01('#FFD700'));
|
||||
});
|
||||
|
||||
it('hexToRgb01 falls back to slate on malformed input', () => {
|
||||
expect(hexToRgb01('not-a-color')).toEqual(hexToRgb01('#6b7280'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEdgeIndexArray', () => {
|
||||
it('emits source/target index pairs', () => {
|
||||
const g = buildObservatoryGraph(
|
||||
response(
|
||||
[node({ id: 'a' }), node({ id: 'b' }), node({ id: 'c' })],
|
||||
[
|
||||
{ source: 'a', target: 'b', weight: 1, type: 'semantic' },
|
||||
{ source: 'b', target: 'c', weight: 1, type: 'semantic' }
|
||||
]
|
||||
)
|
||||
);
|
||||
const arr = buildEdgeIndexArray(g);
|
||||
expect(arr).toHaveLength(4);
|
||||
expect(arr[0]).toBe(g.indexById.get('a'));
|
||||
expect(arr[1]).toBe(g.indexById.get('b'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRecallPath, beatFrameFor } from '../path-builder';
|
||||
import { buildObservatoryGraph } from '../graph-upload';
|
||||
import { UINTS_PER_PATHSTEP, PATH_KIND } from '../types';
|
||||
import type { GraphResponse, GraphNode, GraphEdge } from '$types';
|
||||
|
||||
function node(id: string, extra: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: `label-${id}`,
|
||||
type: 'note',
|
||||
retention: 0.8,
|
||||
tags: [],
|
||||
createdAt: '2026-07-01T00:00:00Z',
|
||||
updatedAt: '2026-07-01T00:00:00Z',
|
||||
isCenter: false,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function edge(source: string, target: string, weight = 1, type = 'semantic'): GraphEdge {
|
||||
return { source, target, weight, type };
|
||||
}
|
||||
|
||||
// A small star graph around a center with a spur — enough for a real story.
|
||||
function response(): GraphResponse {
|
||||
const nodes = [
|
||||
node('center', { isCenter: true }),
|
||||
node('a'),
|
||||
node('b'),
|
||||
node('c'),
|
||||
node('d', { createdAt: '2026-07-02T00:00:00Z', updatedAt: '2026-07-02T00:00:00Z' })
|
||||
];
|
||||
const edges = [
|
||||
edge('center', 'a', 5),
|
||||
edge('center', 'b', 3),
|
||||
edge('a', 'c', 2),
|
||||
edge('c', 'd', 1)
|
||||
];
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
center_id: 'center',
|
||||
depth: 3,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length
|
||||
};
|
||||
}
|
||||
|
||||
describe('beatFrameFor', () => {
|
||||
it('starts at frame 60 and advances 60 per beat', () => {
|
||||
expect(beatFrameFor(0)).toBe(60);
|
||||
expect(beatFrameFor(3)).toBe(240);
|
||||
});
|
||||
|
||||
it('an 8-beat story + afterglow fits inside the 720-frame loop', () => {
|
||||
// last beat at 60 + 7·60 = 480; afterglow envelope ends ≤ +200 frames
|
||||
expect(beatFrameFor(7) + 200).toBeLessThan(720);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRecallPath', () => {
|
||||
it('produces steps that reference valid stable node indices', () => {
|
||||
const resp = response();
|
||||
const graph = buildObservatoryGraph(resp);
|
||||
const { steps, data } = buildRecallPath(resp, graph);
|
||||
|
||||
expect(steps.length).toBeGreaterThan(1);
|
||||
for (const s of steps) {
|
||||
expect(s.targetIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(s.targetIndex).toBeLessThan(graph.nodes.length);
|
||||
expect(s.sourceIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(s.sourceIndex).toBeLessThan(graph.nodes.length);
|
||||
}
|
||||
expect(data.length).toBe(steps.length * UINTS_PER_PATHSTEP);
|
||||
});
|
||||
|
||||
it('starts the story at the center with itself as source', () => {
|
||||
const resp = response();
|
||||
const graph = buildObservatoryGraph(resp);
|
||||
const { steps } = buildRecallPath(resp, graph);
|
||||
expect(steps[0].nodeId).toBe('center');
|
||||
expect(steps[0].sourceIndex).toBe(steps[0].targetIndex);
|
||||
expect(steps[0].beatFrame).toBe(60);
|
||||
});
|
||||
|
||||
it('is deterministic — same data → identical steps', () => {
|
||||
const resp = response();
|
||||
const graph = buildObservatoryGraph(resp);
|
||||
const a = buildRecallPath(resp, graph);
|
||||
const b = buildRecallPath(resp, graph);
|
||||
expect(a.steps).toEqual(b.steps);
|
||||
expect(Array.from(a.data)).toEqual(Array.from(b.data));
|
||||
});
|
||||
|
||||
it('marks contradiction beats as backward-cause hops', () => {
|
||||
const resp = response();
|
||||
// pathfinder treats 'contradicts' edge type as a contradiction
|
||||
resp.edges.push({ source: 'b', target: 'd', weight: 4, type: 'contradicts' });
|
||||
const graph = buildObservatoryGraph(resp);
|
||||
const { steps } = buildRecallPath(resp, graph);
|
||||
const kinds = new Set(steps.map((s) => s.beatKind));
|
||||
if (kinds.has('contradiction')) {
|
||||
const c = steps.find((s) => s.beatKind === 'contradiction')!;
|
||||
expect(c.kind).toBe(PATH_KIND.backwardCause);
|
||||
}
|
||||
// every step kind is one of the two GPU lanes either way
|
||||
for (const s of steps) {
|
||||
expect([PATH_KIND.recall, PATH_KIND.backwardCause]).toContain(s.kind);
|
||||
}
|
||||
});
|
||||
|
||||
it('survives an empty graph', () => {
|
||||
const resp: GraphResponse = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
center_id: '',
|
||||
depth: 3,
|
||||
nodeCount: 0,
|
||||
edgeCount: 0
|
||||
};
|
||||
const graph = buildObservatoryGraph(resp);
|
||||
const { steps, data } = buildRecallPath(resp, graph);
|
||||
expect(steps).toHaveLength(0);
|
||||
expect(data.length).toBeGreaterThan(0); // placeholder lane, no zero-size buffer
|
||||
});
|
||||
});
|
||||
619
apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts
Normal file
619
apps/dashboard/src/lib/observatory/__tests__/rescue-plan.test.ts
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRescuePlan,
|
||||
rescueEnvelopes,
|
||||
pickFailureIndex,
|
||||
bfsFromFailure,
|
||||
pickCauseIndex,
|
||||
pickLookalikes,
|
||||
layoutPositions,
|
||||
hopSlotFor,
|
||||
waveArrivalFrame,
|
||||
lookalikeFrame,
|
||||
UNREACHED,
|
||||
MAX_WAVE_STEPS,
|
||||
RESCUE_K,
|
||||
ARC_FRAME,
|
||||
VERDICT_START,
|
||||
DETONATE_FRAME
|
||||
} from '../rescue-plan';
|
||||
import { buildObservatoryGraph } from '../graph-upload';
|
||||
import { FLOATS_PER_NODE } from '../types';
|
||||
import type { GraphNode, GraphEdge, GraphResponse } from '$types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function gn(id: string, opts: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: opts.label ?? `Memory ${id}`,
|
||||
type: 'note',
|
||||
retention: opts.retention ?? 0.5,
|
||||
tags: opts.tags ?? [],
|
||||
createdAt: opts.createdAt ?? '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
isCenter: opts.isCenter ?? false,
|
||||
suppression_count: opts.suppression_count
|
||||
};
|
||||
}
|
||||
|
||||
function ge(source: string, target: string, type = 'semantic'): GraphEdge {
|
||||
return { source, target, weight: 1, type };
|
||||
}
|
||||
|
||||
function gr(nodes: GraphNode[], edges: GraphEdge[], centerId = 'center'): GraphResponse {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
center_id: centerId,
|
||||
depth: 3,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main fixture. Stable indices after buildObservatoryGraph (center first,
|
||||
* then id-sorted): center=0, cause=1, fail=2, h1=3, h1b=4, h2=5, l1..l4=6..9.
|
||||
*
|
||||
* fail —(temporal)— h1 —(causal)— h2 —(causal)— cause (cause depth 3)
|
||||
* fail — h1b, fail — center, center — l1..l4
|
||||
*/
|
||||
function mainFixture(): GraphResponse {
|
||||
const nodes = [
|
||||
gn('center', { isCenter: true, retention: 0.9 }),
|
||||
gn('fail', { tags: ['failure'], retention: 0.8, label: 'checkout 500s on submit' }),
|
||||
gn('h1', { retention: 0.6 }),
|
||||
gn('h1b', { retention: 0.7 }),
|
||||
gn('h2', { retention: 0.55 }),
|
||||
gn('cause', {
|
||||
retention: 0.1,
|
||||
createdAt: '2025-03-01T12:00:00Z',
|
||||
label: 'schema migration dropped index'
|
||||
}),
|
||||
gn('l1', { retention: 0.62 }),
|
||||
gn('l2', { retention: 0.63 }),
|
||||
gn('l3', { retention: 0.64 }),
|
||||
gn('l4', { retention: 0.65 })
|
||||
];
|
||||
const edges = [
|
||||
ge('fail', 'h1', 'temporal'),
|
||||
ge('h1', 'h2', 'causal'),
|
||||
ge('h2', 'cause', 'causal'),
|
||||
ge('fail', 'h1b'),
|
||||
ge('fail', 'center'),
|
||||
ge('center', 'l1'),
|
||||
ge('center', 'l2'),
|
||||
ge('center', 'l3'),
|
||||
ge('center', 'l4')
|
||||
];
|
||||
return gr(nodes, edges);
|
||||
}
|
||||
|
||||
const SEED = 'vestige-observatory-v1';
|
||||
|
||||
function planFor(response: GraphResponse, seed = SEED) {
|
||||
const graph = buildObservatoryGraph(response);
|
||||
return { graph, plan: buildRescuePlan(response, graph, seed) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('determinism', () => {
|
||||
it('same graph + seed → identical plan, byte-identical typed arrays', () => {
|
||||
const r = mainFixture();
|
||||
const { plan: p1 } = planFor(r);
|
||||
const { plan: p2 } = planFor(r);
|
||||
expect(p1).toEqual(p2);
|
||||
expect(Array.from(p1.waveData)).toEqual(Array.from(p2.waveData));
|
||||
expect(Array.from(p1.pathData)).toEqual(Array.from(p2.pathData));
|
||||
expect(Array.from(p1.hopDepths)).toEqual(Array.from(p2.hopDepths));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Failure selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pickFailureIndex', () => {
|
||||
it('never the center; prefers failure-tagged well-connected node; degree ≥ 2 when available', () => {
|
||||
const r = mainFixture();
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const positions = layoutPositions(graph, SEED);
|
||||
const failure = pickFailureIndex(graph, positions);
|
||||
expect(failure).not.toBe(graph.centerIndex);
|
||||
expect(graph.nodes[failure].id).toBe('fail'); // tagged 'failure', degree 3
|
||||
});
|
||||
|
||||
it('falls back to a non-center node when nothing is tagged and degrees are low', () => {
|
||||
const r = gr(
|
||||
[gn('center', { isCenter: true }), gn('a'), gn('b')],
|
||||
[ge('a', 'b')]
|
||||
);
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const failure = pickFailureIndex(graph, layoutPositions(graph, SEED));
|
||||
expect(failure).not.toBe(graph.centerIndex);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. BFS exactness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bfsFromFailure', () => {
|
||||
it('exact depths on a chain, disconnected node UNREACHED and absent from pathData', () => {
|
||||
// chain: center — a(failure) — b — c — d — e, plus disconnected g
|
||||
const r = gr(
|
||||
[
|
||||
gn('center', { isCenter: true }),
|
||||
gn('a', { tags: ['failure'] }),
|
||||
gn('b'),
|
||||
gn('c'),
|
||||
gn('d', { retention: 0.9 }),
|
||||
gn('e', { retention: 0.2 }),
|
||||
gn('g')
|
||||
],
|
||||
[ge('center', 'a'), ge('a', 'b'), ge('b', 'c'), ge('c', 'd'), ge('d', 'e')]
|
||||
);
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const idx = (id: string) => graph.indexById.get(id)!;
|
||||
const { depths } = bfsFromFailure(graph, idx('a'));
|
||||
expect(depths[idx('a')]).toBe(0);
|
||||
expect(depths[idx('center')]).toBe(1);
|
||||
expect(depths[idx('b')]).toBe(1);
|
||||
expect(depths[idx('c')]).toBe(2);
|
||||
expect(depths[idx('d')]).toBe(3);
|
||||
expect(depths[idx('e')]).toBe(4);
|
||||
expect(depths[idx('g')]).toBe(UNREACHED);
|
||||
|
||||
const plan = buildRescuePlan(r, graph, SEED);
|
||||
expect(plan.viable).toBe(true);
|
||||
// A disconnected node can be a LOOKALIKE (nearest in layout — "looks
|
||||
// similar, causally unrelated" is the story), so kind-2 probe beams may
|
||||
// target it. But the backward wave walks REAL graph edges: no kind-1
|
||||
// wave/arc step may ever touch an unreached node.
|
||||
for (let s = 0; s < plan.pathData.length / 4; s++) {
|
||||
if (plan.pathData[s * 4 + 3] !== 1) continue;
|
||||
expect(plan.pathData[s * 4 + 0]).not.toBe(idx('g'));
|
||||
expect(plan.pathData[s * 4 + 1]).not.toBe(idx('g'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Cause selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pickCauseIndex', () => {
|
||||
it('depth ≥ 3 when available; lowest retention among depth ≥ 3 wins', () => {
|
||||
const r = mainFixture();
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const idx = (id: string) => graph.indexById.get(id)!;
|
||||
const { depths } = bfsFromFailure(graph, idx('fail'));
|
||||
const cause = pickCauseIndex(r, graph, depths, idx('fail'));
|
||||
expect(cause.index).toBe(idx('cause'));
|
||||
expect(cause.depth).toBe(3);
|
||||
});
|
||||
|
||||
it('retention tie → older createdAt wins', () => {
|
||||
// two depth-3 candidates with equal retention, different ages
|
||||
const r = gr(
|
||||
[
|
||||
gn('center', { isCenter: true }),
|
||||
gn('f', { tags: ['failure'] }),
|
||||
gn('x'),
|
||||
gn('y'),
|
||||
gn('old', { retention: 0.2, createdAt: '2025-01-01T00:00:00Z' }),
|
||||
gn('new', { retention: 0.2, createdAt: '2026-06-01T00:00:00Z' })
|
||||
],
|
||||
[
|
||||
ge('center', 'f'),
|
||||
ge('f', 'x'),
|
||||
ge('x', 'y'),
|
||||
ge('y', 'old'),
|
||||
ge('y', 'new')
|
||||
]
|
||||
);
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const idx = (id: string) => graph.indexById.get(id)!;
|
||||
const { depths } = bfsFromFailure(graph, idx('f'));
|
||||
expect(depths[idx('old')]).toBe(3);
|
||||
expect(depths[idx('new')]).toBe(3);
|
||||
const cause = pickCauseIndex(r, graph, depths, idx('f'));
|
||||
expect(cause.index).toBe(idx('old'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Edge-type-preferring parents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('edge-type parents', () => {
|
||||
it('equal-hop dual parents: the causal edge wins the parent chain', () => {
|
||||
// f — u1 (semantic) — v ; f — u2 (semantic) — v via causal edge u2—v
|
||||
const r = gr(
|
||||
[
|
||||
gn('center', { isCenter: true }),
|
||||
gn('f', { tags: ['failure'] }),
|
||||
gn('u1'),
|
||||
gn('u2'),
|
||||
gn('v', { retention: 0.2 })
|
||||
],
|
||||
[
|
||||
ge('center', 'f'),
|
||||
ge('f', 'u1', 'semantic'),
|
||||
ge('f', 'u2', 'semantic'),
|
||||
ge('u1', 'v', 'semantic'),
|
||||
ge('u2', 'v', 'causal')
|
||||
]
|
||||
);
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const idx = (id: string) => graph.indexById.get(id)!;
|
||||
const { depths, parents } = bfsFromFailure(graph, idx('f'));
|
||||
expect(depths[idx('v')]).toBe(2);
|
||||
expect(parents[idx('v')]).toBe(idx('u2'));
|
||||
|
||||
// pathData contains the (u2 → v) wave step
|
||||
const plan = buildRescuePlan(r, graph, SEED);
|
||||
let found = false;
|
||||
for (let s = 0; s < plan.pathData.length / 4; s++) {
|
||||
if (
|
||||
plan.pathData[s * 4 + 0] === idx('u2') &&
|
||||
plan.pathData[s * 4 + 1] === idx('v') &&
|
||||
plan.pathData[s * 4 + 3] === 1
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Relaxation ladder + non-viable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cause relaxation ladder', () => {
|
||||
it('relaxes depth ≥ 3 → 2 on a short chain', () => {
|
||||
// center — f(failure) — x — y : deepest candidate at depth 2
|
||||
const r = gr(
|
||||
[
|
||||
gn('center', { isCenter: true }),
|
||||
gn('f', { tags: ['failure'] }),
|
||||
gn('x'),
|
||||
gn('y', { retention: 0.3 })
|
||||
],
|
||||
[ge('center', 'f'), ge('f', 'x'), ge('x', 'y')]
|
||||
);
|
||||
const { graph, plan } = planFor(r);
|
||||
expect(plan.viable).toBe(true);
|
||||
expect(plan.causeIndex).toBe(graph.indexById.get('y')!);
|
||||
expect(plan.causeDepth).toBe(2);
|
||||
});
|
||||
|
||||
it('1-node graph → viable:false, no throw', () => {
|
||||
const r = gr([gn('center', { isCenter: true })], []);
|
||||
const { plan } = planFor(r);
|
||||
expect(plan.viable).toBe(false);
|
||||
expect(plan.pathMetas).toEqual([]);
|
||||
expect(plan.spineBeats).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Lookalikes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pickLookalikes', () => {
|
||||
it('K = min(4, eligible), layout distances non-decreasing, excludes failure/cause/center', () => {
|
||||
const r = mainFixture();
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const plan = buildRescuePlan(r, graph, SEED);
|
||||
expect(plan.viable).toBe(true);
|
||||
expect(plan.lookalikeIndices.length).toBe(Math.min(RESCUE_K, graph.nodes.length - 3));
|
||||
|
||||
// Recompute via the REAL layout function + same seed.
|
||||
const positions = layoutPositions(graph, SEED);
|
||||
const fi = plan.failureIndex;
|
||||
const d2 = (i: number) => {
|
||||
const dx = positions[i * FLOATS_PER_NODE + 0] - positions[fi * FLOATS_PER_NODE + 0];
|
||||
const dy = positions[i * FLOATS_PER_NODE + 1] - positions[fi * FLOATS_PER_NODE + 1];
|
||||
const dz = positions[i * FLOATS_PER_NODE + 2] - positions[fi * FLOATS_PER_NODE + 2];
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
};
|
||||
for (let k = 1; k < plan.lookalikeIndices.length; k++) {
|
||||
expect(d2(plan.lookalikeIndices[k])).toBeGreaterThanOrEqual(
|
||||
d2(plan.lookalikeIndices[k - 1])
|
||||
);
|
||||
}
|
||||
for (const li of plan.lookalikeIndices) {
|
||||
expect(li).not.toBe(plan.failureIndex);
|
||||
expect(li).not.toBe(plan.causeIndex);
|
||||
expect(li).not.toBe(graph.centerIndex);
|
||||
}
|
||||
|
||||
// direct call agrees with the plan
|
||||
const direct = pickLookalikes(
|
||||
positions,
|
||||
graph.nodes.length,
|
||||
plan.failureIndex,
|
||||
plan.causeIndex,
|
||||
graph.centerIndex
|
||||
);
|
||||
expect(direct).toEqual(plan.lookalikeIndices);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. SEAM PROOF — every node, frames 0 and 719 all-zero
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('loop seam', () => {
|
||||
it('rescueEnvelopes is exactly zero at frames 0 and 719 for EVERY packed word', () => {
|
||||
const r = mainFixture();
|
||||
const { plan } = planFor(r);
|
||||
expect(plan.viable).toBe(true);
|
||||
for (const packed of plan.waveData) {
|
||||
for (const frame of [0, 719]) {
|
||||
const e = rescueEnvelopes(frame, packed, plan.consts);
|
||||
expect(Math.abs(e.x)).toBeLessThan(1e-6);
|
||||
expect(Math.abs(e.y)).toBeLessThan(1e-6);
|
||||
expect(Math.abs(e.z)).toBeLessThan(1e-6);
|
||||
expect(Math.abs(e.w)).toBeLessThan(1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Envelopes fire on the beat map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('envelopes fire', () => {
|
||||
it('y, x, z, w peak on their beats', () => {
|
||||
const r = mainFixture();
|
||||
const { plan } = planFor(r);
|
||||
const c = plan.consts;
|
||||
|
||||
// searchlight: y > 0.9 at Fk on lookalike k
|
||||
plan.lookalikeIndices.forEach((li, k) => {
|
||||
const e = rescueEnvelopes(lookalikeFrame(k), plan.waveData[li], c);
|
||||
expect(e.y).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
// cause ignition: x > 0.99 at frame 580
|
||||
const cw = plan.waveData[plan.causeIndex];
|
||||
expect(rescueEnvelopes(580, cw, c).x).toBeGreaterThan(0.99);
|
||||
|
||||
// backward wave: max z > 0.7 within [W(d), W(d)+28] on a depth-1 node
|
||||
const d1 = plan.hopDepths.findIndex((d, i) => d === 1 && i !== plan.failureIndex);
|
||||
expect(d1).toBeGreaterThanOrEqual(0);
|
||||
const wd = waveArrivalFrame(1, plan.hopSlot);
|
||||
let zMax = 0;
|
||||
for (let f = wd; f <= wd + 28; f++) {
|
||||
zMax = Math.max(zMax, rescueEnvelopes(f, plan.waveData[d1], c).z);
|
||||
}
|
||||
expect(zMax).toBeGreaterThan(0.7);
|
||||
|
||||
// detonation: w > 0.9 at frame 105 on the failure
|
||||
expect(rescueEnvelopes(105, plan.waveData[plan.failureIndex], c).w).toBeGreaterThan(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Packing round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('waveData packing', () => {
|
||||
it('depth/roles/k decode for failure, cause, lookalikes, plain and unreached nodes', () => {
|
||||
// use the BFS-chain fixture with a disconnected node
|
||||
const r = gr(
|
||||
[
|
||||
gn('center', { isCenter: true }),
|
||||
gn('a', { tags: ['failure'] }),
|
||||
gn('b'),
|
||||
gn('c'),
|
||||
gn('d', { retention: 0.9 }),
|
||||
gn('e', { retention: 0.2 }),
|
||||
gn('g')
|
||||
],
|
||||
[ge('center', 'a'), ge('a', 'b'), ge('b', 'c'), ge('c', 'd'), ge('d', 'e')]
|
||||
);
|
||||
const graph = buildObservatoryGraph(r);
|
||||
const idx = (id: string) => graph.indexById.get(id)!;
|
||||
const plan = buildRescuePlan(r, graph, SEED);
|
||||
expect(plan.viable).toBe(true);
|
||||
|
||||
const fw = plan.waveData[plan.failureIndex];
|
||||
expect(fw & 0xffff).toBe(0);
|
||||
expect(fw & 0x10000).not.toBe(0);
|
||||
expect(fw & 0x20000).toBe(0);
|
||||
|
||||
const cw = plan.waveData[plan.causeIndex];
|
||||
expect(cw & 0xffff).toBe(plan.causeDepth);
|
||||
expect(cw & 0x20000).not.toBe(0);
|
||||
expect(cw & 0x10000).toBe(0);
|
||||
|
||||
plan.lookalikeIndices.forEach((li, k) => {
|
||||
const w = plan.waveData[li];
|
||||
expect(w & 0x40000).not.toBe(0);
|
||||
expect((w >>> 19) & 0x7).toBe(k);
|
||||
expect(w & 0xffff).toBe(plan.hopDepths[li]);
|
||||
});
|
||||
|
||||
// unreached node round-trips 0xFFFF and is never failure/cause
|
||||
const gw = plan.waveData[idx('g')];
|
||||
expect(gw & 0xffff).toBe(UNREACHED);
|
||||
expect(gw & 0x30000).toBe(0);
|
||||
|
||||
// plain node (no role bits at all) — main fixture has 3 non-role nodes
|
||||
const rich = mainFixture();
|
||||
const richGraph = buildObservatoryGraph(rich);
|
||||
const richPlan = buildRescuePlan(rich, richGraph, SEED);
|
||||
const roles = new Set([
|
||||
richPlan.failureIndex,
|
||||
richPlan.causeIndex,
|
||||
...richPlan.lookalikeIndices
|
||||
]);
|
||||
const plain = richGraph.nodes.findIndex(
|
||||
(n) => !roles.has(n.index) && n.index !== richGraph.centerIndex
|
||||
);
|
||||
expect(plain).toBeGreaterThanOrEqual(0);
|
||||
expect(richPlan.waveData[plain] & 0x7f0000).toBe(0);
|
||||
expect(richPlan.waveData[plain] & 0xffff).toBe(richPlan.hopDepths[plain]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Ribbon-window invariant + step ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('path steps', () => {
|
||||
it('every beat frame keeps its ribbon window inside [0, 719]; probes first, arc last', () => {
|
||||
const r = mainFixture();
|
||||
const { graph, plan } = planFor(r);
|
||||
const count = plan.pathData.length / 4;
|
||||
expect(count).toBeLessThanOrEqual(RESCUE_K + MAX_WAVE_STEPS + 1);
|
||||
|
||||
for (let s = 0; s < count; s++) {
|
||||
const bf = plan.pathData[s * 4 + 2];
|
||||
expect(bf - 46).toBeGreaterThanOrEqual(0);
|
||||
expect(bf + 90).toBeLessThanOrEqual(719);
|
||||
}
|
||||
|
||||
// probes first, kind 2, beats 138/166/194/222
|
||||
const K = plan.lookalikeIndices.length;
|
||||
for (let k = 0; k < K; k++) {
|
||||
expect(plan.pathData[k * 4 + 0]).toBe(plan.failureIndex);
|
||||
expect(plan.pathData[k * 4 + 1]).toBe(plan.lookalikeIndices[k]);
|
||||
expect(plan.pathData[k * 4 + 2]).toBe(138 + 28 * k);
|
||||
expect(plan.pathData[k * 4 + 3]).toBe(2);
|
||||
}
|
||||
|
||||
// wave steps: kind 1 with bf = W(depth(dst))
|
||||
for (let s = K; s < count - 1; s++) {
|
||||
expect(plan.pathData[s * 4 + 3]).toBe(1);
|
||||
const dst = plan.pathData[s * 4 + 1];
|
||||
expect(plan.pathData[s * 4 + 2]).toBe(
|
||||
waveArrivalFrame(plan.hopDepths[dst], plan.hopSlot)
|
||||
);
|
||||
expect(plan.pathData[s * 4 + 2]).toBeLessThanOrEqual(514);
|
||||
}
|
||||
|
||||
// arc last: cause → failure at 560, kind 1
|
||||
const last = count - 1;
|
||||
expect(plan.pathData[last * 4 + 0]).toBe(plan.causeIndex);
|
||||
expect(plan.pathData[last * 4 + 1]).toBe(plan.failureIndex);
|
||||
expect(plan.pathData[last * 4 + 2]).toBe(ARC_FRAME);
|
||||
expect(plan.pathData[last * 4 + 3]).toBe(1);
|
||||
void graph;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. setPathSteps contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pathMetas contract', () => {
|
||||
it('pathMetas is 1:1 with pathData steps (draw count = metas.length)', () => {
|
||||
const { plan } = planFor(mainFixture());
|
||||
expect(plan.pathMetas.length).toBe(plan.pathData.length / 4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. Spine beats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('spine beats', () => {
|
||||
it('beatFrames strictly increasing and unique; real labels present', () => {
|
||||
const { plan } = planFor(mainFixture());
|
||||
const frames = plan.spineBeats.map((b) => b.beatFrame);
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
expect(frames[i]).toBeGreaterThan(frames[i - 1]);
|
||||
}
|
||||
expect(new Set(frames).size).toBe(frames.length);
|
||||
expect(frames[0]).toBe(DETONATE_FRAME);
|
||||
expect(frames[frames.length - 1]).toBe(VERDICT_START);
|
||||
|
||||
const labels = plan.spineBeats.map((b) => b.label);
|
||||
expect(labels[0]).toContain('checkout 500s on submit');
|
||||
expect(labels.some((l) => l.includes('lookalike ✗'))).toBe(true);
|
||||
expect(labels.some((l) => l.includes('schema migration dropped index'))).toBe(true);
|
||||
expect(labels[labels.length - 1]).toBe('root cause found');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. Verdict receipt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verdict', () => {
|
||||
it('real labels, real date, hop count and K', () => {
|
||||
const { plan } = planFor(mainFixture());
|
||||
expect(plan.verdict.causeLabel).toBe('schema migration dropped index');
|
||||
expect(plan.verdict.failureLabel).toBe('checkout 500s on submit');
|
||||
expect(plan.verdict.causeDate).toBe('2025-03-01');
|
||||
expect(plan.verdict.hops).toBe(3);
|
||||
expect(plan.verdict.k).toBe(4);
|
||||
expect(plan.verdict.receipt).toBe('3 hops back · 2025-03-01 · vector search: 0 for 4');
|
||||
});
|
||||
|
||||
it('labels truncate at 64 chars with an ellipsis', () => {
|
||||
const long = 'x'.repeat(100);
|
||||
const r = mainFixture();
|
||||
const causeNode = r.nodes.find((n) => n.id === 'cause')!;
|
||||
causeNode.label = long;
|
||||
const { plan } = planFor(r);
|
||||
expect(plan.verdict.causeLabel.length).toBe(65);
|
||||
expect(plan.verdict.causeLabel.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. Degenerates survive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('degenerate graphs', () => {
|
||||
it('0-node, 2-node and edgeless graphs → viable:false, no throw, min pathData', () => {
|
||||
const zero = gr([], [], '');
|
||||
const { plan: p0 } = planFor(zero);
|
||||
expect(p0.viable).toBe(false);
|
||||
expect(p0.pathData.length).toBe(4);
|
||||
|
||||
const two = gr([gn('center', { isCenter: true }), gn('a')], []);
|
||||
const { plan: p2 } = planFor(two);
|
||||
expect(p2.viable).toBe(false);
|
||||
expect(p2.pathData.length).toBe(4);
|
||||
|
||||
const edgeless = gr([gn('center', { isCenter: true }), gn('a'), gn('b'), gn('c')], []);
|
||||
const { plan: pe } = planFor(edgeless);
|
||||
expect(pe.viable).toBe(false);
|
||||
expect(pe.spineBeats).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 16. hopSlot clamping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hopSlot', () => {
|
||||
it('D=3 → 84; D=18 → 14 (clamped); W(D) ≤ 514', () => {
|
||||
expect(hopSlotFor(3)).toBe(84);
|
||||
expect(hopSlotFor(18)).toBe(14);
|
||||
expect(waveArrivalFrame(3, hopSlotFor(3))).toBe(512);
|
||||
expect(waveArrivalFrame(18, hopSlotFor(18))).toBe(512);
|
||||
for (let d = 1; d <= 20; d++) {
|
||||
expect(waveArrivalFrame(d, hopSlotFor(d))).toBeLessThanOrEqual(514);
|
||||
}
|
||||
// the main fixture's plan agrees
|
||||
const { plan } = planFor(mainFixture());
|
||||
expect(plan.hopSlot).toBe(84);
|
||||
expect(plan.consts.hopSlot).toBe(84);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
381
apps/dashboard/src/lib/observatory/birth-plan.ts
Normal file
381
apps/dashboard/src/lib/observatory/birth-plan.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
/**
|
||||
* Cognitive Observatory â deterministic birth-plan CPU helpers (Moment B, Task B1).
|
||||
*
|
||||
* Pure CPU: pick a birth target, precompute deterministic `BirthParticle` initial
|
||||
* arrays, build birth beat metadata. No GPU code, no Math.random().
|
||||
*
|
||||
* Particle layout (16 floats / 64 bytes per particle):
|
||||
* start_life : xyz start position, w seed/life scalar (phase offset)
|
||||
* target_size : xyz target position, w base size (1.0 + rng * 1.8)
|
||||
* color_phase : rgb target/base node color, w phase offset
|
||||
* state : xyz current position (shader computes), w alpha
|
||||
*
|
||||
* All start positions form a deterministic hollow shell around the target:
|
||||
* 70% spherical shell (radius 110-180)
|
||||
* 20% tendrils along incident edge directions
|
||||
* 10% near-camera dust plane
|
||||
*/
|
||||
|
||||
import { DemoClock, deterministicSpherePosition } from './demo-clock';
|
||||
import type { ObservatoryGraph } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimelineBeat {
|
||||
label: string;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
}
|
||||
|
||||
export interface BirthPlan {
|
||||
targetIndex: number;
|
||||
targetNodeId: string;
|
||||
/** 16 floats per particle (64 bytes). */
|
||||
particles: Float32Array;
|
||||
/** 4 u32 per edge step (source, target, beatFrame, kind). */
|
||||
edgeSteps: Uint32Array;
|
||||
timeline: TimelineBeat[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLOATS_PER_BIRTH_PARTICLE = 16;
|
||||
const UINTS_PER_BIRTH_EDGE_STEP = 4;
|
||||
|
||||
// Shell radii
|
||||
const SHELL_MIN_RADIUS = 110;
|
||||
const SHELL_MAX_RADIUS = 180;
|
||||
|
||||
// Particle distribution fractions
|
||||
const FRACTION_SHELL = 0.70;
|
||||
const FRACTION_TENDRIL = 0.20;
|
||||
const FRACTION_DUST = 0.10;
|
||||
|
||||
// Edge engraving: each incident edge gets a pulse starting at frame 360
|
||||
const ENGRAVE_START_FRAME = 360;
|
||||
const ENGRAVE_INTERVAL = 18; // frames between successive edge pulses
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick the birth target deterministically from the graph.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Center node's highest-retention neighbor (if graph has edges).
|
||||
* 2. First non-center node after stable graph ordering.
|
||||
* 3. Center node.
|
||||
*/
|
||||
export function pickTargetIndex(graph: ObservatoryGraph): number {
|
||||
// 1. Prefer center's highest-retention neighbor
|
||||
if (graph.edges.length > 0) {
|
||||
const centerIdx = graph.centerIndex;
|
||||
const incidentEdges = graph.edges.filter(
|
||||
(e) => e.sourceIndex === centerIdx || e.targetIndex === centerIdx
|
||||
);
|
||||
if (incidentEdges.length > 0) {
|
||||
let bestNeighborIdx = -1;
|
||||
let bestRetention = -1;
|
||||
for (const edge of incidentEdges) {
|
||||
const neighborIdx =
|
||||
edge.sourceIndex === centerIdx ? edge.targetIndex : edge.sourceIndex;
|
||||
const neighbor = graph.nodes[neighborIdx];
|
||||
if (neighbor && neighbor.retention > bestRetention) {
|
||||
bestRetention = neighbor.retention;
|
||||
bestNeighborIdx = neighborIdx;
|
||||
}
|
||||
}
|
||||
if (bestNeighborIdx >= 0) return bestNeighborIdx;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. First non-center node after stable ordering
|
||||
for (let i = 0; i < graph.nodes.length; i++) {
|
||||
if (i !== graph.centerIndex) return i;
|
||||
}
|
||||
|
||||
// 3. Center node (fallback)
|
||||
return graph.centerIndex;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Particle precomputation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the deterministic particle array for a birth event.
|
||||
*
|
||||
* Uses a fresh DemoClock seeded with `seed + ':birth:' + targetNodeId` so
|
||||
* the same graph + seed always produces the same layout.
|
||||
*/
|
||||
export function buildBirthPlan(
|
||||
graph: ObservatoryGraph,
|
||||
seed: string,
|
||||
particleCount = 8192
|
||||
): BirthPlan {
|
||||
const targetIndex = pickTargetIndex(graph);
|
||||
const targetNode = graph.nodes[targetIndex];
|
||||
const targetNodeId = targetNode.id;
|
||||
|
||||
// Get target node position from the graph (will be in the node state buffer)
|
||||
const targetPos = getNodePosition(graph, targetIndex);
|
||||
|
||||
// Build a fresh DemoClock for deterministic particle placement
|
||||
const clock = new DemoClock({ seed: seed + ':birth:' + targetNodeId });
|
||||
const rng = clock.state.rng;
|
||||
|
||||
// Build particles
|
||||
const particles = new Float32Array(particleCount * FLOATS_PER_BIRTH_PARTICLE);
|
||||
|
||||
const shellCount = Math.floor(particleCount * FRACTION_SHELL);
|
||||
const tendrilCount = Math.floor(particleCount * FRACTION_TENDRIL);
|
||||
const dustCount = particleCount - shellCount - tendrilCount;
|
||||
|
||||
// --- 70%: spherical shell around target ---
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const base = i * FLOATS_PER_BIRTH_PARTICLE;
|
||||
|
||||
// Deterministic position on a sphere around the target
|
||||
const [sx, sy, sz] = deterministicSpherePosition(
|
||||
i,
|
||||
shellCount,
|
||||
SHELL_MIN_RADIUS + rng() * (SHELL_MAX_RADIUS - SHELL_MIN_RADIUS),
|
||||
rng
|
||||
);
|
||||
|
||||
// World-space start position = target + shell offset
|
||||
particles[base + 0] = targetPos[0] + sx;
|
||||
particles[base + 1] = targetPos[1] + sy;
|
||||
particles[base + 2] = targetPos[2] + sz;
|
||||
// w: phase offset (stagger)
|
||||
particles[base + 3] = rng();
|
||||
|
||||
// Target position (same for all particles â convergence target)
|
||||
particles[base + 4] = targetPos[0];
|
||||
particles[base + 5] = targetPos[1];
|
||||
particles[base + 6] = targetPos[2];
|
||||
// w: base size
|
||||
particles[base + 7] = 1.0 + rng() * 1.8;
|
||||
|
||||
// Color: violet dust base (0.55, 0.32, 1.00)
|
||||
particles[base + 8] = 0.55;
|
||||
particles[base + 9] = 0.32;
|
||||
particles[base + 10] = 1.00;
|
||||
// w: phase offset for spectral rim
|
||||
particles[base + 11] = rng();
|
||||
|
||||
// state: zeroed (shader computes current position)
|
||||
particles[base + 12] = 0;
|
||||
particles[base + 13] = 0;
|
||||
particles[base + 14] = 0;
|
||||
particles[base + 15] = 0;
|
||||
}
|
||||
|
||||
// --- 20%: tendrils along incident edge directions ---
|
||||
const incidentEdges = graph.edges.filter(
|
||||
(e) => e.sourceIndex === targetIndex || e.targetIndex === targetIndex
|
||||
);
|
||||
|
||||
for (let i = 0; i < tendrilCount; i++) {
|
||||
const base = (shellCount + i) * FLOATS_PER_BIRTH_PARTICLE;
|
||||
|
||||
// Skip tendrils when no incident edges (center-only graph)
|
||||
if (incidentEdges.length === 0) {
|
||||
// Just place as shell particles (already done above)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick an incident edge direction (cycle through edges)
|
||||
const edgeIdx = i % incidentEdges.length;
|
||||
const edge = incidentEdges[edgeIdx];
|
||||
|
||||
// Direction from target to neighbor
|
||||
const neighborIdx =
|
||||
edge.sourceIndex === targetIndex ? edge.targetIndex : edge.sourceIndex;
|
||||
const neighborPos = getNodePosition(graph, neighborIdx);
|
||||
const dx = neighborPos[0] - targetPos[0];
|
||||
const dy = neighborPos[1] - targetPos[1];
|
||||
const dz = neighborPos[2] - targetPos[2];
|
||||
const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
|
||||
|
||||
// Place along the edge direction, spread out
|
||||
const t = (i / Math.max(1, tendrilCount)) * 2.0 + 0.5; // 0.5 to 2.5
|
||||
const spread = rng() * 30; // perpendicular spread
|
||||
|
||||
// Perpendicular offset (simple cross with a fixed axis)
|
||||
const px = -dy * spread / (len || 1);
|
||||
const py = dx * spread / (len || 1);
|
||||
const pz = 0;
|
||||
|
||||
particles[base + 0] = targetPos[0] + (dx / len) * t * 80 + px;
|
||||
particles[base + 1] = targetPos[1] + (dy / len) * t * 80 + py;
|
||||
particles[base + 2] = targetPos[2] + (dz / len) * t * 80 + pz;
|
||||
particles[base + 3] = rng();
|
||||
|
||||
particles[base + 4] = targetPos[0];
|
||||
particles[base + 5] = targetPos[1];
|
||||
particles[base + 6] = targetPos[2];
|
||||
particles[base + 7] = 1.0 + rng() * 1.8;
|
||||
|
||||
particles[base + 8] = 0.55;
|
||||
particles[base + 9] = 0.32;
|
||||
particles[base + 10] = 1.00;
|
||||
particles[base + 11] = rng();
|
||||
|
||||
particles[base + 12] = 0;
|
||||
particles[base + 13] = 0;
|
||||
particles[base + 14] = 0;
|
||||
particles[base + 15] = 0;
|
||||
}
|
||||
|
||||
// --- 10%: near-camera dust plane for depth sparkle ---
|
||||
// Camera orbits around the field; a "near camera" plane is roughly at
|
||||
// orbit distance. We place these in front of the target along the
|
||||
// camera's approximate view direction (z-axis in our coordinate system).
|
||||
const ORBIT_DISTANCE = 300;
|
||||
for (let i = 0; i < dustCount; i++) {
|
||||
const base = (shellCount + tendrilCount + i) * FLOATS_PER_BIRTH_PARTICLE;
|
||||
|
||||
// Spread in a plane near the camera orbit distance
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const spread = rng() * 120;
|
||||
|
||||
particles[base + 0] = targetPos[0] + Math.cos(angle) * spread;
|
||||
particles[base + 1] = targetPos[1] + Math.sin(angle) * spread;
|
||||
particles[base + 2] = targetPos[2] + ORBIT_DISTANCE * 0.6 + rng() * 40;
|
||||
particles[base + 3] = rng();
|
||||
|
||||
particles[base + 4] = targetPos[0];
|
||||
particles[base + 5] = targetPos[1];
|
||||
particles[base + 6] = targetPos[2];
|
||||
particles[base + 7] = 1.0 + rng() * 1.8;
|
||||
|
||||
particles[base + 8] = 0.55;
|
||||
particles[base + 9] = 0.32;
|
||||
particles[base + 10] = 1.00;
|
||||
particles[base + 11] = rng();
|
||||
|
||||
particles[base + 12] = 0;
|
||||
particles[base + 13] = 0;
|
||||
particles[base + 14] = 0;
|
||||
particles[base + 15] = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge steps for engraving
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const edgeSteps = buildEdgeSteps(graph, targetIndex);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline beats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const timeline = buildTimeline();
|
||||
|
||||
return {
|
||||
targetIndex,
|
||||
targetNodeId,
|
||||
particles,
|
||||
edgeSteps,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the world-space position of a node from the graph.
|
||||
* Uses deterministic sphere placement (same as graph-upload).
|
||||
*/
|
||||
function getNodePosition(
|
||||
graph: ObservatoryGraph,
|
||||
nodeIndex: number
|
||||
): [number, number, number] {
|
||||
const node = graph.nodes[nodeIndex];
|
||||
const n = graph.nodes.length;
|
||||
|
||||
if (node.isCenter && graph.centerIndex === nodeIndex) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
// Use a fixed seed for position (not the birth seed) so positions match
|
||||
// what the NodeRenderer will produce. We use a deterministic "position seed"
|
||||
// derived from the node's index and the graph's center.
|
||||
// The actual NodeRenderer uses the demo seed for perturbation, but for
|
||||
// the birth plan we need to know where the target node will be.
|
||||
// We use a simple golden-angle placement with a fixed perturbation seed.
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const y = 1 - (nodeIndex / (n - 1 || 1)) * 2;
|
||||
const radiusAtY = Math.sqrt(1 - y * y);
|
||||
const theta = goldenAngle * nodeIndex;
|
||||
const fieldRadius = 120;
|
||||
|
||||
// Fixed perturbation (no random â deterministic by index)
|
||||
const px = ((nodeIndex * 7 + 3) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
|
||||
const py = ((nodeIndex * 13 + 7) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
|
||||
const pz = ((nodeIndex * 17 + 11) % 100) / 100 * 0.1 * fieldRadius - 0.05 * fieldRadius;
|
||||
|
||||
return [
|
||||
Math.cos(theta) * radiusAtY * fieldRadius + px,
|
||||
y * fieldRadius + py,
|
||||
Math.sin(theta) * radiusAtY * fieldRadius + pz,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build edge steps for the engraving phase (frames 360+).
|
||||
* Each incident edge from the target gets a pulse.
|
||||
*/
|
||||
function buildEdgeSteps(
|
||||
graph: ObservatoryGraph,
|
||||
targetIndex: number
|
||||
): Uint32Array {
|
||||
const incidentEdges = graph.edges.filter(
|
||||
(e) => e.sourceIndex === targetIndex || e.targetIndex === targetIndex
|
||||
);
|
||||
|
||||
const stepCount = incidentEdges.length;
|
||||
// No incident edges → zero steps (plan L464): BirthRenderer skips the
|
||||
// engrave buffer entirely rather than drawing a phantom 0→0 pulse.
|
||||
if (stepCount === 0) {
|
||||
return new Uint32Array(0);
|
||||
}
|
||||
|
||||
const data = new Uint32Array(stepCount * UINTS_PER_BIRTH_EDGE_STEP);
|
||||
|
||||
for (let k = 0; k < stepCount; k++) {
|
||||
const edge = incidentEdges[k];
|
||||
const neighborIdx =
|
||||
edge.sourceIndex === targetIndex ? edge.targetIndex : edge.sourceIndex;
|
||||
const beatFrame = ENGRAVE_START_FRAME + k * ENGRAVE_INTERVAL;
|
||||
|
||||
data[k * UINTS_PER_BIRTH_EDGE_STEP + 0] = targetIndex; // source = target
|
||||
data[k * UINTS_PER_BIRTH_EDGE_STEP + 1] = neighborIdx; // target = neighbor
|
||||
data[k * UINTS_PER_BIRTH_EDGE_STEP + 2] = beatFrame;
|
||||
data[k * UINTS_PER_BIRTH_EDGE_STEP + 3] = 0; // kind: normal
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the timeline beats for the 720-frame birth loop.
|
||||
* Matches the choreography schedule from the plan.
|
||||
*/
|
||||
function buildTimeline(): TimelineBeat[] {
|
||||
return [
|
||||
{ label: 'latent trace condensing', startFrame: 60, endFrame: 239 },
|
||||
{ label: 'engram coalescence', startFrame: 240, endFrame: 329 },
|
||||
{ label: 'memory ignition', startFrame: 330, endFrame: 359 },
|
||||
{ label: 'associations engrave', startFrame: 360, endFrame: 509 },
|
||||
{ label: 'stabilization', startFrame: 510, endFrame: 659 },
|
||||
];
|
||||
}
|
||||
616
apps/dashboard/src/lib/observatory/birth-renderer.ts
Normal file
616
apps/dashboard/src/lib/observatory/birth-renderer.ts
Normal file
|
|
@ -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<ArrayBuffer> {
|
||||
return (this.birthPlan?.edgeSteps ?? new Uint32Array(0)) as Uint32Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
constructor(opts: BirthRendererOptions) {
|
||||
this.engine = opts.engine;
|
||||
this.nodeRenderer = opts.nodeRenderer;
|
||||
// Only activate when demoMode === 'engram-birth' (demo_id === 1).
|
||||
// We check this each frame in compute() so it's safe to always register.
|
||||
this.active = false;
|
||||
|
||||
this.engine.addPass(this);
|
||||
}
|
||||
|
||||
/** Initialize the birth plan and GPU resources. Call after upload(). */
|
||||
upload(seed: string): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.nodeRenderer.nodeStateBuffer) return;
|
||||
|
||||
const graph = this.nodeRenderer.graph;
|
||||
if (!graph) return;
|
||||
|
||||
// Build the deterministic birth plan (CPU).
|
||||
this.birthPlan = buildBirthPlan(graph, seed);
|
||||
this.timeline = this.birthPlan.timeline;
|
||||
|
||||
const particleCount = this.birthPlan.particles.length / PARTICLE_FLOATS;
|
||||
this.particleCount = particleCount;
|
||||
|
||||
// Create particle storage buffer.
|
||||
this.particleBuffer?.destroy();
|
||||
this.particleBuffer = device.createBuffer({
|
||||
label: 'observatory-birth-particles',
|
||||
size: this.birthPlan.particles.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.particleBuffer, 0, this.birthPlan.particles.buffer as ArrayBuffer);
|
||||
|
||||
// Create edge engraving buffer (B6).
|
||||
this.engraveBuffer?.destroy();
|
||||
this.engraveStepCount = this.birthPlan.edgeSteps.length / 4;
|
||||
if (this.engraveStepCount > 0) {
|
||||
this.engraveBuffer = device.createBuffer({
|
||||
label: 'observatory-birth-engrave',
|
||||
size: this.birthPlan.edgeSteps.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.engraveBuffer, 0, this.birthPlan.edgeSteps.buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
// Create compute pipeline (B3).
|
||||
this.createComputePipeline(device);
|
||||
|
||||
// Create render pipeline (B4).
|
||||
this.createRenderPipeline(device);
|
||||
|
||||
// Create flash/halo pipeline (B5).
|
||||
this.createHaloPipeline(device);
|
||||
|
||||
// Create edge engraving pipeline (B6).
|
||||
this.createEngravePipeline(device);
|
||||
}
|
||||
|
||||
private createComputePipeline(device: GPUDevice): void {
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-birth-compute',
|
||||
code: birthParticlesWGSL
|
||||
});
|
||||
|
||||
this.computePipeline = device.createComputePipeline({
|
||||
label: 'observatory-birth-compute-pipeline',
|
||||
layout: 'auto',
|
||||
compute: { module, entryPoint: 'birth_compute' }
|
||||
});
|
||||
|
||||
// The compute shader declares exactly bindings 0-1; binding anything the
|
||||
// auto layout stripped (it has no binding 2) invalidates the bind group.
|
||||
const entries: GPUBindGroupEntry[] = [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
|
||||
{ binding: 1, resource: { buffer: this.particleBuffer! } }
|
||||
];
|
||||
|
||||
this.computeBindGroup = device.createBindGroup({
|
||||
label: 'observatory-birth-compute-bind',
|
||||
layout: this.computePipeline!.getBindGroupLayout(0),
|
||||
entries
|
||||
});
|
||||
}
|
||||
|
||||
private createRenderPipeline(device: GPUDevice): void {
|
||||
// Particle billboard shader (inline WGSL for render pass).
|
||||
const particleRenderWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
right: vec4<f32>,
|
||||
up: vec4<f32>,
|
||||
};
|
||||
|
||||
struct BirthParticle {
|
||||
start_life: vec4<f32>,
|
||||
target_size: vec4<f32>,
|
||||
color_phase: vec4<f32>,
|
||||
state: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<uniform> camera: Camera;
|
||||
@group(0) @binding(2) var<storage, read> particles: array<BirthParticle>;
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) color: vec3<f32>,
|
||||
@location(2) @interpolate(flat) misc: vec4<f32>,
|
||||
};
|
||||
|
||||
const CORNERS = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, 1.0)
|
||||
);
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32
|
||||
) -> VSOut {
|
||||
var out: VSOut;
|
||||
if (ii >= arrayLength(&particles)) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let particle = particles[ii];
|
||||
let corner = CORNERS[vi];
|
||||
|
||||
// Current position from state.xyz.
|
||||
let pos = particle.state.xyz;
|
||||
|
||||
// Base size from target_size.w.
|
||||
let baseSize = particle.target_size.w;
|
||||
|
||||
// Flash boost during ignition (frames 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<f32>(world, 1.0);
|
||||
out.uv = corner;
|
||||
|
||||
// Color: violet dust (0.55, 0.32, 1.00) with spectral rim.
|
||||
let phase = particle.color_phase.w;
|
||||
let spectralW = fract(params.loop_phase + phase);
|
||||
var spectralColor: vec3<f32>;
|
||||
var stops = array<vec3<f32>, 4>(
|
||||
vec3<f32>(0.55, 0.32, 1.00), // violet base
|
||||
vec3<f32>(0.40, 0.60, 1.00), // blue-violet
|
||||
vec3<f32>(0.70, 0.45, 1.00), // magenta-violet
|
||||
vec3<f32>(0.55, 0.32, 1.00) // wrap
|
||||
);
|
||||
let f = spectralW * 4.0;
|
||||
let i = u32(floor(f)) % 4u;
|
||||
let frac = f - floor(f);
|
||||
spectralColor = mix(stops[i], stops[(i + 1u) % 4u], frac);
|
||||
|
||||
// Alpha from state.w (convergence progress + fade).
|
||||
let alpha = particle.state.w;
|
||||
|
||||
out.color = spectralColor;
|
||||
out.misc = vec4<f32>(baseSize, 0.0, 0.0, alpha);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
||||
let d = length(in.uv);
|
||||
if (d > 1.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let alpha = in.misc.w;
|
||||
let core = smoothstep(0.25, 0.0, d);
|
||||
let halo = pow(max(1.0 - d, 0.0), 2.0);
|
||||
|
||||
// Additive glow: core + halo.
|
||||
let intensity = core * 1.5 + halo * 0.6;
|
||||
|
||||
// Flash boost during ignition.
|
||||
let frame = params.frame;
|
||||
var flash = 0.0;
|
||||
if (frame >= 330.0 && frame <= 359.0) {
|
||||
flash = smoothstep(330.0, 345.0, frame) * 2.0;
|
||||
}
|
||||
|
||||
let color = in.color * (intensity + flash);
|
||||
|
||||
return vec4<f32>(color * params.brightness, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-birth-render',
|
||||
code: particleRenderWGSL
|
||||
});
|
||||
|
||||
this.renderPipeline = device.createRenderPipeline({
|
||||
label: 'observatory-birth-render',
|
||||
layout: 'auto',
|
||||
vertex: { module, entryPoint: 'vs_main' },
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [
|
||||
{
|
||||
format: this.engine.sceneFormat,
|
||||
blend: {
|
||||
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
|
||||
const cameraBuffer = this.nodeRenderer.cameraUniformBuffer;
|
||||
this.renderBindGroup = device.createBindGroup({
|
||||
label: 'observatory-birth-render-bind',
|
||||
layout: this.renderPipeline!.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
|
||||
{ binding: 1, resource: { buffer: cameraBuffer! } },
|
||||
{ binding: 2, resource: { buffer: this.particleBuffer! } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private createHaloPipeline(device: GPUDevice): void {
|
||||
// Flash halo: a glowing ring around the target node (B5).
|
||||
const haloWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
right: vec4<f32>,
|
||||
up: vec4<f32>,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
pos_radius: vec4<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<uniform> camera: Camera;
|
||||
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32
|
||||
) -> VSOut {
|
||||
var out: VSOut;
|
||||
if (ii >= u32(params.node_count)) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let node = nodes[ii];
|
||||
let flags = u32(node.color_flags.w);
|
||||
let is_target = (flags & 4u) != 0u; // flag 2: is birth target
|
||||
|
||||
if (!is_target) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Flash halo: only visible during ignition (frames 330–359).
|
||||
let frame = params.frame;
|
||||
if (frame < 330.0 || frame > 359.0) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Halo ring: expands during flash, fades by frame 359.
|
||||
let flashT = (frame - 330.0) / 29.0; // 0..1
|
||||
let ringRadius = 0.3 + flashT * 0.5; // expands 0.3 → 0.8
|
||||
|
||||
// Quad centered on target position.
|
||||
let pos = node.pos_radius.xyz;
|
||||
let cornerX = (f32(vi) / 3.0 - 1.0); // -1, 0, 1 (3 unique x)
|
||||
let cornerY = (f32(vi % 3) / 1.5 - 1.0); // -1, 0, 1
|
||||
|
||||
// We use 4 vertices for a simple quad (vi 0..3).
|
||||
let cx = cornerX * ringRadius;
|
||||
let cy = cornerY * ringRadius;
|
||||
|
||||
let world = pos
|
||||
+ camera.right.xyz * cx
|
||||
+ camera.up.xyz * cy;
|
||||
|
||||
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
|
||||
|
||||
// UV for radial fade.
|
||||
out.uv = vec2<f32>(cx / ringRadius, cy / ringRadius);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
||||
let d = length(in.uv);
|
||||
if (d > 0.7) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// Flash: white-hot core, violet rim.
|
||||
let flashIntensity = 1.0 - smoothstep(0.0, 0.7, d);
|
||||
let color = vec3<f32>(0.7, 0.4, 1.0) * flashIntensity * 2.0;
|
||||
|
||||
// Fade out as flash ends.
|
||||
let frame = params.frame;
|
||||
let fadeOut = 1.0 - smoothstep(345.0, 359.0, frame);
|
||||
|
||||
return vec4<f32>(color * params.brightness * fadeOut, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-birth-halo',
|
||||
code: haloWGSL
|
||||
});
|
||||
|
||||
this.haloPipeline = device.createRenderPipeline({
|
||||
label: 'observatory-birth-halo',
|
||||
layout: 'auto',
|
||||
vertex: { module, entryPoint: 'vs_main' },
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [
|
||||
{
|
||||
format: this.engine.sceneFormat,
|
||||
blend: {
|
||||
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
|
||||
const cameraBuffer = this.nodeRenderer.cameraUniformBuffer;
|
||||
this.haloBindGroup = device.createBindGroup({
|
||||
label: 'observatory-birth-halo-bind',
|
||||
layout: this.haloPipeline!.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
|
||||
{ binding: 1, resource: { buffer: cameraBuffer! } },
|
||||
{ binding: 2, resource: { buffer: this.nodeRenderer.nodeStateBuffer! } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private createEngravePipeline(device: GPUDevice): void {
|
||||
// Reuse path-ribbon shader for edge engraving (B6).
|
||||
// The birth engraving uses the same triangle-strip ribbon pattern
|
||||
// but with different beat timing (starts at frame 360).
|
||||
|
||||
if (this.engraveStepCount === 0 || !this.engraveBuffer) return;
|
||||
|
||||
const pathModule = device.createShaderModule({
|
||||
label: 'observatory-birth-engrave',
|
||||
code: renderPathWGSL
|
||||
});
|
||||
|
||||
this.engravePipeline = device.createRenderPipeline({
|
||||
label: 'observatory-birth-engrave-pipeline',
|
||||
layout: 'auto',
|
||||
vertex: { module: pathModule, entryPoint: 'vs_main' },
|
||||
fragment: {
|
||||
module: pathModule,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [
|
||||
{
|
||||
format: this.engine.sceneFormat,
|
||||
blend: {
|
||||
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
|
||||
this.engraveBindGroup = device.createBindGroup({
|
||||
label: 'observatory-birth-engrave-bind',
|
||||
layout: this.engravePipeline!.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer! } },
|
||||
{ binding: 1, resource: { buffer: this.nodeRenderer.cameraUniformBuffer! } },
|
||||
{ binding: 2, resource: { buffer: this.nodeRenderer.nodeStateBuffer! } },
|
||||
{ binding: 3, resource: { buffer: this.engraveBuffer! } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/** FramePass.compute — run birth particle simulation. */
|
||||
compute(encoder: GPUCommandEncoder, frame: number): void {
|
||||
// Only active when demoMode === 'engram-birth' (demo_id === 1).
|
||||
const demoId = this.engine.params[9];
|
||||
this.active = demoId === 1;
|
||||
|
||||
if (!this.active || !this.computePipeline || !this.computeBindGroup) return;
|
||||
|
||||
const pass = encoder.beginComputePass({ label: 'observatory-birth-compute' });
|
||||
pass.setPipeline(this.computePipeline);
|
||||
pass.setBindGroup(0, this.computeBindGroup);
|
||||
pass.dispatchWorkgroups(Math.ceil(this.particleCount / 64));
|
||||
pass.end();
|
||||
}
|
||||
|
||||
/** FramePass.render — draw particles, flash halo, edge engraving. */
|
||||
render(pass: GPURenderPassEncoder, frame: number): void {
|
||||
if (!this.active) return;
|
||||
|
||||
// B4: Draw particle billboards (instanced additive).
|
||||
if (this.renderPipeline && this.renderBindGroup && this.particleCount > 0) {
|
||||
pass.setPipeline(this.renderPipeline);
|
||||
pass.setBindGroup(0, this.renderBindGroup);
|
||||
pass.draw(QUAD_VERTS, this.particleCount);
|
||||
}
|
||||
|
||||
// B5: Draw flash halo (only during frames 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;
|
||||
}
|
||||
}
|
||||
139
apps/dashboard/src/lib/observatory/camera.ts
Normal file
139
apps/dashboard/src/lib/observatory/camera.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Cognitive Observatory — minimal column-major mat4 camera math.
|
||||
*
|
||||
* Spec §6: no new dependencies — small local helpers instead of three.js.
|
||||
* All outputs are Float32Array(16) in WebGPU/WGSL column-major order.
|
||||
*/
|
||||
|
||||
export type Mat4 = Float32Array;
|
||||
|
||||
/** Perspective projection (right-handed, depth 0..1 as WebGPU expects). */
|
||||
export function perspective(fovYRad: number, aspect: number, near: number, far: number): Mat4 {
|
||||
const f = 1 / Math.tan(fovYRad / 2);
|
||||
const nf = 1 / (near - far);
|
||||
// column-major
|
||||
const m = new Float32Array(16);
|
||||
m[0] = f / aspect;
|
||||
m[5] = f;
|
||||
m[10] = far * nf;
|
||||
m[11] = -1;
|
||||
m[14] = far * near * nf;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Right-handed lookAt view matrix. */
|
||||
export function lookAt(
|
||||
eye: [number, number, number],
|
||||
target: [number, number, number],
|
||||
up: [number, number, number]
|
||||
): Mat4 {
|
||||
const [ex, ey, ez] = eye;
|
||||
let zx = ex - target[0];
|
||||
let zy = ey - target[1];
|
||||
let zz = ez - target[2];
|
||||
let len = Math.hypot(zx, zy, zz) || 1;
|
||||
zx /= len;
|
||||
zy /= len;
|
||||
zz /= len;
|
||||
|
||||
// x = up × z
|
||||
let xx = up[1] * zz - up[2] * zy;
|
||||
let xy = up[2] * zx - up[0] * zz;
|
||||
let xz = up[0] * zy - up[1] * zx;
|
||||
len = Math.hypot(xx, xy, xz) || 1;
|
||||
xx /= len;
|
||||
xy /= len;
|
||||
xz /= len;
|
||||
|
||||
// y = z × x
|
||||
const yx = zy * xz - zz * xy;
|
||||
const yy = zz * xx - zx * xz;
|
||||
const yz = zx * xy - zy * xx;
|
||||
|
||||
const m = new Float32Array(16);
|
||||
m[0] = xx;
|
||||
m[1] = yx;
|
||||
m[2] = zx;
|
||||
m[4] = xy;
|
||||
m[5] = yy;
|
||||
m[6] = zy;
|
||||
m[8] = xz;
|
||||
m[9] = yz;
|
||||
m[10] = zz;
|
||||
m[12] = -(xx * ex + xy * ey + xz * ez);
|
||||
m[13] = -(yx * ex + yy * ey + yz * ez);
|
||||
m[14] = -(zx * ex + zy * ey + zz * ez);
|
||||
m[15] = 1;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** out = a × b (column-major). */
|
||||
export function multiply(a: Mat4, b: Mat4): Mat4 {
|
||||
const out = new Float32Array(16);
|
||||
for (let c = 0; c < 4; c++) {
|
||||
for (let r = 0; r < 4; r++) {
|
||||
out[c * 4 + r] =
|
||||
a[r] * b[c * 4] +
|
||||
a[4 + r] * b[c * 4 + 1] +
|
||||
a[8 + r] * b[c * 4 + 2] +
|
||||
a[12 + r] * b[c * 4 + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface OrbitCamera {
|
||||
viewProj: Mat4;
|
||||
/** world-space camera right vector (billboarding) */
|
||||
right: [number, number, number];
|
||||
/** world-space camera up vector (billboarding) */
|
||||
up: [number, number, number];
|
||||
eye: [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic slow orbit camera: angle driven by the loop phase (frames),
|
||||
* never wall clock — the same frame always yields the same view. Returns the
|
||||
* view-projection plus the camera basis for GPU billboards.
|
||||
*/
|
||||
export function orbitCamera(
|
||||
phase: number,
|
||||
aspect: number,
|
||||
distance: number,
|
||||
elevation = 0.35
|
||||
): OrbitCamera {
|
||||
const angle = phase * Math.PI * 2;
|
||||
const eye: [number, number, number] = [
|
||||
Math.sin(angle) * distance,
|
||||
distance * elevation,
|
||||
Math.cos(angle) * distance
|
||||
];
|
||||
const proj = perspective((50 * Math.PI) / 180, aspect, 0.1, 4000);
|
||||
const view = lookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||||
|
||||
// camera basis: forward = normalize(target - eye), right = f × up, up = r × f
|
||||
let fx = -eye[0],
|
||||
fy = -eye[1],
|
||||
fz = -eye[2];
|
||||
let len = Math.hypot(fx, fy, fz) || 1;
|
||||
fx /= len;
|
||||
fy /= len;
|
||||
fz /= len;
|
||||
let rx = fy * 0 - fz * 1;
|
||||
let ry = fz * 0 - fx * 0;
|
||||
let rz = fx * 1 - fy * 0;
|
||||
len = Math.hypot(rx, ry, rz) || 1;
|
||||
rx /= len;
|
||||
ry /= len;
|
||||
rz /= len;
|
||||
const ux = ry * fz - rz * fy;
|
||||
const uy = rz * fx - rx * fz;
|
||||
const uz = rx * fy - ry * fx;
|
||||
|
||||
return {
|
||||
viewProj: multiply(proj, view),
|
||||
right: [rx, ry, rz],
|
||||
up: [ux, uy, uz],
|
||||
eye
|
||||
};
|
||||
}
|
||||
137
apps/dashboard/src/lib/observatory/demo-clock.ts
Normal file
137
apps/dashboard/src/lib/observatory/demo-clock.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Deterministic demo clock for the Cognitive Observatory.
|
||||
*
|
||||
* Fixed 60fps loop, 720-frame period (12 seconds), seeded PRNG.
|
||||
* No Math.random() or performance.now() for simulation state.
|
||||
* performance.now() only schedules frames; positions/colors/path are deterministic.
|
||||
*
|
||||
* Pattern: https://gafferongames.com/post/fix_your_timestep/
|
||||
*/
|
||||
|
||||
// ---- MurmurHash3-compatible 32-bit hash (xmur3) ----
|
||||
// Used to hash a seed string into a 32-bit integer for mulberry32.
|
||||
function xmur3(str: string): () => number {
|
||||
let h = 1779033703 ^ str.length;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 2654435761);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
return function () {
|
||||
let t = (h += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Mulberry32 seeded PRNG ----
|
||||
// Fast, deterministic, 32-bit. Good enough for demo visuals.
|
||||
function mulberry32(seed: number) {
|
||||
return function () {
|
||||
let t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- DemoClock ----
|
||||
export interface DemoClockConfig {
|
||||
/** Frames per second (fixed) */
|
||||
fps?: number;
|
||||
/** Frames per loop (default 720 = 12s at 60fps) */
|
||||
loopFrames?: number;
|
||||
/** Seed string for deterministic PRNG */
|
||||
seed: string;
|
||||
}
|
||||
|
||||
export interface DemoClockState {
|
||||
/** Current frame (integer, wraps at loopFrames) */
|
||||
frame: number;
|
||||
/** Loop phase: 0..1 */
|
||||
phase: number;
|
||||
/** PRNG function seeded from the provided seed */
|
||||
rng: () => number;
|
||||
/** Total frames elapsed (monotonic, does not wrap) */
|
||||
totalFrames: number;
|
||||
}
|
||||
|
||||
export class DemoClock {
|
||||
private readonly fps: number;
|
||||
private readonly loopFrames: number;
|
||||
private readonly seedStr: string;
|
||||
private _frame: number;
|
||||
private _totalFrames: number;
|
||||
private _rng: () => number;
|
||||
|
||||
constructor(config: DemoClockConfig) {
|
||||
this.fps = config.fps ?? 60;
|
||||
this.loopFrames = config.loopFrames ?? 720;
|
||||
this.seedStr = config.seed;
|
||||
this._frame = 0;
|
||||
this._totalFrames = 0;
|
||||
// Hash the seed string into a 32-bit integer, then create a mulberry32 PRNG
|
||||
const hash = xmur3(this.seedStr)();
|
||||
this._rng = mulberry32(Math.floor(hash * 2 ** 32));
|
||||
}
|
||||
|
||||
/** Advance the clock by one frame. Returns the new state. */
|
||||
tick(): DemoClockState {
|
||||
this._frame = (this._frame + 1) % this.loopFrames;
|
||||
this._totalFrames++;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Get the current clock state without advancing. */
|
||||
get state(): DemoClockState {
|
||||
return {
|
||||
frame: this._frame,
|
||||
phase: this._frame / this.loopFrames,
|
||||
rng: this._rng,
|
||||
totalFrames: this._totalFrames
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset the clock to frame 0. */
|
||||
reset(): void {
|
||||
this._frame = 0;
|
||||
this._totalFrames = 0;
|
||||
// Re-seed the PRNG from the original seed
|
||||
const hash = xmur3(this.seedStr)();
|
||||
this._rng = mulberry32(Math.floor(hash * 2 ** 32));
|
||||
}
|
||||
|
||||
/** Get the loop duration in seconds. */
|
||||
get loopDuration(): number {
|
||||
return this.loopFrames / this.fps;
|
||||
}
|
||||
|
||||
/** Frames per loop (capture mode needs this to freeze deterministically). */
|
||||
get framesPerLoop(): number {
|
||||
return this.loopFrames;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Utility: deterministic position on a golden-angle sphere ----
|
||||
// Golden-angle placement is deterministic by index. The rng provides
|
||||
// a small seed-based perturbation so different seeds produce different layouts.
|
||||
export function deterministicSpherePosition(
|
||||
index: number,
|
||||
total: number,
|
||||
radius: number,
|
||||
rng: () => number
|
||||
): [number, number, number] {
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const y = 1 - (index / (total - 1 || 1)) * 2; // -1 to 1
|
||||
const radiusAtY = Math.sqrt(1 - y * y);
|
||||
const theta = goldenAngle * index;
|
||||
const x = Math.cos(theta) * radiusAtY;
|
||||
const z = Math.sin(theta) * radiusAtY;
|
||||
|
||||
// Small seed-based perturbation (±5% of radius)
|
||||
const px = (rng() - 0.5) * 0.1 * radius;
|
||||
const py = (rng() - 0.5) * 0.1 * radius;
|
||||
const pz = (rng() - 0.5) * 0.1 * radius;
|
||||
|
||||
return [x * radius + px, y * radius + py, z * radius + pz];
|
||||
}
|
||||
389
apps/dashboard/src/lib/observatory/engine.ts
Normal file
389
apps/dashboard/src/lib/observatory/engine.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* Cognitive Observatory — WebGPU engine.
|
||||
*
|
||||
* Owns adapter/device/context lifecycle, the render loop, resize, and dispose.
|
||||
* Increment 3 scope: boot WebGPU, clear to void #05060a, DPR-clamped resize,
|
||||
* readable fallback when WebGPU is unavailable. Later increments register
|
||||
* pipelines on top of this shell (spec §2, §3.4).
|
||||
*
|
||||
* Hard rules (spec §6):
|
||||
* - No Math.random()/Date.now()/performance.now() deciding simulation state
|
||||
* (the DemoClock is the only sim clock; rAF timestamps only schedule).
|
||||
* - No GPU readback in the frame loop.
|
||||
*/
|
||||
|
||||
import { DemoClock } from './demo-clock';
|
||||
import { PARAMS_FLOATS, demoModeId, type DemoMode } from './types';
|
||||
import { PostChain, SCENE_FORMAT } from './post/post-chain';
|
||||
import { VOID_CLEAR_HDR } from './post/tone-reference';
|
||||
|
||||
/**
|
||||
* Void background — visual DNA §7. #05060a as the DISPLAY value (public API,
|
||||
* pre-post-stack). The HDR scene pass clears to VOID_CLEAR_HDR
|
||||
* (post/tone-reference.ts), whose tonemapped composite result is exactly
|
||||
* this color.
|
||||
*/
|
||||
export const VOID_CLEAR: GPUColor = {
|
||||
r: 0x05 / 255,
|
||||
g: 0x06 / 255,
|
||||
b: 0x0a / 255,
|
||||
a: 1
|
||||
};
|
||||
|
||||
export interface EngineOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
demo: DemoMode;
|
||||
seed: string;
|
||||
/** Max devicePixelRatio to render at (spec: DPR clamp). */
|
||||
maxDpr?: number;
|
||||
/** Called once per completed frame with the loop frame index (telemetry). */
|
||||
onFrame?: (frame: number, fps: number) => void;
|
||||
/**
|
||||
* Capture mode (?frame=N): freeze the simulation at this exact loop frame.
|
||||
* Same URL + frame → identical pixels, for shareable stills (spec §4 Inc 9).
|
||||
*/
|
||||
freezeFrame?: number | null;
|
||||
}
|
||||
|
||||
export type EngineStatus =
|
||||
| { state: 'booting' }
|
||||
| { state: 'running' }
|
||||
| { state: 'unsupported'; reason: string }
|
||||
| { state: 'error'; reason: string }
|
||||
| { state: 'disposed' };
|
||||
|
||||
/**
|
||||
* Frame hook contract for later increments: each registered pass gets the
|
||||
* encoder + the main scene pass (offscreen HDR target) once per frame, after
|
||||
* the clear. The post chain composites the scene to the swapchain afterwards.
|
||||
*/
|
||||
export interface FramePass {
|
||||
/** Encode compute work (before the render pass). */
|
||||
compute?(encoder: GPUCommandEncoder, frame: number): void;
|
||||
/** Encode draw calls inside the main render pass. */
|
||||
render?(pass: GPURenderPassEncoder, frame: number): void;
|
||||
}
|
||||
|
||||
export class ObservatoryEngine {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private device: GPUDevice | null = null;
|
||||
private context: GPUCanvasContext | null = null;
|
||||
private format: GPUTextureFormat = 'bgra8unorm';
|
||||
|
||||
private clock: DemoClock;
|
||||
private demo: DemoMode;
|
||||
private freezeFrame: number | null;
|
||||
private rafId = 0;
|
||||
private running = false;
|
||||
private disposed = false;
|
||||
private maxDpr: number;
|
||||
private onFrame?: (frame: number, fps: number) => void;
|
||||
|
||||
/** Per-frame uniform data (layout: types.PARAMS_FLOATS). */
|
||||
readonly params = new Float32Array(PARAMS_FLOATS);
|
||||
paramsBuffer: GPUBuffer | null = null;
|
||||
|
||||
private passes: FramePass[] = [];
|
||||
private post: PostChain | null = null;
|
||||
private _status: EngineStatus = { state: 'booting' };
|
||||
private statusListeners = new Set<(s: EngineStatus) => void>();
|
||||
|
||||
// fps estimate for telemetry only (never sim state)
|
||||
private lastRafTs = 0;
|
||||
private fpsEstimate = 0;
|
||||
|
||||
// Fixed-timestep accumulator (gafferongames.com/post/fix_your_timestep):
|
||||
// wall clock ONLY schedules how many fixed 60Hz ticks to run — sim state
|
||||
// stays a pure function of the frame index, so a 120Hz ProMotion display
|
||||
// plays the same 12s loop as a 60Hz panel instead of double-speed.
|
||||
private accumulatorMs = 0;
|
||||
private static readonly FIXED_DT_MS = 1000 / 60;
|
||||
|
||||
constructor(opts: EngineOptions) {
|
||||
this.canvas = opts.canvas;
|
||||
this.demo = opts.demo;
|
||||
this.maxDpr = opts.maxDpr ?? 2;
|
||||
this.onFrame = opts.onFrame;
|
||||
this.clock = new DemoClock({ seed: opts.seed });
|
||||
this.freezeFrame =
|
||||
typeof opts.freezeFrame === 'number' && Number.isFinite(opts.freezeFrame)
|
||||
? ((Math.floor(opts.freezeFrame) % this.clock.framesPerLoop) +
|
||||
this.clock.framesPerLoop) %
|
||||
this.clock.framesPerLoop
|
||||
: null;
|
||||
this.params[8] = 1; // brightness default — the void must never eat the field
|
||||
}
|
||||
|
||||
get status(): EngineStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get gpuDevice(): GPUDevice | null {
|
||||
return this.device;
|
||||
}
|
||||
|
||||
get presentationFormat(): GPUTextureFormat {
|
||||
return this.format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format every FramePass render pipeline targets: the offscreen HDR scene
|
||||
* texture (post stack input), NOT the swapchain.
|
||||
*/
|
||||
get sceneFormat(): GPUTextureFormat {
|
||||
return SCENE_FORMAT;
|
||||
}
|
||||
|
||||
get demoClock(): DemoClock {
|
||||
return this.clock;
|
||||
}
|
||||
|
||||
onStatus(cb: (s: EngineStatus) => void): () => void {
|
||||
this.statusListeners.add(cb);
|
||||
cb(this._status);
|
||||
return () => this.statusListeners.delete(cb);
|
||||
}
|
||||
|
||||
private setStatus(s: EngineStatus) {
|
||||
this._status = s;
|
||||
for (const cb of this.statusListeners) cb(s);
|
||||
}
|
||||
|
||||
/** Register a frame pass (later increments: sim, nodes, edges, path, post). */
|
||||
addPass(pass: FramePass): void {
|
||||
this.passes.push(pass);
|
||||
}
|
||||
|
||||
/** Boot WebGPU. Resolves true when running, false when unsupported/error. */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.disposed) return false;
|
||||
|
||||
const gpu = (navigator as Navigator & { gpu?: GPU }).gpu;
|
||||
if (!gpu) {
|
||||
this.setStatus({
|
||||
state: 'unsupported',
|
||||
reason: 'WebGPU is not available in this browser.'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
let adapter: GPUAdapter | null = null;
|
||||
try {
|
||||
adapter = await gpu.requestAdapter();
|
||||
} catch (e) {
|
||||
this.setStatus({
|
||||
state: 'error',
|
||||
reason: e instanceof Error ? e.message : 'requestAdapter failed'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!adapter) {
|
||||
this.setStatus({
|
||||
state: 'unsupported',
|
||||
reason: 'No suitable GPU adapter found.'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.device = await adapter.requestDevice();
|
||||
} catch (e) {
|
||||
this.setStatus({
|
||||
state: 'error',
|
||||
reason: e instanceof Error ? e.message : 'requestDevice failed'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.disposed) {
|
||||
// disposed while awaiting — release the device we just got
|
||||
this.device?.destroy();
|
||||
this.device = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.device.lost.then((info) => {
|
||||
if (this.disposed || info.reason === 'destroyed') return;
|
||||
this.setStatus({ state: 'error', reason: `GPU device lost: ${info.message}` });
|
||||
this.stopLoop();
|
||||
});
|
||||
|
||||
// Surface shader/pipeline validation errors loudly — a silent black
|
||||
// field is the worst failure mode an observatory can have.
|
||||
this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => {
|
||||
console.error('[observatory] WebGPU error:', ev.error.message);
|
||||
};
|
||||
|
||||
const context = this.canvas.getContext('webgpu');
|
||||
if (!context) {
|
||||
this.setStatus({ state: 'error', reason: 'Could not get webgpu canvas context.' });
|
||||
return false;
|
||||
}
|
||||
this.context = context;
|
||||
this.format = gpu.getPreferredCanvasFormat();
|
||||
this.configureContext();
|
||||
|
||||
// Per-frame uniform buffer (written by writeBuffer each frame; no readback).
|
||||
this.paramsBuffer = this.device.createBuffer({
|
||||
label: 'observatory-params',
|
||||
size: this.params.byteLength,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
|
||||
// Post stack (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();
|
||||
}
|
||||
}
|
||||
358
apps/dashboard/src/lib/observatory/firewall-plan.ts
Normal file
358
apps/dashboard/src/lib/observatory/firewall-plan.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* Cognitive Observatory — firewall demo plan (the immune response).
|
||||
*
|
||||
* Pure CPU: given the stable-indexed ObservatoryGraph + the demo seed,
|
||||
* DETERMINISTICALLY pick the intruder (prefer failure/guardrail/confusion
|
||||
* tags, else the lowest-retention leaf), precompute the per-node shockwave
|
||||
* delays from the REAL layout (layoutPositions — never reimplemented), the
|
||||
* severed-edge steps, the spine beats and the verdict copy. No Math.random(),
|
||||
* no Date.now().
|
||||
*
|
||||
* The 720-frame beat map (fixed 60Hz, seamless loop):
|
||||
* 0-90 field at rest
|
||||
* 90-150 INTRUSION — the suspicious memory flares sickly green-white
|
||||
* (demo.y flare band (0..1], 36 integer sine cycles/loop)
|
||||
* 150-330 CRIMSON SHOCKWAVE — a radial front expands from the intruder;
|
||||
* per-node arrival A = 150 + delay, delay = round(144·dist/maxDist)
|
||||
* (demo.w rim, amplitude fading with distance; all rims dead by 320)
|
||||
* 330-480 QUARANTINE — probe beams to the intruder's neighbors flare then
|
||||
* die one by one (kind 2, bf = 345 + 21k) while the MEMBRANE forms
|
||||
* (demo.y membrane band [2.6..2.9] — the lane VALUE RANGE separates
|
||||
* intrusion-flare (0..1] from membrane [2..3], one lane, two reads)
|
||||
* 480-620 VERDICT overlay — "threat quarantined" (RescueVerdict, tone
|
||||
* quarantine, fadeWindow 480/495/605/620)
|
||||
* 620-720 every lane decays to EXACTLY zero (all releases r1 ≤ 680)
|
||||
*
|
||||
* `firewallEnvelopes` is the authoritative CPU mirror of
|
||||
* shaders/firewall.wgsl.ts — the seam-zero unit test machine-checks the loop
|
||||
* guarantee against it.
|
||||
*/
|
||||
|
||||
import { FLOATS_PER_NODE, PATH_KIND, type ObservatoryGraph } from './types';
|
||||
import type { PathStepMeta } from './path-builder';
|
||||
import { layoutPositions, truncateLabel } from './rescue-plan';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beat-map constants (shared with shaders/firewall.wgsl.ts — keep in lockstep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const INTRUSION_FRAME = 90;
|
||||
export const SHOCK_START = 150;
|
||||
/** The front always crosses the field in exactly SHOCK_SPAN frames (adaptive speed). */
|
||||
export const SHOCK_SPAN = 144;
|
||||
export const MEMBRANE_START = 330;
|
||||
/** Sever step k lands at SEVER_BASE + k * SEVER_INTERVAL → 345..450. */
|
||||
export const SEVER_BASE = 345;
|
||||
export const SEVER_INTERVAL = 21;
|
||||
export const MAX_SEVERED = 6;
|
||||
export const FIREWALL_VERDICT_START = 480;
|
||||
export const FIREWALL_VERDICT_END = 620;
|
||||
export const LOOP_FRAMES = 720;
|
||||
/** Tags that mark a memory as suspicious (case-insensitive). */
|
||||
export const INTRUDER_TAGS: readonly string[] = ['failure', 'guardrail', 'confusion'];
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FirewallVerdictCopy {
|
||||
headline: 'threat quarantined';
|
||||
intruderLabel: string;
|
||||
receipt: 'memory held in review · Memory PR opened';
|
||||
}
|
||||
|
||||
export interface FirewallPlan {
|
||||
/** false ⇒ field renders at rest, story suppressed (no fake intruder). */
|
||||
viable: boolean;
|
||||
intruderIndex: number;
|
||||
/** Unique intruder neighbors, ascending index, ≤ MAX_SEVERED. */
|
||||
severedNeighborIndices: number[];
|
||||
/** Per-node shock delay, round(SHOCK_SPAN·dist/maxDist), intruder 0. */
|
||||
shockDelays: number[];
|
||||
/**
|
||||
* 1 u32/node: bits 0-7 shockDelay (0..144), bit 8 isIntruder,
|
||||
* bit 9 isSeverNeighbor, bits 10-13 sever slot k.
|
||||
*/
|
||||
fireData: Uint32Array;
|
||||
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
|
||||
pathData: Uint32Array<ArrayBuffer>;
|
||||
/** MUST be 1:1 with pathData steps (setPathSteps contract). */
|
||||
pathMetas: PathStepMeta[];
|
||||
/** Curated spine beats (route state only, NEVER sent to GPU). */
|
||||
spineBeats: PathStepMeta[];
|
||||
verdict: FirewallVerdictCopy;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection (exported for tests; all ties → ascending node index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick the intruder. Relaxation ladder, each tier ordered by
|
||||
* (retention asc, index asc):
|
||||
* 1. tagged (failure/guardrail/confusion) non-center unsuppressed
|
||||
* 2. leaf (degree ≤ 1) non-center unsuppressed
|
||||
* 3. any non-center unsuppressed
|
||||
* 4. any non-center
|
||||
* No candidate anywhere → -1 (plan viable:false).
|
||||
*/
|
||||
export function pickIntruderIndex(graph: ObservatoryGraph): number {
|
||||
const n = graph.nodes.length;
|
||||
if (n === 0) return -1;
|
||||
const degree = new Uint32Array(n);
|
||||
for (const e of graph.edges) {
|
||||
degree[e.sourceIndex]++;
|
||||
degree[e.targetIndex]++;
|
||||
}
|
||||
const isTagged = (i: number): boolean =>
|
||||
graph.nodes[i].tags.some((t) => INTRUDER_TAGS.includes(t.toLowerCase()));
|
||||
|
||||
const tiers: Array<(i: number) => boolean> = [
|
||||
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && isTagged(i),
|
||||
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && degree[i] <= 1,
|
||||
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed,
|
||||
(i) => i !== graph.centerIndex
|
||||
];
|
||||
for (const accept of tiers) {
|
||||
let best = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!accept(i)) continue;
|
||||
// strict < keeps the lowest index on retention ties (ascending scan)
|
||||
if (best < 0 || graph.nodes[i].retention < graph.nodes[best].retention) best = i;
|
||||
}
|
||||
if (best >= 0) return best;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-node shockwave delay from LAYOUT distance to the intruder:
|
||||
* delay_i = round(SHOCK_SPAN · dist_i / maxDist), clamped [0, 255], intruder 0.
|
||||
* maxDist is floored 1e-6 → 1.0 (co-located degenerate: everything fires at
|
||||
* once — safe, deterministic). Adaptive speed: the front always crosses the
|
||||
* whole field in exactly SHOCK_SPAN frames.
|
||||
*/
|
||||
export function computeShockDelays(
|
||||
positions: Float32Array,
|
||||
nodeCount: number,
|
||||
intruderIndex: number
|
||||
): number[] {
|
||||
const ix = positions[intruderIndex * FLOATS_PER_NODE + 0];
|
||||
const iy = positions[intruderIndex * FLOATS_PER_NODE + 1];
|
||||
const iz = positions[intruderIndex * FLOATS_PER_NODE + 2];
|
||||
const dists = new Array<number>(nodeCount);
|
||||
let maxDist = 0;
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const dx = positions[i * FLOATS_PER_NODE + 0] - ix;
|
||||
const dy = positions[i * FLOATS_PER_NODE + 1] - iy;
|
||||
const dz = positions[i * FLOATS_PER_NODE + 2] - iz;
|
||||
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
dists[i] = d;
|
||||
if (d > maxDist) maxDist = d;
|
||||
}
|
||||
if (maxDist < 1e-6) maxDist = 1.0;
|
||||
const delays = new Array<number>(nodeCount);
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
delays[i] = Math.min(255, Math.max(0, Math.round((SHOCK_SPAN * dists[i]) / maxDist)));
|
||||
}
|
||||
delays[intruderIndex] = 0;
|
||||
return delays;
|
||||
}
|
||||
|
||||
/**
|
||||
* The severed edges: unique intruder neighbors (self-loops excluded — also
|
||||
* already dropped by buildObservatoryGraph — and deduped), ascending index,
|
||||
* capped at MAX_SEVERED. Edgeless intruder → [] (still viable: the membrane
|
||||
* forms around a memory with nothing to sever).
|
||||
*/
|
||||
export function pickSeveredNeighbors(graph: ObservatoryGraph, intruderIndex: number): number[] {
|
||||
const nbrs = new Set<number>();
|
||||
for (const e of graph.edges) {
|
||||
if (e.sourceIndex === intruderIndex && e.targetIndex !== intruderIndex) {
|
||||
nbrs.add(e.targetIndex);
|
||||
}
|
||||
if (e.targetIndex === intruderIndex && e.sourceIndex !== intruderIndex) {
|
||||
nbrs.add(e.sourceIndex);
|
||||
}
|
||||
}
|
||||
return Array.from(nbrs)
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, MAX_SEVERED);
|
||||
}
|
||||
|
||||
export function severFrame(k: number): number {
|
||||
return SEVER_BASE + SEVER_INTERVAL * k;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope math — the authoritative CPU mirror of shaders/firewall.wgsl.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function smooth(a: number, b: number, f: number): number {
|
||||
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function env(f: number, a0: number, a1: number, r0: number, r1: number): number {
|
||||
return smooth(a0, a1, f) * (1 - smooth(r0, r1, f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function of (frame, packed fire word) → the four demo lanes
|
||||
* (x ALWAYS 0, y intruder flare/membrane, z ALWAYS 0, w shock rim/sever blink).
|
||||
* Every envelope has attack a0 ≥ 90 and release r1 ≤ 680 ⇒ exactly 0 at
|
||||
* frames 0 and 719 — the machine-checked seam guarantee. Sines are factors on
|
||||
* zero-at-seam envelopes and run INTEGER cycles per loop (36 flare, 12
|
||||
* membrane). demo.y value ranges: intrusion flare (0..1], membrane
|
||||
* [2.60..2.90] — the fragment shader separates the two reads by range.
|
||||
* Keep in lockstep with firewall_choreo in shaders/firewall.wgsl.ts.
|
||||
*/
|
||||
export function firewallEnvelopes(
|
||||
frame: number,
|
||||
packed: number
|
||||
): { x: number; y: number; z: number; w: number } {
|
||||
const delay = packed & 0xff;
|
||||
const isIntruder = (packed & 0x100) !== 0;
|
||||
const isSever = (packed & 0x200) !== 0;
|
||||
const k = (packed >>> 10) & 0xf;
|
||||
const loopPhase = frame / LOOP_FRAMES;
|
||||
|
||||
let y = 0;
|
||||
let w = 0;
|
||||
if (isIntruder) {
|
||||
// Intrusion flare: sickly strobe, band (0..1]. C¹ handoff into the
|
||||
// membrane over 330-332 — the rise sweeps the flare band exactly once
|
||||
// (the condensation read is intentional).
|
||||
y = env(frame, 90, 96, 310, 332) * (0.55 + 0.45 * Math.sin(TAU * 36 * loopPhase));
|
||||
// Membrane: sustained ring band [2.60..2.90], slow shimmer.
|
||||
y += env(frame, 330, 352, 620, 680) * (2.75 + 0.15 * Math.sin(TAU * 12 * loopPhase));
|
||||
// Source detonation as the front leaves.
|
||||
w = env(frame, 148, 153, 162, 196);
|
||||
} else {
|
||||
const a = SHOCK_START + delay;
|
||||
const amp = 0.9 - 0.45 * (delay / SHOCK_SPAN);
|
||||
// Crimson rim as the front passes; A ∈ [150, 294] ⇒ dead by 320 < 330.
|
||||
w = amp * env(frame, a - 2, a + 3, a + 8, a + 26);
|
||||
if (isSever) {
|
||||
// Node-side receipt of the severed edge; last release 450+24 = 474.
|
||||
const sk = severFrame(k);
|
||||
w += 0.6 * env(frame, sk - 4, sk, sk + 6, sk + 24);
|
||||
}
|
||||
}
|
||||
// x and z are hard 0.0: the recall/thin-film and horizon grammars can
|
||||
// never fire in demo 4 (enforced by the lane-hygiene test).
|
||||
return { x: 0, y, z: 0, w };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UINTS_PER_STEP = 4;
|
||||
|
||||
function emptyPlan(nodeCount: number): FirewallPlan {
|
||||
return {
|
||||
viable: false,
|
||||
intruderIndex: -1,
|
||||
severedNeighborIndices: [],
|
||||
shockDelays: [],
|
||||
fireData: new Uint32Array(nodeCount),
|
||||
pathData: new Uint32Array(4),
|
||||
pathMetas: [],
|
||||
spineBeats: [],
|
||||
verdict: {
|
||||
headline: 'threat quarantined',
|
||||
intruderLabel: '',
|
||||
receipt: 'memory held in review · Memory PR opened'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full deterministic firewall plan. Same graph + seed → identical
|
||||
* plan (byte-identical typed arrays). Empty/center-only graphs survive with
|
||||
* viable:false — the field breathes, nothing pretends to be a threat.
|
||||
*/
|
||||
export function buildFirewallPlan(graph: ObservatoryGraph, seed: string): FirewallPlan {
|
||||
const n = graph.nodes.length;
|
||||
const intruderIndex = pickIntruderIndex(graph);
|
||||
if (n === 0 || intruderIndex < 0) return emptyPlan(n);
|
||||
|
||||
// Shock delays come from the REAL layout (rescue-plan.layoutPositions —
|
||||
// byte-identical to NodeRenderer.upload, never reimplemented).
|
||||
const positions = layoutPositions(graph, seed);
|
||||
const shockDelays = computeShockDelays(positions, n, intruderIndex);
|
||||
const severed = pickSeveredNeighbors(graph, intruderIndex);
|
||||
|
||||
// --- fireData packing (1 u32/node; every node carries its shock delay) ---
|
||||
const fireData = new Uint32Array(n);
|
||||
for (let i = 0; i < n; i++) fireData[i] = shockDelays[i] & 0xff;
|
||||
fireData[intruderIndex] = 0x100; // intruder: delay forced 0
|
||||
severed.forEach((idx, k) => {
|
||||
fireData[idx] |= 0x200 | (k << 10);
|
||||
});
|
||||
|
||||
// --- PathStep emission: probe beams (kind 2) intruder → neighbor, one per
|
||||
// severed edge — they flare then die (the visible severing).
|
||||
// Window invariant: 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
|
||||
};
|
||||
}
|
||||
114
apps/dashboard/src/lib/observatory/firewall-renderer.ts
Normal file
114
apps/dashboard/src/lib/observatory/firewall-renderer.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Cognitive Observatory — firewall choreography pass.
|
||||
*
|
||||
* Compute-only FramePass (no render(): nodes and probe beams draw via the
|
||||
* NodeRenderer's existing pipelines). Uploads the per-node packed fire word
|
||||
* once (static), then each frame extends the choreography INTO the NodeState
|
||||
* demo lanes as a pure function of (frame, role, shockDelay) — no stateful
|
||||
* integration, so capture mode (?frame=N) works with zero special-casing.
|
||||
*
|
||||
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
|
||||
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
|
||||
* the upload $effect creates FirewallRenderer) so firewall_choreo encodes
|
||||
* AFTER recall_sim in the same encoder and its demo-lane overwrite wins.
|
||||
* recall_sim rewrites demo.x every frame with an afterglow window of
|
||||
* bf+40..bf+200 — the k=5 sever beam at bf=450 would otherwise carry residual
|
||||
* ignition into the verdict window.
|
||||
*
|
||||
* Three independent walls keep OTHER demos pixel-identical:
|
||||
* (a) the route constructs this renderer only in the firewall branch,
|
||||
* (b) compute() gates on params[9] === 4 ('firewall' demo index),
|
||||
* (c) the demo-4 vertex/fragment terms in render-nodes.wgsl are themselves
|
||||
* gated on params.demo_id == 4.0.
|
||||
*/
|
||||
|
||||
import type { ObservatoryEngine, FramePass } from './engine';
|
||||
import type { NodeRenderer } from './node-renderer';
|
||||
import type { FirewallPlan } from './firewall-plan';
|
||||
import { firewallWGSL } from './shaders/firewall.wgsl';
|
||||
|
||||
/** DEMO_MODES.indexOf('firewall') — types.ts, verified index 4. */
|
||||
const FIREWALL_DEMO_ID = 4;
|
||||
|
||||
export interface FirewallRendererOptions {
|
||||
engine: ObservatoryEngine;
|
||||
nodeRenderer: NodeRenderer;
|
||||
plan: FirewallPlan;
|
||||
}
|
||||
|
||||
export class FirewallRenderer implements FramePass {
|
||||
private engine: ObservatoryEngine;
|
||||
private nodeRenderer: NodeRenderer;
|
||||
private plan: FirewallPlan;
|
||||
|
||||
private pipeline: GPUComputePipeline | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private fireBuffer: GPUBuffer | null = null;
|
||||
|
||||
constructor(opts: FirewallRendererOptions) {
|
||||
this.engine = opts.engine;
|
||||
this.nodeRenderer = opts.nodeRenderer;
|
||||
this.plan = opts.plan;
|
||||
this.engine.addPass(this);
|
||||
}
|
||||
|
||||
/** Create the fire buffer + compute pipeline. Call after NodeRenderer.upload(). */
|
||||
upload(): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer) return;
|
||||
if (!this.plan.viable) return;
|
||||
if (!this.nodeRenderer.nodeStateBuffer || this.nodeRenderer.nodeCountValue === 0) return;
|
||||
|
||||
this.fireBuffer?.destroy();
|
||||
this.fireBuffer = device.createBuffer({
|
||||
label: 'observatory-firewall-fire',
|
||||
size: Math.max(4, this.plan.fireData.byteLength),
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.fireBuffer, 0, this.plan.fireData.buffer as ArrayBuffer);
|
||||
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-firewall-choreo',
|
||||
code: firewallWGSL
|
||||
});
|
||||
this.pipeline = device.createComputePipeline({
|
||||
label: 'observatory-firewall-choreo',
|
||||
layout: 'auto',
|
||||
compute: { module, entryPoint: 'firewall_choreo' }
|
||||
});
|
||||
|
||||
// EXACTLY the 3 declared bindings — auto layout strips unused bindings
|
||||
// and binding anything extra invalidates the group (the BirthRenderer
|
||||
// lesson, birth-renderer.ts createComputePipeline).
|
||||
this.bindGroup = device.createBindGroup({
|
||||
label: 'observatory-firewall-bind',
|
||||
layout: this.pipeline.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.nodeRenderer.nodeStateBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.fireBuffer } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
|
||||
compute(encoder: GPUCommandEncoder): void {
|
||||
if (this.engine.params[9] !== FIREWALL_DEMO_ID) return;
|
||||
if (!this.pipeline || !this.bindGroup) return;
|
||||
const n = this.nodeRenderer.nodeCountValue;
|
||||
if (n === 0) return;
|
||||
|
||||
const pass = encoder.beginComputePass({ label: 'observatory-firewall-choreo' });
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.dispatchWorkgroups(Math.ceil(n / 64));
|
||||
pass.end();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.fireBuffer?.destroy();
|
||||
this.fireBuffer = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroup = null;
|
||||
}
|
||||
}
|
||||
307
apps/dashboard/src/lib/observatory/forgetting-plan.ts
Normal file
307
apps/dashboard/src/lib/observatory/forgetting-plan.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* Cognitive Observatory — forgetting-horizon demo plan (FSRS as a living system).
|
||||
*
|
||||
* Pure CPU: given the stable-indexed ObservatoryGraph, DETERMINISTICALLY pick
|
||||
* the drifting set (the lowest-retention ~25% of memories), the 3 rescued
|
||||
* memories (mid-retention, well-connected — the ones a recall would plausibly
|
||||
* save), the packed per-node role words, the recall PathSteps and the spine
|
||||
* beats. No Math.random(), no Date.now(), no layout needed — selection is a
|
||||
* pure function of the graph.
|
||||
*
|
||||
* The 720-frame beat map (fixed 60Hz, seamless loop):
|
||||
* 0-90 field at rest
|
||||
* 90-420 THE DRIFT — drifting memories dim and fall toward the horizon
|
||||
* (demo.z rises to the 0.55 plateau, staggered by retention rank)
|
||||
* 300-480 THE RESCUES — 3 fading memories are recalled one by one (recall
|
||||
* ribbons land at 318/378/438; demo.z snaps back, demo.x ignites)
|
||||
* 480-660 the unrescued sink to near-black (demo.z → exactly 1.0 by 640;
|
||||
* the fragment floor keeps them at ~6% — never gone, always
|
||||
* retrievable)
|
||||
* 660-720 master release — every lane decays to EXACTLY zero by frame 712
|
||||
*
|
||||
* `forgettingEnvelopes` and `horizonDrift` are the authoritative CPU mirrors
|
||||
* of shaders/forgetting.wgsl.ts and the demo-3 branch of render-nodes.wgsl.ts;
|
||||
* the seam-zero unit test machine-checks the loop guarantee against them.
|
||||
*/
|
||||
|
||||
import { PATH_KIND, type ObservatoryGraph } from './types';
|
||||
import type { PathStepMeta } from './path-builder';
|
||||
import { truncateLabel } from './rescue-plan';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beat-map constants (shared with shaders/forgetting.wgsl.ts — keep in lockstep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DRIFT_ONSET_BASE = 90;
|
||||
export const DRIFT_ONSET_SPREAD = 42;
|
||||
export const DRIFT_ENGULF = 210;
|
||||
export const PHASE1_LEVEL = 0.55;
|
||||
export const PHASE2_LEVEL = 0.45;
|
||||
export const PHASE2_BASE = 480;
|
||||
export const PHASE2_STAGGER = 24;
|
||||
export const PHASE2_END = 640;
|
||||
/** Rescue k ribbon lands at RESCUE_BASE + k * RESCUE_INTERVAL → 318/378/438. */
|
||||
export const RESCUE_BASE = 318;
|
||||
export const RESCUE_INTERVAL = 60;
|
||||
export const MASTER_R0 = 660;
|
||||
export const MASTER_R1 = 712;
|
||||
export const LOOP_FRAMES = 720;
|
||||
export const FORGETTING_K = 3;
|
||||
/** Fading spine beat k lands at FADING_BASE + k * FADING_INTERVAL → 132/192/252. */
|
||||
export const FADING_BASE = 132;
|
||||
export const FADING_INTERVAL = 60;
|
||||
export const SINK_BEAT_FRAME = 540;
|
||||
export const RETRIEVABLE_BEAT_FRAME = 660;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ForgettingPlan {
|
||||
/** false ⇒ field renders at rest, story suppressed (no fake drift). */
|
||||
viable: boolean;
|
||||
/** Drifting node indices, retention asc (rank order). */
|
||||
driftingIndices: number[];
|
||||
/** Rescued node indices, slot order k = 0..K-1. Always ⊆ driftingIndices. */
|
||||
rescuedIndices: number[];
|
||||
/**
|
||||
* 1 u32/node: bits 0-7 rank (0..255 across the drifting set),
|
||||
* bit 8 isDrifting, bit 9 isRescued, bits 10-11 rescue slot k.
|
||||
* Non-drifting nodes (incl. the center) are exactly 0.
|
||||
*/
|
||||
horizonData: Uint32Array;
|
||||
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
|
||||
pathData: Uint32Array<ArrayBuffer>;
|
||||
/** MUST be 1:1 with pathData steps (setPathSteps contract). */
|
||||
pathMetas: PathStepMeta[];
|
||||
/** Curated spine beats (route state only, NEVER sent to GPU). */
|
||||
spineBeats: PathStepMeta[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection (exported for tests; all ties → ascending node index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The drifting set: exclude the center, sort (retention asc, index asc), take
|
||||
* driftCount = min(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
|
||||
};
|
||||
}
|
||||
114
apps/dashboard/src/lib/observatory/forgetting-renderer.ts
Normal file
114
apps/dashboard/src/lib/observatory/forgetting-renderer.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Cognitive Observatory — forgetting-horizon choreography pass.
|
||||
*
|
||||
* Compute-only FramePass (no render(): nodes and ribbons draw via the
|
||||
* NodeRenderer's existing pipelines). Uploads the per-node packed horizon word
|
||||
* once (static), then each frame extends the choreography INTO the NodeState
|
||||
* demo lanes as a pure function of (frame, role, rank) — no stateful
|
||||
* integration, so capture mode (?frame=N) works with zero special-casing.
|
||||
*
|
||||
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
|
||||
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
|
||||
* the upload $effect creates ForgettingRenderer) so forgetting_choreo encodes
|
||||
* AFTER recall_sim in the same encoder and its demo-lane overwrite wins.
|
||||
* recall_sim rewrites demo.x every frame with an afterglow window of
|
||||
* bf+40..bf+200 — the k=2 rescue ribbon at bf=438 would otherwise carry
|
||||
* residual ignition into the master release.
|
||||
*
|
||||
* Three independent walls keep OTHER demos pixel-identical:
|
||||
* (a) the route constructs this renderer only in the forgetting-horizon branch,
|
||||
* (b) compute() gates on params[9] === 3 ('forgetting-horizon' demo index),
|
||||
* (c) the demo-3 vertex/fragment terms in render-nodes.wgsl are themselves
|
||||
* gated on params.demo_id == 3.0.
|
||||
*/
|
||||
|
||||
import type { ObservatoryEngine, FramePass } from './engine';
|
||||
import type { NodeRenderer } from './node-renderer';
|
||||
import type { ForgettingPlan } from './forgetting-plan';
|
||||
import { forgettingWGSL } from './shaders/forgetting.wgsl';
|
||||
|
||||
/** DEMO_MODES.indexOf('forgetting-horizon') — types.ts, verified index 3. */
|
||||
const HORIZON_DEMO_ID = 3;
|
||||
|
||||
export interface ForgettingRendererOptions {
|
||||
engine: ObservatoryEngine;
|
||||
nodeRenderer: NodeRenderer;
|
||||
plan: ForgettingPlan;
|
||||
}
|
||||
|
||||
export class ForgettingRenderer implements FramePass {
|
||||
private engine: ObservatoryEngine;
|
||||
private nodeRenderer: NodeRenderer;
|
||||
private plan: ForgettingPlan;
|
||||
|
||||
private pipeline: GPUComputePipeline | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private horizonBuffer: GPUBuffer | null = null;
|
||||
|
||||
constructor(opts: ForgettingRendererOptions) {
|
||||
this.engine = opts.engine;
|
||||
this.nodeRenderer = opts.nodeRenderer;
|
||||
this.plan = opts.plan;
|
||||
this.engine.addPass(this);
|
||||
}
|
||||
|
||||
/** Create the horizon buffer + compute pipeline. Call after NodeRenderer.upload(). */
|
||||
upload(): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer) return;
|
||||
if (!this.plan.viable) return;
|
||||
if (!this.nodeRenderer.nodeStateBuffer || this.nodeRenderer.nodeCountValue === 0) return;
|
||||
|
||||
this.horizonBuffer?.destroy();
|
||||
this.horizonBuffer = device.createBuffer({
|
||||
label: 'observatory-forgetting-horizon',
|
||||
size: Math.max(4, this.plan.horizonData.byteLength),
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.horizonBuffer, 0, this.plan.horizonData.buffer as ArrayBuffer);
|
||||
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-forgetting-choreo',
|
||||
code: forgettingWGSL
|
||||
});
|
||||
this.pipeline = device.createComputePipeline({
|
||||
label: 'observatory-forgetting-choreo',
|
||||
layout: 'auto',
|
||||
compute: { module, entryPoint: 'forgetting_choreo' }
|
||||
});
|
||||
|
||||
// EXACTLY the 3 declared bindings — auto layout strips unused bindings
|
||||
// and binding anything extra invalidates the group (the BirthRenderer
|
||||
// lesson, birth-renderer.ts createComputePipeline).
|
||||
this.bindGroup = device.createBindGroup({
|
||||
label: 'observatory-forgetting-bind',
|
||||
layout: this.pipeline.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.nodeRenderer.nodeStateBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.horizonBuffer } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
|
||||
compute(encoder: GPUCommandEncoder): void {
|
||||
if (this.engine.params[9] !== HORIZON_DEMO_ID) return;
|
||||
if (!this.pipeline || !this.bindGroup) return;
|
||||
const n = this.nodeRenderer.nodeCountValue;
|
||||
if (n === 0) return;
|
||||
|
||||
const pass = encoder.beginComputePass({ label: 'observatory-forgetting-choreo' });
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.dispatchWorkgroups(Math.ceil(n / 64));
|
||||
pass.end();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.horizonBuffer?.destroy();
|
||||
this.horizonBuffer = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroup = null;
|
||||
}
|
||||
}
|
||||
143
apps/dashboard/src/lib/observatory/graph-upload.ts
Normal file
143
apps/dashboard/src/lib/observatory/graph-upload.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Cognitive Observatory — graph → GPU upload preparation.
|
||||
*
|
||||
* Converts the API GraphResponse into stable-indexed typed arrays matching the
|
||||
* NodeState buffer layout (types.ts). Pure functions, fully unit-testable.
|
||||
*
|
||||
* Determinism contract (spec §6): node ordering is stable (sorted by id, center
|
||||
* first), positions come from deterministicSpherePosition seeded by the
|
||||
* DemoClock PRNG — same seed → identical layout, different seed → different.
|
||||
*
|
||||
* Visual DNA §7.1 — meaning layer: base color = FSRS state color from the real
|
||||
* dashboard palette (lib/graph/nodes.ts, used verbatim). Aha/failure/confusion
|
||||
* tags override, exactly like the Graph3D 'ahagraph' mode.
|
||||
*/
|
||||
|
||||
import type { GraphResponse } from '$types';
|
||||
import { getMemoryState, getAhaGraphColor, MEMORY_STATE_COLORS } from '$lib/graph/nodes';
|
||||
import {
|
||||
FLOATS_PER_NODE,
|
||||
NODE_LANE,
|
||||
NODE_FLAG,
|
||||
UINTS_PER_EDGE,
|
||||
toObservatoryNode,
|
||||
type ObservatoryGraph,
|
||||
type ObservatoryNode,
|
||||
type ObservatoryEdge
|
||||
} from './types';
|
||||
import { deterministicSpherePosition } from './demo-clock';
|
||||
|
||||
/** Parse '#rrggbb' to 0..1 rgb. Falls back to slate on malformed input. */
|
||||
export function hexToRgb01(hex: string): [number, number, number] {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||
if (!m) return [0x6b / 255, 0x72 / 255, 0x80 / 255]; // unavailable slate
|
||||
const v = parseInt(m[1], 16);
|
||||
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meaning-layer base color for a node (visual DNA §7.1).
|
||||
* Tag kinds override via the REAL Graph3D mapping (getAhaGraphColor — case-
|
||||
* insensitive, aha gold → confusion red → failure/guardrail grey), else the
|
||||
* FSRS state palette. Reused verbatim so Observatory and Graph3D never drift.
|
||||
*/
|
||||
export function nodeBaseColor(node: ObservatoryNode): [number, number, number] {
|
||||
const tagColor = getAhaGraphColor({ tags: node.tags });
|
||||
if (tagColor) return hexToRgb01(tagColor);
|
||||
return hexToRgb01(MEMORY_STATE_COLORS[getMemoryState(node.retention)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable-indexed observatory graph.
|
||||
* Ordering: center node first (index 0), then remaining nodes sorted by id —
|
||||
* deterministic regardless of API response order.
|
||||
*/
|
||||
export function buildObservatoryGraph(response: GraphResponse): ObservatoryGraph {
|
||||
const sorted = [...response.nodes].sort((a, b) => {
|
||||
if (a.isCenter !== b.isCenter) return a.isCenter ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
|
||||
const nodes: ObservatoryNode[] = sorted.map((n, i) => toObservatoryNode(n, i));
|
||||
const indexById = new Map<string, number>();
|
||||
for (const n of nodes) indexById.set(n.id, n.index);
|
||||
|
||||
const edges: ObservatoryEdge[] = [];
|
||||
for (const e of response.edges) {
|
||||
const s = indexById.get(e.source);
|
||||
const t = indexById.get(e.target);
|
||||
if (s === undefined || t === undefined || s === t) continue;
|
||||
edges.push({ sourceIndex: s, targetIndex: t, weight: e.weight, type: e.type });
|
||||
}
|
||||
|
||||
const centerIndex = nodes.findIndex((n) => n.isCenter);
|
||||
return { nodes, edges, indexById, centerIndex: centerIndex < 0 ? 0 : centerIndex };
|
||||
}
|
||||
|
||||
export interface NodeStateBuild {
|
||||
/** FLOATS_PER_NODE floats per node, ready for the storage buffer. */
|
||||
data: Float32Array<ArrayBuffer>;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the NodeState array. `rng` must come from a fresh DemoClock seeded with
|
||||
* the demo seed so the layout is reproducible.
|
||||
*/
|
||||
export function buildNodeStateArray(
|
||||
graph: ObservatoryGraph,
|
||||
rng: () => number,
|
||||
fieldRadius = 120
|
||||
): NodeStateBuild {
|
||||
const n = graph.nodes.length;
|
||||
const data = new Float32Array(n * FLOATS_PER_NODE);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const node = graph.nodes[i];
|
||||
const base = i * FLOATS_PER_NODE;
|
||||
|
||||
// lane 0: position + visual radius
|
||||
const [x, y, z] =
|
||||
node.isCenter && graph.centerIndex === i
|
||||
? [0, 0, 0] // the center memory anchors the field
|
||||
: deterministicSpherePosition(i, n, fieldRadius, rng);
|
||||
// radius: retention-weighted, center node reads largest (meaning layer)
|
||||
const radius = node.isCenter ? 4.2 : 1.4 + node.retention * 1.8;
|
||||
data[base + NODE_LANE.posRadius + 0] = x;
|
||||
data[base + NODE_LANE.posRadius + 1] = y;
|
||||
data[base + NODE_LANE.posRadius + 2] = z;
|
||||
data[base + NODE_LANE.posRadius + 3] = radius;
|
||||
|
||||
// lane 1: velocity (rest) + retention
|
||||
data[base + NODE_LANE.velRetention + 3] = node.retention;
|
||||
|
||||
// lane 2: base color + packed flags
|
||||
const [r, g, b] = nodeBaseColor(node);
|
||||
let flags = 0;
|
||||
if (node.isCenter) flags |= NODE_FLAG.isCenter;
|
||||
if (node.suppressed) flags |= NODE_FLAG.suppressed;
|
||||
// case-insensitive, mirroring getAhaGraphColor's tag semantics
|
||||
const tags = new Set(node.tags.map((t) => t.toLowerCase()));
|
||||
if (tags.has('aha')) flags |= NODE_FLAG.isAha;
|
||||
if (tags.has('failure') || tags.has('guardrail')) flags |= NODE_FLAG.isFailure;
|
||||
if (tags.has('confusion') || tags.has('weak-spot')) flags |= NODE_FLAG.isConfusion;
|
||||
data[base + NODE_LANE.colorFlags + 0] = r;
|
||||
data[base + NODE_LANE.colorFlags + 1] = g;
|
||||
data[base + NODE_LANE.colorFlags + 2] = b;
|
||||
data[base + NODE_LANE.colorFlags + 3] = flags;
|
||||
|
||||
// lane 3: demo activations start at zero
|
||||
}
|
||||
|
||||
return { data, nodeCount: n };
|
||||
}
|
||||
|
||||
/** array<vec2<u32>> edge index buffer. */
|
||||
export function buildEdgeIndexArray(graph: ObservatoryGraph): Uint32Array<ArrayBuffer> {
|
||||
const data = new Uint32Array(Math.max(1, graph.edges.length) * UINTS_PER_EDGE);
|
||||
graph.edges.forEach((e, i) => {
|
||||
data[i * UINTS_PER_EDGE] = e.sourceIndex;
|
||||
data[i * UINTS_PER_EDGE + 1] = e.targetIndex;
|
||||
});
|
||||
return data;
|
||||
}
|
||||
338
apps/dashboard/src/lib/observatory/node-renderer.ts
Normal file
338
apps/dashboard/src/lib/observatory/node-renderer.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* Cognitive Observatory — instanced node renderer (Increment 4).
|
||||
*
|
||||
* Owns the NodeState/EdgeIndex GPU buffers and the additive billboard
|
||||
* pipeline. Registers as a FramePass on the engine: camera uniforms are
|
||||
* written from the deterministic loop phase each frame (orbit), nodes draw
|
||||
* as instanced soft-glow sprites straight from the storage buffer.
|
||||
*
|
||||
* No GPU readback, no wall-clock state (spec §6).
|
||||
*/
|
||||
|
||||
import type { GraphResponse } from '$types';
|
||||
import type { ObservatoryEngine, FramePass } from './engine';
|
||||
import { DemoClock } from './demo-clock';
|
||||
import { orbitCamera } from './camera';
|
||||
import {
|
||||
buildObservatoryGraph,
|
||||
buildNodeStateArray,
|
||||
buildEdgeIndexArray
|
||||
} from './graph-upload';
|
||||
import type { ObservatoryGraph } from './types';
|
||||
import { renderNodesWGSL } from './shaders/render-nodes.wgsl';
|
||||
import { simulateWGSL } from './shaders/simulate.wgsl';
|
||||
import { renderPathWGSL } from './shaders/render-path.wgsl';
|
||||
import { buildRecallPath, type PathStepMeta } from './path-builder';
|
||||
|
||||
/** mat4 (16) + right vec4 (4) + up vec4 (4) floats. */
|
||||
const CAMERA_FLOATS = 24;
|
||||
|
||||
/** Orbit distance fitted to the default field radius (graph-upload). */
|
||||
const ORBIT_DISTANCE = 300;
|
||||
|
||||
export class NodeRenderer implements FramePass {
|
||||
private engine: ObservatoryEngine;
|
||||
private pipeline: GPURenderPipeline | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private cameraBuffer: GPUBuffer | null = null;
|
||||
private nodeBuffer: GPUBuffer | null = null;
|
||||
private edgeBuffer: GPUBuffer | null = null;
|
||||
private cameraData = new Float32Array(CAMERA_FLOATS);
|
||||
private nodeCount = 0;
|
||||
|
||||
// Recall-path simulation (Increment 5)
|
||||
private simPipeline: GPUComputePipeline | null = null;
|
||||
private simBindGroup: GPUBindGroup | null = null;
|
||||
private pathBuffer: GPUBuffer | null = null;
|
||||
|
||||
// Path edge wavefront (Increment 6)
|
||||
private pathPipeline: GPURenderPipeline | null = null;
|
||||
private pathBindGroup: GPUBindGroup | null = null;
|
||||
private pathStepCount = 0;
|
||||
|
||||
graph: ObservatoryGraph | null = null;
|
||||
/** Beat metadata for the timeline spine overlay (Increment 6). */
|
||||
pathSteps: PathStepMeta[] = [];
|
||||
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.engine = engine;
|
||||
engine.addPass(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the graph into GPU buffers. Layout is deterministic: positions
|
||||
* come from a fresh DemoClock PRNG seeded with `seed` (same seed →
|
||||
* identical field, Increment 4 gate).
|
||||
*/
|
||||
upload(response: GraphResponse, seed: string, opts?: { recallPath?: boolean }): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device) return;
|
||||
// Other demo modes (engram-birth, …) bring their own choreography and
|
||||
// upload with recallPath: false so the recall wave stays quiet.
|
||||
const includeRecallPath = opts?.recallPath ?? true;
|
||||
|
||||
const graph = buildObservatoryGraph(response);
|
||||
this.graph = graph;
|
||||
|
||||
const layoutClock = new DemoClock({ seed });
|
||||
const { data, nodeCount } = buildNodeStateArray(graph, layoutClock.state.rng);
|
||||
this.nodeCount = nodeCount;
|
||||
|
||||
this.nodeBuffer?.destroy();
|
||||
this.nodeBuffer = device.createBuffer({
|
||||
label: 'observatory-node-state',
|
||||
size: Math.max(data.byteLength, 64),
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX
|
||||
});
|
||||
device.queue.writeBuffer(this.nodeBuffer, 0, data.buffer as ArrayBuffer);
|
||||
|
||||
const edgeData = buildEdgeIndexArray(graph);
|
||||
this.edgeBuffer?.destroy();
|
||||
this.edgeBuffer = device.createBuffer({
|
||||
label: 'observatory-edge-index',
|
||||
size: edgeData.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.edgeBuffer, 0, edgeData.buffer as ArrayBuffer);
|
||||
|
||||
// Recall path: deterministic story beats → PathStep storage buffer.
|
||||
// (Skipped for demo modes with their own choreography — the buffer is
|
||||
// still created so the sim bind group stays valid, just with 0 steps.)
|
||||
const recall = includeRecallPath
|
||||
? buildRecallPath(response, graph)
|
||||
: { steps: [], data: new Uint32Array(4) };
|
||||
this.pathSteps = recall.steps;
|
||||
this.pathBuffer?.destroy();
|
||||
this.pathBuffer = device.createBuffer({
|
||||
label: 'observatory-path-steps',
|
||||
size: recall.data.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.pathBuffer, 0, recall.data.buffer as ArrayBuffer);
|
||||
this.pathStepCount = this.pathSteps.length;
|
||||
|
||||
// Per-frame counts for every shader that reads Params.
|
||||
this.engine.params[2] = nodeCount;
|
||||
this.engine.params[3] = graph.edges.length;
|
||||
this.engine.params[4] = this.pathSteps.length;
|
||||
|
||||
if (!this.cameraBuffer) {
|
||||
this.cameraBuffer = device.createBuffer({
|
||||
label: 'observatory-camera',
|
||||
size: this.cameraData.byteLength,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
}
|
||||
|
||||
this.createPipeline(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the PathStep buffer after upload (Moment B: the birth engrave
|
||||
* steps ride the same wavefront machinery as recall). Rebuilds the
|
||||
* pipelines/bind groups so they reference the new buffer.
|
||||
*/
|
||||
setPathSteps(data: Uint32Array<ArrayBuffer>, steps: PathStepMeta[]): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device) return;
|
||||
this.pathSteps = steps;
|
||||
this.pathStepCount = steps.length;
|
||||
this.pathBuffer?.destroy();
|
||||
this.pathBuffer = device.createBuffer({
|
||||
label: 'observatory-path-steps',
|
||||
size: Math.max(data.byteLength, 16),
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.pathBuffer, 0, data.buffer as ArrayBuffer);
|
||||
this.engine.params[4] = steps.length;
|
||||
this.createPipeline(device);
|
||||
}
|
||||
|
||||
private createPipeline(device: GPUDevice): void {
|
||||
if (!this.engine.paramsBuffer || !this.cameraBuffer || !this.nodeBuffer) return;
|
||||
|
||||
// Recall-path simulation pipeline (compute-boids pattern, §1).
|
||||
if (this.pathBuffer) {
|
||||
const simModule = device.createShaderModule({
|
||||
label: 'observatory-simulate',
|
||||
code: simulateWGSL
|
||||
});
|
||||
this.simPipeline = device.createComputePipeline({
|
||||
label: 'observatory-recall-sim',
|
||||
layout: 'auto',
|
||||
compute: { module: simModule, entryPoint: 'recall_sim' }
|
||||
});
|
||||
const simEntries: GPUBindGroupEntry[] = [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.nodeBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.pathBuffer } }
|
||||
];
|
||||
if (this.edgeBuffer) {
|
||||
simEntries.push({ binding: 3, resource: { buffer: this.edgeBuffer } });
|
||||
}
|
||||
this.simBindGroup = device.createBindGroup({
|
||||
label: 'observatory-recall-sim-bind',
|
||||
layout: this.simPipeline.getBindGroupLayout(0),
|
||||
entries: simEntries
|
||||
});
|
||||
}
|
||||
|
||||
const module = device.createShaderModule({
|
||||
label: 'observatory-render-nodes',
|
||||
code: renderNodesWGSL
|
||||
});
|
||||
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
label: 'observatory-nodes',
|
||||
layout: 'auto',
|
||||
vertex: { module, entryPoint: 'vs_main' },
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [
|
||||
{
|
||||
format: this.engine.sceneFormat,
|
||||
// Additive: light accumulates on the void (§7.2).
|
||||
blend: {
|
||||
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
|
||||
this.bindGroup = device.createBindGroup({
|
||||
label: 'observatory-nodes-bind',
|
||||
layout: this.pipeline.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.cameraBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.nodeBuffer } }
|
||||
]
|
||||
});
|
||||
|
||||
// Path wavefront ribbons (Increment 6) — drawn after nodes, additive.
|
||||
if (this.pathBuffer) {
|
||||
const pathModule = device.createShaderModule({
|
||||
label: 'observatory-render-path',
|
||||
code: renderPathWGSL
|
||||
});
|
||||
this.pathPipeline = device.createRenderPipeline({
|
||||
label: 'observatory-path',
|
||||
layout: 'auto',
|
||||
vertex: { module: pathModule, entryPoint: 'vs_main' },
|
||||
fragment: {
|
||||
module: pathModule,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [
|
||||
{
|
||||
format: this.engine.sceneFormat,
|
||||
blend: {
|
||||
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
this.pathBindGroup = device.createBindGroup({
|
||||
label: 'observatory-path-bind',
|
||||
layout: this.pathPipeline.getBindGroupLayout(0),
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.cameraBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.nodeBuffer } },
|
||||
{ binding: 3, resource: { buffer: this.pathBuffer } }
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FramePass — write the deterministic orbit camera, then run the
|
||||
* recall-path simulation for this frame (compute before render).
|
||||
*/
|
||||
compute(encoder: GPUCommandEncoder): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.cameraBuffer) return;
|
||||
|
||||
const w = this.engine.params[6] || 1;
|
||||
const h = this.engine.params[7] || 1;
|
||||
const phase = this.engine.params[1];
|
||||
|
||||
const cam = orbitCamera(phase, w / h, ORBIT_DISTANCE);
|
||||
this.cameraData.set(cam.viewProj, 0);
|
||||
this.cameraData[16] = cam.right[0];
|
||||
this.cameraData[17] = cam.right[1];
|
||||
this.cameraData[18] = cam.right[2];
|
||||
this.cameraData[19] = 0;
|
||||
this.cameraData[20] = cam.up[0];
|
||||
this.cameraData[21] = cam.up[1];
|
||||
this.cameraData[22] = cam.up[2];
|
||||
this.cameraData[23] = 0;
|
||||
device.queue.writeBuffer(this.cameraBuffer, 0, this.cameraData);
|
||||
|
||||
if (this.simPipeline && this.simBindGroup && this.nodeCount > 0) {
|
||||
const pass = encoder.beginComputePass({ label: 'observatory-recall-sim' });
|
||||
pass.setPipeline(this.simPipeline);
|
||||
pass.setBindGroup(0, this.simBindGroup);
|
||||
pass.dispatchWorkgroups(Math.ceil(this.nodeCount / 64));
|
||||
pass.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** FramePass — instanced additive draws: nodes, then path ribbons on top. */
|
||||
render(pass: GPURenderPassEncoder): void {
|
||||
if (!this.pipeline || !this.bindGroup || this.nodeCount === 0) return;
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.draw(6, this.nodeCount);
|
||||
|
||||
if (this.pathPipeline && this.pathBindGroup && this.pathStepCount > 0) {
|
||||
pass.setPipeline(this.pathPipeline);
|
||||
pass.setBindGroup(0, this.pathBindGroup);
|
||||
pass.draw(6, this.pathStepCount);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only handles for BirthRenderer (Moment B, Task B2).
|
||||
// No behavior change — these expose existing buffers for other passes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get nodeStateBuffer(): GPUBuffer | null {
|
||||
return this.nodeBuffer;
|
||||
}
|
||||
|
||||
get cameraUniformBuffer(): GPUBuffer | null {
|
||||
return this.cameraBuffer;
|
||||
}
|
||||
|
||||
get nodeCountValue(): number {
|
||||
return this.nodeCount;
|
||||
}
|
||||
|
||||
get pathStepMeta(): PathStepMeta[] {
|
||||
return this.pathSteps;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.nodeBuffer?.destroy();
|
||||
this.edgeBuffer?.destroy();
|
||||
this.cameraBuffer?.destroy();
|
||||
this.pathBuffer?.destroy();
|
||||
this.nodeBuffer = null;
|
||||
this.edgeBuffer = null;
|
||||
this.cameraBuffer = null;
|
||||
this.pathBuffer = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroup = null;
|
||||
this.simPipeline = null;
|
||||
this.simBindGroup = null;
|
||||
this.pathPipeline = null;
|
||||
this.pathBindGroup = null;
|
||||
}
|
||||
}
|
||||
114
apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte
Normal file
114
apps/dashboard/src/lib/observatory/overlays/RescueVerdict.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Demo verdict card — DOM instrument overlay (§7.3 grammar, ported from
|
||||
* docs/launch/causal-brain-demo.html's .verdict).
|
||||
*
|
||||
* Opacity is a PURE function of the frame prop (smoothstep in TS, NO CSS
|
||||
* transitions) so capture mode (?frame=N) renders the exact same card at
|
||||
* the exact same alpha every time. Defaults preserve the salience-rescue
|
||||
* card byte-for-byte (window 600 → 660, triumph tone adds no class);
|
||||
* firewall reuses it with tone="quarantine" + fadeWindow 480/495/605/620.
|
||||
*/
|
||||
|
||||
/** Structural shape — RescueVerdictCopy and FirewallPlan copy both assign. */
|
||||
interface VerdictContent {
|
||||
headline: string;
|
||||
causeLabel: string;
|
||||
receipt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
frame?: number;
|
||||
verdict: VerdictContent;
|
||||
/** [fadeInStart, fadeInEnd, fadeOutStart, fadeOutEnd] loop frames. */
|
||||
fadeWindow?: [number, number, number, number];
|
||||
tone?: 'triumph' | 'quarantine';
|
||||
}
|
||||
|
||||
let {
|
||||
frame = 0,
|
||||
verdict,
|
||||
fadeWindow = [600, 615, 645, 660],
|
||||
tone = 'triumph'
|
||||
}: Props = $props();
|
||||
|
||||
const ss = (a: number, b: number, f: number): number => {
|
||||
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
let opacity = $derived(
|
||||
ss(fadeWindow[0], fadeWindow[1], frame) * (1 - ss(fadeWindow[2], fadeWindow[3], frame))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if opacity > 0.001}
|
||||
<div class="verdict" class:quarantine={tone === 'quarantine'} style="opacity: {opacity}">
|
||||
<div class="k">{verdict.headline}</div>
|
||||
<div class="v">{verdict.causeLabel}</div>
|
||||
<div class="s">{verdict.receipt}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.verdict {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
padding: clamp(18px, 3vw, 40px) clamp(28px, 6vw, 80px);
|
||||
border-radius: 20px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(5, 7, 14, 0.86) 0%,
|
||||
rgba(5, 7, 14, 0.72) 60%,
|
||||
rgba(5, 7, 14, 0) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: clamp(13px, 1.8vw, 18px);
|
||||
color: #9fd0e4;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: clamp(32px, 6.4vw, 72px);
|
||||
font-weight: 600;
|
||||
margin-top: 0.12em;
|
||||
line-height: 1.05;
|
||||
background: linear-gradient(90deg, #7fe6c0, #6ef0e6, #a6dcff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 26px rgba(110, 240, 220, 0.45));
|
||||
}
|
||||
|
||||
.s {
|
||||
font-size: clamp(11px, 1.5vw, 15px);
|
||||
color: #8fb0be;
|
||||
margin-top: 0.6em;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Quarantine tone (firewall verdict) — crimson-ember palette. Triumph adds
|
||||
no class, so the rescue card's DOM + styles stay byte-identical. */
|
||||
.quarantine .k {
|
||||
color: #ffb0a6;
|
||||
}
|
||||
|
||||
.quarantine .v {
|
||||
background: linear-gradient(90deg, #ff6a5e, #ff9d6b, #ffd2a8);
|
||||
filter: drop-shadow(0 0 26px rgba(255, 90, 70, 0.45));
|
||||
}
|
||||
|
||||
.quarantine .s {
|
||||
color: #d9a49a;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Telemetry strip — top instrument overlay (§7.3).
|
||||
*
|
||||
* A floating readout, not a chrome bar: the wrapper is pointer-events-none
|
||||
* so the memory field stays interactive everywhere; only the [url] copy
|
||||
* button opts back in. Copies the FULL shareable demo URL including the
|
||||
* capture frame when one is pinned.
|
||||
*/
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface Props {
|
||||
demoMode?: string;
|
||||
seed?: string;
|
||||
nodeCount?: number;
|
||||
edgeCount?: number;
|
||||
centerId?: string;
|
||||
frameCount?: number;
|
||||
fpsEstimate?: number;
|
||||
freezeFrame?: number | null;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
demoMode = 'recall-path',
|
||||
seed = 'vestige-observatory-v1',
|
||||
nodeCount = 0,
|
||||
edgeCount = 0,
|
||||
centerId = '',
|
||||
frameCount = 0,
|
||||
fpsEstimate = 0,
|
||||
freezeFrame = null,
|
||||
loading = false,
|
||||
error = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Full shareable URL — origin + base + canonical params (+ pinned frame).
|
||||
function copyDemoUrl() {
|
||||
const q = new URLSearchParams({ demo: demoMode, seed });
|
||||
if (freezeFrame !== null) q.set('frame', String(freezeFrame));
|
||||
const url = `${window.location.origin}${base}/observatory?${q.toString()}`;
|
||||
navigator.clipboard.writeText(url).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Floating instrument readout — never a solid chrome bar (§7.3).
|
||||
Every span is nowrap + tabular-nums so live counters never garble or
|
||||
reflow; lower-priority readouts shed below sm/md instead of wrapping. -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-20 pointer-events-none"
|
||||
style="padding-top: env(safe-area-inset-top);"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 px-4 py-2 bg-gradient-to-b from-[#05060a]/85 to-transparent font-mono text-xs [font-variant-numeric:tabular-nums]"
|
||||
>
|
||||
<!-- Left: demo mode + seed (seed sheds below md; mode ellipsizes,
|
||||
never overlaps the right cluster at extreme widths) -->
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<span class="text-[#5dcaa5] tracking-widest uppercase truncate">
|
||||
{demoMode}
|
||||
</span>
|
||||
<span class="hidden md:inline text-[#ffffff]/[0.5] whitespace-nowrap">
|
||||
seed={seed.slice(0, 12)}{seed.length > 12 ? '…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Center: node/edge counts (center id sheds below lg) -->
|
||||
<div class="hidden sm:flex items-center gap-4">
|
||||
<span class="text-[#ffffff]/[0.55] whitespace-nowrap">
|
||||
{nodeCount} nodes · {edgeCount} edges
|
||||
</span>
|
||||
{#if centerId}
|
||||
<span class="hidden lg:inline text-[#ffffff]/[0.5] whitespace-nowrap">
|
||||
center={centerId.slice(0, 8)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: frame, fps, controls — frame padded so the counter never jitters -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[#ffffff]/[0.55] whitespace-nowrap"
|
||||
>frame: {String(frameCount).padStart(3, ' ')}</span
|
||||
>
|
||||
{#if freezeFrame !== null}
|
||||
<span class="text-[#a6dcff] tracking-widest whitespace-nowrap">CAPTURE</span>
|
||||
{:else if fpsEstimate > 0}
|
||||
<span class="text-[#5dcaa5] whitespace-nowrap w-[6ch] text-right">
|
||||
{fpsEstimate}fps
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="text-[#ffffff]/[0.5] hover:text-[#5dcaa5] transition-colors cursor-pointer pointer-events-auto whitespace-nowrap"
|
||||
onclick={copyDemoUrl}
|
||||
title="Copy shareable demo URL"
|
||||
>
|
||||
[url]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- loading/error props are accepted for API parity; the route renders those states itself -->
|
||||
|
||||
128
apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte
Normal file
128
apps/dashboard/src/lib/observatory/overlays/TimelineSpine.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Timeline spine — bottom instrument (Increment 6).
|
||||
*
|
||||
* A 720-frame loop track: one tick per story beat (positioned at
|
||||
* beatFrame/loopFrames), a playhead riding the live frame, and the active
|
||||
* beat's label glowing as the wavefront lands. Pointer-events: none —
|
||||
* pure instrument, never a control surface (§7.3).
|
||||
*/
|
||||
import type { PathStepMeta } from '$lib/observatory/path-builder';
|
||||
|
||||
interface Props {
|
||||
steps?: PathStepMeta[];
|
||||
frame?: number;
|
||||
loopFrames?: number;
|
||||
}
|
||||
|
||||
let { steps = [], frame = 0, loopFrames = 720 }: Props = $props();
|
||||
|
||||
const pct = (f: number) => (f / loopFrames) * 100;
|
||||
|
||||
// A beat is "live" from just before arrival through its afterglow.
|
||||
function beatEnergy(beatFrame: number, f: number): number {
|
||||
const d = f - beatFrame;
|
||||
if (d < -14 || d > 90) return 0;
|
||||
if (d < 0) return 1 + d / 14; // attack
|
||||
return 1 - d / 90; // decay
|
||||
}
|
||||
|
||||
let activeLabel = $derived.by(() => {
|
||||
let best = '';
|
||||
let bestE = 0.15;
|
||||
for (const s of steps) {
|
||||
const e = beatEnergy(s.beatFrame, frame);
|
||||
if (e > bestE) {
|
||||
bestE = e;
|
||||
best = s.label;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if steps.length > 0}
|
||||
<div class="spine">
|
||||
{#if activeLabel}
|
||||
<div class="active-label">{activeLabel}</div>
|
||||
{/if}
|
||||
<div class="track">
|
||||
{#each steps as s (s.beatFrame)}
|
||||
<div
|
||||
class="tick"
|
||||
class:hot={beatEnergy(s.beatFrame, frame) > 0}
|
||||
class:backward={s.kind === 1}
|
||||
style="left: {pct(s.beatFrame)}%; opacity: {0.45 +
|
||||
0.55 * beatEnergy(s.beatFrame, frame)}"
|
||||
title={s.label}
|
||||
></div>
|
||||
{/each}
|
||||
<div class="playhead" style="left: {pct(frame)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spine {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
/* Chromeless route: anchor to the true viewport bottom, clearing the
|
||||
home-indicator on notched phones (safe-area) — audit blocker B3. */
|
||||
bottom: calc(2.5rem + env(safe-area-inset-bottom, 0px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.active-label {
|
||||
text-align: center;
|
||||
margin-bottom: 0.6rem;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #cfe9ff;
|
||||
text-shadow: 0 0 24px rgba(30, 180, 255, 0.35);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 2px;
|
||||
background: #6ef0e6;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.tick.hot {
|
||||
box-shadow: 0 0 12px rgba(110, 240, 230, 0.75);
|
||||
}
|
||||
|
||||
.tick.backward {
|
||||
background: #ff4070;
|
||||
}
|
||||
|
||||
.tick.backward.hot {
|
||||
box-shadow: 0 0 12px rgba(255, 64, 112, 0.75);
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 1.5px;
|
||||
height: 16px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(207, 233, 255, 0.9);
|
||||
box-shadow: 0 0 10px rgba(30, 180, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
BIN
apps/dashboard/src/lib/observatory/path-builder.ts
Normal file
BIN
apps/dashboard/src/lib/observatory/path-builder.ts
Normal file
Binary file not shown.
34
apps/dashboard/src/lib/observatory/post/mip-plan.ts
Normal file
34
apps/dashboard/src/lib/observatory/post/mip-plan.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Cognitive Observatory — bloom mip-chain plan (S2).
|
||||
*
|
||||
* Pure, GPU-free math so vitest can pin the pyramid shape. Mip 0 is HALF the
|
||||
* swapchain resolution (the classic mip-bloom cost/quality point); the chain
|
||||
* stops before the smallest dimension would drop below 8px (degenerate
|
||||
* anisotropic mips smear) and is clamped to at most 6 levels.
|
||||
*
|
||||
* NOTE bloom RADIUS therefore varies with viewport size (more mips on bigger
|
||||
* canvases = wider glow). Brightness does NOT — the composite normalizes the
|
||||
* additive up-chain by textureNumLevels (post-chain.ts).
|
||||
*/
|
||||
|
||||
export interface BloomMipPlan {
|
||||
baseW: number;
|
||||
baseH: number;
|
||||
mipCount: number;
|
||||
sizes: Array<[number, number]>;
|
||||
}
|
||||
|
||||
export function planBloomMips(w: number, h: number): BloomMipPlan {
|
||||
const baseW = Math.max(1, w >> 1);
|
||||
const baseH = Math.max(1, h >> 1); // mip 0 = HALF res
|
||||
// min-dim ≥ 8px at the smallest mip (avoids degenerate anisotropic mips).
|
||||
const mipCount = Math.min(
|
||||
6,
|
||||
Math.max(1, 1 + Math.floor(Math.log2(Math.min(baseW, baseH) / 8)))
|
||||
);
|
||||
const sizes = Array.from({ length: mipCount }, (_, i): [number, number] => [
|
||||
Math.max(1, baseW >> i),
|
||||
Math.max(1, baseH >> i)
|
||||
]);
|
||||
return { baseW, baseH, mipCount, sizes };
|
||||
}
|
||||
340
apps/dashboard/src/lib/observatory/post/post-chain.ts
Normal file
340
apps/dashboard/src/lib/observatory/post/post-chain.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
285
apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts
Normal file
285
apps/dashboard/src/lib/observatory/post/shaders/post.wgsl.ts
Normal file
|
|
@ -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<uniform> params: Params; // composite only
|
||||
@group(0) @binding(1) var src: texture_2d<f32>; // blur chain input
|
||||
@group(0) @binding(2) var samp: sampler; // shared
|
||||
@group(0) @binding(3) var scene_tex: texture_2d<f32>; // composite only
|
||||
@group(0) @binding(4) var bloom_tex: texture_2d<f32>; // composite only (FULL-mip view)
|
||||
|
||||
struct FSOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
};
|
||||
|
||||
// Fullscreen triangle from bit math — no vertex buffer, no arrays.
|
||||
// vi 0/1/2 → clip (-1,-1) (3,-1) (-1,3); uv y flipped so uv(0,0) = top-left.
|
||||
@vertex
|
||||
fn vs_fullscreen(@builtin(vertex_index) vi: u32) -> FSOut {
|
||||
let xy = vec2f(f32((vi << 1u) & 2u), f32(vi & 2u)) * 2.0 - 1.0;
|
||||
var out: FSOut;
|
||||
out.pos = vec4f(xy, 0.0, 1.0);
|
||||
out.uv = vec2f(xy.x, -xy.y) * 0.5 + 0.5;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn luma(c: vec3f) -> f32 {
|
||||
return dot(c, vec3f(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloom downsample — 13-tap Jimenez (SIGGRAPH 2014 "Next Generation Post
|
||||
// Processing in Call of Duty: Advanced Warfare"), taps fully unrolled.
|
||||
//
|
||||
// a b c outer ring at ±2 texels
|
||||
// j k inner ring at ±1 texels
|
||||
// d e f e = center
|
||||
// l m
|
||||
// g h i
|
||||
//
|
||||
// Grouped as 5 overlapping 4-tap boxes: center box (the four inner taps)
|
||||
// weight 0.5, four corner boxes weight 0.125 each. A flat field reproduces
|
||||
// itself EXACTLY (0.5 + 4·0.125 = 1) — that exactness is what the void
|
||||
// preimage in tone-reference.ts depends on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@fragment
|
||||
fn fs_downsample_karis(in: FSOut) -> @location(0) vec4f {
|
||||
let ts = 1.0 / vec2f(textureDimensions(src));
|
||||
let a = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, -2.0) * ts, 0.0).rgb;
|
||||
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -2.0) * ts, 0.0).rgb;
|
||||
let c = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, -2.0) * ts, 0.0).rgb;
|
||||
let d = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 0.0) * ts, 0.0).rgb;
|
||||
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
|
||||
let f = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 0.0) * ts, 0.0).rgb;
|
||||
let g = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 2.0) * ts, 0.0).rgb;
|
||||
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 2.0) * ts, 0.0).rgb;
|
||||
let i = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 2.0) * ts, 0.0).rgb;
|
||||
let j = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
|
||||
let k = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
|
||||
let l = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
|
||||
let m = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
|
||||
|
||||
let box_c = (j + k + l + m) * 0.25;
|
||||
let box_tl = (a + b + d + e) * 0.25;
|
||||
let box_tr = (b + c + e + f) * 0.25;
|
||||
let box_bl = (d + e + g + h) * 0.25;
|
||||
let box_br = (e + f + h + i) * 0.25;
|
||||
|
||||
// Karis average (fireflies killer) — used ONLY on the full→mip0 hop.
|
||||
// Each box is additionally weighted 1/(1 + luma) and the sum RENORMALIZED:
|
||||
// on a flat field every Karis factor is equal, so the result is exact.
|
||||
let w_c = 0.5 / (1.0 + luma(box_c));
|
||||
let w_tl = 0.125 / (1.0 + luma(box_tl));
|
||||
let w_tr = 0.125 / (1.0 + luma(box_tr));
|
||||
let w_bl = 0.125 / (1.0 + luma(box_bl));
|
||||
let w_br = 0.125 / (1.0 + luma(box_br));
|
||||
let sum = w_c * box_c + w_tl * box_tl + w_tr * box_tr + w_bl * box_bl + w_br * box_br;
|
||||
return vec4f(sum / (w_c + w_tl + w_tr + w_bl + w_br), 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_downsample(in: FSOut) -> @location(0) vec4f {
|
||||
let ts = 1.0 / vec2f(textureDimensions(src));
|
||||
let a = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, -2.0) * ts, 0.0).rgb;
|
||||
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -2.0) * ts, 0.0).rgb;
|
||||
let c = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, -2.0) * ts, 0.0).rgb;
|
||||
let d = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 0.0) * ts, 0.0).rgb;
|
||||
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
|
||||
let f = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 0.0) * ts, 0.0).rgb;
|
||||
let g = textureSampleLevel(src, samp, in.uv + vec2f(-2.0, 2.0) * ts, 0.0).rgb;
|
||||
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 2.0) * ts, 0.0).rgb;
|
||||
let i = textureSampleLevel(src, samp, in.uv + vec2f( 2.0, 2.0) * ts, 0.0).rgb;
|
||||
let j = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
|
||||
let k = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
|
||||
let l = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
|
||||
let m = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
|
||||
|
||||
let box_c = (j + k + l + m) * 0.25;
|
||||
let box_tl = (a + b + d + e) * 0.25;
|
||||
let box_tr = (b + c + e + f) * 0.25;
|
||||
let box_bl = (d + e + g + h) * 0.25;
|
||||
let box_br = (e + f + h + i) * 0.25;
|
||||
return vec4f(box_c * 0.5 + (box_tl + box_tr + box_bl + box_br) * 0.125, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloom upsample — 9-tap 3×3 tent, 1/16·[1 2 1; 2 4 2; 1 2 1], radius = one
|
||||
// SOURCE-mip texel. Rendered with additive one/one blending onto the stored
|
||||
// downsample of the destination mip (accumulate-up-the-chain). The resulting
|
||||
// DC gain of exactly mipCount is normalized in fs_composite.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@fragment
|
||||
fn fs_upsample_tent(in: FSOut) -> @location(0) vec4f {
|
||||
let ts = 1.0 / vec2f(textureDimensions(src));
|
||||
let a = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, -1.0) * ts, 0.0).rgb;
|
||||
let b = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, -1.0) * ts, 0.0).rgb;
|
||||
let c = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, -1.0) * ts, 0.0).rgb;
|
||||
let d = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 0.0) * ts, 0.0).rgb;
|
||||
let e = textureSampleLevel(src, samp, in.uv, 0.0).rgb;
|
||||
let f = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 0.0) * ts, 0.0).rgb;
|
||||
let g = textureSampleLevel(src, samp, in.uv + vec2f(-1.0, 1.0) * ts, 0.0).rgb;
|
||||
let h = textureSampleLevel(src, samp, in.uv + vec2f( 0.0, 1.0) * ts, 0.0).rgb;
|
||||
let i = textureSampleLevel(src, samp, in.uv + vec2f( 1.0, 1.0) * ts, 0.0).rgb;
|
||||
let sum = (a + c + g + i) + (b + d + f + h) * 2.0 + e * 4.0;
|
||||
return vec4f(sum * (1.0 / 16.0), 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite — bloom-add → PBR Neutral → grain → vignette (order is mandated).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Khronos PBR Neutral — EXACT port of the Khronos reference implementation.
|
||||
// Hue-preserving; the FSRS palette keeps its channel ordering. Pinned to the
|
||||
// CPU mirror in post/tone-reference.ts (pbrNeutralReference) — keep in
|
||||
// lockstep, the void-preimage tests run against the mirror.
|
||||
fn pbr_neutral(color_in: vec3f) -> vec3f {
|
||||
let start_compression = 0.8 - 0.04;
|
||||
let desaturation = 0.15;
|
||||
var color = color_in;
|
||||
let x = min(color.r, min(color.g, color.b));
|
||||
// WGSL select(false_value, true_value, condition) — argument order trap.
|
||||
let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
|
||||
color = color - vec3f(offset);
|
||||
let peak = max(color.r, max(color.g, color.b));
|
||||
if (peak < start_compression) {
|
||||
return color;
|
||||
}
|
||||
let d = 1.0 - start_compression;
|
||||
let new_peak = 1.0 - d * d / (peak + d - start_compression);
|
||||
color = color * (new_peak / peak);
|
||||
let g = 1.0 / (desaturation * (peak - new_peak) + 1.0);
|
||||
// mix weight = 1 - g per the Khronos spec.
|
||||
return mix(color, vec3f(new_peak), 1.0 - g);
|
||||
}
|
||||
|
||||
// PCG hash — integers only, 24-bit-exact output in [0, 1). Deterministic.
|
||||
fn pcg(v: u32) -> u32 {
|
||||
var s = v * 747796405u + 2891336453u;
|
||||
let t = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
|
||||
return (t >> 22u) ^ t;
|
||||
}
|
||||
|
||||
fn hashf(p: vec2u, f: u32) -> f32 {
|
||||
return f32(pcg(p.x ^ pcg(p.y ^ pcg(f))) >> 8u) / 16777216.0;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_composite(in: FSOut) -> @location(0) vec4f {
|
||||
let pix = vec2u(in.pos.xy);
|
||||
// Exact 1:1 fetch (alpha discarded — see module header).
|
||||
let scene = textureLoad(scene_tex, pix, 0).rgb;
|
||||
|
||||
// Bloom, normalized by the mip count: the additive up-chain has DC gain
|
||||
// exactly mipCount, so /mips makes flat-field gain exactly 1 — the void
|
||||
// preimage holds and brightness is viewport-stable. Chromatic dispersion
|
||||
// rides the bloom term ONLY (BLOOM_CHROMATIC_TEXELS = 0.0 kills it).
|
||||
let mips = f32(textureNumLevels(bloom_tex));
|
||||
let dims = vec2f(textureDimensions(bloom_tex));
|
||||
let dvec = in.uv - vec2f(0.5);
|
||||
let off = dvec * (BLOOM_CHROMATIC_TEXELS * dot(dvec, dvec) * 4.0) / dims;
|
||||
let bloom = vec3f(
|
||||
textureSampleLevel(bloom_tex, samp, in.uv - off, 0.0).r,
|
||||
textureSampleLevel(bloom_tex, samp, in.uv, 0.0).g,
|
||||
textureSampleLevel(bloom_tex, samp, in.uv + off, 0.0).b
|
||||
) / mips;
|
||||
|
||||
var c = pbr_neutral(scene + BLOOM_STRENGTH * bloom);
|
||||
|
||||
// Seeded TPDF film grain (post-tonemap dither): keyed to the WRAPPED loop
|
||||
// frame → 720-periodic and capture-pinned. Full strength in the shadows
|
||||
// (kills #05060a banding), fades out of highlights.
|
||||
let f = u32(params.frame + 0.5);
|
||||
let n = hashf(pix, f) + hashf(pix ^ vec2u(0x9E3779B9u, 0x85EBCA6Bu), f) - 1.0;
|
||||
let w = 1.0 - smoothstep(0.0, 0.8, luma(c));
|
||||
c += GRAIN_AMP * n * w;
|
||||
|
||||
// cos⁴ vignette: cos⁴θ = (1 + r²·tan²)⁻², aspect-normalized so rn = 1.0
|
||||
// exactly at the corners regardless of viewport shape. Lifted floor keeps
|
||||
// it an observatory, not a tunnel.
|
||||
let ar = vec2f(params.viewport_w / max(params.viewport_h, 1.0), 1.0);
|
||||
let rn = length((in.uv * 2.0 - 1.0) * ar) / length(ar);
|
||||
let k = rn * rn * VIGNETTE_TAN * VIGNETTE_TAN;
|
||||
c *= mix(VIGNETTE_LIFT, 1.0, 1.0 / ((1.0 + k) * (1.0 + k)));
|
||||
|
||||
// NO gamma encode — display-referred pass-through, matching the pre-post
|
||||
// look where shader outputs went straight to the swapchain.
|
||||
return vec4f(c, 1.0);
|
||||
}
|
||||
`;
|
||||
75
apps/dashboard/src/lib/observatory/post/tone-reference.ts
Normal file
75
apps/dashboard/src/lib/observatory/post/tone-reference.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Cognitive Observatory — Khronos PBR Neutral tone mapping, CPU reference.
|
||||
*
|
||||
* Exact TS mirror of pbr_neutral() in post/shaders/post.wgsl.ts. vitest can't
|
||||
* run WGSL on a GPU, so all determinism-critical tonemap math is verified
|
||||
* against this mirror — keep the two in lockstep.
|
||||
*
|
||||
* IMPORTANT: PBR Neutral is NOT the identity below the compression knee. The
|
||||
* black-offset branch subtracts `offset` from EVERY pixel (0.04 when the min
|
||||
* channel is ≥ 0.08, else x − 6.25x²). A naive #05060a clear would therefore
|
||||
* tonemap to ≈ #010206 (crushed void). VOID_CLEAR_HDR below is the analytic
|
||||
* preimage that lands the post stack EXACTLY back on #05060a.
|
||||
*/
|
||||
|
||||
import { BLOOM_STRENGTH } from './post-chain';
|
||||
|
||||
/** Khronos PBR Neutral reference (https://github.com/KhronosGroup/ToneMapping). */
|
||||
export function pbrNeutralReference(
|
||||
rgb: readonly [number, number, number]
|
||||
): [number, number, number] {
|
||||
const startCompression = 0.8 - 0.04;
|
||||
const desaturation = 0.15;
|
||||
|
||||
let [r, g, b] = rgb;
|
||||
const x = Math.min(r, Math.min(g, b));
|
||||
const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
|
||||
r -= offset;
|
||||
g -= offset;
|
||||
b -= offset;
|
||||
|
||||
const peak = Math.max(r, Math.max(g, b));
|
||||
if (peak < startCompression) return [r, g, b];
|
||||
|
||||
const d = 1 - startCompression;
|
||||
const newPeak = 1 - (d * d) / (peak + d - startCompression);
|
||||
const scale = newPeak / peak;
|
||||
r *= scale;
|
||||
g *= scale;
|
||||
b *= scale;
|
||||
|
||||
const gMix = 1 / (desaturation * (peak - newPeak) + 1);
|
||||
// mix(color, vec3(newPeak), 1 - g) per the Khronos spec.
|
||||
const w = 1 - gMix;
|
||||
return [r + w * (newPeak - r), g + w * (newPeak - g), b + w * (newPeak - b)];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VOID_CLEAR_HDR — the HDR clear color whose post-stack output is #05060a.
|
||||
//
|
||||
// Derivation (verified by tone-reference.test.ts):
|
||||
// - The normalized bloom chain has flat-field gain exactly 1 (renormalized
|
||||
// Karis + exact box/tent weights + /mipCount in the composite), so a flat
|
||||
// void field enters the tonemap as (1 + BLOOM_STRENGTH) · v = 1.18 · v.
|
||||
// - Below the knee, out = in − offset with offset = x − 6.25x² (x = min
|
||||
// channel < 0.08), hence out_min = 6.25x². Solving out_min = 5/255:
|
||||
// x = sqrt((5/255)/6.25) = 0.05601120…
|
||||
// offset = x − 5/255 = 0.03640336…
|
||||
// g_in = 6/255 + offset; b_in = 10/255 + offset
|
||||
// peak = b_in ≈ 0.0756 < 0.76 → the compression branch is NOT taken.
|
||||
// - Divide by (1 + BLOOM_STRENGTH): pbrNeutral(1.18 · VOID_CLEAR_HDR) is
|
||||
// EXACTLY (5/255, 6/255, 10/255) = #05060a.
|
||||
// Literals: r ≈ 0.0474671, g ≈ 0.0507905, b ≈ 0.0640839.
|
||||
// - f16 quantization of the clear (ulp ≈ 3e-5 near 0.05) keeps the
|
||||
// tonemapped void well inside ±0.5/255 — verified safe.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VOID_R_IN = Math.sqrt(5 / 255 / 6.25); // 0.05601120…
|
||||
const VOID_OFFSET = VOID_R_IN - 5 / 255; // 0.03640336…
|
||||
|
||||
export const VOID_CLEAR_HDR: GPUColorDict = {
|
||||
r: VOID_R_IN / (1 + BLOOM_STRENGTH),
|
||||
g: (6 / 255 + VOID_OFFSET) / (1 + BLOOM_STRENGTH),
|
||||
b: (10 / 255 + VOID_OFFSET) / (1 + BLOOM_STRENGTH),
|
||||
a: 1
|
||||
};
|
||||
634
apps/dashboard/src/lib/observatory/rescue-plan.ts
Normal file
634
apps/dashboard/src/lib/observatory/rescue-plan.ts
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
/**
|
||||
* Cognitive Observatory — Retroactive Salience Backfill demo plan (salience-rescue).
|
||||
*
|
||||
* Pure CPU: given the API GraphResponse + the stable-indexed ObservatoryGraph +
|
||||
* the demo seed, DETERMINISTICALLY pick the failure memory, the K=4 lookalikes
|
||||
* (nearest in LAYOUT space — exactly what vector search would return), the true
|
||||
* cause (graph-distance ≥ 3, low retention, old), the BFS hop-depth map for the
|
||||
* backward wave, the PathSteps (probe beams, wave tree, causal arc), the spine
|
||||
* beats, and the verdict copy. No Math.random(), no Date.now() — Date.parse is
|
||||
* only applied to DATA timestamps.
|
||||
*
|
||||
* The 720-frame beat map (fixed 60Hz, seamless loop):
|
||||
* 0-90 field at rest
|
||||
* 90-120 DETONATION — failure flares crimson (demo.w)
|
||||
* 120-260 SEARCHLIGHT — K lookalikes flare cold-white sequentially (demo.y)
|
||||
* 260-520 BACKWARD WAVE — hop-by-hop interrogation away from the failure (demo.z)
|
||||
* 520-600 CAUSE IGNITES — thin-film recall envelope (demo.x) + causal arc
|
||||
* 600-660 VERDICT — DOM overlay
|
||||
* 660-720 decay to rest — every envelope is exactly 0 at frames 0 and 719
|
||||
*
|
||||
* `rescueEnvelopes` is the authoritative CPU mirror of shaders/rescue.wgsl.ts —
|
||||
* the seam-zero unit test machine-checks the loop guarantee against it.
|
||||
*/
|
||||
|
||||
import type { GraphResponse } from '$types';
|
||||
import { DemoClock } from './demo-clock';
|
||||
import { buildNodeStateArray } from './graph-upload';
|
||||
import { FLOATS_PER_NODE, PATH_KIND, type ObservatoryGraph } from './types';
|
||||
import type { PathStepMeta } from './path-builder';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beat-map constants (shared with shaders/rescue.wgsl.ts via RescueShaderConsts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RESCUE_K = 4;
|
||||
export const DETONATE_FRAME = 90;
|
||||
/** Lookalike k flares at LOOKALIKE_BASE + k * LOOKALIKE_INTERVAL → 138,166,194,222. */
|
||||
export const LOOKALIKE_BASE = 138;
|
||||
export const LOOKALIKE_INTERVAL = 28;
|
||||
export const WAVE_START = 260;
|
||||
export const WAVE_ARRIVAL_CAP = 514;
|
||||
export const ARC_FRAME = 560;
|
||||
export const VERDICT_START = 600;
|
||||
export const VERDICT_END = 660;
|
||||
export const UNREACHED = 0xffff;
|
||||
export const MAX_WAVE_STEPS = 48;
|
||||
export const LOOP_FRAMES = 720;
|
||||
|
||||
/** BFS parent preference: causal chains first — RSB semantics. Unknown → 5. */
|
||||
export const EDGE_TYPE_RANK: Record<string, number> = {
|
||||
causal: 0,
|
||||
temporal: 1,
|
||||
shared_concepts: 2,
|
||||
complementary: 3,
|
||||
semantic: 4
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RescueShaderConsts {
|
||||
/** Frames per BFS hop for the backward wave: clamp(⌊252/D⌋, 14, 84). */
|
||||
hopSlot: number;
|
||||
/** BFS depth of the cause (D). Wave lane fires only for 1 ≤ d ≤ D. */
|
||||
causeDepth: number;
|
||||
}
|
||||
|
||||
export interface RescueVerdictCopy {
|
||||
headline: 'root cause found';
|
||||
causeLabel: string;
|
||||
failureLabel: string;
|
||||
causeDate: string;
|
||||
hops: number;
|
||||
k: number;
|
||||
/** `${hops} hops back · ${causeDate} · vector search: 0 for ${k}` */
|
||||
receipt: string;
|
||||
}
|
||||
|
||||
export interface RescuePlan {
|
||||
/** false ⇒ field renders, story suppressed (no fake cause on tiny graphs). */
|
||||
viable: boolean;
|
||||
failureIndex: number;
|
||||
causeIndex: number;
|
||||
lookalikeIndices: number[];
|
||||
/** Per-node BFS hop depth from the failure; UNREACHED; failure = 0. */
|
||||
hopDepths: Uint16Array;
|
||||
causeDepth: number;
|
||||
hopSlot: number;
|
||||
/** 1 u32/node: bits 0-15 hopDepth, 16 isFailure, 17 isCause, 18 isLookalike, 19-21 k. */
|
||||
waveData: Uint32Array;
|
||||
/** UINTS_PER_PATHSTEP u32 per step; min Uint32Array(4). */
|
||||
pathData: Uint32Array<ArrayBuffer>;
|
||||
/**
|
||||
* MUST be 1:1 with pathData steps — setPathSteps sets params[4] and the
|
||||
* ribbon draw count from THIS array's length.
|
||||
*/
|
||||
pathMetas: PathStepMeta[];
|
||||
/** Curated spine beats (route state only, NEVER sent to GPU). Unique beatFrames. */
|
||||
spineBeats: PathStepMeta[];
|
||||
verdict: RescueVerdictCopy;
|
||||
consts: RescueShaderConsts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout parity — the ONE correct way to know where nodes are on screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Byte-identical replica of NodeRenderer.upload's layout: same function
|
||||
* (buildNodeStateArray), same fresh DemoClock, same rng consumption (incl. the
|
||||
* center-node skip). Do NOT reimplement golden-angle placement here.
|
||||
*/
|
||||
export function layoutPositions(graph: ObservatoryGraph, seed: string): Float32Array {
|
||||
const layoutClock = new DemoClock({ seed });
|
||||
return buildNodeStateArray(graph, layoutClock.state.rng).data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection algorithms (all exported for tests; ties → ascending node index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nodeDegrees(graph: ObservatoryGraph): Uint32Array {
|
||||
const degree = new Uint32Array(graph.nodes.length);
|
||||
for (const e of graph.edges) {
|
||||
degree[e.sourceIndex]++;
|
||||
degree[e.targetIndex]++;
|
||||
}
|
||||
return degree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the failure memory: non-center, unsuppressed, well-connected, prefer
|
||||
* failure/guardrail (then confusion/weak-spot) tags, prefer nodes away from
|
||||
* the field center in layout (≥ 0.45 · fieldRadius 120 = 54).
|
||||
* Relaxation ladder: degree ≥ 2 → any unsuppressed non-center → any
|
||||
* non-center → center (degenerate single-node field). Empty graph → -1.
|
||||
*/
|
||||
export function pickFailureIndex(graph: ObservatoryGraph, positions: Float32Array): number {
|
||||
const n = graph.nodes.length;
|
||||
if (n === 0) return -1;
|
||||
const degree = nodeDegrees(graph);
|
||||
|
||||
const score = (i: number): number => {
|
||||
const node = graph.nodes[i];
|
||||
const tags = new Set(node.tags.map((t) => t.toLowerCase()));
|
||||
let s = 0;
|
||||
if (tags.has('failure') || tags.has('guardrail')) s += 3;
|
||||
if (tags.has('confusion') || tags.has('weak-spot')) s += 2;
|
||||
s += Math.min(degree[i], 8) / 8;
|
||||
const x = positions[i * FLOATS_PER_NODE + 0];
|
||||
const y = positions[i * FLOATS_PER_NODE + 1];
|
||||
const z = positions[i * FLOATS_PER_NODE + 2];
|
||||
if (Math.sqrt(x * x + y * y + z * z) >= 54) s += 0.5;
|
||||
return s;
|
||||
};
|
||||
|
||||
const tiers: Array<(i: number) => boolean> = [
|
||||
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed && degree[i] >= 2,
|
||||
(i) => i !== graph.centerIndex && !graph.nodes[i].suppressed,
|
||||
(i) => i !== graph.centerIndex,
|
||||
() => true
|
||||
];
|
||||
for (const accept of tiers) {
|
||||
let best = -1;
|
||||
let bestScore = -Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!accept(i)) continue;
|
||||
const s = score(i);
|
||||
// strict > keeps the lowest index on ties (ascending scan)
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best >= 0) return best;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undirected BFS from the failure. Hop depths are TRUE BFS distances
|
||||
* (layer order is edge-rank independent); parent pointers are chosen in a
|
||||
* second pass preferring causal/temporal edges (RSB semantics), then lowest
|
||||
* neighbor index — fully deterministic.
|
||||
*/
|
||||
export function bfsFromFailure(
|
||||
graph: ObservatoryGraph,
|
||||
failureIndex: number
|
||||
): { depths: Uint16Array; parents: Int32Array } {
|
||||
const n = graph.nodes.length;
|
||||
const depths = new Uint16Array(n).fill(UNREACHED);
|
||||
const parents = new Int32Array(n).fill(-1);
|
||||
if (failureIndex < 0 || failureIndex >= n) return { depths, parents };
|
||||
|
||||
const adj: Array<Array<{ nbr: number; rank: number }>> = Array.from({ length: n }, () => []);
|
||||
for (const e of graph.edges) {
|
||||
const rank = EDGE_TYPE_RANK[e.type] ?? 5;
|
||||
adj[e.sourceIndex].push({ nbr: e.targetIndex, rank });
|
||||
adj[e.targetIndex].push({ nbr: e.sourceIndex, rank });
|
||||
}
|
||||
for (const list of adj) list.sort((a, b) => a.rank - b.rank || a.nbr - b.nbr);
|
||||
|
||||
depths[failureIndex] = 0;
|
||||
const queue = [failureIndex];
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const u = queue[qi];
|
||||
for (const { nbr } of adj[u]) {
|
||||
if (depths[nbr] === UNREACHED) {
|
||||
depths[nbr] = depths[u] + 1;
|
||||
queue.push(nbr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parent pass: first depth-(d-1) neighbor in (rank, index) order wins.
|
||||
for (let v = 0; v < n; v++) {
|
||||
if (depths[v] === UNREACHED || depths[v] === 0) continue;
|
||||
for (const { nbr } of adj[v]) {
|
||||
if (depths[nbr] === depths[v] - 1) {
|
||||
parents[v] = nbr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { depths, parents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the true cause: graph-distant (depth ≥ 3, relaxed 2 → 1), low
|
||||
* retention (hard ≤ 0.45, relaxed away if empty — visually slate/dim), old.
|
||||
* score = 2·(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<string, string>();
|
||||
for (const nd of response.nodes) createdById.set(nd.id, nd.createdAt);
|
||||
|
||||
for (const minDepth of [3, 2, 1]) {
|
||||
const cand: number[] = [];
|
||||
for (let i = 0; i < graph.nodes.length; i++) {
|
||||
if (i === graph.centerIndex || i === failureIndex) continue;
|
||||
const d = depths[i];
|
||||
if (d === UNREACHED || d < minDepth) continue;
|
||||
cand.push(i);
|
||||
}
|
||||
if (cand.length === 0) continue;
|
||||
|
||||
let pool = cand.filter((i) => graph.nodes[i].retention <= 0.45);
|
||||
if (pool.length === 0) pool = cand;
|
||||
|
||||
// ageRank across the pool: oldest Date.parse(createdAt) → 1.
|
||||
const times = new Map<number, number>();
|
||||
let minT = Infinity;
|
||||
let maxT = -Infinity;
|
||||
for (const i of pool) {
|
||||
const raw = createdById.get(graph.nodes[i].id);
|
||||
const t = raw ? Date.parse(raw) : NaN;
|
||||
if (Number.isFinite(t)) {
|
||||
times.set(i, t);
|
||||
if (t < minT) minT = t;
|
||||
if (t > maxT) maxT = t;
|
||||
}
|
||||
}
|
||||
const ageRank = (i: number): number => {
|
||||
const t = times.get(i);
|
||||
if (t === undefined) return 0;
|
||||
if (maxT === minT) return 1;
|
||||
return (maxT - t) / (maxT - minT);
|
||||
};
|
||||
const score = (i: number): number =>
|
||||
2 * (1 - graph.nodes[i].retention) + (0.5 * Math.min(depths[i], 6)) / 6 + 0.5 * ageRank(i);
|
||||
|
||||
pool.sort((a, b) => {
|
||||
const sa = score(a);
|
||||
const sb = score(b);
|
||||
if (sb !== sa) return sb - sa;
|
||||
if (depths[b] !== depths[a]) return depths[b] - depths[a];
|
||||
return a - b;
|
||||
});
|
||||
return { index: pool[0], depth: depths[pool[0]] };
|
||||
}
|
||||
return { index: -1, depth: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* K = min(4, eligible) nearest neighbors of the failure in LAYOUT space,
|
||||
* excluding failure/cause/center — "looks similar" is literal: these are the
|
||||
* nodes vector search would return. Flare order = nearest first.
|
||||
*/
|
||||
export function pickLookalikes(
|
||||
positions: Float32Array,
|
||||
nodeCount: number,
|
||||
failureIndex: number,
|
||||
causeIndex: number,
|
||||
centerIndex: number
|
||||
): number[] {
|
||||
const fx = positions[failureIndex * FLOATS_PER_NODE + 0];
|
||||
const fy = positions[failureIndex * FLOATS_PER_NODE + 1];
|
||||
const fz = positions[failureIndex * FLOATS_PER_NODE + 2];
|
||||
const cand: Array<{ i: number; d2: number }> = [];
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
if (i === failureIndex || i === causeIndex || i === centerIndex) continue;
|
||||
const dx = positions[i * FLOATS_PER_NODE + 0] - fx;
|
||||
const dy = positions[i * FLOATS_PER_NODE + 1] - fy;
|
||||
const dz = positions[i * FLOATS_PER_NODE + 2] - fz;
|
||||
cand.push({ i, d2: dx * dx + dy * dy + dz * dz });
|
||||
}
|
||||
cand.sort((a, b) => a.d2 - b.d2 || a.i - b.i);
|
||||
return cand.slice(0, RESCUE_K).map((c) => c.i);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wave timing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** σ = clamp(⌊252/D⌋, 14, 84): the wave reaches the cause 6-10 frames before ignition. */
|
||||
export function hopSlotFor(causeDepth: number): number {
|
||||
const d = Math.max(1, causeDepth);
|
||||
return Math.min(84, Math.max(14, Math.floor(252 / d)));
|
||||
}
|
||||
|
||||
/** W(d) = min(WAVE_START + σ·d, WAVE_ARRIVAL_CAP). */
|
||||
export function waveArrivalFrame(depth: number, hopSlot: number): number {
|
||||
return Math.min(WAVE_START + hopSlot * depth, WAVE_ARRIVAL_CAP);
|
||||
}
|
||||
|
||||
export function lookalikeFrame(k: number): number {
|
||||
return LOOKALIKE_BASE + LOOKALIKE_INTERVAL * k;
|
||||
}
|
||||
|
||||
export function truncateLabel(label: string): string {
|
||||
return label.length > 64 ? label.slice(0, 64) + '…' : label;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope math — the authoritative CPU mirror of shaders/rescue.wgsl.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function smooth(a: number, b: number, f: number): number {
|
||||
const t = Math.min(1, Math.max(0, (f - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function env(f: number, a0: number, a1: number, r0: number, r1: number): number {
|
||||
return smooth(a0, a1, f) * (1 - smooth(r0, r1, f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function of (frame, packed wave word, shader consts) → the four demo
|
||||
* lanes (x recall/ignition, y searchlight, z wave, w shock). Every term is
|
||||
* A·S(a0,a1,f)·(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 }
|
||||
};
|
||||
}
|
||||
116
apps/dashboard/src/lib/observatory/rescue-renderer.ts
Normal file
116
apps/dashboard/src/lib/observatory/rescue-renderer.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Cognitive Observatory — salience-rescue choreography pass.
|
||||
*
|
||||
* Compute-only FramePass (no render(): nodes and ribbons draw via the
|
||||
* NodeRenderer's existing pipelines). Uploads the per-node packed wave word
|
||||
* once (static), then each frame extends the choreography INTO the NodeState
|
||||
* demo lanes as a pure function of (frame, role, hopDepth) — no stateful
|
||||
* integration, so capture mode (?frame=N) works with zero special-casing.
|
||||
*
|
||||
* PASS ORDER IS LOAD-BEARING: this pass MUST be constructed AFTER the
|
||||
* NodeRenderer (the route guarantees it: handleReady creates NodeRenderer,
|
||||
* the upload $effect creates RescueRenderer) so rescue_choreo encodes AFTER
|
||||
* recall_sim in the same encoder and its demo-lane overwrite wins. recall_sim
|
||||
* rewrites demo.x every frame with an afterglow window of bf+40..bf+200 — the
|
||||
* causal arc at bf=560 would otherwise carry visible residual across the
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
target_size: vec4<f32>,
|
||||
color_phase: vec4<f32>,
|
||||
state: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> particles: array<BirthParticle>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn birth_compute(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
if (i >= arrayLength(&particles)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture mode (params._pad == 1.0): skip physics integration.
|
||||
// The storage-buffer state stays frozen at initial upload values.
|
||||
if (params._pad == 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var particle = particles[i];
|
||||
let frame = params.frame;
|
||||
let phase = params.loop_phase;
|
||||
|
||||
// --- Convergence choreography (integer cycles per 720-frame loop) ---
|
||||
|
||||
// Phase offset (stagger) from start_life.w: 0..1 → delays convergence.
|
||||
let stagger = particle.start_life.w;
|
||||
|
||||
// Effective frame: staggered loop frame (wraps at 720).
|
||||
let effFrame = fract(phase + stagger * 0.15) * 720.0;
|
||||
|
||||
// --- Phase 1: latent trace condensing (frames 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<f32>(mix(startPos, targetPos, t), particle.state.w);
|
||||
|
||||
// Alpha: particles fade in during convergence, fade out during reset.
|
||||
let fadeIn = smoothstep(0.0, 60.0, effFrame);
|
||||
particle.state.w = max(particle.state.w, fadeIn * 0.8);
|
||||
|
||||
particles[i] = particle;
|
||||
}
|
||||
`;
|
||||
125
apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts
Normal file
125
apps/dashboard/src/lib/observatory/shaders/firewall.wgsl.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Cognitive Observatory — firewall choreography compute pass (WGSL).
|
||||
*
|
||||
* One invocation per node. Decodes the packed fire word (firewall-plan.ts:
|
||||
* bits 0-7 shockDelay, bit 8 isIntruder, bit 9 isSeverNeighbor, bits 10-13
|
||||
* sever slot k) and writes ALL FOUR NodeState demo lanes as PURE functions of
|
||||
* (params.frame, params.loop_phase, packed word):
|
||||
*
|
||||
* demo.x ALWAYS 0.0 — the recall/thin-film grammar can never fire here
|
||||
* demo.y intruder only: intrusion flare band (0..1], 36 integer sine
|
||||
* cycles/loop, then the sustained MEMBRANE band [2.60..2.90], 12
|
||||
* integer cycles — one lane, two value ranges (render-nodes.wgsl
|
||||
* separates them with min(fy,1) vs smoothstep(1.5, 2.2, fy))
|
||||
* demo.z ALWAYS 0.0 — the forgetting-horizon grammar can never fire here
|
||||
* demo.w crimson shock: source detonation on the intruder, per-node rim as
|
||||
* the radial front passes (arrival A = 150 + delay, amplitude fades
|
||||
* with distance), sever-blink receipts at 345 + 21k
|
||||
*
|
||||
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
|
||||
* recall_sim rewrites demo.x every frame from the path buffer (afterglow
|
||||
* decays bf+40..bf+200 — the k=5 sever beam at bf=450 would leave residual
|
||||
* demo.x near the seam). Overwriting all four lanes here is simultaneously
|
||||
* the choreography, the loop-seam guarantee, and free ?frame=N capture
|
||||
* support (stateless: same frame in → same lanes out).
|
||||
*
|
||||
* Every envelope has attack a0 ≥ 90 and release r1 ≤ 680 ⇒ exact 0.0 at
|
||||
* frames 0 and 719. Sines are factors on zero-at-seam envelopes with INTEGER
|
||||
* cycles per loop. The CPU mirror (firewall-plan.ts firewallEnvelopes) is
|
||||
* machine-checked by the seam-zero test — keep both in lockstep.
|
||||
*
|
||||
* Bind group = EXACTLY the 3 declared bindings (params, nodes, fire).
|
||||
*/
|
||||
|
||||
export const firewallWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
pos_radius: vec4<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
|
||||
// 1 u32/node: bits 0-7 shockDelay, 8 isIntruder, 9 isSeverNeighbor,
|
||||
// 10-13 sever slot k (firewall-plan.ts packing). Every node carries a delay.
|
||||
@group(0) @binding(2) var<storage, read> fire: array<u32>;
|
||||
|
||||
const TAU: f32 = 6.28318530717958647;
|
||||
|
||||
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
|
||||
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
if (i >= u32(params.node_count)) {
|
||||
return;
|
||||
}
|
||||
if (i >= arrayLength(&fire)) {
|
||||
return;
|
||||
}
|
||||
// Belt-and-braces atop the TS gate: firewall is demo index 4.
|
||||
if (params.demo_id != 4.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let packed = fire[i];
|
||||
let delay = f32(packed & 0xffu);
|
||||
let is_intruder = (packed & 0x100u) != 0u;
|
||||
let is_sever = (packed & 0x200u) != 0u;
|
||||
let k = f32((packed >> 10u) & 0xfu);
|
||||
|
||||
let f = params.frame;
|
||||
|
||||
var fy = 0.0;
|
||||
var fw = 0.0;
|
||||
|
||||
if (is_intruder) {
|
||||
// Intrusion flare: sickly strobe, band (0..1], 36 integer cycles/loop.
|
||||
// C¹ handoff into the membrane over 330-332 (the rise sweeps the flare
|
||||
// band exactly once — the condensation read is intentional).
|
||||
fy = env(f, 90.0, 96.0, 310.0, 332.0)
|
||||
* (0.55 + 0.45 * sin(TAU * 36.0 * params.loop_phase));
|
||||
// Membrane: sustained ring band [2.60..2.90], 12 integer cycles/loop.
|
||||
fy = fy + env(f, 330.0, 352.0, 620.0, 680.0)
|
||||
* (2.75 + 0.15 * sin(TAU * 12.0 * params.loop_phase));
|
||||
// Source detonation as the front leaves.
|
||||
fw = env(f, 148.0, 153.0, 162.0, 196.0);
|
||||
} else {
|
||||
// Crimson rim as the radial front passes: arrival A = 150 + delay,
|
||||
// amplitude fades with distance; A ∈ [150, 294] ⇒ all rims dead by 320.
|
||||
let a = 150.0 + delay;
|
||||
let amp = 0.9 - 0.45 * (delay / 144.0);
|
||||
fw = amp * env(f, a - 2.0, a + 3.0, a + 8.0, a + 26.0);
|
||||
if (is_sever) {
|
||||
// Node-side receipt of the severed edge; last release 474.
|
||||
let sk = 345.0 + 21.0 * k;
|
||||
fw = fw + 0.6 * env(f, sk - 4.0, sk, sk + 6.0, sk + 24.0);
|
||||
}
|
||||
}
|
||||
|
||||
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
|
||||
// lanes pass through untouched (the force sim owns them). demo.x and
|
||||
// demo.z are hard 0.0: the recall and horizon grammars can never fire here.
|
||||
var node = nodes[i];
|
||||
node.demo = vec4<f32>(0.0, fy, 0.0, fw);
|
||||
nodes[i] = node;
|
||||
}
|
||||
`;
|
||||
114
apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts
Normal file
114
apps/dashboard/src/lib/observatory/shaders/forgetting.wgsl.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Cognitive Observatory — forgetting-horizon choreography compute pass (WGSL).
|
||||
*
|
||||
* One invocation per node. Decodes the packed horizon word (forgetting-plan.ts:
|
||||
* bits 0-7 rank, bit 8 isDrifting, bit 9 isRescued, bits 10-11 rescue slot k)
|
||||
* and writes ALL FOUR NodeState demo lanes as PURE functions of
|
||||
* (params.frame, packed word):
|
||||
*
|
||||
* demo.x rescue ignition (existing thin-film recall response) on the 3 rescued
|
||||
* demo.y ALWAYS 0.0 — the rescue searchlight grammar can never fire here
|
||||
* demo.z horizon fade-and-fall (vertex drift + shrink, fragment dim to ~6%)
|
||||
* demo.w ALWAYS 0.0 — the shock grammar can never fire here
|
||||
*
|
||||
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
|
||||
* recall_sim rewrites demo.x every frame from the path buffer (afterglow decays
|
||||
* bf+40..bf+200 — the k=2 rescue ribbon at bf=438 would leave residual demo.x
|
||||
* near the seam). Overwriting all four lanes here is simultaneously the
|
||||
* choreography, the loop-seam guarantee, and free ?frame=N capture support
|
||||
* (stateless: same frame in → same lanes out).
|
||||
*
|
||||
* Every term has attack a0 ≥ 90 and is multiplied by the master release
|
||||
* 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<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
|
||||
// 1 u32/node: bits 0-7 rank, 8 isDrifting, 9 isRescued, 10-11 rescue slot k
|
||||
// (forgetting-plan.ts packing). Non-drifting nodes are exactly 0.
|
||||
@group(0) @binding(2) var<storage, read> horizon: array<u32>;
|
||||
|
||||
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
|
||||
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn forgetting_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
if (i >= u32(params.node_count)) {
|
||||
return;
|
||||
}
|
||||
if (i >= arrayLength(&horizon)) {
|
||||
return;
|
||||
}
|
||||
// Belt-and-braces atop the TS gate: forgetting-horizon is demo index 3.
|
||||
if (params.demo_id != 3.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let packed = horizon[i];
|
||||
let is_drifting = (packed & 0x100u) != 0u;
|
||||
let is_rescued = (packed & 0x200u) != 0u;
|
||||
let rank01 = f32(packed & 0xffu) / 255.0;
|
||||
let k = f32((packed >> 10u) & 0x3u);
|
||||
|
||||
let f = params.frame;
|
||||
// Master release: every lane is exactly 0.0 by frame 712 — the seam wall.
|
||||
let master = 1.0 - smoothstep(660.0, 712.0, f);
|
||||
|
||||
var dx = 0.0;
|
||||
var dz = 0.0;
|
||||
|
||||
if (is_drifting) {
|
||||
let onset = 90.0 + 42.0 * rank01;
|
||||
// Phase 1 — the drift: dim + fall to the 0.55 plateau, retention-staggered.
|
||||
let phase1 = 0.55 * smoothstep(onset, onset + 210.0, f);
|
||||
if (is_rescued) {
|
||||
let rk = 318.0 + 60.0 * k;
|
||||
// Snap-back begins 22 frames before the recall ribbon lands at rk.
|
||||
dz = master * phase1 * (1.0 - smoothstep(rk - 22.0, rk + 6.0, f));
|
||||
// Ignition rides the EXISTING recall response (render-nodes.wgsl):
|
||||
// spectral() thin-film band + white-hot core + sprite swell for free.
|
||||
dx = master * env(f, rk - 26.0, rk, rk + 60.0, rk + 130.0);
|
||||
} else {
|
||||
// Phase 2 — the sink: to exactly 1.0 over 640..660 (the ~6% floor era).
|
||||
let phase2 = 0.45 * smoothstep(480.0 + 24.0 * rank01, 640.0, f);
|
||||
dz = master * (phase1 + phase2);
|
||||
}
|
||||
}
|
||||
|
||||
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
|
||||
// lanes pass through untouched (the force sim owns them). demo.y and
|
||||
// demo.w are hard 0.0: the rescue/firewall grammars can never fire here.
|
||||
var node = nodes[i];
|
||||
node.demo = vec4<f32>(dx, 0.0, dz, 0.0);
|
||||
nodes[i] = node;
|
||||
}
|
||||
`;
|
||||
166
apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts
Normal file
166
apps/dashboard/src/lib/observatory/shaders/render-edges.wgsl.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Cognitive Observatory — edge wavefront render shader (WGSL).
|
||||
*
|
||||
* Draws additive edges between nodes, with a traveling wavefront that
|
||||
* travels from source → target at the beat frame. The wavefront is a
|
||||
* glowing pulse that rides along the edge, brightening as it approaches
|
||||
* the target node (spec §7.2: additive bloom, thin-film spectral glow).
|
||||
*
|
||||
* Layout contracts: Params = types.PARAMS_FLOATS, Edge = 2×vec2<u32>
|
||||
* (types.ts UINTS_PER_EDGE), PathStep = 4×vec4<u32> (types.ts
|
||||
* UINTS_PER_PATHSTEP).
|
||||
*/
|
||||
export const renderEdgesWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
right: vec4<f32>,
|
||||
up: vec4<f32>,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
pos_radius: vec4<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<uniform> camera: Camera;
|
||||
// Source/target node indices (2 u32 per edge).
|
||||
@group(0) @binding(2) var<storage, read> edges: array<vec2<u32>>;
|
||||
// PathStep buffer for wavefront timing.
|
||||
@group(0) @binding(3) var<storage, read> path: array<vec4<u32>>;
|
||||
// NodeState storage buffer (positions for edge endpoints).
|
||||
@group(0) @binding(4) var<storage, read> nodes: array<Node>;
|
||||
|
||||
// Iridescent thin-film band — ported EXACTLY from causal-brain-demo.html
|
||||
// spectral(w) (visual DNA §7.1): indigo → cyan-teal → mint → magenta rim.
|
||||
fn spectral(w_in: f32) -> vec3<f32> {
|
||||
let w = fract(w_in);
|
||||
let stops = array<vec3<f32>, 4>(
|
||||
vec3<f32>(0.20, 0.28, 0.95), // indigo
|
||||
vec3<f32>(0.20, 0.85, 0.90), // cyan-teal
|
||||
vec3<f32>(0.45, 1.00, 0.72), // mint
|
||||
vec3<f32>(0.85, 0.45, 1.00) // magenta rim
|
||||
);
|
||||
let f = w * 4.0;
|
||||
let i = u32(floor(f)) % 4u;
|
||||
let frac = f - floor(f);
|
||||
let a = stops[i];
|
||||
let b = stops[(i + 1u) % 4u];
|
||||
return mix(a, b, frac);
|
||||
}
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) color: vec3<f32>,
|
||||
@location(1) width: f32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32
|
||||
) -> VSOut {
|
||||
var out: VSOut;
|
||||
|
||||
let edgeCount = u32(params.edge_count);
|
||||
if (ii >= edgeCount) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let edge = edges[ii];
|
||||
let srcIdx = edge.x;
|
||||
let tgtIdx = edge.y;
|
||||
|
||||
if (srcIdx >= u32(params.node_count) || tgtIdx >= u32(params.node_count)) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let src = nodes[srcIdx];
|
||||
let tgt = nodes[tgtIdx];
|
||||
|
||||
// Two vertices per edge: source (vi=0) and target (vi=1).
|
||||
let pos = select(src.pos_radius.xyz, tgt.pos_radius.xyz, vi == 1u);
|
||||
|
||||
// World-space position.
|
||||
let world = pos;
|
||||
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
|
||||
|
||||
// Wavefront computation: find the nearest path beat for this edge.
|
||||
let pathCount = u32(params.path_count);
|
||||
var waveIntensity = 0.0;
|
||||
var waveT = 1.0; // 0 = source, 1 = target
|
||||
|
||||
for (var s = 0u; s < pathCount; s = s + 1u) {
|
||||
let step = path[s];
|
||||
let srcIdxS = step.x;
|
||||
let tgtIdxS = step.y;
|
||||
let bf = f32(step.z);
|
||||
|
||||
// Check if this path step uses the same source→target.
|
||||
if (srcIdxS == srcIdx && tgtIdxS == tgtIdx) {
|
||||
let frame = params.frame;
|
||||
// Wavefront: sharp pulse traveling from source to target.
|
||||
let attack = smoothstep(bf - 10.0, bf + 2.0, frame);
|
||||
let decay = 1.0 - smoothstep(bf + 30.0, bf + 180.0, frame);
|
||||
waveIntensity = max(waveIntensity, attack * decay);
|
||||
|
||||
// Wave position along edge (0 = source, 1 = target).
|
||||
let arrival = bf - 10.0;
|
||||
let end = bf + 30.0;
|
||||
if (frame >= arrival && frame <= end) {
|
||||
waveT = (frame - arrival) / (end - arrival);
|
||||
} else if (frame > end) {
|
||||
waveT = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edge base color: blend of source and target node base colors.
|
||||
let srcColor = src.color_flags.rgb;
|
||||
let tgtColor = tgt.color_flags.rgb;
|
||||
let baseColor = mix(srcColor, tgtColor, 0.5);
|
||||
|
||||
// Wavefront color: thin-film spectral band, modulated by wave position.
|
||||
let waveColor = spectral(waveT + params.loop_phase);
|
||||
|
||||
// Combine: base edge (dim) + wavefront pulse (bright, additive).
|
||||
let edgeAlpha = 0.08 * params.brightness; // dim connecting line
|
||||
let waveAlpha = waveIntensity * 0.9 * params.brightness; // bright pulse
|
||||
|
||||
// Spectral hue rides the wavefront.
|
||||
out.color = baseColor * edgeAlpha + waveColor * waveAlpha;
|
||||
|
||||
// Line width: thicker at the wavefront for visibility.
|
||||
out.width = 1.0 + waveIntensity * 3.0;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
||||
// Soft edge: feather the line edges.
|
||||
let alpha = smoothstep(0.0, 0.5, in.width) * 0.6;
|
||||
// Additive: alpha is ignored, light accumulates.
|
||||
return vec4<f32>(in.color, 1.0);
|
||||
}
|
||||
`;
|
||||
269
apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts
Normal file
269
apps/dashboard/src/lib/observatory/shaders/render-nodes.wgsl.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Cognitive Observatory — node billboard shader (WGSL).
|
||||
*
|
||||
* Instanced soft-glow sprites read straight from the NodeState storage buffer
|
||||
* (compute-boids render pattern, spec §1). Additive blending onto the void so
|
||||
* overlapping memories build light instead of z-fighting.
|
||||
*
|
||||
* Visual DNA §7: base hue = FSRS state color (meaning at rest); the global
|
||||
* breath `pulse` modulates halo energy so the field is alive even when idle.
|
||||
* Layout contracts: Params = types.PARAMS_FLOATS, Node = 4×vec4f (types.ts).
|
||||
*/
|
||||
export const renderNodesWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
right: vec4<f32>,
|
||||
up: vec4<f32>,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
pos_radius: vec4<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<uniform> camera: Camera;
|
||||
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
|
||||
|
||||
// Iridescent thin-film band — ported EXACTLY from causal-brain-demo.html
|
||||
// spectral(w) (visual DNA §7.1): indigo → cyan-teal → mint → magenta rim,
|
||||
// wrapping. Activation glow rides this band; base color stays FSRS state.
|
||||
fn spectral(w_in: f32) -> vec3<f32> {
|
||||
let w = fract(w_in);
|
||||
// var, not let: WGSL only allows dynamic indexing through a reference.
|
||||
var stops = array<vec3<f32>, 4>(
|
||||
vec3<f32>(0.20, 0.28, 0.95), // indigo
|
||||
vec3<f32>(0.20, 0.85, 0.90), // cyan-teal
|
||||
vec3<f32>(0.45, 1.00, 0.72), // mint
|
||||
vec3<f32>(0.85, 0.45, 1.00) // magenta rim
|
||||
);
|
||||
let f = w * 4.0;
|
||||
let i = u32(floor(f)) % 4u;
|
||||
let frac = f - floor(f);
|
||||
let a = stops[i];
|
||||
let b = stops[(i + 1u) % 4u];
|
||||
return mix(a, b, frac);
|
||||
}
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
// Per-instance constants: flat interpolation guarantees the flag bit
|
||||
// field survives the raster stage bit-exact (no barycentric rounding).
|
||||
@location(1) @interpolate(flat) color: vec3<f32>,
|
||||
// x retention, y flags (bit field as f32), z recall intensity, w radius
|
||||
@location(2) @interpolate(flat) misc: vec4<f32>,
|
||||
// Per-demo choreography lanes (demo.y, demo.z, demo.w), gated by demo_id:
|
||||
// rescue (2) searchlight/wave/shock, forgetting-horizon (3) fade-and-fall,
|
||||
// firewall (4) flare-membrane/shock. Each demo's choreography pass is the
|
||||
// ONLY writer of its lanes, and every gated term below is an exact no-op
|
||||
// when its lane is 0.0 — other demos stay pixel-identical.
|
||||
@location(3) @interpolate(flat) demo_yzw: vec3<f32>,
|
||||
};
|
||||
|
||||
// Quad corners for two triangles (vertex_index 0..5).
|
||||
const CORNERS = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, 1.0)
|
||||
);
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32
|
||||
) -> VSOut {
|
||||
var out: VSOut;
|
||||
if (ii >= u32(params.node_count)) {
|
||||
// degenerate — clipped away
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let node = nodes[ii];
|
||||
let corner = CORNERS[vi];
|
||||
|
||||
// Breath: halo geometry swells ~6% on the global pulse (§7.2), and the
|
||||
// center memory breathes a touch deeper — a heartbeat, not a strobe.
|
||||
let flags = u32(node.color_flags.w);
|
||||
let is_center = (flags & 1u) != 0u;
|
||||
var breath = 1.0 + 0.06 * params.pulse;
|
||||
if (is_center) {
|
||||
breath = 1.0 + 0.12 * params.pulse;
|
||||
}
|
||||
|
||||
// Sprite spans ~3.2× the core radius so the halo has room to feather out.
|
||||
// Recall activation swells the sprite — the wavefront physically blooms.
|
||||
// Per-demo choreography lanes swell it too, gated by demo_id so each
|
||||
// demo's grammar can never leak into another (lanes are 0.0 elsewhere,
|
||||
// and the gate makes the no-op structural, not just numerical).
|
||||
let recall = node.demo.x;
|
||||
let dy = node.demo.y;
|
||||
let dz = node.demo.z;
|
||||
let dw = node.demo.w;
|
||||
var lane_swell = 0.0;
|
||||
if (params.demo_id == 2.0) {
|
||||
// salience-rescue: searchlight pop, wave shiver, shock bloom.
|
||||
lane_swell = 0.5 * dy + 0.25 * dz + 0.9 * dw;
|
||||
} else if (params.demo_id == 4.0) {
|
||||
// firewall: intrusion flare pop (band (0..1]), membrane presence
|
||||
// (band [2.6..2.9] via the range gate), crimson shock bloom.
|
||||
lane_swell = 0.35 * min(dy, 1.0) + 0.3 * smoothstep(1.5, 2.2, dy) + 0.55 * dw;
|
||||
}
|
||||
// forgetting-horizon (demo 3): VISUAL displacement toward the horizon —
|
||||
// down and away from the field axis, ~40.5 units at dz = 1 — plus a
|
||||
// shrink. pos_radius is NEVER written (the force sim owns positions);
|
||||
// drift is pure of demo.z, so ?frame=N capture stays exact. CPU mirror:
|
||||
// forgetting-plan.ts horizonDrift().
|
||||
var horizon_scale = 1.0;
|
||||
var drift = vec3<f32>(0.0);
|
||||
if (params.demo_id == 3.0) {
|
||||
let dzc = clamp(dz, 0.0, 1.0);
|
||||
horizon_scale = 1.0 - 0.35 * dzc;
|
||||
if (dz > 0.0) {
|
||||
let p = node.pos_radius.xyz;
|
||||
let r_xz = max(length(p.xz), 0.001);
|
||||
let away = vec3<f32>(p.x / r_xz, 0.0, p.z / r_xz);
|
||||
drift = dzc * (vec3<f32>(0.0, -34.0, 0.0) + away * 22.0);
|
||||
}
|
||||
}
|
||||
let half_size = node.pos_radius.w * 3.2 * breath * (1.0 + 0.9 * recall)
|
||||
* (1.0 + lane_swell) * horizon_scale;
|
||||
let world = node.pos_radius.xyz + drift
|
||||
+ camera.right.xyz * corner.x * half_size
|
||||
+ camera.up.xyz * corner.y * half_size;
|
||||
|
||||
out.clip = camera.view_proj * vec4<f32>(world, 1.0);
|
||||
out.uv = corner;
|
||||
out.color = node.color_flags.rgb;
|
||||
out.misc = vec4<f32>(node.vel_retention.w, node.color_flags.w, node.demo.x, node.pos_radius.w);
|
||||
out.demo_yzw = vec3<f32>(dy, dz, dw);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
||||
let d = length(in.uv);
|
||||
if (d > 1.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let retention = in.misc.x;
|
||||
let flags = u32(in.misc.y);
|
||||
let suppressed = (flags & 2u) != 0u;
|
||||
let is_center = (flags & 1u) != 0u;
|
||||
|
||||
// Soft sprite: hot core + feathered halo. The halo rides the breath pulse.
|
||||
let core = smoothstep(0.22, 0.0, d);
|
||||
let halo = pow(max(1.0 - d, 0.0), 2.4);
|
||||
var intensity = core * 1.35 + halo * (0.42 + 0.18 * params.pulse);
|
||||
|
||||
// Meaning layer: low-retention memories glow dimmer (drifting toward the
|
||||
// horizon), the center anchor reads brightest.
|
||||
intensity = intensity * (0.45 + 0.55 * retention);
|
||||
if (is_center) {
|
||||
intensity = intensity * 1.6;
|
||||
}
|
||||
if (suppressed) {
|
||||
intensity = intensity * 0.28;
|
||||
}
|
||||
|
||||
var color = in.color * intensity;
|
||||
|
||||
// Forgetting-horizon (demo 3): multiplicative dim toward near-black as
|
||||
// demo.z rises. Floor 0.06 — never fully gone, always retrievable. Sits
|
||||
// BEFORE the recall block so a rescued memory's ignition burns through
|
||||
// the fade. demo_yzw.y carries demo.z (vec3 = y/z/w lanes).
|
||||
if (params.demo_id == 3.0) {
|
||||
color = color * mix(1.0, 0.06, clamp(in.demo_yzw.y, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// Recall activation (§7.1): the thin-film band takes over as the wave
|
||||
// lands. Hue drifts exactly ONE full spectral cycle per 720-frame loop
|
||||
// (loop_phase wraps 0→1 and spectral() fract-wraps) — oil-slick shimmer
|
||||
// with a mathematically invisible loop seam.
|
||||
let recall = in.misc.z;
|
||||
if (recall > 0.001) {
|
||||
let band = spectral(0.1 + params.loop_phase + d * 0.35);
|
||||
let activation = band * recall * (core * 1.7 + halo * 0.9);
|
||||
// white-hot pinpoint at full ignition
|
||||
let flash = vec3<f32>(1.0, 1.0, 1.0) * core * recall * 0.55;
|
||||
color = color + activation + flash;
|
||||
}
|
||||
|
||||
// Per-demo choreography lanes — gated by demo_id AND on nonzero values so
|
||||
// every other demo is pixel-unchanged (each demo's pass is the only
|
||||
// writer of its lanes, and lanes are exactly 0.0 everywhere else).
|
||||
if (params.demo_id == 2.0) {
|
||||
if (in.demo_yzw.x > 0.001) {
|
||||
// Searchlight: cold clinical white — unmistakably NOT the spectral grammar.
|
||||
color = color + vec3<f32>(0.82, 0.90, 1.00) * in.demo_yzw.x * (core * 1.8 + halo * 0.7);
|
||||
}
|
||||
if (in.demo_yzw.y > 0.001) {
|
||||
// Interrogation shimmer: icy spectral strobe as the wave scrubs the past.
|
||||
color = color + spectral(0.55 + params.loop_phase) * in.demo_yzw.y * (core * 0.9 + halo * 0.5)
|
||||
+ vec3<f32>(1.0) * core * in.demo_yzw.y * 0.2;
|
||||
}
|
||||
if (in.demo_yzw.z > 0.001) {
|
||||
// Detonation: crimson blaze + warm-white pinpoint.
|
||||
color = color + vec3<f32>(1.00, 0.16, 0.10) * in.demo_yzw.z * (core * 1.9 + halo * 1.1)
|
||||
+ vec3<f32>(1.0, 0.85, 0.8) * core * in.demo_yzw.z * 0.4;
|
||||
}
|
||||
} else if (params.demo_id == 4.0) {
|
||||
// firewall: demo.y carries TWO value bands — intrusion flare (0..1]
|
||||
// and membrane [2.6..2.9] — separated by range, one lane. demo.w is
|
||||
// the crimson shock rim / sever blink. (demo_yzw = y/z/w lanes.)
|
||||
let fy = in.demo_yzw.x;
|
||||
let fw = in.demo_yzw.z;
|
||||
// Intrusion flare: sickly green-white — a hue deliberately OUTSIDE
|
||||
// both the FSRS palette and the thin-film band. Continuous across the
|
||||
// band boundary (fades out as fy climbs toward the membrane band).
|
||||
let flare = min(fy, 1.0) * (1.0 - smoothstep(1.0, 1.8, fy));
|
||||
if (flare > 0.001) {
|
||||
color = color + vec3<f32>(0.62, 1.00, 0.55) * flare * (core * 1.7 + halo * 0.9)
|
||||
+ vec3<f32>(0.90, 1.00, 0.85) * core * flare * 0.5;
|
||||
}
|
||||
// Membrane: quarantine ring at d ≈ 0.75 with fresnel-ish falloff —
|
||||
// green body, crimson edge. exp(-q·q) squares by multiplication and
|
||||
// the pow base is clamped ≥ 0 (no pow(neg) anywhere).
|
||||
let mw = smoothstep(1.5, 2.2, fy);
|
||||
if (mw > 0.001) {
|
||||
let q = (d - 0.75) * 9.0;
|
||||
let ring = exp(-q * q);
|
||||
let fresnel = pow(clamp(d / 0.75, 0.0, 1.0), 3.0);
|
||||
let ring_col = mix(vec3<f32>(0.55, 1.00, 0.60), vec3<f32>(1.00, 0.20, 0.16),
|
||||
smoothstep(0.72, 0.92, d));
|
||||
color = color + ring_col * ring * fresnel * mw * 1.4;
|
||||
}
|
||||
// Shockwave: crimson RIM as the front passes (a rim, not a blaze).
|
||||
if (fw > 0.001) {
|
||||
let rim = smoothstep(0.45, 0.8, d) * (1.0 - smoothstep(0.85, 1.0, d));
|
||||
color = color + vec3<f32>(1.00, 0.14, 0.10) * rim * fw * 1.5
|
||||
+ vec3<f32>(1.00, 0.60, 0.50) * core * fw * 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
// Additive target (src=one, dst=one): alpha is ignored, light accumulates.
|
||||
return vec4<f32>(color * params.brightness, 1.0);
|
||||
}
|
||||
`;
|
||||
181
apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts
Normal file
181
apps/dashboard/src/lib/observatory/shaders/render-path.wgsl.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Cognitive Observatory — recall path edge wavefront (Increment 6).
|
||||
*
|
||||
* One instanced screen-aligned quad per path step. The vertex shader fetches
|
||||
* both endpoint nodes from the NodeState storage buffer (GraphWaGu edge_vert
|
||||
* pattern, spec §1) and extrudes a constant-screen-width ribbon between them.
|
||||
* The fragment draws a light packet traveling source → target, timed to land
|
||||
* exactly on the beat frame, with a fading trail behind it.
|
||||
*
|
||||
* Deterministic: wave position is a pure function of (frame, beatFrame).
|
||||
*/
|
||||
export const renderPathWGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
right: vec4<f32>,
|
||||
up: vec4<f32>,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
pos_radius: vec4<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<uniform> camera: Camera;
|
||||
@group(0) @binding(2) var<storage, read> nodes: array<Node>;
|
||||
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
|
||||
@group(0) @binding(3) var<storage, read> path: array<vec4<u32>>;
|
||||
|
||||
// Same thin-film band as the node shader (§7.1).
|
||||
fn spectral(w_in: f32) -> vec3<f32> {
|
||||
let w = fract(w_in);
|
||||
// var, not let: WGSL only allows dynamic indexing through a reference.
|
||||
var stops = array<vec3<f32>, 4>(
|
||||
vec3<f32>(0.20, 0.28, 0.95),
|
||||
vec3<f32>(0.20, 0.85, 0.90),
|
||||
vec3<f32>(0.45, 1.00, 0.72),
|
||||
vec3<f32>(0.85, 0.45, 1.00)
|
||||
);
|
||||
let f = w * 4.0;
|
||||
let i = u32(floor(f)) % 4u;
|
||||
let frac = f - floor(f);
|
||||
let a = stops[i];
|
||||
let b = stops[(i + 1u) % 4u];
|
||||
return mix(a, b, frac);
|
||||
}
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
// x: t along segment (0 source → 1 target), y: side (-1..1)
|
||||
@location(0) uv: vec2<f32>,
|
||||
// x: beat frame, y: kind, z: segment visible (0 skips degenerate steps)
|
||||
// Per-instance constant — flat keeps it bit-exact through the raster.
|
||||
@location(1) @interpolate(flat) beat: vec3<f32>,
|
||||
};
|
||||
|
||||
// (t, side) corners for two triangles of the ribbon.
|
||||
const RIBBON = array<vec2<f32>, 6>(
|
||||
vec2<f32>(0.0, -1.0),
|
||||
vec2<f32>(1.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(0.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(0.0, 1.0)
|
||||
);
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32
|
||||
) -> VSOut {
|
||||
var out: VSOut;
|
||||
if (ii >= u32(params.path_count)) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
out.beat = vec3<f32>(0.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let step = path[ii];
|
||||
let src = nodes[step.x];
|
||||
let dst = nodes[step.y];
|
||||
let corner = RIBBON[vi];
|
||||
|
||||
// Degenerate (origin beat: source == target) — emit nothing visible.
|
||||
if (step.x == step.y) {
|
||||
out.clip = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
out.beat = vec3<f32>(0.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let a = camera.view_proj * vec4<f32>(src.pos_radius.xyz, 1.0);
|
||||
let b = camera.view_proj * vec4<f32>(dst.pos_radius.xyz, 1.0);
|
||||
|
||||
// NDC-space perpendicular for constant screen width.
|
||||
let ndc_a = a.xy / max(a.w, 0.0001);
|
||||
let ndc_b = b.xy / max(b.w, 0.0001);
|
||||
var dir = ndc_b - ndc_a;
|
||||
let dlen = max(length(dir), 0.0001);
|
||||
dir = dir / dlen;
|
||||
let perp = vec2<f32>(-dir.y, dir.x);
|
||||
|
||||
// Ribbon half-width in NDC (aspect-corrected), ~2.5 px on a 900px-tall view.
|
||||
let px = 2.5 / max(params.viewport_h, 1.0) * 2.0;
|
||||
let width = vec2<f32>(px * (params.viewport_h / max(params.viewport_w, 1.0)), px);
|
||||
|
||||
let base = mix(a, b, corner.x);
|
||||
let offset = perp * width * corner.y * base.w;
|
||||
out.clip = vec4<f32>(base.xy + offset, base.zw);
|
||||
out.uv = vec2<f32>(corner.x, corner.y);
|
||||
out.beat = vec3<f32>(f32(step.z), f32(step.w), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
||||
if (in.beat.z < 0.5) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let frame = params.frame;
|
||||
let bf = in.beat.x;
|
||||
let t = in.uv.x;
|
||||
|
||||
// Wave departs 45 frames before the beat and lands exactly on it.
|
||||
let progress = clamp((frame - (bf - 45.0)) / 45.0, 0.0, 1.0);
|
||||
// Nothing before departure; trail lingers ~90 frames after arrival.
|
||||
let live = smoothstep(bf - 46.0, bf - 44.0, frame)
|
||||
* (1.0 - smoothstep(bf + 40.0, bf + 90.0, frame));
|
||||
if (live <= 0.001) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// The light packet: gaussian around the wavefront position.
|
||||
let dwave = (t - progress) * 14.0;
|
||||
let packet = exp(-dwave * dwave);
|
||||
|
||||
// Fading trail behind the packet — provenance stays visible a beat.
|
||||
var trail = 0.0;
|
||||
if (t < progress) {
|
||||
trail = (1.0 - (progress - t)) * 0.22;
|
||||
}
|
||||
|
||||
// Feather across the ribbon width.
|
||||
let across = 1.0 - abs(in.uv.y);
|
||||
let profile = across * across;
|
||||
|
||||
// Backward/contradiction hops burn hotter into the magenta rim (§7.4).
|
||||
// Hue drifts one full spectral cycle per loop (seamless at the wrap).
|
||||
// Kind 2 (salience-rescue probe): a gray failing beam — vector search
|
||||
// visibly probing lookalikes and coming back empty. Kinds 0/1 unchanged.
|
||||
var band = spectral(0.15 + t * 0.35 + params.loop_phase);
|
||||
var packet_white = 0.35;
|
||||
if (in.beat.y > 1.5) {
|
||||
band = vec3<f32>(0.62, 0.66, 0.72);
|
||||
packet_white = 0.18;
|
||||
} else if (in.beat.y > 0.5) {
|
||||
band = mix(band, vec3<f32>(1.0, 0.25, 0.45), 0.55);
|
||||
}
|
||||
|
||||
let energy = (packet * 1.6 + trail) * profile * live;
|
||||
let color = band * energy + vec3<f32>(1.0) * packet * profile * live * packet_white;
|
||||
return vec4<f32>(color * params.brightness, 1.0);
|
||||
}
|
||||
`;
|
||||
148
apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts
Normal file
148
apps/dashboard/src/lib/observatory/shaders/rescue.wgsl.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Cognitive Observatory — salience-rescue choreography compute pass (WGSL).
|
||||
*
|
||||
* One invocation per node. Decodes the packed wave word (rescue-plan.ts:
|
||||
* bits 0-15 hopDepth, 16 isFailure, 17 isCause, 18 isLookalike, 19-21 k) and
|
||||
* writes ALL FOUR NodeState demo lanes as PURE functions of
|
||||
* (params.frame, params.loop_phase, packed word):
|
||||
*
|
||||
* demo.x cause ignition (existing thin-film recall response) + symptom backlight
|
||||
* demo.y searchlight cold-white flare on the K lookalikes + ash residue
|
||||
* demo.z backward-wave interrogation flicker + scanned ember
|
||||
* demo.w detonation spike + wound simmer + shockwave blinks + recognition flare
|
||||
*
|
||||
* This pass MUST encode AFTER recall_sim (simulate.wgsl) in the same encoder:
|
||||
* recall_sim rewrites demo.x every frame from the path buffer (afterglow decays
|
||||
* bf+40..bf+200 — the causal arc at bf=560 would leave a visible residual at
|
||||
* frame 719). Overwriting all four lanes here is simultaneously the
|
||||
* choreography, the loop-seam guarantee, and free ?frame=N capture support
|
||||
* (stateless: same frame in → same lanes out).
|
||||
*
|
||||
* Every envelope term is A·smoothstep(a0,a1,f)·(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<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
|
||||
// 1 u32/node: bits 0-15 hopDepth (0xffff unreached), 16 failure, 17 cause,
|
||||
// 18 lookalike, 19-21 lookalike k (rescue-plan.ts packing).
|
||||
@group(0) @binding(2) var<storage, read> wave: array<u32>;
|
||||
|
||||
const HOP_SLOT: f32 = ${hopSlot};
|
||||
const CAUSE_DEPTH: f32 = ${causeDepth};
|
||||
const TAU: f32 = 6.28318530717958647;
|
||||
|
||||
fn env(f: f32, a0: f32, a1: f32, r0: f32, r1: f32) -> f32 {
|
||||
return smoothstep(a0, a1, f) * (1.0 - smoothstep(r0, r1, f));
|
||||
}
|
||||
|
||||
fn arrival(d: f32) -> f32 {
|
||||
return min(260.0 + HOP_SLOT * d, 514.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn rescue_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
if (i >= u32(params.node_count)) {
|
||||
return;
|
||||
}
|
||||
if (i >= arrayLength(&wave)) {
|
||||
return;
|
||||
}
|
||||
// Belt-and-braces atop the TS gate: salience-rescue is demo index 2.
|
||||
if (params.demo_id != 2.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let packed = wave[i];
|
||||
let depth_u = packed & 0xffffu;
|
||||
let d = f32(depth_u);
|
||||
let is_failure = (packed & 0x10000u) != 0u;
|
||||
let is_cause = (packed & 0x20000u) != 0u;
|
||||
let is_look = (packed & 0x40000u) != 0u;
|
||||
let look_k = f32((packed >> 19u) & 0x7u);
|
||||
|
||||
let f = params.frame;
|
||||
|
||||
var dx = 0.0;
|
||||
var dy = 0.0;
|
||||
var dz = 0.0;
|
||||
var dw = 0.0;
|
||||
|
||||
if (is_failure) {
|
||||
// Detonation spike, wound simmer, recognition flare as the arc lands.
|
||||
dw = dw + env(f, 90.0, 96.0, 120.0, 168.0);
|
||||
dw = dw + 0.35 * env(f, 100.0, 130.0, 600.0, 656.0);
|
||||
dw = dw + 0.35 * env(f, 552.0, 562.0, 580.0, 640.0);
|
||||
// Symptom backlight while the cause burns.
|
||||
dx = dx + 0.4 * env(f, 556.0, 566.0, 620.0, 668.0);
|
||||
}
|
||||
if (!is_failure && depth_u >= 1u && depth_u <= 12u) {
|
||||
// Shockwave blink: crimson concussion, 3 frames/hop of REAL graph distance.
|
||||
dw = dw + 0.75 * exp(-0.3 * d)
|
||||
* env(f, 92.0 + 3.0 * d, 96.0 + 3.0 * d, 96.0 + 3.0 * d, 122.0 + 3.0 * d);
|
||||
}
|
||||
if (is_look) {
|
||||
let fk = 138.0 + 28.0 * look_k;
|
||||
// Searchlight flare — cold pop, sequential, on camera.
|
||||
dy = dy + env(f, fk - 6.0, fk, fk + 10.0, fk + 26.0);
|
||||
// Ash residue — the struck-through lookalike stays in frame until the verdict.
|
||||
dy = dy + 0.15 * smoothstep(fk + 10.0, fk + 26.0, f) * (1.0 - smoothstep(600.0, 656.0, f));
|
||||
}
|
||||
if (!is_failure && depth_u >= 1u && d <= CAUSE_DEPTH) {
|
||||
let wd = arrival(d);
|
||||
// Interrogation flicker: 24 integer sine cycles per loop, per-depth phase.
|
||||
let flicker = 0.75 + 0.25 * sin(TAU * 24.0 * params.loop_phase + 1.7 * d);
|
||||
dz = dz + env(f, wd - 10.0, wd, wd + 28.0, wd + 64.0) * flicker;
|
||||
// Scanned ember.
|
||||
dz = dz + 0.08 * smoothstep(wd + 28.0, wd + 64.0, f) * (1.0 - smoothstep(580.0, 640.0, f));
|
||||
}
|
||||
if (is_cause) {
|
||||
// Cause ignition rides the EXISTING recall response (render-nodes.wgsl):
|
||||
// spectral() thin-film band + white-hot core + sprite swell at full intensity.
|
||||
dx = dx + env(f, 520.0, 546.0, 640.0, 700.0);
|
||||
}
|
||||
|
||||
// WGSL forbids swizzle stores — reconstruct the FULL vec4; pos/vel/color
|
||||
// lanes pass through untouched (the force sim owns them).
|
||||
var node = nodes[i];
|
||||
node.demo = vec4<f32>(dx, dy, dz, dw);
|
||||
nodes[i] = node;
|
||||
}
|
||||
`;
|
||||
}
|
||||
163
apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts
Normal file
163
apps/dashboard/src/lib/observatory/shaders/simulate.wgsl.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Cognitive Observatory — recall-path + force simulation compute pass (Increment 7).
|
||||
*
|
||||
* One invocation per node (workgroup 64, dispatch ceil(N/64) — the canonical
|
||||
* compute-boids pattern, spec §1). Each node:
|
||||
*
|
||||
* 1. Scans the small PathStep buffer (≤ 8 beats) and computes its recall
|
||||
* activation envelope for this loop frame (arrival / departure).
|
||||
* 2. Computes deterministic GPU force settle: edge springs, O(N²)
|
||||
* repulsion (≤ 500 nodes), gentle centering, damping, velocity cap,
|
||||
* and position integration.
|
||||
*
|
||||
* Writes NodeState.demo.x (recall intensity). Deterministic: everything is a
|
||||
* pure function of (frame, path buffer, node state) — no randomness, no wall
|
||||
* clock. Center node (isCenter flag) never moves.
|
||||
*
|
||||
* PASS ORDER (salience-rescue): rescue_choreo (shaders/rescue.wgsl.ts) MUST
|
||||
* encode AFTER this pass in the same encoder — it overwrites all four demo
|
||||
* lanes so the arc-afterglow demo.x written here (decays bf+40..bf+200) never
|
||||
* crosses the 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<f32>,
|
||||
vel_retention: vec4<f32>,
|
||||
color_flags: vec4<f32>,
|
||||
demo: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
|
||||
// x source index, y target index, z beat frame, w kind (0 recall, 1 backward)
|
||||
@group(0) @binding(2) var<storage, read> path: array<vec4<u32>>;
|
||||
// x source index, y target node index (Increment 7: force simulation edges)
|
||||
@group(0) @binding(3) var<storage, read> edges: array<vec2<u32>>;
|
||||
|
||||
// --- Force-simulation helpers (Increment 7) ---
|
||||
|
||||
fn safe_normalize(v: vec3<f32>) -> vec3<f32> {
|
||||
let l = length(v);
|
||||
if (l < 0.0001) { return vec3<f32>(0.0); }
|
||||
return v / l;
|
||||
}
|
||||
|
||||
fn clamp_len(v: vec3<f32>, hi: f32) -> vec3<f32> {
|
||||
let l = length(v);
|
||||
if (l > hi && l > 0.0001) { return v * (hi / l); }
|
||||
return v;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
if (i >= u32(params.node_count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = params.frame;
|
||||
var intensity = 0.0;
|
||||
|
||||
let steps = u32(params.path_count);
|
||||
for (var s = 0u; s < steps; s = s + 1u) {
|
||||
let step = path[s];
|
||||
let bf = f32(step.z);
|
||||
|
||||
if (step.y == i) {
|
||||
// Arrival: sharp attack as the wavefront lands, slow afterglow.
|
||||
let attack = smoothstep(bf - 14.0, bf + 4.0, frame);
|
||||
let decay = 1.0 - smoothstep(bf + 40.0, bf + 200.0, frame);
|
||||
intensity = max(intensity, attack * decay);
|
||||
}
|
||||
if (step.x == i && step.x != step.y) {
|
||||
// Departure: the source shimmers as the wave leaves it.
|
||||
let rise = smoothstep(bf - 55.0, bf - 30.0, frame);
|
||||
let fall = 1.0 - smoothstep(bf + 10.0, bf + 70.0, frame);
|
||||
intensity = max(intensity, rise * fall * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
var node = nodes[i];
|
||||
let flags = u32(node.color_flags.w);
|
||||
let is_center = (flags & 1u) != 0u;
|
||||
|
||||
// Write recall intensity (existing behavior preserved).
|
||||
node.demo.x = intensity;
|
||||
|
||||
// --- Increment 7: force simulation ---
|
||||
|
||||
// Capture mode (params._pad == 1.0): skip physics integration entirely.
|
||||
// The storage-buffer state stays frozen at initial upload values,
|
||||
// making same URL + frame → identical pixels (spec §4 Inc 9).
|
||||
if (params._pad == 0.0) {
|
||||
// 7B: center anchor — center node never moves.
|
||||
// (WGSL forbids swizzle stores — reconstruct the vec4, preserving .w.)
|
||||
if (is_center) {
|
||||
node.pos_radius = vec4<f32>(0.0, 0.0, 0.0, node.pos_radius.w);
|
||||
node.vel_retention = vec4<f32>(0.0, 0.0, 0.0, node.vel_retention.w);
|
||||
nodes[i] = node;
|
||||
return;
|
||||
}
|
||||
|
||||
let pos = node.pos_radius.xyz;
|
||||
var force = vec3<f32>(0.0);
|
||||
|
||||
// 7C: edge springs — scan existing edgeBuffer, no atomics.
|
||||
for (var e = 0u; e < u32(params.edge_count); e = e + 1u) {
|
||||
let edge = edges[e];
|
||||
var other_idx = 0xffffffffu;
|
||||
if (edge.x == i) { other_idx = edge.y; }
|
||||
if (edge.y == i) { other_idx = edge.x; }
|
||||
if (other_idx != 0xffffffffu && other_idx < u32(params.node_count)) {
|
||||
let other = nodes[other_idx].pos_radius.xyz;
|
||||
let delta = other - pos;
|
||||
let dist = max(length(delta), 0.001);
|
||||
let dir = delta / dist;
|
||||
let stretch = dist - 34.0;
|
||||
force = force + dir * stretch * 0.00055;
|
||||
}
|
||||
}
|
||||
|
||||
// 7D: soft repulsion (only ≤ 500 nodes for performance).
|
||||
if (u32(params.node_count) <= 500u) {
|
||||
for (var j = 0u; j < u32(params.node_count); j = j + 1u) {
|
||||
if (j == i) { continue; }
|
||||
let other = nodes[j].pos_radius.xyz;
|
||||
let delta = pos - other;
|
||||
let d2 = max(dot(delta, delta), 9.0);
|
||||
force = force + safe_normalize(delta) * (7.5 / d2);
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle centering: keeps the field in frame without crushing it.
|
||||
force = force + (-pos) * 0.0008;
|
||||
|
||||
// 7B: velocity damping + cap, then position integration.
|
||||
var vel = node.vel_retention.xyz;
|
||||
vel = (vel + force) * 0.88;
|
||||
vel = clamp_len(vel, 0.42);
|
||||
node.vel_retention = vec4<f32>(vel, node.vel_retention.w);
|
||||
node.pos_radius = vec4<f32>(pos + vel, node.pos_radius.w);
|
||||
}
|
||||
// When capture_mode (params._pad == 1.0), node is NOT written back —
|
||||
// the storage buffer retains its initial upload values, guaranteeing
|
||||
// deterministic pixels for the same frame index.
|
||||
nodes[i] = node;
|
||||
}
|
||||
`;
|
||||
159
apps/dashboard/src/lib/observatory/types.ts
Normal file
159
apps/dashboard/src/lib/observatory/types.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Cognitive Observatory — shared CPU-side types + GPU buffer layout constants.
|
||||
*
|
||||
* The Observatory is a full-bleed WebGPU "living memory field". Node/particle
|
||||
* state lives in GPU storage buffers (boids ping-pong pattern, spec §1); this
|
||||
* module defines the exact byte layout so the TS uploader and the WGSL shaders
|
||||
* agree lane-for-lane.
|
||||
*
|
||||
* Visual DNA (spec §7): a node's BASE hue = its FSRS state color (meaning),
|
||||
* its ACTIVATION glow rides the thin-film spectral band (transcendence).
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphEdge } from '$types';
|
||||
|
||||
/** Demo modes reachable as deterministic loops: ?demo=<mode>&loop=1 */
|
||||
export type DemoMode =
|
||||
| 'recall-path'
|
||||
| 'engram-birth'
|
||||
| 'salience-rescue'
|
||||
| 'forgetting-horizon'
|
||||
| 'firewall';
|
||||
|
||||
export const DEMO_MODES: readonly DemoMode[] = [
|
||||
'recall-path',
|
||||
'engram-birth',
|
||||
'salience-rescue',
|
||||
'forgetting-horizon',
|
||||
'firewall'
|
||||
] as const;
|
||||
|
||||
export function isDemoMode(v: string): v is DemoMode {
|
||||
return (DEMO_MODES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPU buffer layout — NodeState
|
||||
// ---------------------------------------------------------------------------
|
||||
// Each node occupies 4 × vec4<f32> = 64 bytes, 16-byte aligned lanes so the
|
||||
// WGSL struct maps 1:1 with no padding surprises.
|
||||
//
|
||||
// lane 0 pos_radius : xyz world position + w visual radius
|
||||
// lane 1 vel_retention : xyz velocity + w FSRS retention (0..1)
|
||||
// lane 2 color_flags : rgb base color + w packed flags (as f32)
|
||||
// lane 3 demo : x recall intensity, y birth phase,
|
||||
// z ripple phase, w shock phase
|
||||
//
|
||||
// FLOATS_PER_NODE is what the uploader writes; keep it in lockstep with the
|
||||
// WGSL `struct NodeState`.
|
||||
export const FLOATS_PER_NODE = 16;
|
||||
export const BYTES_PER_NODE = FLOATS_PER_NODE * 4; // 64
|
||||
|
||||
// Lane offsets (in floats) for the uploader.
|
||||
export const NODE_LANE = {
|
||||
posRadius: 0, // +0..+3
|
||||
velRetention: 4, // +4..+7
|
||||
colorFlags: 8, // +8..+11
|
||||
demo: 12 // +12..+15
|
||||
} as const;
|
||||
|
||||
// Packed visual flags (stored in color_flags.w as an f32 bit field via bitcast
|
||||
// on the GPU; on the CPU side we assemble the integer then Math.fround it).
|
||||
export const NODE_FLAG = {
|
||||
isCenter: 1 << 0,
|
||||
suppressed: 1 << 1,
|
||||
isAha: 1 << 2,
|
||||
isFailure: 1 << 3,
|
||||
isConfusion: 1 << 4
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPU buffer layout — EdgeIndex (static) and PathStep (demo path)
|
||||
// ---------------------------------------------------------------------------
|
||||
/** array<vec2<u32>> source/target node indices. 2 u32 per edge. */
|
||||
export const UINTS_PER_EDGE = 2;
|
||||
|
||||
/**
|
||||
* array<vec4<u32>> per path beat:
|
||||
* x source node index, y target node index, z beat frame, w kind
|
||||
* kind: 0 normal recall hop, 1 backward-cause hop (salience rescue),
|
||||
* 2 probe beam (salience rescue: vector search failing on camera).
|
||||
*/
|
||||
export const UINTS_PER_PATHSTEP = 4;
|
||||
|
||||
export const PATH_KIND = {
|
||||
recall: 0,
|
||||
backwardCause: 1,
|
||||
probe: 2
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame uniforms — must match the WGSL `struct Params` exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Laid out as a flat Float32Array; sizes chosen so the whole block is a
|
||||
// multiple of 16 bytes (WebGPU uniform alignment requirement).
|
||||
//
|
||||
// [0] frame (loop frame, wraps at loopFrames)
|
||||
// [1] loopPhase (0..1)
|
||||
// [2] nodeCount
|
||||
// [3] edgeCount
|
||||
// [4] pathCount
|
||||
// [5] pulse (0.5 + 0.5*sin — global breath, spec §7.2)
|
||||
// [6] viewportW
|
||||
// [7] viewportH
|
||||
// [8] brightness (from graphState)
|
||||
// [9] demoId (DemoMode index)
|
||||
// [10] time (fixed sim seconds = frame / fps; NOT wall clock)
|
||||
// [11] _pad
|
||||
export const PARAMS_FLOATS = 12;
|
||||
export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 48 (multiple of 16)
|
||||
|
||||
export function demoModeId(mode: DemoMode): number {
|
||||
const i = DEMO_MODES.indexOf(mode);
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CPU-side observatory graph (stable-indexed view of the API GraphResponse).
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ObservatoryNode {
|
||||
id: string;
|
||||
index: number; // stable position in the NodeState buffer
|
||||
label: string;
|
||||
type: string;
|
||||
retention: number;
|
||||
tags: string[];
|
||||
isCenter: boolean;
|
||||
suppressed: boolean;
|
||||
}
|
||||
|
||||
export interface ObservatoryEdge {
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
weight: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ObservatoryGraph {
|
||||
nodes: ObservatoryNode[];
|
||||
edges: ObservatoryEdge[];
|
||||
/** id -> stable buffer index */
|
||||
indexById: Map<string, number>;
|
||||
centerIndex: number;
|
||||
}
|
||||
|
||||
/** Narrow the loose API GraphNode into what the Observatory needs. */
|
||||
export function toObservatoryNode(n: GraphNode, index: number): ObservatoryNode {
|
||||
return {
|
||||
id: n.id,
|
||||
index,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
retention: typeof n.retention === 'number' ? n.retention : 0,
|
||||
tags: Array.isArray(n.tags) ? n.tags : [],
|
||||
isCenter: !!n.isCenter,
|
||||
suppressed: (n.suppression_count ?? 0) > 0
|
||||
};
|
||||
}
|
||||
|
||||
export type { GraphNode, GraphEdge };
|
||||
286
apps/dashboard/src/routes/(app)/observatory/+page.svelte
Normal file
286
apps/dashboard/src/routes/(app)/observatory/+page.svelte
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { GraphResponse } from '$types';
|
||||
import TelemetryStrip from '$lib/observatory/overlays/TelemetryStrip.svelte';
|
||||
import TimelineSpine from '$lib/observatory/overlays/TimelineSpine.svelte';
|
||||
import RescueVerdict from '$lib/observatory/overlays/RescueVerdict.svelte';
|
||||
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
|
||||
import { isDemoMode, type DemoMode } from '$lib/observatory/types';
|
||||
import type { ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import { NodeRenderer } from '$lib/observatory/node-renderer';
|
||||
import { BirthRenderer } from '$lib/observatory/birth-renderer';
|
||||
import { RescueRenderer } from '$lib/observatory/rescue-renderer';
|
||||
import { buildRescuePlan, type RescuePlan } from '$lib/observatory/rescue-plan';
|
||||
import { ForgettingRenderer } from '$lib/observatory/forgetting-renderer';
|
||||
import { buildForgettingPlan } from '$lib/observatory/forgetting-plan';
|
||||
import { FirewallRenderer } from '$lib/observatory/firewall-renderer';
|
||||
import { buildFirewallPlan, type FirewallPlan } from '$lib/observatory/firewall-plan';
|
||||
import type { PathStepMeta } from '$lib/observatory/path-builder';
|
||||
|
||||
// URL contract: ?demo=recall-path&seed=vestige-observatory-v1
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const demoParam = params.get('demo') ?? 'recall-path';
|
||||
const demoMode: DemoMode = isDemoMode(demoParam) ? demoParam : 'recall-path';
|
||||
const seedValue = params.get('seed') ?? 'vestige-observatory-v1';
|
||||
// Capture mode: ?frame=N freezes the sim at one loop frame (identical pixels).
|
||||
const frameParam = params.get('frame');
|
||||
const freezeFrame = frameParam !== null && frameParam !== '' ? Number(frameParam) : null;
|
||||
// Recording mode: ?capture=1 hides EVERY DOM instrument — pure canvas for
|
||||
// clips/stills. &hud=1 keeps the instruments; H toggles at runtime.
|
||||
let showHud = $state(!(params.get('capture') === '1' && params.get('hud') !== '1'));
|
||||
const isCapture = params.get('capture') === '1';
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'h' || e.key === 'H') showHud = !showHud;
|
||||
}
|
||||
|
||||
let graphData: GraphResponse | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Telemetry state — frames come from the engine's deterministic DemoClock.
|
||||
let frameCount = $state(0);
|
||||
let fpsEstimate = $state(0);
|
||||
let nodeCount = $state(0);
|
||||
let edgeCount = $state(0);
|
||||
let centerId = $state('');
|
||||
|
||||
// Engine + renderer handles (upload happens once both are ready).
|
||||
let engine = $state<ObservatoryEngine | null>(null);
|
||||
let renderer: NodeRenderer | null = null;
|
||||
let birthRenderer: BirthRenderer | null = null;
|
||||
let rescueRenderer: RescueRenderer | null = null;
|
||||
let rescuePlan = $state<RescuePlan | null>(null);
|
||||
let forgettingRenderer: ForgettingRenderer | null = null;
|
||||
let firewallRenderer: FirewallRenderer | null = null;
|
||||
let firewallPlan = $state<FirewallPlan | null>(null);
|
||||
let uploaded = false;
|
||||
let pathSteps = $state<PathStepMeta[]>([]);
|
||||
|
||||
async function loadGraph() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await api.graph({
|
||||
max_nodes: 300,
|
||||
depth: 3,
|
||||
sort: 'recent'
|
||||
});
|
||||
graphData = data;
|
||||
nodeCount = data.nodeCount;
|
||||
edgeCount = data.edgeCount;
|
||||
centerId = data.center_id;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load graph data';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFrame(frame: number, fps: number) {
|
||||
frameCount = frame;
|
||||
fpsEstimate = fps;
|
||||
}
|
||||
|
||||
function handleReady(e: ObservatoryEngine) {
|
||||
// A fresh engine (first boot or HMR re-create) always needs an upload.
|
||||
uploaded = false;
|
||||
engine = e;
|
||||
renderer = new NodeRenderer(e);
|
||||
}
|
||||
|
||||
// Upload the memory field once the engine AND the graph are both ready.
|
||||
$effect(() => {
|
||||
if (engine && renderer && graphData && !uploaded) {
|
||||
uploaded = true;
|
||||
const isBirth = demoMode === 'engram-birth';
|
||||
const isRescue = demoMode === 'salience-rescue';
|
||||
const isHorizon = demoMode === 'forgetting-horizon';
|
||||
const isFirewall = demoMode === 'firewall';
|
||||
renderer.upload(graphData, seedValue, {
|
||||
recallPath: !isBirth && !isRescue && !isHorizon && !isFirewall
|
||||
});
|
||||
|
||||
if (isBirth) {
|
||||
// Moment B: violet dust converges into a newborn memory.
|
||||
birthRenderer = new BirthRenderer({ engine, nodeRenderer: renderer, seed: seedValue });
|
||||
birthRenderer.upload(seedValue);
|
||||
|
||||
// B6: the engrave steps ride the proven recall wavefront system —
|
||||
// pulses travel outward from the newborn, neighbors bloom on landing.
|
||||
const engrave = birthRenderer.engraveSteps;
|
||||
const engraveMetas = [];
|
||||
for (let i = 0; i < engrave.length / 4; i++) {
|
||||
engraveMetas.push({
|
||||
sourceIndex: engrave[i * 4],
|
||||
targetIndex: engrave[i * 4 + 1],
|
||||
beatFrame: engrave[i * 4 + 2],
|
||||
kind: engrave[i * 4 + 3],
|
||||
beatKind: 'engrave',
|
||||
nodeId: `engrave-${i}`,
|
||||
label: 'edge engraved'
|
||||
});
|
||||
}
|
||||
renderer.setPathSteps(engrave, engraveMetas);
|
||||
|
||||
// Spine shows the birth beats (converge → flash → engrave).
|
||||
pathSteps = birthRenderer.timeline.map((b, i) => ({
|
||||
sourceIndex: 0,
|
||||
targetIndex: 0,
|
||||
beatFrame: b.startFrame,
|
||||
kind: 0,
|
||||
beatKind: 'birth',
|
||||
nodeId: `birth-${i}`,
|
||||
label: b.label
|
||||
}));
|
||||
} else if (isRescue) {
|
||||
// Moment C: Retroactive Salience Backfill — vector search fails on
|
||||
// camera, then Vestige reaches backward through time and ignites
|
||||
// the true cause. All choreography is a pure CPU plan; the
|
||||
// RescueRenderer overwrites the demo lanes AFTER recall_sim
|
||||
// (construction order = pass order — load-bearing for the seam).
|
||||
const plan = buildRescuePlan(graphData, renderer.graph!, seedValue);
|
||||
rescuePlan = plan;
|
||||
if (plan.viable) {
|
||||
rescueRenderer = new RescueRenderer({ engine, nodeRenderer: renderer, plan });
|
||||
rescueRenderer.upload();
|
||||
// Probe beams + backward wave tree + causal arc ride the proven
|
||||
// path-ribbon machinery. Metas are 1:1 with the GPU steps
|
||||
// (setPathSteps sets params[4] + draw count from META length).
|
||||
renderer.setPathSteps(plan.pathData, plan.pathMetas);
|
||||
}
|
||||
// Spine shows the curated story beats (unique beatFrames), never
|
||||
// the raw GPU steps.
|
||||
pathSteps = plan.spineBeats;
|
||||
} else if (isHorizon) {
|
||||
// Moment D: the forgetting horizon — FSRS as a visible living
|
||||
// system. The lowest-retention memories dim and fall; three are
|
||||
// recalled back on camera; the rest sink to a 6% floor (never
|
||||
// deleted). Pure CPU plan; the ForgettingRenderer overwrites the
|
||||
// demo lanes AFTER recall_sim (construction order = pass order).
|
||||
const plan = buildForgettingPlan(renderer.graph!);
|
||||
if (plan.viable) {
|
||||
forgettingRenderer = new ForgettingRenderer({ engine, nodeRenderer: renderer, plan });
|
||||
forgettingRenderer.upload();
|
||||
// The 3 rescue ribbons ride the proven path-ribbon machinery.
|
||||
renderer.setPathSteps(plan.pathData, plan.pathMetas);
|
||||
}
|
||||
// No verdict overlay for this moment — the spine narrates.
|
||||
pathSteps = plan.spineBeats;
|
||||
} else if (isFirewall) {
|
||||
// Moment E: the immune response — a suspicious memory flares,
|
||||
// a crimson shockwave crosses the field, its edges sever one by
|
||||
// one, a membrane rings it, the verdict card lands. Pure CPU
|
||||
// plan; the FirewallRenderer overwrites the demo lanes AFTER
|
||||
// recall_sim (construction order = pass order).
|
||||
const plan = buildFirewallPlan(renderer.graph!, seedValue);
|
||||
firewallPlan = plan;
|
||||
if (plan.viable) {
|
||||
firewallRenderer = new FirewallRenderer({ engine, nodeRenderer: renderer, plan });
|
||||
firewallRenderer.upload();
|
||||
// The sever probe-beams ride the proven path-ribbon machinery.
|
||||
renderer.setPathSteps(plan.pathData, plan.pathMetas);
|
||||
}
|
||||
pathSteps = plan.spineBeats;
|
||||
} else {
|
||||
pathSteps = renderer.pathSteps;
|
||||
}
|
||||
|
||||
// Start the story at frame 0 now that the field exists — where the
|
||||
// loop begins must never depend on how long the API call took.
|
||||
engine.demoClock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadGraph();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Full-bleed void stage: #05060a -->
|
||||
<div class="fixed inset-0 overflow-hidden bg-[#05060a]" class:cursor-none={isCapture}>
|
||||
<!-- Canvas layer (z-index 0) — the living memory field -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<ObservatoryCanvas
|
||||
demo={demoMode}
|
||||
seed={seedValue}
|
||||
{freezeFrame}
|
||||
onframe={handleFrame}
|
||||
onready={handleReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- DOM overlay layer (pointer-events:none) — instruments only.
|
||||
?capture=1 hides it all (pure canvas for recording); H toggles. -->
|
||||
{#if showHud}
|
||||
<div class="absolute inset-0 z-10 pointer-events-none">
|
||||
<!-- Top telemetry strip -->
|
||||
<TelemetryStrip
|
||||
demoMode={demoMode}
|
||||
seed={seedValue}
|
||||
nodeCount={nodeCount}
|
||||
edgeCount={edgeCount}
|
||||
centerId={centerId}
|
||||
frameCount={frameCount}
|
||||
fpsEstimate={fpsEstimate}
|
||||
{freezeFrame}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
|
||||
<div class="text-[#5dcaa5] font-mono text-sm tracking-widest animate-pulse">
|
||||
LOADING MEMORY FIELD...
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error state -->
|
||||
{#if error && !loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
|
||||
<div class="text-red-400 font-mono text-sm border border-red-900/50 bg-red-950/30 px-4 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timeline spine: beat ticks + playhead riding the loop -->
|
||||
<TimelineSpine steps={pathSteps} frame={frameCount} />
|
||||
|
||||
<!-- Salience-rescue verdict card (frames 600-660, frame-driven opacity) -->
|
||||
{#if demoMode === 'salience-rescue' && rescuePlan?.viable}
|
||||
<RescueVerdict frame={frameCount} verdict={rescuePlan.verdict} />
|
||||
{/if}
|
||||
|
||||
<!-- Firewall quarantine verdict card (frames 480-620, crimson tone) -->
|
||||
{#if demoMode === 'firewall' && firewallPlan?.viable}
|
||||
<RescueVerdict
|
||||
frame={frameCount}
|
||||
tone="quarantine"
|
||||
fadeWindow={[480, 495, 605, 620]}
|
||||
verdict={{
|
||||
headline: firewallPlan.verdict.headline,
|
||||
causeLabel: firewallPlan.verdict.intruderLabel,
|
||||
receipt: firewallPlan.verdict.receipt
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Empty graph state -->
|
||||
{#if !loading && graphData && graphData.nodeCount === 0}
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto">
|
||||
<div class="text-[#5dcaa5] font-mono text-sm tracking-widest">
|
||||
NO MEMORIES IN FIELD
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- VISUAL DNA §7: void #05060a is the base (bg utility above) — no exceptions -->
|
||||
5
apps/dashboard/src/routes/(app)/observatory/+page.ts
Normal file
5
apps/dashboard/src/routes/(app)/observatory/+page.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// The Observatory is a WebGPU client-only surface: it reads
|
||||
// window.location for its ?demo= contract and boots a GPU device on mount.
|
||||
// SSR would 500 on `window` and can never render the field anyway.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
|
@ -29,15 +29,19 @@
|
|||
$page.url.pathname.startsWith(base) ? $page.url.pathname.slice(base.length) || '/' : $page.url.pathname
|
||||
);
|
||||
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
|
||||
// The Observatory is a full-bleed cinematic surface (spec §7): DOM =
|
||||
// instrument overlays ONLY — no app chrome, no websocket toasts, no nav.
|
||||
// Same bare-children bypass as marketing routes so recordings stay clean.
|
||||
let isImmersiveRoute = $derived(dashboardPath.startsWith('/observatory'));
|
||||
|
||||
onMount(() => {
|
||||
if (!isMarketingRoute) {
|
||||
if (!isMarketingRoute && !isImmersiveRoute) {
|
||||
websocket.connect();
|
||||
}
|
||||
const teardownTheme = initTheme();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (isMarketingRoute) return;
|
||||
if (isMarketingRoute || isImmersiveRoute) return;
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
showCommandPalette = !showCommandPalette;
|
||||
|
|
@ -98,6 +102,7 @@
|
|||
// set reused the same Unicode glyph across multiple items; every entry here
|
||||
// now has a distinct silhouette that reads instantly.
|
||||
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
|
||||
{ href: '/observatory', label: 'Observatory', icon: 'sparkle', shortcut: 'O' },
|
||||
{ href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' },
|
||||
{ href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' },
|
||||
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
|
||||
|
|
@ -140,7 +145,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if isMarketingRoute}
|
||||
{#if isMarketingRoute || isImmersiveRoute}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- Ambient background orbs -->
|
||||
|
|
@ -259,7 +264,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Command Palette overlay -->
|
||||
{#if showCommandPalette && !isMarketingRoute}
|
||||
{#if showCommandPalette && !isMarketingRoute && !isImmersiveRoute}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4 bg-void/60 backdrop-blur-sm"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ export default defineConfig({
|
|||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3927',
|
||||
target: process.env.VESTIGE_API_TARGET ?? 'http://127.0.0.1:3927',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:3927',
|
||||
target: (process.env.VESTIGE_API_TARGET ?? 'http://127.0.0.1:3927').replace(
|
||||
'http',
|
||||
'ws'
|
||||
),
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
assets/causebench-recall-at-1.svg
Normal file
46
assets/causebench-recall-at-1.svg
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<svg viewBox="0 0 680 430" xmlns="http://www.w3.org/2000/svg" width="680" height="430" role="img" aria-label="CauseBench root-cause recall at 1: Vestige scores 60% on synthetic and 50% on real; Dense vector, Hybrid-RRF, BM25, TF-IDF and Recency all score 0%." font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">
|
||||
<title>CauseBench root-cause recall@1 — Vestige 60% / 50%, every resemblance retriever 0%</title>
|
||||
|
||||
<text x="24" y="34" font-size="17" font-weight="600" fill="#1d9e75">Root-cause recall@1 — can the retriever rank the true cause #1?</text>
|
||||
<text x="24" y="56" font-size="13" fill="#888780">CauseBench · a cause that shares an entity with the failure but none of its words</text>
|
||||
|
||||
<line x1="330" y1="78" x2="330" y2="308" stroke="#88878055" stroke-width="1"/>
|
||||
<line x1="450" y1="78" x2="450" y2="308" stroke="#88878055" stroke-width="1"/>
|
||||
<line x1="570" y1="78" x2="570" y2="308" stroke="#88878055" stroke-width="1"/>
|
||||
<line x1="210" y1="78" x2="210" y2="308" stroke="#888780" stroke-width="1.5"/>
|
||||
|
||||
<text x="210" y="330" font-size="11" fill="#888780" text-anchor="middle">0%</text>
|
||||
<text x="330" y="330" font-size="11" fill="#888780" text-anchor="middle">20%</text>
|
||||
<text x="450" y="330" font-size="11" fill="#888780" text-anchor="middle">40%</text>
|
||||
<text x="570" y="330" font-size="11" fill="#888780" text-anchor="middle">60%</text>
|
||||
|
||||
<text x="196" y="99" font-size="13" font-weight="600" fill="#0f6e56" text-anchor="end">Vestige</text>
|
||||
<rect x="210" y="88" width="216" height="17" rx="4" fill="#1d9e75"/>
|
||||
<rect x="210" y="109" width="180" height="17" rx="4" fill="#5dcaa5"/>
|
||||
<text x="434" y="101" font-size="12" font-weight="600" fill="#0f6e56">60% synthetic</text>
|
||||
<text x="398" y="122" font-size="12" fill="#0f6e56">50% real</text>
|
||||
|
||||
<text x="196" y="158" font-size="13" fill="#888780" text-anchor="end">Dense vector (nomic)</text>
|
||||
<circle cx="212" cy="153" r="2.5" fill="#888780"/>
|
||||
<text x="224" y="157" font-size="12" fill="#888780">0%</text>
|
||||
|
||||
<text x="196" y="191" font-size="13" fill="#888780" text-anchor="end">Hybrid-RRF (BM25 + dense)</text>
|
||||
<circle cx="212" cy="186" r="2.5" fill="#888780"/>
|
||||
<text x="224" y="190" font-size="12" fill="#888780">0%</text>
|
||||
|
||||
<text x="196" y="224" font-size="13" fill="#888780" text-anchor="end">BM25</text>
|
||||
<circle cx="212" cy="219" r="2.5" fill="#888780"/>
|
||||
<text x="224" y="223" font-size="12" fill="#888780">0%</text>
|
||||
|
||||
<text x="196" y="257" font-size="13" fill="#888780" text-anchor="end">TF-IDF</text>
|
||||
<circle cx="212" cy="252" r="2.5" fill="#888780"/>
|
||||
<text x="224" y="256" font-size="12" fill="#888780">0%</text>
|
||||
|
||||
<text x="196" y="290" font-size="13" fill="#888780" text-anchor="end">Recency</text>
|
||||
<circle cx="212" cy="285" r="2.5" fill="#888780"/>
|
||||
<text x="224" y="289" font-size="12" fill="#888780">0%</text>
|
||||
|
||||
<line x1="24" y1="356" x2="656" y2="356" stroke="#88878040" stroke-width="1"/>
|
||||
<text x="24" y="380" font-size="12" fill="#888780">Every resemblance retriever ranks the true cause #1 exactly 0% of the time.</text>
|
||||
<text x="24" y="400" font-size="11" fill="#888780">Vestige also lands the cause in the top 5 in 100% of cases. Source: benchmarks/causebench/RESULTS.md · reproduce with 3 commands.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
108
docs/launch/backward-trace-animation-storyboard.md
Normal file
108
docs/launch/backward-trace-animation-storyboard.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Backward-Trace Animation — Storyboard ("The X Rocket")
|
||||
|
||||
**Purpose:** the single most shareable launch artifact. A ~15s looping clip,
|
||||
pinned to the top of the launch thread on X and used as the Show HN demo. It
|
||||
shows the ONE thing nothing else does: when a failure hits, Vestige's arrow
|
||||
snaps *backward through time* past the confounders to the quiet change that
|
||||
actually caused it — while a vector search sits stuck on the symptom.
|
||||
|
||||
**Design law (from 2026 viral-demo research):** animate the *mechanism*
|
||||
legibly, no narration needed, payoff visible in the first 1.5s, loops clean, no
|
||||
logo intro. The tldraw / X-algorithm-viz / CodeReel pattern: the demo IS the
|
||||
pitch.
|
||||
|
||||
---
|
||||
|
||||
## The case shown (canonical CauseBench archetype)
|
||||
|
||||
Uses the exact incident SHAPE the benchmark is built on (see
|
||||
`benchmarks/causebench/data/real/*.meta.json`: a config/limit/cert/migration/flag
|
||||
change that shares an *entity* with a later failure but *none of its words*, with
|
||||
a more-recent confounder planted to defeat naive recency).
|
||||
|
||||
Timeline of stored memories (left = 3 weeks ago, right = today):
|
||||
|
||||
| When | Memory | Role |
|
||||
|---|---|---|
|
||||
| 21 days ago | `Lowered connection-pool max from 100 → 20 in db.config.yaml` | **the true cause** (shares entity `db.config.yaml` / the DB, not words) |
|
||||
| 9 days ago | `Renamed UserService to AccountService across the API` | confounder (loud, recent, unrelated) |
|
||||
| 4 days ago | `Bumped Postgres driver to 5.2` | confounder (shares "Postgres", more recent than cause) |
|
||||
| **today** | `PagerDuty: checkout timing out under load — "connection acquisition timeout"` | **the failure (symptom)** |
|
||||
|
||||
Vector search ranks by resemblance → it surfaces the driver bump and the
|
||||
timeout log (they share words like "connection", "Postgres"). It ranks the
|
||||
pool-size change near the *bottom* — that memory shares no vocabulary with
|
||||
"timeout under load". Vestige's Retroactive Salience Backfill reaches backward
|
||||
along the shared entity (the database / `db.config.yaml`) and promotes the
|
||||
pool-size change to #1.
|
||||
|
||||
---
|
||||
|
||||
## Frame-by-frame (15s, 30fps, loops)
|
||||
|
||||
**Beat 0 — 0.0s to 1.5s · THE HOOK (payoff visible immediately)**
|
||||
- A red failure card slams in at the right edge (today): `checkout timing out — connection acquisition timeout`. Subtle shake.
|
||||
- Caption, one line, monospace: `your agent's bug, today →`
|
||||
- (Why first: a scroller must see the payoff-shape in 1.5s or they're gone.)
|
||||
|
||||
**Beat 1 — 1.5s to 4.0s · THE TIMELINE FILLS**
|
||||
- A horizontal time axis draws left→right. Four memory cards fade in at their dates: the pool-size change (far left, dim/dormant), the two confounders (mid), the failure (right, red).
|
||||
- The dormant cause is visibly *faded* — small, low-contrast. It looks unimportant. That's the point.
|
||||
|
||||
**Beat 2 — 4.0s to 6.5s · VECTOR SEARCH TRIES (and fails)**
|
||||
- Label appears: `vector search` with a small magnifying-glass icon.
|
||||
- Three thin gray "similarity" beams shoot from the failure card to the cards that *share words*: the driver bump and (self) the log. A rank badge appears: the true cause gets tagged `#7` in gray, sinking to the bottom.
|
||||
- Caption: `finds what looks like the bug`
|
||||
- Beat ends with a gray ✕ over the search — it never touched the real cause.
|
||||
|
||||
**Beat 3 — 6.5s to 10.5s · THE ARROW SNAPS BACK (the money moment)**
|
||||
- Everything else desaturates. A single bright teal arrow launches from the failure card and travels *right-to-left, backward in time*, deliberately skipping the two confounders (each pings faintly and is passed over — "shares words, not entity" micro-label flickers).
|
||||
- The arrow lands on the dormant pool-size card 21 days back. On contact the card **ignites**: scales up, fills teal, snaps from dim to bright. A link line labeled `same entity: db.config.yaml` connects them.
|
||||
- Caption: `Vestige reaches back to what caused it`
|
||||
- This is the frame that gets clipped and reshared. Make the snap fast and physical (ease-in, slight overshoot).
|
||||
|
||||
**Beat 4 — 10.5s to 13.0s · THE VERDICT**
|
||||
- The promoted cause card shows a teal `#1` badge; the vector `#7` badge greys beside it for contrast.
|
||||
- Two-line stat, big: `root-cause recall@1` / `Vestige 60% · vector search 0%`
|
||||
- Small honest sub-label: `on CauseBench · reproducible`
|
||||
|
||||
**Beat 5 — 13.0s to 15.0s · SIGNATURE + LOOP RESET**
|
||||
- Wordmark `vestige` fades in bottom-left, tiny. Tagline: `memory that finds the cause, not the resemblance.`
|
||||
- Everything gently fades and the failure card is already sliding back in at the right — the loop restart is seamless (no hard cut).
|
||||
|
||||
---
|
||||
|
||||
## Production notes
|
||||
|
||||
- **Format:** build in HTML/CSS/SVG (animated), screen-record to MP4 + GIF.
|
||||
Alternatively Remotion (React) if you want a clean MP4 export pipeline — but
|
||||
the animated-SVG prototype below is enough to record from directly.
|
||||
- **Palette:** teal `#1d9e75` = Vestige/cause/truth; gray `#888780` = vector
|
||||
search/confounders/noise; red `#e24b4a` = the failure only. Two-color
|
||||
discipline + one alarm color. Works on light and dark.
|
||||
- **Text:** monospace for the memory-card content (feels like real logs);
|
||||
sans for captions. No narration — captions carry it, so it works muted in a
|
||||
feed (most X video autoplays silent).
|
||||
- **Honesty guardrails (non-negotiable, same as the chart):** the stat frame
|
||||
must say `60%` with `on CauseBench` attached, and pair it with `vector search
|
||||
0%`. Never imply an industry benchmark. Never show the FALSE cross-session
|
||||
claim.
|
||||
- **Loop length:** 15s is the sweet spot for X autoplay + a clean GIF under
|
||||
~8MB. If the GIF is too heavy, cut Beat 1 to 2s and land at 13s total.
|
||||
|
||||
---
|
||||
|
||||
## The X post it pins to
|
||||
|
||||
> Every AI memory tool is built on vector search. Vector search finds what
|
||||
> *looks like* your bug.
|
||||
>
|
||||
> But a root cause never looks like the bug it creates.
|
||||
>
|
||||
> So I built memory that reaches *backward in time* to the change that actually
|
||||
> caused it. 60% root-cause recall where vector search scores 0%. 🧵👇
|
||||
>
|
||||
> [pinned: the 15s clip]
|
||||
|
||||
(Then the thread: the wall-of-zeros chart, the repro command, the "here's where
|
||||
it's weak" honesty tweet.)
|
||||
282
docs/launch/causal-brain-demo.html
Normal file
282
docs/launch/causal-brain-demo.html
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Vestige — causal brain demo</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html,body { height:100%; background:#05060a; overflow:hidden; }
|
||||
#stage { position:fixed; inset:0; }
|
||||
canvas { display:block; width:100%; height:100%; }
|
||||
.layer { position:fixed; inset:0; pointer-events:none; }
|
||||
.cap {
|
||||
position:fixed; left:0; right:0; bottom:8.5%;
|
||||
text-align:center; font:500 clamp(15px,2.4vw,26px)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
|
||||
letter-spacing:.01em; color:#cfe9ff; opacity:0; transition:opacity .5s ease;
|
||||
text-shadow:0 0 24px rgba(30,180,255,.35); padding:0 6%;
|
||||
}
|
||||
.cap.mono { font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace; font-size:clamp(13px,1.9vw,20px); }
|
||||
.verdict {
|
||||
position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); text-align:center; opacity:0;
|
||||
transition:opacity .6s ease; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
|
||||
padding:clamp(18px,3vw,40px) clamp(28px,6vw,80px); border-radius:20px;
|
||||
background:radial-gradient(ellipse at center, rgba(5,7,14,.86) 0%, rgba(5,7,14,.72) 60%, rgba(5,7,14,0) 100%);
|
||||
}
|
||||
.verdict .k { font-size:clamp(13px,1.8vw,18px); color:#9fd0e4; letter-spacing:.16em; text-transform:uppercase; }
|
||||
.verdict .v { font-size:clamp(32px,6.4vw,72px); font-weight:600; margin-top:.12em; line-height:1.05;
|
||||
background:linear-gradient(90deg,#7fe6c0,#6ef0e6,#a6dcff); -webkit-background-clip:text; background-clip:text; color:transparent;
|
||||
filter:drop-shadow(0 0 26px rgba(110,240,220,.45)); }
|
||||
.verdict .s { font-size:clamp(11px,1.5vw,15px); color:#8fb0be; margin-top:.6em; font-family:"SF Mono",ui-monospace,monospace; letter-spacing:.04em; }
|
||||
.wordmark { position:fixed; left:5%; bottom:6%; font:600 clamp(16px,2.2vw,24px)/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
letter-spacing:.28em; color:#5dcaa5; opacity:0; transition:opacity .8s ease; text-shadow:0 0 20px rgba(93,202,165,.4); }
|
||||
.replay { position:fixed; right:16px; bottom:14px; font:400 13px/1 -apple-system,sans-serif; color:#7c8a97;
|
||||
background:rgba(255,255,255,.04); border:.5px solid rgba(255,255,255,.14); border-radius:8px; padding:6px 12px; cursor:pointer; pointer-events:auto; }
|
||||
.replay:hover { background:rgba(255,255,255,.08); color:#cfe9ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"><canvas id="c"></canvas></div>
|
||||
<div class="layer">
|
||||
<div class="cap mono" id="cap"></div>
|
||||
<div class="verdict" id="verdict">
|
||||
<div class="k">root-cause recall@1</div>
|
||||
<div class="v">Vestige 60% · vector search 0%</div>
|
||||
<div class="s">on CauseBench · reproducible</div>
|
||||
</div>
|
||||
<div class="wordmark" id="wordmark">VESTIGE</div>
|
||||
</div>
|
||||
<button class="replay" id="replay">replay</button>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
|
||||
const cap = document.getElementById('cap'), verdict = document.getElementById('verdict');
|
||||
const wordmark = document.getElementById('wordmark');
|
||||
let W, H, DPR;
|
||||
function viewport(){
|
||||
const w = window.innerWidth || document.documentElement.clientWidth || document.getElementById('stage').clientWidth || 1280;
|
||||
const h = window.innerHeight || document.documentElement.clientHeight || document.getElementById('stage').clientHeight || 720;
|
||||
return { w: Math.max(w, 320), h: Math.max(h, 240) };
|
||||
}
|
||||
function resize(){
|
||||
DPR = Math.min(devicePixelRatio||1, 2);
|
||||
const { w, h } = viewport();
|
||||
W = cv.width = w*DPR; H = cv.height = h*DPR;
|
||||
cv.style.width = w+'px'; cv.style.height = h+'px';
|
||||
}
|
||||
function reflow(){ resize(); if(typeof seedBrain==='function'){ seedBrain(); placeActors(); } }
|
||||
resize(); addEventListener('resize', reflow);
|
||||
if(window.ResizeObserver){ let first=true; new ResizeObserver(()=>{ if(first){first=false;return;} reflow(); }).observe(document.getElementById('stage')); }
|
||||
|
||||
// ---- iridescent thin-film palette: indigo -> teal -> cyan -> magenta rim ----
|
||||
// hand-tuned so the CORE reads indigo/teal (living tissue) and motion shifts it
|
||||
// toward cyan/magenta, never muddy orange. w in [0,1].
|
||||
function spectral(w){
|
||||
w = ((w%1)+1)%1;
|
||||
// 4 anchor colors around the film band, catmull-ish blend
|
||||
const stops = [
|
||||
[0.20,0.28,0.95], // indigo
|
||||
[0.20,0.85,0.90], // cyan-teal
|
||||
[0.45,1.00,0.72], // mint
|
||||
[0.85,0.45,1.00], // magenta rim
|
||||
];
|
||||
const f = w*stops.length, i = Math.floor(f)%stops.length, frac = f-Math.floor(f);
|
||||
const a = stops[i], b = stops[(i+1)%stops.length];
|
||||
return [ a[0]+(b[0]-a[0])*frac, a[1]+(b[1]-a[1])*frac, a[2]+(b[2]-a[2])*frac ];
|
||||
}
|
||||
|
||||
// ---- brain point cloud: two overlapping lobes, denser near centre ----
|
||||
const N = 4200;
|
||||
const pts = [];
|
||||
function seedBrain(){
|
||||
pts.length = 0;
|
||||
const cx = W*0.5, cy = H*0.55, s = Math.min(W,H)*0.30;
|
||||
for(let i=0;i<N;i++){
|
||||
// pick a lobe (clear left/right hemispheres)
|
||||
const lobe = Math.random()<0.5 ? -1 : 1;
|
||||
const r = Math.pow(Math.random(), 0.5); // center-weighted -> dense core
|
||||
const a = Math.random()*Math.PI*2;
|
||||
const ex = 0.62, ey = 0.98;
|
||||
let x = Math.cos(a)*r*ex, y = Math.sin(a)*r*ey;
|
||||
x += lobe*0.40; // hemisphere split
|
||||
// pinch the top (frontal) and round the base so silhouette reads as a brain
|
||||
y *= (1.0 - 0.18*Math.max(0,-y));
|
||||
// sulci wrinkle
|
||||
const wob = 0.06*Math.sin(6*x)*Math.sin(5*y);
|
||||
const px = cx + (x+wob)*s, py = cy + (y+wob)*s;
|
||||
pts.push({
|
||||
hx:px, hy:py, // brain-home
|
||||
x:px + (Math.random()-0.5)*60, y:py + (Math.random()-0.5)*60,
|
||||
vx:0, vy:0,
|
||||
// base hue biased toward the indigo->teal core of the palette
|
||||
base:0.02+Math.random()*0.42,
|
||||
life:Math.random(),
|
||||
r:0.8+Math.random()*1.6,
|
||||
ignite:0, // 0..1 activation glow
|
||||
});
|
||||
}
|
||||
}
|
||||
seedBrain(); addEventListener('resize', seedBrain);
|
||||
|
||||
// ---- named actors (screen positions in brain space) ----
|
||||
function homeAt(fx, fy){ // fractional brain coords -> nearest cloud point
|
||||
const cx=W*0.5, cy=H*0.55, s=Math.min(W,H)*0.30;
|
||||
return { x: cx+fx*s, y: cy+fy*s };
|
||||
}
|
||||
let actors = {};
|
||||
function placeActors(){
|
||||
actors = {
|
||||
cause: homeAt(-0.92, 0.35), // deep left lobe, dormant
|
||||
conf1: homeAt(-0.10,-0.45),
|
||||
conf2: homeAt( 0.30, 0.55),
|
||||
fail: homeAt( 0.92,-0.10), // deep right, the symptom
|
||||
};
|
||||
}
|
||||
placeActors(); addEventListener('resize', placeActors);
|
||||
|
||||
// ---- animation clock / beats ----
|
||||
let t0 = performance.now();
|
||||
const DUR = 15000;
|
||||
let arrow = 0; // 0..1 backward-trace progress
|
||||
let failFlare = 0, vecStall = 0, causeGlow = 0;
|
||||
|
||||
function setCap(s, mono){ cap.textContent = s; cap.className = 'cap'+(mono?' mono':''); cap.style.opacity = s? 1:0; }
|
||||
|
||||
function schedule(el){ el.style.opacity = 1; }
|
||||
function reset(){
|
||||
t0 = performance.now(); arrow=0; failFlare=0; vecStall=0; causeGlow=0;
|
||||
verdict.style.opacity=0; wordmark.style.opacity=0; setCap('');
|
||||
seedBrain();
|
||||
}
|
||||
document.getElementById('replay').addEventListener('click', reset);
|
||||
|
||||
// ---- draw a soft additive glow blob ----
|
||||
function glow(x,y,rad,col,alpha){
|
||||
const g = ctx.createRadialGradient(x,y,0,x,y,rad);
|
||||
g.addColorStop(0, `rgba(${col[0]},${col[1]},${col[2]},${alpha})`);
|
||||
g.addColorStop(1, `rgba(${col[0]},${col[1]},${col[2]},0)`);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x,y,rad,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
function frame(now){
|
||||
const t = (now - t0);
|
||||
if(t > DUR){ reset(); requestAnimationFrame(frame); return; }
|
||||
const pulse = 0.5 + 0.5*Math.sin(now*0.002); // ~0.32Hz breath
|
||||
|
||||
// ---- beat logic ----
|
||||
// 0.0-1.5s brain forms + settles; failure fades in ~0.3s
|
||||
// 4.0-6.5s vector search stalls on confounders
|
||||
// 6.5-10.5s backward trace fires
|
||||
// 10.5-13s verdict
|
||||
// 13-15s signature
|
||||
failFlare = t>300 ? Math.min(1,(t-300)/700) : 0;
|
||||
if(t>4000 && t<6500){ vecStall = Math.min(1,(t-4000)/600); setCap('vector search: finds what looks like the bug', true); }
|
||||
if(t>=1500 && t<4000){ setCap('your agent’s bug, today →', true); }
|
||||
if(t>=6500 && t<10500){
|
||||
arrow = Math.min(1,(t-6500)/3400);
|
||||
vecStall = Math.max(0, vecStall-0.02);
|
||||
setCap('Vestige reaches back to what caused it', true);
|
||||
}
|
||||
if(t>=9200){ causeGlow = Math.min(1,(t-9200)/900); }
|
||||
if(t>=10500 && t<13000){ setCap(''); verdict.style.opacity=1; }
|
||||
if(t>=13000){ wordmark.style.opacity=1; setCap('memory that finds the cause, not the resemblance', false); }
|
||||
|
||||
// field recedes behind the verdict so the headline is the focus
|
||||
let fieldDim = 1;
|
||||
if(t>=10200 && t<13000) fieldDim = 0.34;
|
||||
else if(t>=13000) fieldDim = 0.7;
|
||||
|
||||
// ---- render ----
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = 'rgba(5,6,10,0.30)'; // trails / motion blur
|
||||
ctx.fillRect(0,0,W,H);
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
|
||||
const formT = Math.min(1, t/1400);
|
||||
// update + draw particles
|
||||
for(let i=0;i<pts.length;i++){
|
||||
const p = pts[i];
|
||||
// spring to brain-home + gentle curl-ish drift
|
||||
const k = 0.02*formT;
|
||||
p.vx += (p.hx-p.x)*k; p.vy += (p.hy-p.y)*k;
|
||||
const ca = now*0.0006 + i*0.3;
|
||||
p.vx += Math.cos(ca+p.hy*0.01)*0.06; p.vy += Math.sin(ca+p.hx*0.01)*0.06;
|
||||
// breathing: tighten toward center on systole
|
||||
p.vx *= 0.90; p.vy *= 0.90;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
|
||||
// ignition decays
|
||||
p.ignite *= 0.94;
|
||||
|
||||
// hue: base + velocity-driven film shift + breath
|
||||
const speed = Math.hypot(p.vx,p.vy);
|
||||
let w = p.base + Math.min(speed*0.05,0.25) + 0.05*pulse + p.ignite*0.35;
|
||||
const col = spectral(w);
|
||||
// brighter, luminous tissue — floor keeps it from muddy, scale keeps it vivid
|
||||
let R = 30+col[0]*225, G = 40+col[1]*215, B = 90+col[2]*165;
|
||||
let a = (0.09 + 0.16*formT + p.ignite*0.7) * fieldDim;
|
||||
const rad = p.r*DPR*(0.85 + p.ignite*3);
|
||||
glow(p.x, p.y, rad*3.4, [R|0,G|0,B|0], a*0.45);
|
||||
ctx.fillStyle = `rgba(${R|0},${G|0},${B|0},${Math.min(1,a*1.9)})`;
|
||||
ctx.beginPath(); ctx.arc(p.x,p.y,rad,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// ---- failure flare (red) ----
|
||||
if(failFlare>0){
|
||||
const shake = failFlare<1 ? (Math.random()-0.5)*4*DPR : 0;
|
||||
glow(actors.fail.x+shake, actors.fail.y, 70*DPR*(0.7+0.3*pulse), [230,70,70], 0.5*failFlare);
|
||||
glow(actors.fail.x+shake, actors.fail.y, 24*DPR, [255,180,170], 0.8*failFlare);
|
||||
}
|
||||
|
||||
// ---- vector search: gray beams to confounders, then a fail cross ----
|
||||
if(vecStall>0){
|
||||
ctx.strokeStyle = `rgba(150,150,150,${0.5*vecStall})`;
|
||||
ctx.lineWidth = 1.2*DPR; ctx.setLineDash([5*DPR,4*DPR]);
|
||||
[actors.conf1, actors.conf2].forEach(c=>{
|
||||
ctx.beginPath(); ctx.moveTo(actors.fail.x,actors.fail.y); ctx.lineTo(c.x,c.y); ctx.stroke();
|
||||
});
|
||||
ctx.setLineDash([]);
|
||||
// faint pulses on the confounders it wrongly surfaces
|
||||
[actors.conf1, actors.conf2].forEach(c=> glow(c.x,c.y,26*DPR,[150,150,150],0.25*vecStall));
|
||||
}
|
||||
|
||||
// ---- the backward-trace arrow: bright iridescent light travelling fail -> cause ----
|
||||
if(arrow>0){
|
||||
const ax = actors.fail.x + (actors.cause.x-actors.fail.x)*arrow;
|
||||
const ay = actors.fail.y + (actors.cause.y-actors.fail.y)*arrow
|
||||
+ Math.sin(arrow*Math.PI)* -60*DPR; // arc up through the cortex
|
||||
// trailing comet
|
||||
for(let s=0;s<14;s++){
|
||||
const tt = Math.max(0, arrow - s*0.02);
|
||||
const tx = actors.fail.x + (actors.cause.x-actors.fail.x)*tt;
|
||||
const ty = actors.fail.y + (actors.cause.y-actors.fail.y)*tt + Math.sin(tt*Math.PI)*-60*DPR;
|
||||
glow(tx,ty, (10-s*0.5)*DPR, [90,230,210], 0.22*(1-s/14));
|
||||
}
|
||||
glow(ax,ay, 16*DPR, [140,245,255], 0.9);
|
||||
glow(ax,ay, 40*DPR, [90,220,255], 0.4);
|
||||
// ignite nearby particles as it passes (propagating thought)
|
||||
for(let i=0;i<pts.length;i++){
|
||||
const p=pts[i]; const d=Math.hypot(p.x-ax,p.y-ay);
|
||||
if(d < 55*DPR) p.ignite = Math.min(1, p.ignite + (1-d/(55*DPR))*0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cause neuron ignites ----
|
||||
if(causeGlow>0){
|
||||
glow(actors.cause.x, actors.cause.y, 80*DPR*(0.7+0.3*pulse), [93,230,180], 0.55*causeGlow);
|
||||
glow(actors.cause.x, actors.cause.y, 26*DPR, [180,255,220], 0.9*causeGlow);
|
||||
// ripple ring
|
||||
const rr = (1-((t-9200)%1400/1400))*90*DPR;
|
||||
ctx.strokeStyle = `rgba(93,230,180,${0.4*causeGlow})`; ctx.lineWidth=2*DPR;
|
||||
ctx.beginPath(); ctx.arc(actors.cause.x,actors.cause.y, 90*DPR-rr, 0, Math.PI*2); ctx.stroke();
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue