mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
1b23516579
commit
07921cfd52
6 changed files with 149 additions and 24 deletions
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue