openclaw/extensions/memory-ruvector/p1-ruvllm.test.ts
File a801c7e721 feat(memory-ruvector): add ruvLLM adaptive learning features
Implements ruvLLM integration with multi-temporal learning:

P0 - Foundation:
- Extended config schema for ruvllm options
- TrajectoryRecorder for search pattern recording
- ContextInjector for agent prompt enrichment
- SONA engine integration with trajectory support

P1 - Learning Core:
- PatternStore with K-means++ clustering
- Search re-ranking using learned patterns
- GraphExpander for automatic edge discovery
- ruvector_recall tool (pattern-aware recall)

P2 - Adaptive Loops:
- BackgroundLoop (30s interval pattern clustering)
- InstantLoop (real-time feedback processing)
- RelationshipInferrer (entity extraction)
- ruvector_learn tool (manual knowledge injection)

P3 - Advanced Features:
- EWCConsolidator (catastrophic forgetting prevention)
- ConsolidationLoop (deep pattern analysis)
- GraphAttention (multi-head context aggregation)
- Pattern export/import CLI commands

Tests: 275 passing (229 + 46 new)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 08:14:01 +01:00

1186 lines
36 KiB
TypeScript

/**
* P1 ruvLLM Feature Tests
*
* Comprehensive tests for:
* 1. PatternStore - addSample(), cluster(), findSimilar(), export/import
* 2. GraphExpander - expandFromSearch(), suggestRelationships()
* 3. RuvectorClient.rerank() - pattern-based re-ranking
* 4. ruvector_recall tool - usePatterns, expandGraph options
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// =============================================================================
// Mock ruvector package with class-based mocks
// =============================================================================
// Mock function instances (need to be at module scope for class methods)
const mockVectorDb = {
insert: vi.fn().mockResolvedValue(undefined),
insertBatch: vi.fn().mockResolvedValue([]),
search: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
delete: vi.fn().mockResolvedValue(true),
len: vi.fn().mockResolvedValue(0),
isEmpty: vi.fn().mockResolvedValue(true),
close: vi.fn().mockResolvedValue(undefined),
};
const mockSonaEngine = {
setEnabled: vi.fn(),
isEnabled: vi.fn().mockReturnValue(false),
beginTrajectory: vi.fn().mockReturnValue("trajectory-id"),
addStep: vi.fn(),
endTrajectory: vi.fn(),
applyMicroLora: vi.fn(),
findPatterns: vi.fn().mockReturnValue([]),
getStats: vi.fn().mockReturnValue({ patternsLearned: 0 }),
forceLearn: vi.fn(),
};
const mockCodeGraph = {
createNode: vi.fn().mockResolvedValue(undefined),
createEdge: vi.fn().mockResolvedValue(undefined),
cypher: vi.fn().mockResolvedValue({ columns: [], rows: [] }),
neighbors: vi.fn().mockResolvedValue([]),
};
// Create mock class constructors
class MockVectorDb {
insert = mockVectorDb.insert;
insertBatch = mockVectorDb.insertBatch;
search = mockVectorDb.search;
get = mockVectorDb.get;
delete = mockVectorDb.delete;
len = mockVectorDb.len;
isEmpty = mockVectorDb.isEmpty;
close = mockVectorDb.close;
}
class MockSonaEngine {
static withConfig = vi.fn().mockImplementation(() => new MockSonaEngine());
setEnabled = mockSonaEngine.setEnabled;
isEnabled = mockSonaEngine.isEnabled;
beginTrajectory = mockSonaEngine.beginTrajectory;
addStep = mockSonaEngine.addStep;
endTrajectory = mockSonaEngine.endTrajectory;
applyMicroLora = mockSonaEngine.applyMicroLora;
findPatterns = mockSonaEngine.findPatterns;
getStats = mockSonaEngine.getStats;
forceLearn = mockSonaEngine.forceLearn;
}
class MockCodeGraph {
createNode = mockCodeGraph.createNode;
createEdge = mockCodeGraph.createEdge;
cypher = mockCodeGraph.cypher;
neighbors = mockCodeGraph.neighbors;
}
class MockRuvectorLayer {}
vi.mock("ruvector", () => ({
VectorDb: MockVectorDb,
VectorDB: MockVectorDb,
SonaEngine: MockSonaEngine,
CodeGraph: MockCodeGraph,
RuvectorLayer: MockRuvectorLayer,
default: {
VectorDb: MockVectorDb,
VectorDB: MockVectorDb,
},
}));
// Helper to create mock logger
function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
// Helper to create fake API
function createFakeApi() {
return {
logger: createMockLogger(),
config: { get: vi.fn(), set: vi.fn() },
storage: { get: vi.fn(), set: vi.fn() },
registerTool: vi.fn(),
registerService: vi.fn(),
};
}
// =============================================================================
// PatternStore Tests (P1 ruvLLM)
// =============================================================================
describe("PatternStore", () => {
let PatternStore: typeof import("./sona/patterns.js").PatternStore;
beforeEach(async () => {
vi.clearAllMocks();
const module = await import("./sona/patterns.js");
PatternStore = module.PatternStore;
});
describe("addSample()", () => {
it("adds samples with high relevance scores", () => {
const store = new PatternStore({ qualityThreshold: 0.5 });
store.addSample({
id: "sample-1",
queryVector: [0.1, 0.2, 0.3],
resultVector: [0.4, 0.5, 0.6],
relevanceScore: 0.8,
timestamp: Date.now(),
});
expect(store.getSampleCount()).toBe(1);
});
it("filters out low relevance samples below threshold", () => {
const store = new PatternStore({ qualityThreshold: 0.5 });
store.addSample({
id: "sample-1",
queryVector: [0.1, 0.2, 0.3],
resultVector: [0.4, 0.5, 0.6],
relevanceScore: 0.3, // Below threshold
timestamp: Date.now(),
});
expect(store.getSampleCount()).toBe(0);
});
it("triggers auto re-clustering after enough samples", () => {
// minSamplesPerCluster * 2 = 6 samples triggers re-cluster
const store = new PatternStore({
qualityThreshold: 0.5,
minSamplesPerCluster: 3,
});
// Add 6 samples to trigger clustering
for (let i = 0; i < 6; i++) {
store.addSample({
id: `sample-${i}`,
queryVector: [i * 0.1, i * 0.2],
resultVector: [i * 0.3, i * 0.4],
relevanceScore: 0.8,
timestamp: Date.now(),
});
}
expect(store.getSampleCount()).toBe(6);
// Clusters should be created after auto re-cluster
expect(store.getClusterCount()).toBeGreaterThanOrEqual(0);
});
});
describe("cluster()", () => {
it("does nothing with too few samples", () => {
const store = new PatternStore({ minSamplesPerCluster: 5 });
store.addSample({
id: "sample-1",
queryVector: [0.1, 0.2],
resultVector: [0.3, 0.4],
relevanceScore: 0.9,
timestamp: Date.now(),
});
store.cluster();
expect(store.getClusterCount()).toBe(0);
});
it("creates clusters when enough samples exist", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 3,
});
// Add enough samples for clustering
for (let i = 0; i < 6; i++) {
store.addSample({
id: `sample-${i}`,
queryVector: [i * 0.1, i * 0.2, i * 0.3],
resultVector: [i * 0.4, i * 0.5, i * 0.6],
relevanceScore: 0.7 + i * 0.05,
timestamp: Date.now(),
});
}
store.cluster();
const clusters = store.getClusters();
expect(clusters.length).toBeGreaterThan(0);
expect(clusters.length).toBeLessThanOrEqual(3);
});
it("calculates average quality for clusters", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 1,
});
// Add samples with known scores
store.addSample({
id: "s1",
queryVector: [1, 0],
resultVector: [0, 1],
relevanceScore: 0.8,
timestamp: Date.now(),
});
store.addSample({
id: "s2",
queryVector: [1, 0.1],
resultVector: [0.1, 1],
relevanceScore: 0.9,
timestamp: Date.now(),
});
store.cluster();
const clusters = store.getClusters();
if (clusters.length > 0) {
expect(clusters[0].avgQuality).toBeGreaterThan(0);
expect(clusters[0].avgQuality).toBeLessThanOrEqual(1);
}
});
});
describe("findSimilar()", () => {
it("returns empty array when no clusters exist", () => {
const store = new PatternStore();
const patterns = store.findSimilar([0.1, 0.2, 0.3], 5);
expect(patterns).toHaveLength(0);
});
it("finds similar patterns to query vector", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 3,
});
// Add samples clustered around specific vectors
for (let i = 0; i < 6; i++) {
store.addSample({
id: `sample-${i}`,
queryVector: [1, 0, 0],
resultVector: [0, 1, 0],
relevanceScore: 0.9,
timestamp: Date.now(),
});
}
store.cluster();
const patterns = store.findSimilar([1, 0, 0], 5);
expect(patterns.length).toBeGreaterThanOrEqual(0);
if (patterns.length > 0) {
expect(patterns[0]).toHaveProperty("id");
expect(patterns[0]).toHaveProperty("centroid");
expect(patterns[0]).toHaveProperty("clusterSize");
expect(patterns[0]).toHaveProperty("avgQuality");
}
});
it("respects k limit", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 10,
});
// Add samples to create multiple clusters
for (let i = 0; i < 20; i++) {
store.addSample({
id: `sample-${i}`,
queryVector: [Math.random(), Math.random()],
resultVector: [Math.random(), Math.random()],
relevanceScore: 0.8,
timestamp: Date.now(),
});
}
store.cluster();
const patterns = store.findSimilar([0.5, 0.5], 2);
expect(patterns.length).toBeLessThanOrEqual(2);
});
});
describe("updateFromFeedback()", () => {
it("updates sample relevance score", () => {
const store = new PatternStore({ qualityThreshold: 0.5 });
store.addSample({
id: "sample-1",
queryVector: [0.1, 0.2],
resultVector: [0.3, 0.4],
relevanceScore: 0.6,
timestamp: Date.now(),
});
store.updateFromFeedback("sample-1", 0.95);
const samples = store.getSamples();
expect(samples[0].relevanceScore).toBe(0.95);
});
it("does nothing for non-existent sample", () => {
const store = new PatternStore();
// Should not throw
store.updateFromFeedback("non-existent", 0.9);
expect(store.getSampleCount()).toBe(0);
});
it("updates cluster avgQuality when sample is in a cluster", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 1,
});
// Add samples and cluster them
store.addSample({
id: "s1",
queryVector: [1, 0],
resultVector: [0, 1],
relevanceScore: 0.7,
timestamp: Date.now(),
});
store.addSample({
id: "s2",
queryVector: [1, 0.1],
resultVector: [0.1, 1],
relevanceScore: 0.7,
timestamp: Date.now(),
});
store.cluster();
const clustersBefore = store.getClusters();
const avgQualityBefore = clustersBefore[0]?.avgQuality ?? 0;
// Update feedback for one sample
store.updateFromFeedback("s1", 0.99);
const clustersAfter = store.getClusters();
const avgQualityAfter = clustersAfter[0]?.avgQuality ?? 0;
// Average quality should increase
expect(avgQualityAfter).toBeGreaterThanOrEqual(avgQualityBefore);
});
});
describe("export/import", () => {
it("exports clusters and samples", () => {
const store = new PatternStore({
minSamplesPerCluster: 2,
maxClusters: 2,
});
for (let i = 0; i < 4; i++) {
store.addSample({
id: `sample-${i}`,
queryVector: [i * 0.1, i * 0.2],
resultVector: [i * 0.3, i * 0.4],
relevanceScore: 0.8,
timestamp: Date.now(),
});
}
store.cluster();
const exported = store.export();
expect(exported).toHaveProperty("clusters");
expect(exported).toHaveProperty("samples");
expect(Array.isArray(exported.clusters)).toBe(true);
expect(Array.isArray(exported.samples)).toBe(true);
expect(exported.samples.length).toBe(4);
});
it("imports previously exported state", () => {
const store1 = new PatternStore({ minSamplesPerCluster: 2 });
store1.addSample({
id: "s1",
queryVector: [0.1, 0.2],
resultVector: [0.3, 0.4],
relevanceScore: 0.9,
timestamp: Date.now(),
});
store1.addSample({
id: "s2",
queryVector: [0.2, 0.3],
resultVector: [0.4, 0.5],
relevanceScore: 0.85,
timestamp: Date.now(),
});
store1.cluster();
const exported = store1.export();
const store2 = new PatternStore();
store2.import(exported);
expect(store2.getSampleCount()).toBe(2);
expect(store2.getClusterCount()).toBe(store1.getClusterCount());
});
it("throws on invalid import data", () => {
const store = new PatternStore();
expect(() => store.import(null as any)).toThrow(/invalid import data/i);
expect(() => store.import({} as any)).toThrow(/clusters must be an array/i);
expect(() => store.import({ clusters: [] } as any)).toThrow(/samples must be an array/i);
});
});
});
// =============================================================================
// GraphExpander Tests (P1 ruvLLM)
// =============================================================================
describe("GraphExpander", () => {
let GraphExpander: typeof import("./graph/expansion.js").GraphExpander;
function createMockGraph() {
return {
edgeExists: vi.fn().mockResolvedValue(false),
addEdge: vi.fn().mockResolvedValue("edge-id"),
getNeighbors: vi.fn().mockResolvedValue([]),
getNodeVector: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
};
}
beforeEach(async () => {
vi.clearAllMocks();
const module = await import("./graph/expansion.js");
GraphExpander = module.GraphExpander;
});
describe("expandFromSearch()", () => {
it("returns empty result when fewer than 2 results", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph);
const result = await expander.expandFromSearch("test query", [
{ entry: { id: "id1", vector: [], metadata: { text: "test" } }, score: 0.9 },
]);
expect(result.createdEdges).toHaveLength(0);
expect(result.skippedEdges).toBe(0);
expect(result.processingTimeMs).toBeGreaterThanOrEqual(0);
});
it("creates edges between results with high combined scores", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, {
similarityThreshold: 0.7,
bidirectional: false,
});
const results = [
{ entry: { id: "id1", vector: [1, 0], metadata: { text: "a" } }, score: 0.9 },
{ entry: { id: "id2", vector: [0, 1], metadata: { text: "b" } }, score: 0.85 },
];
const result = await expander.expandFromSearch("test query", results);
expect(mockGraph.addEdge).toHaveBeenCalled();
expect(result.createdEdges.length).toBeGreaterThan(0);
});
it("skips edges that already exist", async () => {
const mockGraph = createMockGraph();
mockGraph.edgeExists.mockResolvedValue(true);
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const results = [
{ entry: { id: "id1", vector: [], metadata: { text: "a" } }, score: 0.9 },
{ entry: { id: "id2", vector: [], metadata: { text: "b" } }, score: 0.8 },
];
const result = await expander.expandFromSearch("test query", results);
expect(result.skippedEdges).toBeGreaterThan(0);
expect(result.createdEdges).toHaveLength(0);
});
it("creates bidirectional edges when configured", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, {
similarityThreshold: 0.5,
bidirectional: true,
});
const results = [
{ entry: { id: "id1", vector: [], metadata: { text: "a" } }, score: 0.9 },
{ entry: { id: "id2", vector: [], metadata: { text: "b" } }, score: 0.8 },
];
await expander.expandFromSearch("test query", results);
// Should create edges in both directions
expect(mockGraph.addEdge.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("respects maxEdgesPerExpansion limit", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, {
similarityThreshold: 0.3,
maxEdgesPerExpansion: 2,
bidirectional: false,
});
const results = [
{ entry: { id: "id1", vector: [], metadata: { text: "a" } }, score: 0.9 },
{ entry: { id: "id2", vector: [], metadata: { text: "b" } }, score: 0.85 },
{ entry: { id: "id3", vector: [], metadata: { text: "c" } }, score: 0.8 },
{ entry: { id: "id4", vector: [], metadata: { text: "d" } }, score: 0.75 },
];
const result = await expander.expandFromSearch("test query", results);
expect(result.createdEdges.length).toBeLessThanOrEqual(2);
});
});
describe("suggestRelationships()", () => {
it("returns empty when node vector not found", async () => {
const mockGraph = createMockGraph();
mockGraph.getNodeVector.mockResolvedValue(null);
const expander = new GraphExpander(mockGraph);
const suggestions = await expander.suggestRelationships("node-1");
expect(suggestions).toHaveLength(0);
});
it("returns empty when no candidates provided", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph);
const suggestions = await expander.suggestRelationships("node-1");
expect(suggestions).toHaveLength(0);
});
it("filters out existing neighbors from suggestions", async () => {
const mockGraph = createMockGraph();
mockGraph.getNeighbors.mockResolvedValue([{ id: "neighbor-1" }]);
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const candidates = [
{ entry: { id: "neighbor-1", vector: [], metadata: {} }, score: 0.9 }, // Existing neighbor
{ entry: { id: "candidate-1", vector: [], metadata: {} }, score: 0.8 },
];
const suggestions = await expander.suggestRelationships("node-1", candidates);
// Should not include the existing neighbor
expect(suggestions.find((s) => s.targetId === "neighbor-1")).toBeUndefined();
});
it("includes confidence and reason in suggestions", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const candidates = [
{ entry: { id: "candidate-1", vector: [], metadata: { category: "preference" } }, score: 0.85 },
];
const suggestions = await expander.suggestRelationships("node-1", candidates);
if (suggestions.length > 0) {
expect(suggestions[0]).toHaveProperty("confidence");
expect(suggestions[0]).toHaveProperty("reason");
expect(suggestions[0]).toHaveProperty("relationship");
expect(suggestions[0].confidence).toBe(0.85);
}
});
it("infers relationship type from metadata category", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const preferenceCandidate = [
{ entry: { id: "c1", vector: [], metadata: { category: "preference" } }, score: 0.8 },
];
const suggestions = await expander.suggestRelationships("node-1", preferenceCandidate);
if (suggestions.length > 0) {
expect(suggestions[0].relationship).toBe("shares_preference");
}
});
});
describe("expandFromFeedback()", () => {
it("creates edges from query to selected results", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const samples = [
{ queryId: "q1", resultId: "r1", relevanceScore: 0.9 },
{ queryId: "q2", resultId: "r2", relevanceScore: 0.8 },
];
const result = await expander.expandFromFeedback(samples);
expect(mockGraph.addEdge).toHaveBeenCalled();
expect(result.createdEdges.length).toBeGreaterThan(0);
// Verify "selected_from" relationship type
const createdEdge = result.createdEdges[0];
expect(createdEdge.relationship).toBe("selected_from");
});
it("skips samples below similarity threshold", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.8 });
const samples = [
{ queryId: "q1", resultId: "r1", relevanceScore: 0.5 }, // Below threshold
];
const result = await expander.expandFromFeedback(samples);
expect(result.createdEdges).toHaveLength(0);
});
it("creates co_selected edges between results from same query", async () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, { similarityThreshold: 0.5 });
const samples = [
{ queryId: "q1", resultId: "r1", relevanceScore: 0.9 },
{ queryId: "q1", resultId: "r2", relevanceScore: 0.85 },
];
const result = await expander.expandFromFeedback(samples);
const coSelectedEdges = result.createdEdges.filter((e) => e.relationship === "co_selected");
expect(coSelectedEdges.length).toBeGreaterThanOrEqual(0);
});
});
describe("configuration", () => {
it("getConfig returns current configuration", () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph, {
similarityThreshold: 0.8,
maxEdgesPerExpansion: 5,
});
const config = expander.getConfig();
expect(config.similarityThreshold).toBe(0.8);
expect(config.maxEdgesPerExpansion).toBe(5);
});
it("updateConfig changes settings", () => {
const mockGraph = createMockGraph();
const expander = new GraphExpander(mockGraph);
expander.updateConfig({
similarityThreshold: 0.9,
bidirectional: false,
});
const config = expander.getConfig();
expect(config.similarityThreshold).toBe(0.9);
expect(config.bidirectional).toBe(false);
});
});
});
// =============================================================================
// RuvectorClient.rerank() Tests (P1 ruvLLM)
// =============================================================================
describe("RuvectorClient.rerank()", () => {
let RuvectorClient: typeof import("./client.js").RuvectorClient;
beforeEach(async () => {
vi.clearAllMocks();
const module = await import("./client.js");
RuvectorClient = module.RuvectorClient;
});
it("returns original results when pattern store not initialized", async () => {
const logger = createMockLogger();
const client = new RuvectorClient({ dimension: 1536 }, logger);
await client.connect();
const results = [
{ entry: { id: "r1", vector: [0.1, 0.2], metadata: { text: "test" } }, score: 0.9 },
{ entry: { id: "r2", vector: [0.3, 0.4], metadata: { text: "test2" } }, score: 0.8 },
];
const reranked = client.rerank(results, [0.1, 0.2]);
expect(reranked).toEqual(results);
});
it("returns original results when no patterns match", async () => {
const logger = createMockLogger();
const client = new RuvectorClient({ dimension: 4 }, logger);
await client.connect();
client.initializePatternStore({ minSamplesPerCluster: 2 });
// Add samples but don't cluster
const patternStore = client.getPatternStore();
patternStore?.addSample({
id: "s1",
queryVector: [1, 0, 0, 0],
resultVector: [0, 1, 0, 0],
relevanceScore: 0.9,
timestamp: Date.now(),
});
const results = [
{ entry: { id: "r1", vector: [0.1, 0.2, 0.3, 0.4], metadata: { text: "test" } }, score: 0.9 },
];
const reranked = client.rerank(results, [0.5, 0.5, 0.5, 0.5]);
// Should return original results since no clusters exist
expect(reranked.length).toBe(results.length);
});
it("boosts results matching learned patterns", async () => {
const logger = createMockLogger();
const client = new RuvectorClient({ dimension: 4 }, logger);
await client.connect();
client.initializePatternStore({ minSamplesPerCluster: 2, maxClusters: 1 });
const patternStore = client.getPatternStore();
// Add samples to create a pattern
patternStore?.addSample({
id: "s1",
queryVector: [1, 0, 0, 0],
resultVector: [0.9, 0.1, 0, 0],
relevanceScore: 0.95,
timestamp: Date.now(),
});
patternStore?.addSample({
id: "s2",
queryVector: [0.95, 0.05, 0, 0],
resultVector: [0.85, 0.15, 0, 0],
relevanceScore: 0.9,
timestamp: Date.now(),
});
patternStore?.cluster();
const results = [
{ entry: { id: "r1", vector: [0.9, 0.1, 0, 0], metadata: { text: "matching" } }, score: 0.8 },
{ entry: { id: "r2", vector: [0, 0, 0.9, 0.1], metadata: { text: "not matching" } }, score: 0.85 },
];
const reranked = client.rerank(results, [1, 0, 0, 0], 0.2);
// Results should be re-ordered based on pattern matching
expect(reranked.length).toBe(2);
// The matching result should be boosted
if (patternStore?.getClusterCount() ?? 0 > 0) {
expect(reranked[0].score).toBeGreaterThanOrEqual(results[0].score);
}
});
it("caps boosted scores at 1.0", async () => {
const logger = createMockLogger();
const client = new RuvectorClient({ dimension: 4 }, logger);
await client.connect();
client.initializePatternStore({ minSamplesPerCluster: 2, maxClusters: 1 });
const patternStore = client.getPatternStore();
// Add high-quality samples
patternStore?.addSample({
id: "s1",
queryVector: [1, 0, 0, 0],
resultVector: [1, 0, 0, 0],
relevanceScore: 1.0,
timestamp: Date.now(),
});
patternStore?.addSample({
id: "s2",
queryVector: [1, 0, 0, 0],
resultVector: [1, 0, 0, 0],
relevanceScore: 1.0,
timestamp: Date.now(),
});
patternStore?.cluster();
const results = [
{ entry: { id: "r1", vector: [1, 0, 0, 0], metadata: { text: "test" } }, score: 0.99 },
];
const reranked = client.rerank(results, [1, 0, 0, 0], 0.5);
// Score should be capped at 1.0
expect(reranked[0].score).toBeLessThanOrEqual(1.0);
});
it("returns empty array for empty results", async () => {
const logger = createMockLogger();
const client = new RuvectorClient({ dimension: 1536 }, logger);
await client.connect();
client.initializePatternStore();
const reranked = client.rerank([], [0.1, 0.2]);
expect(reranked).toHaveLength(0);
});
});
// =============================================================================
// ruvector_recall Tool Tests (P1 ruvLLM)
// =============================================================================
describe("createRuvectorRecallTool", () => {
let createRuvectorRecallTool: typeof import("./tool.js").createRuvectorRecallTool;
beforeEach(async () => {
vi.clearAllMocks();
const module = await import("./tool.js");
createRuvectorRecallTool = module.createRuvectorRecallTool;
});
function createMockClient() {
return {
search: vi.fn().mockResolvedValue([]),
searchWithPatterns: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
getNeighbors: vi.fn().mockResolvedValue([]),
isGraphInitialized: vi.fn().mockReturnValue(false),
getPatternStore: vi.fn().mockReturnValue(null),
};
}
it("returns disabled result when service is not running", async () => {
const api = createFakeApi();
const service = {
isRunning: () => false,
getClient: () => {
throw new Error("not running");
},
};
const embedQuery = vi.fn();
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test" });
expect((result as any).details.disabled).toBe(true);
expect((result as any).details.error).toContain("not running");
});
it("has correct tool schema and metadata", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn();
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
expect(tool.name).toBe("ruvector_recall");
expect(tool.label).toBe("Pattern-Aware Memory Recall");
expect(tool.parameters).toBeDefined();
});
it("uses searchWithPatterns when usePatterns=true", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.searchWithPatterns.mockResolvedValue([
{
entry: { id: "r1", vector: [], metadata: { text: "result 1", category: "fact" } },
score: 0.9,
},
]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test", usePatterns: true });
expect(mockClient.searchWithPatterns).toHaveBeenCalledWith(
expect.objectContaining({ usePatterns: true }),
);
expect((result as any).details.results).toHaveLength(1);
});
it("uses regular search when usePatterns=false", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.search.mockResolvedValue([
{
entry: { id: "r1", vector: [], metadata: { text: "result 1" } },
score: 0.85,
},
]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test", usePatterns: false });
expect(mockClient.search).toHaveBeenCalled();
expect(mockClient.searchWithPatterns).not.toHaveBeenCalled();
});
it("expands graph when expandGraph=true and graph is initialized", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.isGraphInitialized.mockReturnValue(true);
mockClient.searchWithPatterns.mockResolvedValue([
{
entry: { id: "r1", vector: [], metadata: { text: "search result" } },
score: 0.9,
},
]);
mockClient.getNeighbors.mockResolvedValue([
{ id: "neighbor-1", labels: ["Entity"] },
]);
mockClient.get.mockResolvedValue({
id: "neighbor-1",
vector: [],
metadata: { text: "neighbor content" },
});
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", {
query: "test",
expandGraph: true,
graphDepth: 2,
});
expect(mockClient.getNeighbors).toHaveBeenCalled();
expect((result as any).details.graphResults).toBeDefined();
});
it("does not expand graph when expandGraph=false", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.isGraphInitialized.mockReturnValue(true);
mockClient.searchWithPatterns.mockResolvedValue([
{
entry: { id: "r1", vector: [], metadata: { text: "result" } },
score: 0.9,
},
]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test", expandGraph: false });
expect(mockClient.getNeighbors).not.toHaveBeenCalled();
expect((result as any).details.graphResults).toHaveLength(0);
});
it("clamps k parameter to valid range", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.searchWithPatterns.mockResolvedValue([]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
// Test with k > 100
await tool.execute("call-1", { query: "test", k: 500 });
expect(mockClient.searchWithPatterns).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100 }),
);
// Test with k < 1
await tool.execute("call-2", { query: "test", k: -5 });
expect(mockClient.searchWithPatterns).toHaveBeenCalledWith(
expect.objectContaining({ limit: 1 }),
);
});
it("clamps patternBoost to 0-1 range", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.searchWithPatterns.mockResolvedValue([]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
// Test with patternBoost > 1
await tool.execute("call-1", { query: "test", patternBoost: 2.5 });
expect(mockClient.searchWithPatterns).toHaveBeenCalledWith(
expect.objectContaining({ patternBoost: 1 }),
);
// Test with patternBoost < 0
await tool.execute("call-2", { query: "test", patternBoost: -0.5 });
expect(mockClient.searchWithPatterns).toHaveBeenCalledWith(
expect.objectContaining({ patternBoost: 0 }),
);
});
it("handles errors gracefully and returns disabled result", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.searchWithPatterns.mockRejectedValue(new Error("Search failed"));
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test" });
expect((result as any).details.disabled).toBe(true);
expect((result as any).details.error).toContain("Search failed");
});
it("includes pattern info in message when available", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
const mockPatternStore = {
getClusterCount: vi.fn().mockReturnValue(5),
getSampleCount: vi.fn().mockReturnValue(25),
};
mockClient.getPatternStore.mockReturnValue(mockPatternStore);
mockClient.searchWithPatterns.mockResolvedValue([
{
entry: { id: "r1", vector: [], metadata: { text: "result", category: "fact" } },
score: 0.9,
},
]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test", usePatterns: true });
expect((result as any).details.message).toContain("patterns");
expect((result as any).details.message).toContain("5 clusters");
expect((result as any).details.message).toContain("25 samples");
});
it("returns appropriate message when no results found", async () => {
const api = createFakeApi();
const mockClient = createMockClient();
mockClient.searchWithPatterns.mockResolvedValue([]);
const service = {
isRunning: () => true,
getClient: () => mockClient,
};
const embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1));
const tool = createRuvectorRecallTool({
api: api as any,
service: service as any,
embedQuery,
});
const result = await tool.execute("call-1", { query: "test" });
expect((result as any).details.message).toContain("No matching memories found");
expect((result as any).details.results).toHaveLength(0);
});
});