feat(observatory): RSB causal recall wavefront on the live field (Phase 4)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 22:19:25 -07:00
parent 1b23516579
commit 07921cfd52
6 changed files with 149 additions and 24 deletions

View file

@ -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([]);

View file

@ -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;
}

View file

@ -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) => {

View file

@ -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<GraphNode> & { id: string }): GraphNode {
@ -73,22 +79,29 @@ function gnode(partial: Partial<GraphNode> & { id: string }): GraphNode {
};
}
function makeGraph(): ReturnType<typeof buildObservatoryGraph> {
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<typeof buildObservatoryGraph> {
return buildObservatoryGraph(makeResponse());
}
function ev(type: string, data: Record<string, unknown>, tsMs: number): VestigeEvent {
@ -98,11 +111,19 @@ function ev(type: string, data: Record<string, unknown>, 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();

View file

@ -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). */