openclaw/extensions/memory-ruvector/sona/patterns.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

493 lines
14 KiB
TypeScript

/**
* Pattern Clustering for ruvLLM Learning Core (P1)
*
* Implements K-means++ clustering for learned patterns from SONA feedback.
* Patterns are used to re-rank search results based on historical relevance.
*/
import type { LearnedPattern } from "../types.js";
// =============================================================================
// Types
// =============================================================================
/**
* A cluster of similar patterns learned from user feedback.
*/
export type PatternCluster = {
/** Unique cluster identifier */
id: string;
/** Centroid vector representing the cluster center */
centroid: number[];
/** IDs of patterns belonging to this cluster */
members: string[];
/** Average quality score of members */
avgQuality: number;
/** Timestamp of last update */
lastUpdated: number;
};
/**
* A feedback sample used for pattern learning.
*/
export type FeedbackSample = {
/** Unique sample identifier */
id: string;
/** Query vector that was searched */
queryVector: number[];
/** Result vector that was selected */
resultVector: number[];
/** Relevance score from user (0-1) */
relevanceScore: number;
/** Timestamp of the feedback */
timestamp: number;
};
/**
* Configuration for pattern clustering.
*/
export type PatternClusterConfig = {
/** Maximum number of clusters (default: 10) */
maxClusters?: number;
/** Minimum samples per cluster (default: 3) */
minSamplesPerCluster?: number;
/** Convergence threshold for K-means (default: 0.001) */
convergenceThreshold?: number;
/** Maximum iterations for K-means (default: 100) */
maxIterations?: number;
/** Minimum quality threshold for learning (default: 0.5) */
qualityThreshold?: number;
};
// =============================================================================
// PatternStore
// =============================================================================
/**
* Store for learned patterns with K-means++ clustering.
*
* Patterns are learned from search feedback and used to:
* 1. Re-rank search results based on historical relevance
* 2. Suggest similar content based on clustered preferences
* 3. Improve search quality over time through adaptation
*/
export class PatternStore {
private clusters: Map<string, PatternCluster> = new Map();
private samples: FeedbackSample[] = [];
private config: Required<PatternClusterConfig>;
private clusterIdCounter = 0;
constructor(config: PatternClusterConfig = {}) {
this.config = {
maxClusters: config.maxClusters ?? 10,
minSamplesPerCluster: config.minSamplesPerCluster ?? 3,
convergenceThreshold: config.convergenceThreshold ?? 0.001,
maxIterations: config.maxIterations ?? 100,
qualityThreshold: config.qualityThreshold ?? 0.5,
};
}
// ===========================================================================
// Sample Management
// ===========================================================================
/**
* Add a feedback sample to the store.
* Triggers re-clustering if enough samples have accumulated.
*
* @param sample - Feedback sample to add
*/
addSample(sample: FeedbackSample): void {
// Only learn from high-quality feedback
if (sample.relevanceScore < this.config.qualityThreshold) {
return;
}
this.samples.push(sample);
// Re-cluster periodically (every minSamplesPerCluster * 2 new samples)
const reclusterThreshold = this.config.minSamplesPerCluster * 2;
if (this.samples.length % reclusterThreshold === 0) {
this.cluster();
}
}
/**
* Get all stored samples.
*/
getSamples(): readonly FeedbackSample[] {
return this.samples;
}
/**
* Get sample count.
*/
getSampleCount(): number {
return this.samples.length;
}
// ===========================================================================
// Clustering
// ===========================================================================
/**
* Run K-means++ clustering on accumulated samples.
* Updates the cluster centroids and assignments.
*/
cluster(): void {
if (this.samples.length < this.config.minSamplesPerCluster) {
return;
}
// Determine number of clusters (adaptive based on sample count)
const k = Math.min(
this.config.maxClusters,
Math.max(1, Math.floor(this.samples.length / this.config.minSamplesPerCluster)),
);
// Extract vectors for clustering (use combined query+result representation)
const vectors = this.samples.map((s) => this.combineVectors(s.queryVector, s.resultVector));
// Run K-means++ clustering
const { centroids, assignments } = this.kMeansPlusPlus(vectors, k);
// Build new clusters
const newClusters = new Map<string, PatternCluster>();
const now = Date.now();
for (let i = 0; i < k; i++) {
const memberIndices = assignments
.map((a, idx) => (a === i ? idx : -1))
.filter((idx) => idx !== -1);
if (memberIndices.length < this.config.minSamplesPerCluster) {
// Skip clusters that are too small
continue;
}
const memberIds: string[] = [];
let qualitySum = 0;
for (const idx of memberIndices) {
const sample = this.samples[idx];
if (sample) {
memberIds.push(sample.id);
qualitySum += sample.relevanceScore;
}
}
const avgQuality = memberIndices.length > 0 ? qualitySum / memberIndices.length : 0;
const clusterId = `cluster-${this.clusterIdCounter++}`;
newClusters.set(clusterId, {
id: clusterId,
centroid: centroids[i],
members: memberIds,
avgQuality,
lastUpdated: now,
});
}
this.clusters = newClusters;
}
/**
* K-means++ clustering algorithm.
*
* @param vectors - Array of vectors to cluster
* @param k - Number of clusters
* @returns Centroids and cluster assignments
*/
private kMeansPlusPlus(
vectors: number[][],
k: number,
): { centroids: number[][]; assignments: number[] } {
if (vectors.length === 0 || k <= 0) {
return { centroids: [], assignments: [] };
}
const n = vectors.length;
const dim = vectors[0].length;
// Initialize centroids using K-means++ seeding
const centroids: number[][] = [];
const assignments = Array.from({ length: n }, () => 0);
// First centroid: random selection
const firstIdx = Math.floor(Math.random() * n);
centroids.push([...vectors[firstIdx]]);
// Remaining centroids: probability proportional to squared distance
for (let c = 1; c < k; c++) {
const distances = vectors.map((v) => {
const minDist = centroids.reduce(
(min, centroid) => Math.min(min, this.squaredDistance(v, centroid)),
Infinity,
);
return minDist;
});
const totalDist = distances.reduce((sum, d) => sum + d, 0);
if (totalDist === 0) {
// All points are at centroids, pick random
const idx = Math.floor(Math.random() * n);
centroids.push([...vectors[idx]]);
continue;
}
// Weighted random selection
let r = Math.random() * totalDist;
let selectedIdx = 0;
for (let i = 0; i < n; i++) {
r -= distances[i];
if (r <= 0) {
selectedIdx = i;
break;
}
}
centroids.push([...vectors[selectedIdx]]);
}
// Iterate until convergence
for (let iter = 0; iter < this.config.maxIterations; iter++) {
// Assign points to nearest centroid
for (let i = 0; i < n; i++) {
let minDist = Infinity;
let minIdx = 0;
for (let c = 0; c < k; c++) {
const dist = this.squaredDistance(vectors[i], centroids[c]);
if (dist < minDist) {
minDist = dist;
minIdx = c;
}
}
assignments[i] = minIdx;
}
// Update centroids
const newCentroids: number[][] = Array.from({ length: k }, () =>
Array.from({ length: dim }, () => 0),
);
const counts = Array.from({ length: k }, () => 0);
for (let i = 0; i < n; i++) {
const c = assignments[i];
counts[c]++;
const vec = vectors[i];
const centroid = newCentroids[c];
if (vec && centroid) {
for (let d = 0; d < dim; d++) {
centroid[d] += vec[d] ?? 0;
}
}
}
// Normalize and check convergence
let maxShift = 0;
for (let c = 0; c < k; c++) {
if (counts[c] > 0) {
for (let d = 0; d < dim; d++) {
newCentroids[c][d] /= counts[c];
}
const shift = this.squaredDistance(centroids[c], newCentroids[c]);
maxShift = Math.max(maxShift, shift);
centroids[c] = newCentroids[c];
}
}
if (maxShift < this.config.convergenceThreshold) {
break;
}
}
return { centroids, assignments };
}
// ===========================================================================
// Pattern Matching
// ===========================================================================
/**
* Find patterns similar to a query vector.
*
* @param queryVector - Vector to find similar patterns for
* @param k - Maximum number of patterns to return (default: 5)
* @returns Array of similar patterns
*/
findSimilar(queryVector: number[], k = 5): LearnedPattern[] {
if (this.clusters.size === 0) {
return [];
}
// Score each cluster by similarity to query
const scored: Array<{ cluster: PatternCluster; similarity: number }> = [];
for (const cluster of this.clusters.values()) {
// Compare query to cluster centroid (using only query dimensions)
const queryDim = queryVector.length;
const centroidQuery = cluster.centroid.slice(0, queryDim);
const similarity = this.cosineSimilarity(queryVector, centroidQuery);
scored.push({ cluster, similarity });
}
// Sort by similarity descending
scored.sort((a, b) => b.similarity - a.similarity);
// Convert to LearnedPattern format
return scored.slice(0, k).map(({ cluster }) => ({
id: cluster.id,
centroid: cluster.centroid,
clusterSize: cluster.members.length,
avgQuality: cluster.avgQuality,
}));
}
/**
* Get all clusters.
*/
getClusters(): PatternCluster[] {
return Array.from(this.clusters.values());
}
/**
* Get cluster count.
*/
getClusterCount(): number {
return this.clusters.size;
}
// ===========================================================================
// Feedback Updates
// ===========================================================================
/**
* Update patterns based on new feedback.
* Adjusts cluster quality scores and may trigger re-clustering.
*
* @param sampleId - ID of the sample that received feedback
* @param newRelevanceScore - Updated relevance score
*/
updateFromFeedback(sampleId: string, newRelevanceScore: number): void {
// Find the sample and update it
const sample = this.samples.find((s) => s.id === sampleId);
if (!sample) {
return;
}
const oldScore = sample.relevanceScore;
sample.relevanceScore = newRelevanceScore;
// Find cluster containing this sample
for (const cluster of this.clusters.values()) {
if (cluster.members.includes(sampleId)) {
// Update average quality
const n = cluster.members.length;
cluster.avgQuality = (cluster.avgQuality * n - oldScore + newRelevanceScore) / n;
cluster.lastUpdated = Date.now();
break;
}
}
}
// ===========================================================================
// Serialization
// ===========================================================================
/**
* Export store state for persistence.
*/
export(): { clusters: PatternCluster[]; samples: FeedbackSample[] } {
return {
clusters: Array.from(this.clusters.values()),
samples: [...this.samples],
};
}
/**
* Import previously exported state.
* @throws {Error} If data structure is invalid
*/
import(data: { clusters: PatternCluster[]; samples: FeedbackSample[] }): void {
// Validate input structure
if (!data || typeof data !== "object") {
throw new Error("Invalid import data: must be an object");
}
if (!Array.isArray(data.clusters)) {
throw new Error("Invalid import data: clusters must be an array");
}
if (!Array.isArray(data.samples)) {
throw new Error("Invalid import data: samples must be an array");
}
this.clusters = new Map(data.clusters.map((c) => [c.id, c]));
this.samples = [...data.samples];
// Update counter to avoid ID collisions
const maxId = data.clusters.reduce((max, c) => {
const match = c.id.match(/cluster-(\d+)/);
return match ? Math.max(max, parseInt(match[1], 10) + 1) : max;
}, 0);
this.clusterIdCounter = maxId;
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Combine query and result vectors into a single representation.
* Uses concatenation for simplicity (could use more sophisticated methods).
*/
private combineVectors(query: number[], result: number[]): number[] {
// Ensure same dimension by padding/truncating
const dim = Math.max(query.length, result.length);
const combined: number[] = [];
for (let i = 0; i < dim; i++) {
combined.push(query[i] ?? 0);
}
for (let i = 0; i < dim; i++) {
combined.push(result[i] ?? 0);
}
return combined;
}
/**
* Calculate squared Euclidean distance between two vectors.
*/
private squaredDistance(a: number[], b: number[]): number {
const len = Math.max(a.length, b.length);
let sum = 0;
for (let i = 0; i < len; i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
sum += diff * diff;
}
return sum;
}
/**
* Calculate cosine similarity between two vectors.
*/
private cosineSimilarity(a: number[], b: number[]): number {
const len = Math.min(a.length, b.length);
if (len === 0) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < len; i++) {
const aVal = a[i] ?? 0;
const bVal = b[i] ?? 0;
dotProduct += aVal * bVal;
normA += aVal * aVal;
normB += bVal * bVal;
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
return dotProduct / denominator;
}
}