import { describe, expect, it } from "bun:test"; import { cascadeForget, DEFAULT_CASCADE_FORGET_CONFIG, type CascadeForgetConfig, } from "../cascade-forget.js"; import type { MemoryEntry, MemorySearchResult } from "../store.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeEntry(id: string, text: string, overrides: Partial = {}): MemoryEntry { return { id, text, vector: [1, 0, 0], category: "events", scope: "project:test", importance: 0.6, timestamp: Date.now() - 7 * 86_400_000, metadata: "{}", ...overrides, }; } function createMockStore( entries: MemoryEntry[], searchResults: MemorySearchResult[] = [], ) { const data = new Map(entries.map(e => [e.id, { ...e }])); const updates: Array<{ id: string; importance?: number; metadata?: string }> = []; return { updates, data, async vectorSearch(_vector: number[], limit = 5, minScore = 0.3, _scopeFilter?: string[]) { return searchResults.filter(r => r.score >= minScore).slice(0, limit); }, async update(id: string, upd: any, _scopeFilter?: string[]) { const entry = data.get(id); if (!entry) return null; if (upd.importance !== undefined) entry.importance = upd.importance; if (upd.metadata !== undefined) entry.metadata = upd.metadata; updates.push({ id, ...upd }); return entry; }, } as any; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("cascade-forget", () => { it("returns empty when forgotten entry has no vector", async () => { const store = createMockStore([]); const result = await cascadeForget(store, { id: "forgotten-1", vector: [], scope: "project:test", }); expect(result.demotedCount).toBe(0); }); it("demotes related entries proportional to similarity", async () => { const related1 = makeEntry("rel-1", "high similarity", { importance: 0.6 }); const related2 = makeEntry("rel-2", "medium similarity", { importance: 0.6 }); const searchResults: MemorySearchResult[] = [ { entry: related1, score: 0.95 }, // high sim → big demotion { entry: related2, score: 0.75 }, // lower sim → small demotion ]; const store = createMockStore([related1, related2], searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(2); // High similarity (0.95): demotion = 0.3 * (0.95-0.70)/0.30 = 0.25 // New importance: 0.6 - 0.25 = 0.35 expect(store.data.get("rel-1")!.importance).toBeCloseTo(0.35, 1); // Medium similarity (0.75): demotion = 0.3 * (0.75-0.70)/0.30 = 0.05 // New importance: 0.6 - 0.05 = 0.55 expect(store.data.get("rel-2")!.importance).toBeCloseTo(0.55, 1); }); it("skips the forgotten entry itself", async () => { const forgotten = makeEntry("forgotten-1", "to be forgotten", { importance: 0.6 }); const searchResults: MemorySearchResult[] = [ { entry: forgotten, score: 1.0 }, ]; const store = createMockStore([forgotten], searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(0); }); it("skips already-archived entries", async () => { const archived = makeEntry("arch-1", "archived", { importance: 0.6, metadata: JSON.stringify({ evolution: { status: "archived" } }), }); const searchResults: MemorySearchResult[] = [ { entry: archived, score: 0.9 }, ]; const store = createMockStore([archived], searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(0); }); it("respects importance floor", async () => { const lowEntry = makeEntry("low-1", "already low", { importance: 0.1 }); const searchResults: MemorySearchResult[] = [ { entry: lowEntry, score: 0.95 }, ]; const store = createMockStore([lowEntry], searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(1); // 0.1 - 0.25 = -0.15 → clamped to floor 0.05 expect(store.data.get("low-1")!.importance).toBe(0.05); }); it("demotes tier when importance drops below threshold", async () => { const workingEntry = makeEntry("w-1", "working tier", { importance: 0.6, metadata: JSON.stringify({ tier: "working" }), }); const searchResults: MemorySearchResult[] = [ { entry: workingEntry, score: 0.95 }, ]; const store = createMockStore([workingEntry], searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(1); const updatedMeta = JSON.parse(store.data.get("w-1")!.metadata!); // Importance drops to ~0.35, below 0.5 → demoted to peripheral expect(updatedMeta.tier).toBe("peripheral"); expect(updatedMeta.cascade_forget).toHaveLength(1); }); it("respects maxDemotePerForget limit", async () => { const entries: MemoryEntry[] = []; const searchResults: MemorySearchResult[] = []; for (let i = 0; i < 20; i++) { const e = makeEntry(`rel-${i}`, `entry ${i}`, { importance: 0.6 }); entries.push(e); searchResults.push({ entry: e, score: 0.85 }); } const store = createMockStore(entries, searchResults); const result = await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); expect(result.demotedCount).toBe(10); // default maxDemotePerForget }); it("writes audit trail in metadata", async () => { const related = makeEntry("rel-1", "related note", { importance: 0.6 }); const searchResults: MemorySearchResult[] = [ { entry: related, score: 0.85 }, ]; const store = createMockStore([related], searchResults); await cascadeForget(store, { id: "forgotten-1", vector: [1, 0, 0], scope: "project:test", }); const meta = JSON.parse(store.data.get("rel-1")!.metadata!); expect(meta.cascade_forget).toHaveLength(1); expect(meta.cascade_forget[0].forgottenId).toBe("forgotte"); // first 8 chars expect(meta.cascade_forget[0].from).toBe(0.6); expect(typeof meta.cascade_forget[0].to).toBe("number"); }); });