tasq/node_modules/@claude-flow/memory/dist/memory-graph.test.js

609 lines
26 KiB
JavaScript

/**
* Tests for MemoryGraph - Knowledge Graph Module
*
* TDD London School (mock-first) tests for graph construction,
* PageRank computation, community detection, and graph-aware ranking.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryGraph } from './memory-graph.js';
import { createDefaultEntry } from './types.js';
// ===== Test Helpers =====
function makeEntry(id, refs = [], meta) {
const entry = createDefaultEntry({
key: id,
content: `content-${id}`,
references: refs,
metadata: meta,
});
// Override the auto-generated id with a deterministic one for testing
return { ...entry, id };
}
function createMockBackend(entries) {
return {
initialize: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockImplementation(async (id) => {
return entries.find((e) => e.id === id) || null;
}),
getByKey: vi.fn().mockResolvedValue(null),
update: vi.fn().mockImplementation(async (id, _update) => {
return entries.find((e) => e.id === id) || null;
}),
delete: vi.fn().mockResolvedValue(true),
query: vi.fn().mockResolvedValue(entries),
search: vi.fn().mockResolvedValue([]),
bulkInsert: vi.fn().mockResolvedValue(undefined),
bulkDelete: vi.fn().mockResolvedValue(0),
count: vi.fn().mockResolvedValue(entries.length),
listNamespaces: vi.fn().mockResolvedValue(['default']),
clearNamespace: vi.fn().mockResolvedValue(0),
getStats: vi.fn().mockResolvedValue({
totalEntries: entries.length,
entriesByNamespace: {},
entriesByType: {},
memoryUsage: 0,
avgQueryTime: 0,
avgSearchTime: 0,
}),
healthCheck: vi.fn().mockResolvedValue({
status: 'healthy',
components: {
storage: { status: 'healthy', latency: 0 },
index: { status: 'healthy', latency: 0 },
cache: { status: 'healthy', latency: 0 },
},
timestamp: Date.now(),
issues: [],
recommendations: [],
}),
};
}
// ===== Tests =====
describe('MemoryGraph', () => {
let graph;
beforeEach(() => {
graph = new MemoryGraph();
});
// ===== Constructor =====
describe('constructor', () => {
it('should create with default configuration', () => {
const g = new MemoryGraph();
const stats = g.getStats();
expect(stats.nodeCount).toBe(0);
expect(stats.edgeCount).toBe(0);
});
it('should accept custom configuration', () => {
const config = {
pageRankDamping: 0.9,
maxNodes: 100,
similarityThreshold: 0.5,
};
const g = new MemoryGraph(config);
const stats = g.getStats();
expect(stats.nodeCount).toBe(0);
});
});
// ===== addNode / removeNode =====
describe('addNode', () => {
it('should add a node from a MemoryEntry', () => {
const entry = makeEntry('node-1');
graph.addNode(entry);
expect(graph.getStats().nodeCount).toBe(1);
});
it('should extract category from metadata', () => {
const entry = makeEntry('node-1', [], { category: 'security' });
graph.addNode(entry);
// Verify through getTopNodes after computing pagerank
graph.computePageRank();
const top = graph.getTopNodes(1);
expect(top.length).toBe(1);
expect(top[0].id).toBe('node-1');
});
it('should ignore when at maxNodes capacity', () => {
const g = new MemoryGraph({ maxNodes: 2 });
g.addNode(makeEntry('a'));
g.addNode(makeEntry('b'));
g.addNode(makeEntry('c'));
expect(g.getStats().nodeCount).toBe(2);
});
it('should allow re-adding an existing node without counting toward capacity', () => {
const g = new MemoryGraph({ maxNodes: 2 });
g.addNode(makeEntry('a'));
g.addNode(makeEntry('b'));
// Re-add existing node should succeed
g.addNode(makeEntry('a'));
expect(g.getStats().nodeCount).toBe(2);
});
it('should mark graph as dirty', () => {
graph.addNode(makeEntry('node-1'));
expect(graph.getStats().pageRankComputed).toBe(false);
});
});
describe('removeNode', () => {
it('should remove a node and clean up edges', () => {
const a = makeEntry('a');
const b = makeEntry('b');
graph.addNode(a);
graph.addNode(b);
graph.addEdge('a', 'b', 'reference');
expect(graph.getStats().edgeCount).toBe(1);
graph.removeNode('a');
expect(graph.getStats().nodeCount).toBe(1);
expect(graph.getStats().edgeCount).toBe(0);
});
it('should remove incoming edges to the deleted node', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('c', 'b', 'reference');
expect(graph.getStats().edgeCount).toBe(2);
graph.removeNode('b');
expect(graph.getStats().edgeCount).toBe(0);
});
it('should handle removing a non-existent node gracefully', () => {
expect(() => graph.removeNode('does-not-exist')).not.toThrow();
});
it('should clean up pageRank and community entries', () => {
graph.addNode(makeEntry('a'));
graph.computePageRank();
graph.detectCommunities();
graph.removeNode('a');
const top = graph.getTopNodes(10);
expect(top.length).toBe(0);
});
});
// ===== addEdge =====
describe('addEdge', () => {
beforeEach(() => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
});
it('should add an edge between existing nodes', () => {
graph.addEdge('a', 'b', 'reference');
expect(graph.getStats().edgeCount).toBe(1);
});
it('should update weight to max when edge already exists', () => {
graph.addEdge('a', 'b', 'reference', 0.5);
graph.addEdge('a', 'b', 'reference', 0.8);
expect(graph.getStats().edgeCount).toBe(1);
// The weight should now be 0.8 (max of 0.5 and 0.8)
});
it('should not downgrade weight when adding with lower value', () => {
graph.addEdge('a', 'b', 'reference', 0.9);
graph.addEdge('a', 'b', 'reference', 0.3);
// Edge count should remain 1 (updated, not duplicated)
expect(graph.getStats().edgeCount).toBe(1);
});
it('should skip when source node is missing', () => {
graph.addEdge('missing', 'b', 'reference');
expect(graph.getStats().edgeCount).toBe(0);
});
it('should skip when target node is missing', () => {
graph.addEdge('a', 'missing', 'reference');
expect(graph.getStats().edgeCount).toBe(0);
});
it('should create reverse edge entry', () => {
graph.addEdge('a', 'b', 'reference');
// Verify via getNeighbors traversal (reverse edges are used internally)
// If we remove 'a', 'b' should lose its incoming edge
graph.removeNode('a');
expect(graph.getStats().edgeCount).toBe(0);
});
it('should support multiple edge types', () => {
graph.addEdge('a', 'b', 'reference');
graph.addEdge('a', 'c', 'similar', 0.9);
expect(graph.getStats().edgeCount).toBe(2);
});
});
// ===== buildFromBackend =====
describe('buildFromBackend', () => {
it('should build graph from backend entries with references', async () => {
const entries = [
makeEntry('a', ['b', 'c']),
makeEntry('b', ['c']),
makeEntry('c', []),
];
const backend = createMockBackend(entries);
await graph.buildFromBackend(backend);
expect(graph.getStats().nodeCount).toBe(3);
// a->b, a->c, b->c = 3 edges
expect(graph.getStats().edgeCount).toBe(3);
});
it('should handle an empty backend', async () => {
const backend = createMockBackend([]);
await graph.buildFromBackend(backend);
expect(graph.getStats().nodeCount).toBe(0);
expect(graph.getStats().edgeCount).toBe(0);
});
it('should respect maxNodes limit', async () => {
const g = new MemoryGraph({ maxNodes: 2 });
const entries = [makeEntry('a'), makeEntry('b'), makeEntry('c')];
const backend = createMockBackend(entries);
await g.buildFromBackend(backend);
expect(g.getStats().nodeCount).toBe(2);
});
it('should skip reference edges to nodes not in graph', async () => {
// 'a' references 'z' which is not in the entries
const entries = [makeEntry('a', ['z']), makeEntry('b', [])];
const backend = createMockBackend(entries);
await graph.buildFromBackend(backend);
expect(graph.getStats().nodeCount).toBe(2);
// a->z should be skipped because 'z' is not a node
expect(graph.getStats().edgeCount).toBe(0);
});
it('should emit graph:built event', async () => {
const handler = vi.fn();
graph.on('graph:built', handler);
const backend = createMockBackend([makeEntry('a')]);
await graph.buildFromBackend(backend);
expect(handler).toHaveBeenCalledWith({ nodeCount: 1 });
});
});
// ===== computePageRank =====
describe('computePageRank', () => {
it('should return empty map for empty graph', () => {
const ranks = graph.computePageRank();
expect(ranks.size).toBe(0);
});
it('should compute rank of 1.0 for a single node', () => {
graph.addNode(makeEntry('a'));
const ranks = graph.computePageRank();
expect(ranks.size).toBe(1);
expect(ranks.get('a')).toBeCloseTo(1.0, 5);
});
it('should converge for two connected nodes', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
const ranks = graph.computePageRank();
expect(ranks.size).toBe(2);
// Both should have positive values that sum close to 1
const total = (ranks.get('a') || 0) + (ranks.get('b') || 0);
expect(total).toBeCloseTo(1.0, 2);
});
it('should give higher rank to node with more incoming edges', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'c', 'reference');
graph.addEdge('b', 'c', 'reference');
const ranks = graph.computePageRank();
const rankC = ranks.get('c') || 0;
const rankA = ranks.get('a') || 0;
const rankB = ranks.get('b') || 0;
expect(rankC).toBeGreaterThan(rankA);
expect(rankC).toBeGreaterThan(rankB);
});
it('should respect damping factor', () => {
const g = new MemoryGraph({ pageRankDamping: 0.5 });
g.addNode(makeEntry('a'));
g.addNode(makeEntry('b'));
g.addEdge('a', 'b', 'reference');
const ranks = g.computePageRank();
// With damping 0.5, the influence of links is reduced
expect(ranks.get('a')).toBeDefined();
expect(ranks.get('b')).toBeDefined();
});
it('should converge within maxIterations', () => {
const handler = vi.fn();
graph.on('pagerank:computed', handler);
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
graph.computePageRank();
expect(handler).toHaveBeenCalledTimes(1);
const { iterations } = handler.mock.calls[0][0];
expect(iterations).toBeLessThanOrEqual(50);
expect(iterations).toBeGreaterThan(0);
});
it('should handle disconnected components', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addNode(makeEntry('d'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('c', 'd', 'reference');
const ranks = graph.computePageRank();
expect(ranks.size).toBe(4);
// Each component should have similar rank distribution
const total = [...ranks.values()].reduce((s, v) => s + v, 0);
expect(total).toBeCloseTo(1.0, 2);
});
it('should mark graph as not dirty after computation', () => {
graph.addNode(makeEntry('a'));
expect(graph.getStats().pageRankComputed).toBe(false);
graph.computePageRank();
expect(graph.getStats().pageRankComputed).toBe(true);
});
it('should emit pagerank:computed event', () => {
const handler = vi.fn();
graph.on('pagerank:computed', handler);
graph.computePageRank();
expect(handler).toHaveBeenCalledTimes(1);
});
});
// ===== detectCommunities =====
describe('detectCommunities', () => {
it('should assign isolated nodes to individual communities', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
const communities = graph.detectCommunities();
const unique = new Set(communities.values());
expect(unique.size).toBe(3);
});
it('should group connected nodes into the same community', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('b', 'a', 'reference');
const communities = graph.detectCommunities();
// With bidirectional edges, they should converge
expect(communities.get('a')).toBe(communities.get('b'));
});
it('should detect two disconnected clusters', () => {
// Cluster 1: a <-> b
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('b', 'a', 'reference');
// Cluster 2: c <-> d
graph.addNode(makeEntry('c'));
graph.addNode(makeEntry('d'));
graph.addEdge('c', 'd', 'reference');
graph.addEdge('d', 'c', 'reference');
const communities = graph.detectCommunities();
expect(communities.get('a')).toBe(communities.get('b'));
expect(communities.get('c')).toBe(communities.get('d'));
expect(communities.get('a')).not.toBe(communities.get('c'));
});
it('should emit communities:detected event', () => {
const handler = vi.fn();
graph.on('communities:detected', handler);
graph.addNode(makeEntry('a'));
graph.detectCommunities();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].communityCount).toBe(1);
});
it('should handle empty graph', () => {
const communities = graph.detectCommunities();
expect(communities.size).toBe(0);
});
});
// ===== rankWithGraph =====
describe('rankWithGraph', () => {
it('should blend vector score and pagerank', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'c', 'reference');
graph.addEdge('b', 'c', 'reference');
const searchResults = [
{ entry: makeEntry('a'), score: 0.9, distance: 0.1 },
{ entry: makeEntry('c'), score: 0.7, distance: 0.3 },
];
const ranked = graph.rankWithGraph(searchResults);
expect(ranked.length).toBe(2);
// Each result should have all fields
expect(ranked[0].score).toBeDefined();
expect(ranked[0].pageRank).toBeDefined();
expect(ranked[0].combinedScore).toBeDefined();
});
it('should respect alpha parameter', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
const results = [
{ entry: makeEntry('a'), score: 0.5, distance: 0.5 },
{ entry: makeEntry('b'), score: 0.5, distance: 0.5 },
];
// With alpha=1.0, only vector score matters
const ranked1 = graph.rankWithGraph(results, 1.0);
expect(ranked1[0].combinedScore).toBeCloseTo(0.5, 3);
// With alpha=0.0, only pagerank matters
const ranked0 = graph.rankWithGraph(results, 0.0);
// Node b has an incoming edge so should rank higher
expect(ranked0[0].entry.id).toBe('b');
});
it('should handle entries not in graph', () => {
graph.addNode(makeEntry('a'));
graph.computePageRank();
const results = [
{ entry: makeEntry('a'), score: 0.8, distance: 0.2 },
{ entry: makeEntry('unknown'), score: 0.9, distance: 0.1 },
];
const ranked = graph.rankWithGraph(results);
expect(ranked.length).toBe(2);
// Unknown entry should have pageRank of 0
const unknownResult = ranked.find((r) => r.entry.id === 'unknown');
expect(unknownResult?.pageRank).toBe(0);
});
it('should sort results by combinedScore descending', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'c', 'reference');
graph.addEdge('b', 'c', 'reference');
const results = [
{ entry: makeEntry('a'), score: 0.5, distance: 0.5 },
{ entry: makeEntry('b'), score: 0.5, distance: 0.5 },
{ entry: makeEntry('c'), score: 0.5, distance: 0.5 },
];
const ranked = graph.rankWithGraph(results, 0.5);
for (let i = 0; i < ranked.length - 1; i++) {
expect(ranked[i].combinedScore).toBeGreaterThanOrEqual(ranked[i + 1].combinedScore);
}
});
it('should include community info when communities are detected', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
graph.detectCommunities();
const results = [
{ entry: makeEntry('a'), score: 0.8, distance: 0.2 },
];
const ranked = graph.rankWithGraph(results);
expect(ranked[0].community).toBeDefined();
});
});
// ===== getTopNodes =====
describe('getTopNodes', () => {
it('should return top N nodes by pageRank', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'c', 'reference');
graph.addEdge('b', 'c', 'reference');
const top = graph.getTopNodes(1);
expect(top.length).toBe(1);
expect(top[0].id).toBe('c');
});
it('should handle requesting more nodes than available', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
const top = graph.getTopNodes(10);
expect(top.length).toBe(2);
});
it('should return empty array for empty graph', () => {
const top = graph.getTopNodes(5);
expect(top.length).toBe(0);
});
it('should include community label', () => {
graph.addNode(makeEntry('a'));
graph.detectCommunities();
const top = graph.getTopNodes(1);
expect(top[0].community).toBeDefined();
});
});
// ===== getNeighbors =====
describe('getNeighbors', () => {
beforeEach(() => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addNode(makeEntry('d'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('b', 'c', 'reference');
graph.addEdge('c', 'd', 'reference');
});
it('should return direct neighbors at depth 1', () => {
const neighbors = graph.getNeighbors('a', 1);
expect(neighbors.size).toBe(1);
expect(neighbors.has('b')).toBe(true);
});
it('should return extended neighbors at depth 2', () => {
const neighbors = graph.getNeighbors('a', 2);
expect(neighbors.size).toBe(2);
expect(neighbors.has('b')).toBe(true);
expect(neighbors.has('c')).toBe(true);
});
it('should return all reachable nodes at depth 3', () => {
const neighbors = graph.getNeighbors('a', 3);
expect(neighbors.size).toBe(3);
expect(neighbors.has('b')).toBe(true);
expect(neighbors.has('c')).toBe(true);
expect(neighbors.has('d')).toBe(true);
});
it('should handle node with no neighbors', () => {
const neighbors = graph.getNeighbors('d', 1);
expect(neighbors.size).toBe(0);
});
it('should not include the start node in results', () => {
const neighbors = graph.getNeighbors('a', 10);
expect(neighbors.has('a')).toBe(false);
});
it('should default to depth 1', () => {
const neighbors = graph.getNeighbors('a');
expect(neighbors.size).toBe(1);
expect(neighbors.has('b')).toBe(true);
});
});
// ===== addSimilarityEdges =====
describe('addSimilarityEdges', () => {
it('should add edges for similar entries above threshold', async () => {
const embedding = new Float32Array([1, 0, 0]);
const entryA = { ...makeEntry('a'), embedding };
const entryB = makeEntry('b');
graph.addNode(entryA);
graph.addNode(entryB);
const searchResults = [
{ entry: entryB, score: 0.9, distance: 0.1 },
];
const backend = createMockBackend([entryA, entryB]);
backend.search.mockResolvedValue(searchResults);
const added = await graph.addSimilarityEdges(backend, 'a');
expect(added).toBe(1);
expect(graph.getStats().edgeCount).toBe(1);
});
it('should return 0 if entry has no embedding', async () => {
const entry = makeEntry('a');
graph.addNode(entry);
const backend = createMockBackend([entry]);
const added = await graph.addSimilarityEdges(backend, 'a');
expect(added).toBe(0);
});
it('should return 0 if entry does not exist', async () => {
const backend = createMockBackend([]);
const added = await graph.addSimilarityEdges(backend, 'missing');
expect(added).toBe(0);
});
it('should skip self-references in search results', async () => {
const embedding = new Float32Array([1, 0, 0]);
const entryA = { ...makeEntry('a'), embedding };
graph.addNode(entryA);
const searchResults = [
{ entry: entryA, score: 1.0, distance: 0.0 },
];
const backend = createMockBackend([entryA]);
backend.search.mockResolvedValue(searchResults);
const added = await graph.addSimilarityEdges(backend, 'a');
expect(added).toBe(0);
});
});
// ===== getStats =====
describe('getStats', () => {
it('should return correct initial stats', () => {
const stats = graph.getStats();
expect(stats.nodeCount).toBe(0);
expect(stats.edgeCount).toBe(0);
expect(stats.avgDegree).toBe(0);
expect(stats.communityCount).toBe(0);
expect(stats.pageRankComputed).toBe(false);
expect(stats.maxPageRank).toBe(0);
expect(stats.minPageRank).toBe(0);
});
it('should reflect graph state after adding nodes and edges', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addNode(makeEntry('c'));
graph.addEdge('a', 'b', 'reference');
graph.addEdge('a', 'c', 'reference');
const stats = graph.getStats();
expect(stats.nodeCount).toBe(3);
expect(stats.edgeCount).toBe(2);
expect(stats.avgDegree).toBeCloseTo(2 / 3, 5);
});
it('should report pageRank min/max after computation', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.addEdge('a', 'b', 'reference');
graph.computePageRank();
const stats = graph.getStats();
expect(stats.pageRankComputed).toBe(true);
expect(stats.maxPageRank).toBeGreaterThan(0);
expect(stats.minPageRank).toBeGreaterThan(0);
expect(stats.maxPageRank).toBeGreaterThanOrEqual(stats.minPageRank);
});
it('should count communities after detection', () => {
graph.addNode(makeEntry('a'));
graph.addNode(makeEntry('b'));
graph.detectCommunities();
const stats = graph.getStats();
expect(stats.communityCount).toBe(2);
});
});
});
//# sourceMappingURL=memory-graph.test.js.map