/ src / lib / server / memory / memory-graph.test.ts
memory-graph.test.ts
  1  import { describe, it } from 'node:test'
  2  import assert from 'node:assert'
  3  import {
  4    normalizeLinkedMemoryIds,
  5    normalizeMemoryLookupLimits,
  6    resolveLookupRequest,
  7    traverseLinkedMemoryGraph,
  8  } from '@/lib/server/memory/memory-graph'
  9  import type { MemoryLookupLimits, LinkedMemoryNode } from '@/lib/server/memory/memory-graph'
 10  
 11  describe('normalizeLinkedMemoryIds', () => {
 12    it('filters empty strings and self-references', () => {
 13      assert.deepStrictEqual(
 14        normalizeLinkedMemoryIds(['a', '', 'b', '  ', 'a', 'c'], 'self'),
 15        ['a', 'b', 'c']
 16      )
 17    })
 18  
 19    it('returns empty array for non-array input', () => {
 20      assert.deepStrictEqual(normalizeLinkedMemoryIds(null), [])
 21      assert.deepStrictEqual(normalizeLinkedMemoryIds(undefined), [])
 22      assert.deepStrictEqual(normalizeLinkedMemoryIds('not-an-array'), [])
 23    })
 24  
 25    it('deduplicates ids', () => {
 26      assert.deepStrictEqual(
 27        normalizeLinkedMemoryIds(['a', 'b', 'a', 'a', 'c'], undefined),
 28        ['a', 'b', 'c']
 29      )
 30    })
 31  })
 32  
 33  describe('normalizeMemoryLookupLimits', () => {
 34    it('returns defaults for empty settings', () => {
 35      const limits = normalizeMemoryLookupLimits({})
 36      assert.strictEqual(limits.maxDepth, 3)
 37      assert.strictEqual(limits.maxPerLookup, 20)
 38      assert.strictEqual(limits.maxLinkedExpansion, 60)
 39    })
 40  
 41    it('clamps to valid ranges', () => {
 42      const limits = normalizeMemoryLookupLimits({
 43        memoryReferenceDepth: 100,
 44        maxMemoriesPerLookup: 1000,
 45        maxLinkedMemoriesExpanded: 5000,
 46      })
 47      assert.strictEqual(limits.maxDepth, 12) // max
 48      assert.strictEqual(limits.maxPerLookup, 200) // max
 49      assert.strictEqual(limits.maxLinkedExpansion, 1000) // max
 50    })
 51  
 52    it('allows zeros for depth and linked expansion', () => {
 53      const limits = normalizeMemoryLookupLimits({
 54        memoryReferenceDepth: 0,
 55        maxMemoriesPerLookup: 5,
 56        maxLinkedMemoriesExpanded: 0,
 57      })
 58      assert.strictEqual(limits.maxDepth, 0)
 59      assert.strictEqual(limits.maxPerLookup, 5)
 60      assert.strictEqual(limits.maxLinkedExpansion, 0)
 61    })
 62  })
 63  
 64  describe('resolveLookupRequest', () => {
 65    const defaults: MemoryLookupLimits = {
 66      maxDepth: 3,
 67      maxPerLookup: 20,
 68      maxLinkedExpansion: 60,
 69    }
 70  
 71    it('uses defaults for empty request', () => {
 72      assert.deepStrictEqual(resolveLookupRequest(defaults, {}), defaults)
 73    })
 74  
 75    it('overrides with request values', () => {
 76      const result = resolveLookupRequest(defaults, { depth: 2, limit: 10, linkedLimit: 30 })
 77      assert.strictEqual(result.maxDepth, 2)
 78      assert.strictEqual(result.maxPerLookup, 10)
 79      assert.strictEqual(result.maxLinkedExpansion, 30)
 80    })
 81  
 82    it('caps at defaults maxima', () => {
 83      const result = resolveLookupRequest(defaults, { depth: 100, limit: 1000, linkedLimit: 5000 })
 84      assert.strictEqual(result.maxDepth, 3)
 85      assert.strictEqual(result.maxPerLookup, 20)
 86      assert.strictEqual(result.maxLinkedExpansion, 60)
 87    })
 88  })
 89  
 90  describe('traverseLinkedMemoryGraph', () => {
 91    const fetchByIds = (ids: string[]): LinkedMemoryNode[] => {
 92      return ids.map((id) => ({
 93        id,
 94        linkedMemoryIds: id === 'a' ? ['b', 'c'] : id === 'b' ? ['d'] : [],
 95      }))
 96    }
 97  
 98    it('returns empty for empty seeds', () => {
 99      const result = traverseLinkedMemoryGraph([], { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds)
100      assert.strictEqual(result.entries.length, 0)
101      assert.strictEqual(result.truncated, false)
102      assert.strictEqual(result.expandedLinkedCount, 0)
103    })
104  
105    it('traverses linked nodes by depth', () => {
106      const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c'] }]
107      const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 2, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds)
108      const ids = result.entries.map((n) => n.id)
109      assert.ok(ids.includes('a'))
110      assert.ok(ids.includes('b'))
111      assert.ok(ids.includes('c'))
112      assert.ok(ids.includes('d')) // depth 2
113      assert.strictEqual(result.expandedLinkedCount, 3) // b, c, d
114    })
115  
116    it('respects maxDepth', () => {
117      const limitedFetch = (ids: string[]): LinkedMemoryNode[] => {
118        const map: Record<string, string[]> = { a: ['b'], b: ['c'], c: ['d'], d: [] }
119        return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] }))
120      }
121      const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }]
122      const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 1, maxPerLookup: 20, maxLinkedExpansion: 60 }, limitedFetch)
123      const ids = result.entries.map((n) => n.id)
124      assert.ok(ids.includes('a'))
125      assert.ok(ids.includes('b'))
126      assert.ok(!ids.includes('c')) // depth 1 stops before c
127    })
128  
129    it('respects maxPerLookup', () => {
130      const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e', 'f'] }]
131      const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 3, maxLinkedExpansion: 60 }, fetchByIds)
132      assert.strictEqual(result.entries.length, 3)
133      assert.strictEqual(result.truncated, true)
134    })
135  
136    it('respects maxLinkedExpansion', () => {
137      const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e', 'f'] }]
138      const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 2 }, fetchByIds)
139      assert.strictEqual(result.expandedLinkedCount, 2)
140      assert.strictEqual(result.truncated, true)
141    })
142  
143    it('handles circular links', () => {
144      const circularFetch = (ids: string[]): LinkedMemoryNode[] => {
145        const map: Record<string, string[]> = { a: ['b'], b: ['a'] }
146        return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] }))
147      }
148      const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }]
149      const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 10, maxPerLookup: 100, maxLinkedExpansion: 100 }, circularFetch)
150      assert.strictEqual(result.entries.length, 2) // just a and b
151      assert.strictEqual(result.truncated, false)
152    })
153  })