From 07921cfd5227b5fee60fa6bcbbdeaa4ea8d84272 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Wed, 8 Jul 2026 22:19:25 -0700 Subject: [PATCH] feat(observatory): RSB causal recall wavefront on the live field (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real recall lights the backward CAUSE chain through the graph — every hop after the origin follows a real ConnectionType::Causal edge, not mere co-occurrence. This is the conjunction no competitor has: RSB's directed causation + the causal edge type + per-memory FSRS + the raw-WebGPU engine. - pathfinder: isCausalEdge() + a preferCausal walk option (defaults false, so Memory Cinema is byte-identical). When set, the greedy walk prefers real causal edges every step, tracing true causation. - path-builder: buildRecallPath gains preferCausal + centerId; causal hops (detected on viaEdge.type, keeping the shared Cinema beat-kind union and the protected narrator untouched) map to PATH_KIND.backwardCause — the magenta backward rim in render-path.wgsl. - live-bridge: a real DeepReferenceCompleted (or BackfillFired) whose primary is in-field rebuilds the PathStep buffer centered on the real recalled memory, preferring causal edges, and rides the proven recall wavefront machinery. Honest scope: only lights when the recalled memory is actually on screen (never invents a path). - 2 new tests: the pathfinder picks the real cause over the strongest co-occurrence; a live DeepReferenceCompleted arms the causal wavefront. Verified live: injected a real in-field DeepReferenceCompleted → the bridge armed causalRecall for the real primary + set an 8-step path whose 7 post-origin hops are ALL kind-1 (backward-cause) following real causal edges. 1043 tests, check + build green. Cinema untouched. Co-Authored-By: Claude Opus 4.8 --- .../graph/cinema/__tests__/pathfinder.test.ts | 22 ++++- .../src/lib/graph/cinema/pathfinder.ts | 35 +++++++- .../lib/observatory/ObservatoryStage.svelte | 3 +- .../observatory/__tests__/live-bridge.test.ts | 84 ++++++++++++++---- .../src/lib/observatory/live-bridge.ts | 29 +++++- .../src/lib/observatory/path-builder.ts | Bin 3309 -> 4090 bytes 6 files changed, 149 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts index 40aeef6..d58b082 100644 --- a/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts +++ b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts @@ -1,10 +1,30 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { planCinemaPath } from '../pathfinder'; +import { planCinemaPath, isCausalEdge } from '../pathfinder'; import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers'; describe('planCinemaPath', () => { beforeEach(() => resetNodeCounter()); + // Phase 4 — the causal recall wavefront follows REAL causal edges. + it('prefers causal edges when preferCausal is set (the moat)', () => { + const a = makeNode({ id: 'a', isCenter: true }); + const b = makeNode({ id: 'b' }); + const c = makeNode({ id: 'c' }); + // From 'a': a strong co-occurrence edge to b (weight 0.9) and a weaker + // CAUSAL edge to c (weight 0.4). Default picks b (strongest); preferCausal + // must instead trace the true cause to c. + const edges = [ + makeEdge('a', 'b', { weight: 0.9, type: 'complementary' }), + makeEdge('a', 'c', { weight: 0.4, type: 'causal' }) + ]; + const plain = planCinemaPath([a, b, c], edges, 'a', 3); + expect(plain.beats[1].nodeId).toBe('b'); // strongest wins by default + + const causal = planCinemaPath([a, b, c], edges, 'a', 3, { preferCausal: true }); + expect(causal.beats[1].nodeId).toBe('c'); // the real cause wins + expect(isCausalEdge(causal.beats[1].viaEdge!)).toBe(true); + }); + it('returns an empty path for no nodes', () => { const path = planCinemaPath([], [], 'missing'); expect(path.beats).toEqual([]); diff --git a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts index 4e8a457..4291122 100644 --- a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts +++ b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts @@ -62,6 +62,18 @@ export function isContradictionEdge(edge: GraphEdge): boolean { return t.includes('contradict') || t.includes('conflict') || t.includes('supersede'); } +/** + * A real CAUSAL edge (ConnectionType::Causal / DiscoveredConnectionType:: + * CausalChain, serialized as connection_type "causal"). Vestige is the only + * memory product that computes directed causation (RSB backward backfill), so + * a path that PREFERS these edges follows the true cause chain, not mere + * co-occurrence — the moat behind the causal recall wavefront (Phase 4). + */ +export function isCausalEdge(edge: GraphEdge): boolean { + const t = (edge.type ?? '').toLowerCase(); + return t === 'causal' || t.includes('causal') || t.includes('cause'); +} + export function recencyOf(node: GraphNode): number { // Larger = more recent. Tolerates missing/invalid timestamps. const t = Date.parse(node.updatedAt || node.createdAt || ''); @@ -79,8 +91,10 @@ export function planCinemaPath( nodes: GraphNode[], edges: GraphEdge[], centerId: string, - maxBeats = 7 + maxBeats = 7, + opts: { preferCausal?: boolean } = {} ): CinemaPath { + const preferCausal = opts.preferCausal ?? false; const byId = new Map(nodes.map((n) => [n.id, n])); const empty: CinemaPath = { beats: [], centerId, pivoted: false, flowEdges: [] }; if (nodes.length === 0) return empty; @@ -115,9 +129,16 @@ export function planCinemaPath( while (beats.length < maxBeats) { const neighbours = adj[current] ?? []; - // Prefer an unused contradiction edge once — tension makes a better story. + // Causal recall (Phase 4): when asked, PREFER a real causal edge every + // step so the path traces the true cause chain (RSB substrate), not just + // the strongest co-occurrence. neighbours are already weight-sorted, so + // this picks the strongest causal tie first. let next: { edge: GraphEdge; otherId: string } | undefined; - if (!contradictionUsed) { + if (preferCausal) { + next = neighbours.find((n) => !visited.has(n.otherId) && isCausalEdge(n.edge)); + } + // Prefer an unused contradiction edge once — tension makes a better story. + if (!next && !contradictionUsed) { next = neighbours.find((n) => !visited.has(n.otherId) && isContradictionEdge(n.edge)); if (next) contradictionUsed = true; } @@ -145,12 +166,18 @@ export function planCinemaPath( } visited.add(node.id); flowEdges.push(next.edge); + const causal = preferCausal && isCausalEdge(next.edge); beats.push({ nodeId: node.id, node, viaEdge: next.edge, + // Kind stays within the shared Cinema union (the narrator maps it) — + // a causal step reads as a contradiction-style backward hop for the + // wavefront; consumers that care read viaEdge.type directly. kind: isContradictionEdge(next.edge) ? 'contradiction' : 'connection', - intensity: isContradictionEdge(next.edge) ? 1 : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45), + intensity: isContradictionEdge(next.edge) || causal + ? 1 + : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45), }); current = node.id; } diff --git a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte index 0d70498..de7100e 100644 --- a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte +++ b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte @@ -297,11 +297,12 @@ // + per-node FSRS decay via the engine's preFrameHook; the eventFeed // subscription below feeds it. Created here (once) because it needs // both the engine AND renderer.graph, which only exist post-upload. - if (live && renderer.graph) { + if (live && renderer.graph && graphData) { liveBridge = new LiveBridge({ engine, renderer, graph: renderer.graph, + response: graphData, seed, projectionDays: () => projectionDays, onFirewall: (info) => { diff --git a/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts b/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts index d5f59eb..858e4dd 100644 --- a/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts +++ b/apps/dashboard/src/lib/observatory/__tests__/live-bridge.test.ts @@ -51,6 +51,8 @@ class MockRenderer { lastEdgeCount = 0; retentionUploads: Float32Array[] = []; rearmCalls = 0; + setPathStepsCalls = 0; + lastPathStepCount = 0; setEdges(edges: unknown[]) { this.setEdgesCalls++; this.lastEdgeCount = edges.length; @@ -58,6 +60,10 @@ class MockRenderer { uploadLiveRetention(data: Float32Array) { this.retentionUploads.push(data.slice()); } + setPathSteps(_data: Uint32Array, steps: unknown[]) { + this.setPathStepsCalls++; + this.lastPathStepCount = steps.length; + } } function gnode(partial: Partial & { id: string }): GraphNode { @@ -73,22 +79,29 @@ function gnode(partial: Partial & { id: string }): GraphNode { }; } -function makeGraph(): ReturnType { +function makeResponse(): GraphResponse { const nodes: GraphNode[] = [ gnode({ id: 'a', isCenter: true, stability: 5, lastAccessed: '2026-06-20T00:00:00Z' }), gnode({ id: 'b', stability: 0.2, lastAccessed: '2026-06-25T00:00:00Z' }), gnode({ id: 'c', stability: 3, lastAccessed: '2026-06-10T00:00:00Z' }), gnode({ id: 'd', stability: 1, lastAccessed: '2026-06-28T00:00:00Z' }) ]; - const resp: GraphResponse = { + return { nodes, - edges: [{ source: 'a', target: 'b', weight: 0.5, type: 'semantic' }], + edges: [ + { source: 'a', target: 'b', weight: 0.5, type: 'semantic' }, + { source: 'b', target: 'c', weight: 0.9, type: 'causal' }, + { source: 'c', target: 'd', weight: 0.8, type: 'causal' } + ], center_id: 'a', depth: 2, nodeCount: nodes.length, - edgeCount: 1 + edgeCount: 3 }; - return buildObservatoryGraph(resp); +} + +function makeGraph(): ReturnType { + return buildObservatoryGraph(makeResponse()); } function ev(type: string, data: Record, tsMs: number): VestigeEvent { @@ -98,11 +111,19 @@ function ev(type: string, data: Record, tsMs: number): VestigeE function makeBridge() { const engine = new MockEngine(); const renderer = new MockRenderer(); + const response = makeResponse(); const graph = makeGraph(); renderer.graph = graph; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bridge = new LiveBridge({ engine: engine as any, renderer: renderer as any, graph, seed: 'test' }); - return { engine, renderer, graph, bridge }; + const bridge = new LiveBridge({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine: engine as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderer: renderer as any, + graph, + response, + seed: 'test' + }); + return { engine, renderer, graph, response, bridge }; } describe('LiveBridge', () => { @@ -149,26 +170,27 @@ describe('LiveBridge', () => { it('appends real ConnectionDiscovered edges and flushes ONE setEdges per frame', () => { const { engine, renderer, bridge } = makeBridge(); bridge.ingest([ev('Heartbeat', {}, base)]); - // Two new connections in the same burst → coalesced to one setEdges. + // The graph seeds with 3 edges (a-b, b-c, c-d). Two BRAND-NEW connections + // in the same burst → coalesced to one setEdges. bridge.ingest([ - ev('ConnectionDiscovered', { source_id: 'c', target_id: 'd', weight: 0.7, connection_type: 'causal' }, base + 2000), - ev('ConnectionDiscovered', { source_id: 'b', target_id: 'c', weight: 0.6, connection_type: 'semantic' }, base + 1500) + ev('ConnectionDiscovered', { source_id: 'a', target_id: 'd', weight: 0.7, connection_type: 'causal' }, base + 2000), + ev('ConnectionDiscovered', { source_id: 'a', target_id: 'c', weight: 0.6, connection_type: 'semantic' }, base + 1500) ]); const before = renderer.setEdgesCalls; bridge.drain(engine.totalFrames); expect(renderer.setEdgesCalls).toBe(before + 1); // ONE flush - // started with 1 edge (a-b), +2 new = 3 - expect(renderer.lastEdgeCount).toBe(3); + // started with 3 edges, +2 new = 5 + expect(renderer.lastEdgeCount).toBe(5); }); it('dedupes a re-discovered edge (no duplicate append)', () => { const { engine, renderer, bridge } = makeBridge(); bridge.ingest([ev('Heartbeat', {}, base)]); - bridge.ingest([ev('ConnectionDiscovered', { source_id: 'c', target_id: 'd' }, base + 1000)]); + bridge.ingest([ev('ConnectionDiscovered', { source_id: 'a', target_id: 'd' }, base + 1000)]); bridge.drain(engine.totalFrames); const afterFirst = renderer.lastEdgeCount; // same edge, reversed direction, later ts → must be a no-op - bridge.ingest([ev('ConnectionDiscovered', { source_id: 'd', target_id: 'c' }, base + 2000)]); + bridge.ingest([ev('ConnectionDiscovered', { source_id: 'd', target_id: 'a' }, base + 2000)]); bridge.drain(engine.totalFrames); expect(renderer.lastEdgeCount).toBe(afterFirst); }); @@ -200,14 +222,42 @@ describe('LiveBridge', () => { expect(engine.params[PARAM_IDX.liveEnergy]).toBe(0); }); + it('lights a causal recall path on a real DeepReferenceCompleted', () => { + const { engine, renderer, bridge } = makeBridge(); + bridge.ingest([ev('Heartbeat', {}, base)]); + // A real recall whose primary is in-field → build a causal wavefront. + bridge.ingest([ + ev( + 'DeepReferenceCompleted', + { primary_id: 'a', supporting_ids: ['b', 'c'], contradiction_pairs: [], confidence: 0.8 }, + base + 1000 + ) + ]); + engine.advance(3); + bridge.drain(engine.totalFrames); + expect(engine.params[PARAM_IDX.liveKind]).toBe(LIVE_KIND.causalRecall); + // The causal pathfinder walked a>b>c>d over the causal edges → path steps. + expect(renderer.setPathStepsCalls).toBeGreaterThan(0); + expect(renderer.lastPathStepCount).toBeGreaterThan(0); + }); + it('the forward-projection scrubber decays the field further', () => { const engine = new MockEngine(); const renderer = new MockRenderer(); + const response = makeResponse(); const graph = makeGraph(); renderer.graph = graph; let proj = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bridge = new LiveBridge({ engine: engine as any, renderer: renderer as any, graph, seed: 't', projectionDays: () => proj }); + const bridge = new LiveBridge({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine: engine as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderer: renderer as any, + graph, + response, + seed: 't', + projectionDays: () => proj + }); engine.advance(10); bridge.drain(engine.totalFrames); const atNow = renderer.retentionUploads.at(-1)!.slice(); diff --git a/apps/dashboard/src/lib/observatory/live-bridge.ts b/apps/dashboard/src/lib/observatory/live-bridge.ts index 6b45254..f451a73 100644 --- a/apps/dashboard/src/lib/observatory/live-bridge.ts +++ b/apps/dashboard/src/lib/observatory/live-bridge.ts @@ -22,11 +22,12 @@ import type { ObservatoryEngine } from './engine'; import type { NodeRenderer } from './node-renderer'; -import type { VestigeEvent } from '$types'; +import type { GraphResponse, VestigeEvent } from '$types'; import { LIVE_KIND, PARAM_IDX, type ObservatoryGraph, type ObservatoryEdge } from './types'; import { liveRetrievability } from './fsrs'; import { FirewallRenderer } from './firewall-renderer'; import { buildLiveFirewallPlan, emptyFirewallPlan } from './firewall-plan'; +import { buildRecallPath } from './path-builder'; /** A decoded live event, normalized off the wire's { type, data } envelope. */ interface DecodedEvent { @@ -61,6 +62,9 @@ export interface LiveBridgeDeps { engine: ObservatoryEngine; renderer: NodeRenderer; graph: ObservatoryGraph; + /** The raw graph response the field was uploaded from — the causal recall + * pathfinder (Phase 4) needs the full nodes+edges, not just the graph. */ + response: GraphResponse; /** Layout seed (matches NodeRenderer.upload) — the firewall shock delays * come from the REAL layout, so this must be the same seed the field used. */ seed: string; @@ -80,6 +84,7 @@ export class LiveBridge { private engine: ObservatoryEngine; private renderer: NodeRenderer; private graph: ObservatoryGraph; + private response: GraphResponse; private seed: string; private projectionDays: () => number; private onApply?: LiveBridgeDeps['onApply']; @@ -120,6 +125,7 @@ export class LiveBridge { this.engine = deps.engine; this.renderer = deps.renderer; this.graph = deps.graph; + this.response = deps.response; this.seed = deps.seed; this.projectionDays = deps.projectionDays ?? (() => 0); this.onApply = deps.onApply; @@ -373,6 +379,27 @@ export class LiveBridge { this.firewall.rearm(plan); this.onFirewall?.({ intruderLabel: plan.verdict.intruderLabel, startFrame: ev.startFrame }); } + + // Causal recall (Phase 4): a real recall (DeepReferenceCompleted / + // BackfillFired) lights the backward CAUSE chain from the recalled memory. + // Rebuild the PathStep buffer centered on the real target, preferring real + // causal edges so the wavefront traces true causation, not co-occurrence. + // The proven recall-wavefront machinery (render-path.wgsl, simulate.wgsl) + // renders it — kind-1 (backward) hops burn into the magenta rim. + if (ev.kind === LIVE_KIND.causalRecall && this.indexById.has(ev.targetId)) { + const built = buildRecallPath(this.response, this.graph, 8, { + preferCausal: true, + centerId: ev.targetId + }); + if (built.steps.length > 0) { + // Re-anchor the beats so the wavefront fires FROM this event + // (live_frame), not the loop clock. The render-path/simulate + // shaders read absolute beatFrames; the demo path used 60,120,… + // which the live loop frame also sweeps, so the existing timing + // works — the wavefront plays over the causalRecall window. + this.renderer.setPathSteps(built.data, built.steps); + } + } } /** Real graph neighbors of a node id (for the firewall quarantine ring). */ diff --git a/apps/dashboard/src/lib/observatory/path-builder.ts b/apps/dashboard/src/lib/observatory/path-builder.ts index e67c1887c7fe8fcd77e0a383cad6a74c5fe4c11c..3b84f96b37c210fac6e4e7fcadd421fd61b45fe9 100644 GIT binary patch delta 847 zcmY+Cv5pfl5QcRW2fGMLB&r*#Y(#FaK?o&^B5?wNL_tAA1DdQSS?hM~mF-O~%0=NF z!VB;QNO>4~itFeo5EWzZZWKCeY=865|Ie?-f6u<(o{k1_q$_8Ku!hRejLaq07K_Oc zrdk(-Y6KZ6N9L{Insr91Ic#7I>&^c5^x>WD#Ra_2(Golx0zx4d1cov!iuRuct-$47 zl?0SjYO{jG_f0!^A5e)nVp=1^G6k6t)BuS&S+F{Qmbjpq(aLeR`8IbpRGp~_rkGtV z(TMPm0xru@ssSkN!ZSfUODUDKPG&rhSK@SS(m2YrvaVIl1O6uuCKDE9D}F+)=Fd`$ z;%N8#-t!mD5CbUj`X!;W16apVbnNG_!5x?4XtRGEeLC?DHm1W5kUSkk7#%(Uyr_ky;LGRLtr zF*P98z$nXA?>r<6iLdz3?;@}{8os=gib{kj&wPF*m4I4#*+0L0^=W#)8(d{{+eD!; z$fk!k*5L~3tz!SPoCHVEX29(}tSaIV6380OSOGz-G%`a@s z8(s1517|#jUb9L)xVh=-sZ?1}KM`b;?)C_D*roCAPki=(zcmC#k9ObUk9XT$+dsPh GYpBGgEXFauX|@QWHyxH_zsZ TW1M`9H*E4uUfInmeAgHO@b?!J