feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control

First AI memory system to model forgetting as a neuroscience-grounded
PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24),
Rac1 cascade worker, migration V10, and dashboard forgetting indicators.

Based on:
- Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right
  lateral PFC as the domain-general inhibitory controller; SIF
  compounds with each stopping attempt.
- Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 —
  Rac1 GTPase as the active synaptic destabilization mechanism.

What's new:
* `suppress` MCP tool — each call compounds `suppression_count` and
  subtracts a `0.15 × count` penalty (saturating at 80%) from
  retrieval scores during hybrid search. Distinct from delete
  (removes) and demote (one-shot).
* Rac1 cascade worker — background sweep piggybacks the 6h
  consolidation loop, walks `memory_connections` edges from
  recently-suppressed seeds, applies attenuated FSRS decay to
  co-activated neighbors. You don't just forget Jake — you fade
  the café, the roommate, the birthday.
* 24h labile window — reversible via `suppress({id, reverse: true})`
  within 24 hours. Matches Nader reconsolidation semantics.
* Migration V10 — additive-only (`suppression_count`, `suppressed_at`
  + partial indices). All v2.0.x DBs upgrade seamlessly on first launch.
* Dashboard: `ForgettingIndicator.svelte` pulses when suppressions
  are active. 3D graph nodes dim to 20% opacity when suppressed.
  New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`,
  `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`.
* Search pipeline: SIF penalty inserted into the accessibility stage
  so it stacks on top of passive FSRS decay.
* Tool count bumped 23 → 24. Cognitive modules 29 → 30.

Memories persist — they are INHIBITED, not erased. `memory.get(id)`
returns full content through any number of suppressions. The 24h
labile window is a grace period for regret.

Also fixes issue #31 (dashboard graph view buggy) as a companion UI
bug discovered during the v2.0.5 audit cycle:

* Root cause: node glow `SpriteMaterial` had no `map`, so
  `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive
  blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square
  edges into hard-edged glowing cubes.
* Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used
  as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog
  density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a`
  to brand violet `0x8b5cf6` with higher opacity. Added explicit
  `scene.background` and a 2000-point starfield for depth.
* 21 regression tests added in `ui-fixes.test.ts` locking every
  invariant in (shared texture singleton, depthWrite:false, scale
  ×6, bloom magic numbers via source regex, starfield presence).

Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed
Clippy: clean across all targets, zero warnings
Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green
Versions: workspace aligned at 2.0.5 across all 6 crates/packages

Closes #31
This commit is contained in:
Sam Valladares 2026-04-14 17:30:30 -05:00
parent 95bde93b49
commit 8178beb961
359 changed files with 8277 additions and 3416 deletions

View file

@ -48,12 +48,14 @@ describe('EdgeManager', () => {
expect(line.userData.target).toBe('b');
});
it('caps opacity at 0.6', () => {
it('caps opacity at 0.8 (raised from 0.6 in v2.0.6 issue #31 fix)', () => {
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
expect(line.material.opacity).toBeLessThanOrEqual(0.8);
// Baseline floor too — with weight 10 we should be at the cap, not below old 0.6
expect(line.material.opacity).toBeGreaterThanOrEqual(0.6);
});
});
@ -122,7 +124,8 @@ describe('EdgeManager', () => {
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0.5);
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
expect(line.material.opacity).toBe(0.65);
});
it('uses easeOutCubic for smooth deceleration', () => {
@ -266,7 +269,8 @@ describe('EdgeManager', () => {
// Both should be fully grown
expect(manager.group.children.length).toBe(2);
manager.group.children.forEach((child) => {
expect((child as any).material.opacity).toBe(0.5);
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
expect((child as any).material.opacity).toBe(0.65);
});
});

View file

@ -3,15 +3,29 @@
*/
import { vi } from 'vitest';
// Minimal canvas gradient mock — collects colour stops so tests can inspect
// them if they want to, but is mostly a no-op for runtime.
function createMockGradient() {
return {
colorStops: [] as Array<{ offset: number; color: string }>,
addColorStop(offset: number, color: string) {
this.colorStops.push({ offset, color });
},
};
}
// Minimal canvas 2D context mock
const mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
createRadialGradient: vi.fn(() => createMockGradient()),
createLinearGradient: vi.fn(() => createMockGradient()),
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '',
fillStyle: '' as string | object,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,

View file

@ -282,10 +282,16 @@ export class MeshBasicMaterial extends BaseMaterial {
}
export class LineBasicMaterial extends BaseMaterial {
depthWrite = true;
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.blending === 'number') this.blending = params.blending;
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
}
}
}
@ -303,10 +309,19 @@ export class PointsMaterial extends BaseMaterial {
}
export class SpriteMaterial extends BaseMaterial {
depthWrite = true;
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.blending === 'number') this.blending = params.blending;
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
if (params.map && typeof params.map === 'object') {
this.map = params.map as { dispose: () => void };
}
}
}
}

View file

@ -0,0 +1,236 @@
/**
* Regression tests for vestige issue #31 (v2.0.6 Phase 1 dashboard UI fix).
*
* Before v2.0.6 the graph view rendered "glowing cubes" instead of round halos,
* with navy edges swallowed by heavy fog. Root cause: the node glow SpriteMaterial
* had no `map` set, so THREE.Sprite rendered as a solid-coloured plane whose
* square edges were then amplified by UnrealBloomPass into hard bright squares.
*
* These tests lock in every property that was broken so any regression surfaces
* as a red test instead of shipping another ugly screenshot into the issue tracker.
*
* The scene.ts assertions are intentionally source-level (fs regex) because the
* real scene.ts pulls in three/addons (OrbitControls, EffectComposer, UnrealBloomPass,
* WebGLRenderer) which are painful to mock in isolation. Reading the .ts file and
* regex-checking the magic numbers catches accidental revert/tweaks without needing
* a full WebGL harness.
*/
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { NodeManager } from '../nodes';
import { EdgeManager } from '../edges';
import { Vector3, AdditiveBlending } from './three-mock';
import { makeNode, makeEdge, resetNodeCounter } from './helpers';
// ---------------------------------------------------------------------------
// 1. Node glow sprite — THE fix for the "glowing cubes" artifact
// ---------------------------------------------------------------------------
describe('issue #31 — node glow sprites render as round halos, not squares', () => {
let manager: NodeManager;
beforeEach(() => {
resetNodeCounter();
manager = new NodeManager();
});
it('glow SpriteMaterial has a map set (the root cause of the square artifact)', () => {
manager.createNodes([makeNode({ id: 'a', retention: 0.8 })]);
const glow = manager.glowMap.get('a')!;
const mat = glow.material as any;
// Without a map, THREE.Sprite renders as a solid coloured plane —
// additive blend + bloom then turns it into a glowing square.
// The fix generates a shared radial-gradient CanvasTexture and assigns
// it here, so bloom has a soft circular shape to diffuse.
expect(mat.map).not.toBeNull();
expect(mat.map).toBeDefined();
});
it('glow sprites on multiple nodes SHARE the same texture instance (singleton cache)', () => {
// The shared texture is a module-level cache — if a future refactor
// accidentally creates one per-node we'll leak memory on large graphs.
manager.createNodes([
makeNode({ id: 'a' }),
makeNode({ id: 'b' }),
makeNode({ id: 'c' }),
]);
const a = (manager.glowMap.get('a')!.material as any).map;
const b = (manager.glowMap.get('b')!.material as any).map;
const c = (manager.glowMap.get('c')!.material as any).map;
expect(a).toBe(b);
expect(b).toBe(c);
});
it('glow sprite has depthWrite:false to prevent z-fighting with the sphere behind it', () => {
manager.createNodes([makeNode({ id: 'a' })]);
const mat = manager.glowMap.get('a')!.material as any;
expect(mat.depthWrite).toBe(false);
});
it('glow sprite uses additive blending (required for bloom to read as light)', () => {
manager.createNodes([makeNode({ id: 'a' })]);
const mat = manager.glowMap.get('a')!.material as any;
expect(mat.blending).toBe(AdditiveBlending);
});
it('glow sprite scale uses the new 6× multiplier (was 4× — gradient needed more footprint)', () => {
// size = 0.5 + retention*2 → 0.5 + 1.0*2 = 2.5
// glow scale with new formula: 2.5 * 6 * 1.0 = 15
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
const glow = manager.glowMap.get('full')!;
expect(glow.scale.x).toBeCloseTo(15, 5);
expect(glow.scale.y).toBeCloseTo(15, 5);
});
it('glow sprite base opacity is 0.3 + retention*0.35 (was 0.15 + retention*0.2)', () => {
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
const mat = manager.glowMap.get('full')!.material as any;
// 0.3 + 1.0 * 0.35 = 0.65
expect(mat.opacity).toBeCloseTo(0.65, 5);
});
it('suppressed node glow opacity drops to 0.1 (v2.0.5 active forgetting)', () => {
manager.createNodes([makeNode({ id: 's', retention: 0.8, suppression_count: 2 })]);
const mat = manager.glowMap.get('s')!.material as any;
expect(mat.opacity).toBeCloseTo(0.1, 5);
});
});
// ---------------------------------------------------------------------------
// 2. Edge materials — dark navy → brand violet, higher opacity
// ---------------------------------------------------------------------------
describe('issue #31 — edges are brand violet and actually visible', () => {
let manager: EdgeManager;
let positions: Map<string, InstanceType<typeof Vector3>>;
beforeEach(() => {
manager = new EdgeManager();
positions = new Map([
['a', new Vector3(0, 0, 0)],
['b', new Vector3(10, 0, 0)],
]);
});
it('edge color is brand violet 0x8b5cf6, not the old dark navy 0x4a4a7a', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
const line = manager.group.children[0] as any;
const c = line.material.color;
// 0x8b5cf6 → r=139/255, g=92/255, b=246/255
expect(c.r).toBeCloseTo(0x8b / 255, 3);
expect(c.g).toBeCloseTo(0x5c / 255, 3);
expect(c.b).toBeCloseTo(0xf6 / 255, 3);
// And definitely NOT the old navy 0x4a4a7a (74/255, 74/255, 122/255)
expect(c.r).not.toBeCloseTo(0x4a / 255, 3);
});
it('edges have depthWrite:false so they additively blend through fog cleanly', () => {
manager.createEdges([makeEdge('a', 'b')], positions);
const line = manager.group.children[0] as any;
expect(line.material.depthWrite).toBe(false);
});
it('edge opacity base is 0.25 + weight*0.5 (was 0.1 + weight*0.5)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
const line = manager.group.children[0] as any;
// 0.25 + 0.5 * 0.5 = 0.5
expect(line.material.opacity).toBeCloseTo(0.5, 5);
});
it('edge opacity with low weight still reads (new floor catches regressions)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 0.0 })], positions);
const line = manager.group.children[0] as any;
// Floor is 0.25 — used to be 0.1 which was invisible through fog
expect(line.material.opacity).toBeGreaterThanOrEqual(0.25);
});
it('edge opacity cap is 0.8 (was 0.6)', () => {
manager.createEdges([makeEdge('a', 'b', { weight: 100.0 })], positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeCloseTo(0.8, 5);
});
});
// ---------------------------------------------------------------------------
// 3. Scene config — source-level regex assertions (scene.ts needs three/addons)
// ---------------------------------------------------------------------------
describe('issue #31 — scene.ts bloom/fog/starfield config is locked in', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
let src: string;
beforeAll(() => {
src = readFileSync(resolve(__dirname, '../scene.ts'), 'utf-8');
});
it('fog density is reduced from 0.008 → 0.0035', () => {
// Positive match: the new density appears inside a FogExp2 call
expect(src).toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.0035/i);
// Negative match: the old aggressive density is gone
expect(src).not.toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.008\b/i);
});
it('bloom strength is 0.55 (was 0.8 — was blown out)', () => {
// Match on the constructor signature: (size, strength, radius, threshold)
expect(src).toMatch(
/new UnrealBloomPass\([\s\S]*?,\s*0\.55,\s*0\.6,\s*0\.2\s*\)/
);
// Old values must be gone
expect(src).not.toMatch(/new UnrealBloomPass\([\s\S]*?,\s*0\.8,\s*0\.4,\s*0\.85\s*\)/);
});
it('scene.background is explicitly set (not left as default black void)', () => {
expect(src).toMatch(/scene\.background\s*=/);
});
it('a starfield is created and added to the scene', () => {
// createStarfield helper exists and is called at least once
expect(src).toMatch(/function\s+createStarfield\s*\(/);
expect(src).toMatch(/createStarfield\s*\(\s*\)/);
expect(src).toMatch(/scene\.add\(\s*starfield\s*\)/);
});
it('starfield is exposed on SceneContext (so dispose/update can touch it later)', () => {
expect(src).toMatch(/starfield:\s*THREE\.Points/);
});
it('ACESFilmicToneMapping still active (did not accidentally revert tone map)', () => {
expect(src).toMatch(/ACESFilmicToneMapping/);
});
});
// ---------------------------------------------------------------------------
// 4. Source-level checks on nodes.ts — the shared glow texture helper
// ---------------------------------------------------------------------------
describe('issue #31 — nodes.ts glow texture helper exists and is a singleton', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
let src: string;
beforeAll(() => {
src = readFileSync(resolve(__dirname, '../nodes.ts'), 'utf-8');
});
it('shared glow texture cache exists at module level', () => {
expect(src).toMatch(/let\s+sharedGlowTexture/);
expect(src).toMatch(/function\s+getGlowTexture\s*\(/);
});
it('radial gradient has a transparent outer stop (not hard edge)', () => {
// The key insight — colour stops must go to rgba(255,255,255,0) at the edge
expect(src).toMatch(/createRadialGradient/);
expect(src).toMatch(/rgba\(255,\s*255,\s*255,\s*0(?:\.0)?\)/);
});
it('SpriteMaterial is constructed with a map parameter', () => {
expect(src).toMatch(/new THREE\.SpriteMaterial\(\{[\s\S]*?map:\s*getGlowTexture\(\)/);
});
});