openclaw/extensions/memory-ruvector/graph/expansion.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

460 lines
14 KiB
TypeScript

/**
* Graph Expansion for ruvLLM Learning Core (P1)
*
* Provides automatic edge discovery for the knowledge graph based on
* vector similarity and search patterns.
*/
import type { GraphEdge, VectorSearchResult } from "../types.js";
// =============================================================================
// Types
// =============================================================================
/**
* Configuration for graph expansion.
*/
export type GraphExpansionConfig = {
/** Minimum similarity threshold for creating edges (default: 0.7) */
similarityThreshold?: number;
/** Maximum edges to create per expansion (default: 10) */
maxEdgesPerExpansion?: number;
/** Default relationship type for auto-discovered edges (default: "similar_to") */
defaultRelationship?: string;
/** Enable bidirectional edges (default: true) */
bidirectional?: boolean;
/** Decay factor for edge weights based on similarity (default: 1.0) */
weightDecayFactor?: number;
};
/**
* A suggested relationship between nodes.
*/
export type RelationshipSuggestion = {
/** Source node ID */
sourceId: string;
/** Target node ID */
targetId: string;
/** Suggested relationship type */
relationship: string;
/** Confidence score (0-1) */
confidence: number;
/** Reason for the suggestion */
reason: string;
};
/**
* Result of a graph expansion operation.
*/
export type ExpansionResult = {
/** Edges that were created */
createdEdges: GraphEdge[];
/** Edges that were skipped (already exist) */
skippedEdges: number;
/** Total processing time in ms */
processingTimeMs: number;
};
/**
* Interface for graph operations needed by the expander.
*/
export interface GraphOperations {
/** Check if an edge exists between two nodes */
edgeExists(sourceId: string, targetId: string, relationship?: string): Promise<boolean>;
/** Add an edge to the graph */
addEdge(edge: GraphEdge): Promise<string>;
/** Get neighbors of a node */
getNeighbors(nodeId: string, depth?: number): Promise<Array<{ id: string; labels?: string[] }>>;
/** Get vector for a node ID */
getNodeVector(nodeId: string): Promise<number[] | null>;
}
// =============================================================================
// GraphExpander
// =============================================================================
/**
* Automatic edge discovery for knowledge graphs.
*
* Uses vector similarity and search patterns to discover relationships
* between memory nodes, enriching the graph structure over time.
*/
export class GraphExpander {
private config: Required<GraphExpansionConfig>;
private graph: GraphOperations;
constructor(graph: GraphOperations, config: GraphExpansionConfig = {}) {
this.graph = graph;
this.config = {
similarityThreshold: config.similarityThreshold ?? 0.7,
maxEdgesPerExpansion: config.maxEdgesPerExpansion ?? 10,
defaultRelationship: config.defaultRelationship ?? "similar_to",
bidirectional: config.bidirectional ?? true,
weightDecayFactor: config.weightDecayFactor ?? 1.0,
};
}
// ===========================================================================
// Core Expansion Methods
// ===========================================================================
/**
* Expand graph edges based on search results.
*
* Creates edges between results that appear together in search results,
* indicating semantic similarity.
*
* @param query - Original search query (for context)
* @param results - Search results to analyze
* @returns Expansion result with created edges
*/
async expandFromSearch(
query: string,
results: VectorSearchResult[],
): Promise<ExpansionResult> {
const startTime = Date.now();
const createdEdges: GraphEdge[] = [];
let skippedEdges = 0;
if (results.length < 2) {
return {
createdEdges: [],
skippedEdges: 0,
processingTimeMs: Date.now() - startTime,
};
}
// Create edges between results that appear together
// Higher-scored results are more strongly connected
const edgesToCreate: Array<{ source: string; target: string; weight: number }> = [];
for (let i = 0; i < results.length - 1; i++) {
for (let j = i + 1; j < results.length; j++) {
const resultA = results[i];
const resultB = results[j];
// Calculate edge weight based on both scores
const combinedScore = (resultA.score + resultB.score) / 2;
if (combinedScore < this.config.similarityThreshold) {
continue;
}
const weight = combinedScore * this.config.weightDecayFactor;
edgesToCreate.push({
source: resultA.entry.id,
target: resultB.entry.id,
weight,
});
}
}
// Sort by weight and limit
edgesToCreate.sort((a, b) => b.weight - a.weight);
const topEdges = edgesToCreate.slice(0, this.config.maxEdgesPerExpansion);
// Create edges (checking for duplicates)
for (const { source, target, weight } of topEdges) {
const exists = await this.graph.edgeExists(source, target, this.config.defaultRelationship);
if (exists) {
skippedEdges++;
continue;
}
const edge: GraphEdge = {
sourceId: source,
targetId: target,
relationship: this.config.defaultRelationship,
weight,
properties: {
discoveredFrom: "search",
query: query.slice(0, 100),
createdAt: Date.now(),
},
};
await this.graph.addEdge(edge);
createdEdges.push(edge);
// Create reverse edge if bidirectional
if (this.config.bidirectional) {
const reverseExists = await this.graph.edgeExists(
target,
source,
this.config.defaultRelationship,
);
if (!reverseExists) {
const reverseEdge: GraphEdge = {
...edge,
sourceId: target,
targetId: source,
};
await this.graph.addEdge(reverseEdge);
createdEdges.push(reverseEdge);
}
}
}
return {
createdEdges,
skippedEdges,
processingTimeMs: Date.now() - startTime,
};
}
/**
* Suggest relationships for a node based on vector similarity.
*
* @param nodeId - Node to find relationships for
* @param candidates - Candidate nodes to consider (optional, uses neighbors if not provided)
* @returns Array of relationship suggestions
*/
async suggestRelationships(
nodeId: string,
candidates?: VectorSearchResult[],
): Promise<RelationshipSuggestion[]> {
const suggestions: RelationshipSuggestion[] = [];
// Get the node's vector
const nodeVector = await this.graph.getNodeVector(nodeId);
if (!nodeVector) {
return suggestions;
}
// Get existing neighbors to exclude
const existingNeighbors = await this.graph.getNeighbors(nodeId, 1);
const neighborIds = new Set(existingNeighbors.map((n) => n.id));
// Use provided candidates or would need external search (return empty if no candidates)
if (!candidates || candidates.length === 0) {
return suggestions;
}
// Filter candidates and calculate similarity
for (const candidate of candidates) {
// Skip self and existing neighbors
if (candidate.entry.id === nodeId || neighborIds.has(candidate.entry.id)) {
continue;
}
// Use the search score as similarity
const similarity = candidate.score;
if (similarity >= this.config.similarityThreshold) {
// Determine relationship type based on metadata
const relationship = this.inferRelationship(
candidate.entry.metadata,
similarity,
);
suggestions.push({
sourceId: nodeId,
targetId: candidate.entry.id,
relationship: relationship.type,
confidence: similarity,
reason: relationship.reason,
});
}
}
// Sort by confidence descending
suggestions.sort((a, b) => b.confidence - a.confidence);
return suggestions.slice(0, this.config.maxEdgesPerExpansion);
}
/**
* Expand graph from a set of feedback samples.
*
* Creates edges between queries and their selected results,
* and between results selected from similar queries.
*
* @param samples - Feedback samples with query-result pairs
* @returns Expansion result
*/
async expandFromFeedback(
samples: Array<{
queryId: string;
resultId: string;
relevanceScore: number;
}>,
): Promise<ExpansionResult> {
const startTime = Date.now();
const createdEdges: GraphEdge[] = [];
let skippedEdges = 0;
// Create edges from queries to selected results
for (const sample of samples) {
if (sample.relevanceScore < this.config.similarityThreshold) {
continue;
}
try {
const exists = await this.graph.edgeExists(
sample.queryId,
sample.resultId,
"selected_from",
);
if (exists) {
skippedEdges++;
continue;
}
const edge: GraphEdge = {
sourceId: sample.queryId,
targetId: sample.resultId,
relationship: "selected_from",
weight: sample.relevanceScore,
properties: {
discoveredFrom: "feedback",
relevanceScore: sample.relevanceScore,
createdAt: Date.now(),
},
};
await this.graph.addEdge(edge);
createdEdges.push(edge);
} catch {
// Skip this sample if edge operations fail (e.g., invalid node IDs)
continue;
}
}
// Create edges between co-selected results (results selected from similar queries)
const resultGroups = new Map<string, string[]>();
for (const sample of samples) {
if (sample.relevanceScore < this.config.similarityThreshold) continue;
const group = resultGroups.get(sample.queryId) ?? [];
group.push(sample.resultId);
resultGroups.set(sample.queryId, group);
}
for (const results of resultGroups.values()) {
if (results.length < 2) continue;
for (let i = 0; i < results.length - 1 && createdEdges.length < this.config.maxEdgesPerExpansion * 2; i++) {
for (let j = i + 1; j < results.length; j++) {
try {
const exists = await this.graph.edgeExists(results[i], results[j], "co_selected");
if (exists) {
skippedEdges++;
continue;
}
const edge: GraphEdge = {
sourceId: results[i],
targetId: results[j],
relationship: "co_selected",
weight: 0.8,
properties: {
discoveredFrom: "feedback_coselection",
createdAt: Date.now(),
},
};
await this.graph.addEdge(edge);
createdEdges.push(edge);
} catch {
// Skip this edge if operations fail
continue;
}
}
}
}
return {
createdEdges,
skippedEdges,
processingTimeMs: Date.now() - startTime,
};
}
// ===========================================================================
// Configuration
// ===========================================================================
/**
* Update expansion configuration.
*/
updateConfig(config: Partial<GraphExpansionConfig>): void {
if (config.similarityThreshold !== undefined) {
this.config.similarityThreshold = config.similarityThreshold;
}
if (config.maxEdgesPerExpansion !== undefined) {
this.config.maxEdgesPerExpansion = config.maxEdgesPerExpansion;
}
if (config.defaultRelationship !== undefined) {
this.config.defaultRelationship = config.defaultRelationship;
}
if (config.bidirectional !== undefined) {
this.config.bidirectional = config.bidirectional;
}
if (config.weightDecayFactor !== undefined) {
this.config.weightDecayFactor = config.weightDecayFactor;
}
}
/**
* Get current configuration.
*/
getConfig(): Required<GraphExpansionConfig> {
return { ...this.config };
}
// ===========================================================================
// Private Helpers
// ===========================================================================
/**
* Infer relationship type from metadata.
*/
private inferRelationship(
metadata: Record<string, unknown>,
similarity: number,
): { type: string; reason: string } {
// Check for category match
const category = metadata.category as string | undefined;
if (category) {
switch (category) {
case "preference":
return {
type: "shares_preference",
reason: `Both are user preferences (similarity: ${(similarity * 100).toFixed(0)}%)`,
};
case "fact":
return {
type: "relates_to",
reason: `Related factual information (similarity: ${(similarity * 100).toFixed(0)}%)`,
};
case "decision":
return {
type: "informs_decision",
reason: `Related decision context (similarity: ${(similarity * 100).toFixed(0)}%)`,
};
case "entity":
return {
type: "references",
reason: `References similar entities (similarity: ${(similarity * 100).toFixed(0)}%)`,
};
}
}
// Check for channel/user match
const channel = metadata.channel as string | undefined;
if (channel) {
return {
type: "same_context",
reason: `Same channel context: ${channel} (similarity: ${(similarity * 100).toFixed(0)}%)`,
};
}
// Default relationship
return {
type: this.config.defaultRelationship,
reason: `High semantic similarity (${(similarity * 100).toFixed(0)}%)`,
};
}
}