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>
493 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|