/** * Tests for Tier 3.2: Conditional access reinforcement (prediction error gating). * * Validates that: * 1. High-similarity (low-novelty) results are NOT reinforced * 2. Low-similarity (high-novelty) results ARE reinforced * 3. Cooldown prevents duplicate reinforcement within the window * 4. When no scores provided, all entries are reinforced (backward compat) * 5. shouldReinforce() logic is correct in isolation */ import { describe, expect, it, beforeEach } from "bun:test"; import { AccessTracker, DEFAULT_ACCESS_TRACKER_CONFIG } from "../access-tracker.js"; import type { MemoryEntry } from "../store.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeEntry(id: string, overrides: Partial = {}): MemoryEntry { return { id, text: `memory-${id}`, vector: [1, 0, 0], category: "events", scope: "project:test", importance: 0.7, timestamp: Date.now(), metadata: JSON.stringify({ accessCount: 0, lastAccessedAt: 0 }), ...overrides, }; } function createMockStore(entries: MemoryEntry[]) { const data = new Map(entries.map(e => [e.id, { ...e }])); const updates: Array<{ id: string; metadata?: string }> = []; // 模拟 store.patchMetadata 单写通道:读最新 metadata → patchFn → 写回。 return { updates, data, async patchMetadata( id: string, patchFn: (meta: Record, entry: MemoryEntry) => Record, _scopeFilter?: string[], ) { const entry = data.get(id); if (!entry) return null; let meta: Record; try { const parsed: unknown = JSON.parse(entry.metadata || "{}"); meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { meta = {}; } const patched = patchFn(meta, entry); entry.metadata = JSON.stringify(patched); updates.push({ id, metadata: entry.metadata }); return entry; }, }; } // --------------------------------------------------------------------------- // shouldReinforce() unit tests // --------------------------------------------------------------------------- describe("shouldReinforce", () => { let tracker: AccessTracker; beforeEach(() => { tracker = new AccessTracker( createMockStore([]) as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, noveltyThreshold: 0.35, cooldownMs: 300_000 }, ); }); it("rejects high-similarity (low-novelty) results", () => { // similarity 0.90 → novelty 0.10 < threshold 0.35 → reject expect(tracker.shouldReinforce("a", 0.90)).toBe(false); }); it("rejects exact match (similarity 1.0)", () => { expect(tracker.shouldReinforce("a", 1.0)).toBe(false); }); it("accepts low-similarity (high-novelty) results", () => { // similarity 0.50 → novelty 0.50 > threshold 0.35 → accept expect(tracker.shouldReinforce("a", 0.50)).toBe(true); }); it("accepts borderline novelty (exactly at threshold)", () => { // similarity 0.65 → novelty 0.35 = threshold 0.35 → accept (not strictly less) expect(tracker.shouldReinforce("a", 0.65)).toBe(true); }); it("rejects same entry within cooldown window", () => { // First call: accepted expect(tracker.shouldReinforce("a", 0.50)).toBe(true); // Simulate recording to set cooldown tracker.recordAccess(["a"], [0.50]); // Second call within cooldown: rejected expect(tracker.shouldReinforce("a", 0.50)).toBe(false); }); it("accepts different entry even if another is on cooldown", () => { tracker.recordAccess(["a"], [0.50]); expect(tracker.shouldReinforce("b", 0.50)).toBe(true); }); it("bypasses novelty gate when threshold is 0", () => { const noGateTracker = new AccessTracker( createMockStore([]) as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, noveltyThreshold: 0, cooldownMs: 0 }, ); // Even high similarity passes expect(noGateTracker.shouldReinforce("a", 0.99)).toBe(true); }); }); // --------------------------------------------------------------------------- // recordAccess() with scores // --------------------------------------------------------------------------- describe("recordAccess with novelty gating", () => { it("only queues novel entries for flush", async () => { const entries = [makeEntry("a"), makeEntry("b"), makeEntry("c")]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 0, // immediate flush for testing noveltyThreshold: 0.35, cooldownMs: 0, }); // scores: a=0.90 (low novelty, reject), b=0.50 (novel, accept), c=0.40 (novel, accept) tracker.recordAccess(["a", "b", "c"], [0.90, 0.50, 0.40]); await tracker.flush(); // Only b and c should have been updated const updatedIds = store.updates.map(u => u.id); expect(updatedIds).not.toContain("a"); expect(updatedIds).toContain("b"); expect(updatedIds).toContain("c"); }); it("reinforces all entries when no scores provided (backward compat)", async () => { const entries = [makeEntry("a"), makeEntry("b")]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 0, noveltyThreshold: 0.35, cooldownMs: 0, }); // No scores → all reinforced tracker.recordAccess(["a", "b"]); await tracker.flush(); const updatedIds = store.updates.map(u => u.id); expect(updatedIds).toContain("a"); expect(updatedIds).toContain("b"); }); it("respects cooldown across multiple recordAccess calls", async () => { const entries = [makeEntry("x")]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 0, noveltyThreshold: 0.35, cooldownMs: 600_000, // 10 min cooldown }); // First call: novel enough → queued tracker.recordAccess(["x"], [0.50]); await tracker.flush(); expect(store.updates.length).toBe(1); // Second call: same entry within cooldown → not queued tracker.recordAccess(["x"], [0.50]); await tracker.flush(); // Still only 1 update (no new flush) expect(store.updates.length).toBe(1); }); it("skips flush scheduling when all entries are gated out", () => { const store = createMockStore([]); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 5000, noveltyThreshold: 0.35, cooldownMs: 0, }); // All scores too high (low novelty) → nothing queued tracker.recordAccess(["a", "b"], [0.95, 0.90]); expect(tracker.pendingCount).toBe(0); }); it("accessCount increments correctly after novelty-gated flush", async () => { const entries = [makeEntry("m1")]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 0, noveltyThreshold: 0.35, cooldownMs: 0, }); tracker.recordAccess(["m1"], [0.40]); // novel → accepted await tracker.flush(); const updated = store.data.get("m1")!; const meta = JSON.parse(updated.metadata); expect(meta.accessCount).toBe(1); }); }); // --------------------------------------------------------------------------- // destroy() cleans cooldown // --------------------------------------------------------------------------- describe("destroy cleans cooldown", () => { it("clears cooldown map on destroy", () => { const tracker = new AccessTracker( createMockStore([]) as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, cooldownMs: 600_000 }, ); tracker.recordAccess(["a"], [0.40]); tracker.destroy(); // After destroy, same entry should be reinforceable again expect(tracker.shouldReinforce("a", 0.40)).toBe(true); }); }); // --------------------------------------------------------------------------- // Distinct-reader bookkeeping (Artel read fan-out) + default-config semantics // --------------------------------------------------------------------------- describe("default config after 2026-07-23 production fix", () => { it("novelty gate is OFF by default (high-similarity hits are reinforced)", () => { const tracker = new AccessTracker(createMockStore([]) as any); // 生产实测修复:真实相关命中 cosine >0.65 也必须打点 expect(tracker.shouldReinforce("a", 0.95)).toBe(true); }); }); describe("distinct-reader bookkeeping", () => { it("writes readerIds and distinctReaderCount on flush", async () => { const entries = [makeEntry("a")]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 1, cooldownMs: 0, }); tracker.recordAccess(["a"]); await tracker.flush(); const meta = JSON.parse(store.data.get("a")!.metadata!) as Record; expect(meta.accessCount).toBe(1); expect(meta.readerIds).toEqual([tracker.readerId]); expect(meta.distinctReaderCount).toBe(1); }); it("dedups same reader and unions different readers", async () => { // 2026-08-23 语义订正:同进程内的多个 AccessTracker 现在共享一个 readerId。 // 旧版每个实例各随机一个,与本文件测的那句注释(access-tracker.ts:63 // "one stdio MCP server process ≈ one CC session")直接矛盾——components 按 // profile 缓存,同一个会话开两个 profile 就凭空多出一个"读者", // 把 distinctReaderCount 抬高、进而虚抬 skill-promotion 的 readBoost。 // 所以「不同读者」这一半改用显式 readerId 表达(跨进程/跨会话的真实形态)。 const entries = [makeEntry("a")]; const store = createMockStore(entries); const t1 = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 1, cooldownMs: 0 }); t1.recordAccess(["a"]); await t1.flush(); t1.recordAccess(["a"]); await t1.flush(); // same reader twice → still 1 distinct const t2 = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 1, cooldownMs: 0 }, "r-second99"); t2.recordAccess(["a"]); await t2.flush(); // genuinely different reader → 2 distinct const meta = JSON.parse(store.data.get("a")!.metadata!) as Record; expect(meta.accessCount).toBe(3); expect((meta.readerIds as string[]).sort()).toEqual([t1.readerId, t2.readerId].sort()); expect(meta.distinctReaderCount).toBe(2); }); it("does not inflate distinct readers when one session builds several trackers", async () => { // 这是上面那条订正保护的东西:同一会话的两个 profile 不该被数成两个读者。 const entries = [makeEntry("a")]; const store = createMockStore(entries); const cfg = { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 1, cooldownMs: 0 }; for (const tracker of [new AccessTracker(store as any, cfg), new AccessTracker(store as any, cfg)]) { tracker.recordAccess(["a"]); await tracker.flush(); } const meta = JSON.parse(store.data.get("a")!.metadata!) as Record; expect((meta.readerIds as string[]).length).toBe(1); expect(meta.distinctReaderCount).toBe(1); }); it("saturates readerIds at READER_ID_CAP without growing the array", async () => { const capped = Array.from({ length: 8 }, (_, i) => `r-cap${i}`); const entries = [makeEntry("a", { metadata: JSON.stringify({ accessCount: 8, readerIds: capped, distinctReaderCount: 8 }), })]; const store = createMockStore(entries); const tracker = new AccessTracker(store as any, { ...DEFAULT_ACCESS_TRACKER_CONFIG, flushIntervalMs: 1, cooldownMs: 0 }); tracker.recordAccess(["a"]); await tracker.flush(); const meta = JSON.parse(store.data.get("a")!.metadata!) as Record; expect((meta.readerIds as string[]).length).toBe(8); // no growth past cap expect(meta.distinctReaderCount).toBe(8); // saturates, no overcount expect(meta.accessCount).toBe(9); // access still counted }); });