diff --git a/perf/benchmark-pruner.mjs b/perf/benchmark-pruner.mjs new file mode 100644 index 0000000..044532f --- /dev/null +++ b/perf/benchmark-pruner.mjs @@ -0,0 +1,44 @@ +import { createLifecyclePlan } from '../src/services/memory/pruner.js'; +import crypto from 'node:crypto'; + +const COUNT = 30000; +const DUPLICATE_WINDOW_MS = 1000 * 60 * 60; // 1 hour + +function createMemory(i) { + return { + id: `mem-${i}`, + agent_id: 'agent-1', + session_id: 's1', + type: 'event', + content: `memory content ${i}`, + summary: `memory summary ${i}`, + tags: ['tag-a', 'tag-b'], + importance: 0.5, + embedding_id: null, + created_at: Date.now() - Math.floor(Math.random() * 10 * 24 * 60 * 60 * 1000), // Random time in last 10 days + last_seen: Date.now(), + source: 'ingest', + ttl_days: null, + merged_into: null, + pinned: false, + }; +} + +const memories = Array.from({ length: COUNT }, (_, i) => createMemory(i)); + +// Mock embedding function +const getEmbedding = (memory) => { + // Generate a pseudo-random embedding based on ID to be deterministic but varied + const val = parseInt(memory.id.split('-')[1]) % 100; + return [val, 100 - val]; +}; + +console.time('createLifecyclePlan'); +await createLifecyclePlan(memories, { + now: Date.now(), + retentionMs: 30 * 24 * 60 * 60 * 1000, + duplicateWindowMs: DUPLICATE_WINDOW_MS, + getEmbedding, + generateSummary: async () => JSON.stringify({ summary: 'merged', importance: 0.5 }), +}); +console.timeEnd('createLifecyclePlan'); diff --git a/src/services/memory/pruner.js b/src/services/memory/pruner.js index bf996d6..cd5f25e 100644 --- a/src/services/memory/pruner.js +++ b/src/services/memory/pruner.js @@ -67,22 +67,29 @@ function evaluateCandidate(memory, policy) { * @param {{ getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, similarityThreshold: number, duplicateWindowMs: number }} options */ function groupNearDuplicates(memories, options) { + // Sort by created_at to enable early exit in the inner loop + const sortedMemories = [...memories].sort((a, b) => a.created_at - b.created_at); + const groups = []; const consumed = new Set(); - for (let i = 0; i < memories.length; i += 1) { - const base = memories[i]; + for (let i = 0; i < sortedMemories.length; i += 1) { + const base = sortedMemories[i]; if (consumed.has(base.id) || base.merged_into) continue; const baseEmbedding = options.getEmbedding?.(base); const group = [base]; - for (let j = i + 1; j < memories.length; j += 1) { - const probe = memories[j]; + for (let j = i + 1; j < sortedMemories.length; j += 1) { + const probe = sortedMemories[j]; + + // Optimization: Since sorted by created_at, if the time difference exceeds the window, + // all subsequent items will also be outside the window. + if (probe.created_at - base.created_at > options.duplicateWindowMs) break; + if (consumed.has(probe.id) || probe.merged_into || probe.id === base.id) continue; - const withinWindow = Math.abs(base.created_at - probe.created_at) <= options.duplicateWindowMs; - if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue; + if (!hasTagOverlap(base.tags, probe.tags)) continue; const similarity = cosineSimilarity(baseEmbedding, options.getEmbedding?.(probe)); if (similarity >= options.similarityThreshold) {