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>
465 lines
13 KiB
TypeScript
465 lines
13 KiB
TypeScript
/**
|
|
* Trajectory Recording for ruvLLM
|
|
*
|
|
* Records search trajectories (query -> results -> feedback) for learning.
|
|
* Trajectories capture the full context of search operations to enable
|
|
* adaptive learning and pattern recognition.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import type { Trajectory, TrajectoryStats, TrajectoryRecordingConfig } from "../types.js";
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Input for recording a new trajectory.
|
|
*/
|
|
export type TrajectoryInput = {
|
|
/** The search query text */
|
|
query: string;
|
|
/** The query vector embedding */
|
|
queryVector: number[];
|
|
/** IDs of results returned */
|
|
resultIds: string[];
|
|
/** Relevance scores for each result */
|
|
resultScores: number[];
|
|
/** Session ID for grouping */
|
|
sessionId?: string;
|
|
/** Additional metadata */
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
/**
|
|
* Options for retrieving trajectories.
|
|
*/
|
|
export type GetTrajectoriesOptions = {
|
|
/** Maximum number of trajectories to return */
|
|
limit?: number;
|
|
/** Filter by session ID */
|
|
sessionId?: string;
|
|
/** Only include trajectories with feedback */
|
|
withFeedbackOnly?: boolean;
|
|
/** Minimum feedback score to include */
|
|
minFeedbackScore?: number;
|
|
/** Start time filter (inclusive) */
|
|
startTime?: number;
|
|
/** End time filter (inclusive) */
|
|
endTime?: number;
|
|
};
|
|
|
|
/**
|
|
* Logger interface for trajectory recorder.
|
|
*/
|
|
export type TrajectoryLogger = {
|
|
info?: (message: string) => void;
|
|
warn: (message: string) => void;
|
|
debug?: (message: string) => void;
|
|
};
|
|
|
|
// =============================================================================
|
|
// TrajectoryRecorder Class
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Records and manages search trajectories for learning.
|
|
*
|
|
* Trajectories capture:
|
|
* - Original search query and vector
|
|
* - Result IDs and scores
|
|
* - User feedback on result quality
|
|
* - Timestamp and session context
|
|
*
|
|
* Usage:
|
|
* ```typescript
|
|
* const recorder = new TrajectoryRecorder({ enabled: true, maxTrajectories: 1000 }, logger);
|
|
*
|
|
* // Record a search trajectory
|
|
* const id = recorder.record({
|
|
* query: "user preferences",
|
|
* queryVector: [...],
|
|
* resultIds: ["id1", "id2"],
|
|
* resultScores: [0.9, 0.8],
|
|
* });
|
|
*
|
|
* // Add feedback when user selects a result
|
|
* recorder.addFeedback(id, 0.95);
|
|
*
|
|
* // Get recent trajectories for learning
|
|
* const recent = recorder.getRecent(100);
|
|
*
|
|
* // Prune old trajectories
|
|
* recorder.prune();
|
|
* ```
|
|
*/
|
|
export class TrajectoryRecorder {
|
|
private trajectories: Map<string, Trajectory> = new Map();
|
|
private trajectoryOrder: string[] = []; // Track insertion order for LRU pruning
|
|
private config: TrajectoryRecordingConfig;
|
|
private logger: TrajectoryLogger;
|
|
|
|
constructor(config: TrajectoryRecordingConfig, logger: TrajectoryLogger) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
}
|
|
|
|
/**
|
|
* Check if trajectory recording is enabled.
|
|
*/
|
|
isEnabled(): boolean {
|
|
return this.config.enabled;
|
|
}
|
|
|
|
/**
|
|
* Record a new search trajectory.
|
|
*
|
|
* @param input - Trajectory data to record
|
|
* @returns The trajectory ID
|
|
*/
|
|
record(input: TrajectoryInput): string {
|
|
if (!this.config.enabled) {
|
|
return "";
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const trajectory: Trajectory = {
|
|
id,
|
|
query: input.query,
|
|
queryVector: input.queryVector,
|
|
resultIds: input.resultIds,
|
|
resultScores: input.resultScores,
|
|
feedback: null,
|
|
timestamp: Date.now(),
|
|
sessionId: input.sessionId ?? null,
|
|
metadata: input.metadata,
|
|
};
|
|
|
|
this.trajectories.set(id, trajectory);
|
|
this.trajectoryOrder.push(id);
|
|
|
|
this.logger.debug?.(
|
|
`trajectory: recorded ${id} (query: "${input.query.slice(0, 50)}...", results: ${input.resultIds.length})`,
|
|
);
|
|
|
|
// Auto-prune if we've exceeded the limit
|
|
if (this.trajectoryOrder.length > this.config.maxTrajectories) {
|
|
this.prune();
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Add feedback to an existing trajectory.
|
|
*
|
|
* @param trajectoryId - ID of the trajectory to update
|
|
* @param feedback - Feedback score (0-1, higher is better)
|
|
* @returns true if feedback was added, false if trajectory not found
|
|
*/
|
|
addFeedback(trajectoryId: string, feedback: number): boolean {
|
|
if (!this.config.enabled) {
|
|
return false;
|
|
}
|
|
|
|
const trajectory = this.trajectories.get(trajectoryId);
|
|
if (!trajectory) {
|
|
this.logger.warn(`trajectory: cannot add feedback - trajectory ${trajectoryId} not found`);
|
|
return false;
|
|
}
|
|
|
|
// Clamp feedback to valid range
|
|
const clampedFeedback = Math.max(0, Math.min(1, feedback));
|
|
trajectory.feedback = clampedFeedback;
|
|
|
|
this.logger.debug?.(
|
|
`trajectory: added feedback ${clampedFeedback.toFixed(2)} to ${trajectoryId}`,
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get a specific trajectory by ID.
|
|
*
|
|
* @param trajectoryId - ID of the trajectory to retrieve
|
|
* @returns The trajectory, or null if not found
|
|
*/
|
|
get(trajectoryId: string): Trajectory | null {
|
|
return this.trajectories.get(trajectoryId) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get recent trajectories, optionally filtered.
|
|
*
|
|
* @param options - Filter and limit options
|
|
* @returns Array of trajectories, newest first
|
|
*/
|
|
getRecent(options: GetTrajectoriesOptions = {}): Trajectory[] {
|
|
const {
|
|
limit = 100,
|
|
sessionId,
|
|
withFeedbackOnly = false,
|
|
minFeedbackScore,
|
|
startTime,
|
|
endTime,
|
|
} = options;
|
|
|
|
const results: Trajectory[] = [];
|
|
|
|
// Iterate in reverse order (newest first)
|
|
for (let i = this.trajectoryOrder.length - 1; i >= 0 && results.length < limit; i--) {
|
|
const id = this.trajectoryOrder[i];
|
|
const trajectory = this.trajectories.get(id);
|
|
if (!trajectory) continue;
|
|
|
|
// Apply filters
|
|
if (sessionId && trajectory.sessionId !== sessionId) continue;
|
|
if (withFeedbackOnly && trajectory.feedback === null) continue;
|
|
if (minFeedbackScore !== undefined && (trajectory.feedback === null || trajectory.feedback < minFeedbackScore)) continue;
|
|
if (startTime !== undefined && trajectory.timestamp < startTime) continue;
|
|
if (endTime !== undefined && trajectory.timestamp > endTime) continue;
|
|
|
|
results.push(trajectory);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get all trajectories for a specific session.
|
|
*
|
|
* @param sessionId - Session ID to filter by
|
|
* @returns Array of trajectories for the session
|
|
*/
|
|
getBySession(sessionId: string): Trajectory[] {
|
|
return this.getRecent({ sessionId, limit: this.config.maxTrajectories });
|
|
}
|
|
|
|
/**
|
|
* Get trajectories with high-quality feedback for learning.
|
|
*
|
|
* @param minScore - Minimum feedback score (default: 0.7)
|
|
* @param limit - Maximum number to return (default: 100)
|
|
* @returns Array of high-quality trajectories
|
|
*/
|
|
getHighQuality(minScore = 0.7, limit = 100): Trajectory[] {
|
|
return this.getRecent({
|
|
withFeedbackOnly: true,
|
|
minFeedbackScore: minScore,
|
|
limit,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find similar trajectories based on query vector.
|
|
*
|
|
* @param queryVector - Query vector to compare against
|
|
* @param limit - Maximum number to return (default: 10)
|
|
* @param minSimilarity - Minimum cosine similarity (default: 0.7)
|
|
* @returns Array of similar trajectories with similarity scores
|
|
*/
|
|
findSimilar(
|
|
queryVector: number[],
|
|
limit = 10,
|
|
minSimilarity = 0.7,
|
|
): Array<{ trajectory: Trajectory; similarity: number }> {
|
|
const results: Array<{ trajectory: Trajectory; similarity: number }> = [];
|
|
|
|
for (const trajectory of this.trajectories.values()) {
|
|
const similarity = this.cosineSimilarity(queryVector, trajectory.queryVector);
|
|
if (similarity >= minSimilarity) {
|
|
results.push({ trajectory, similarity });
|
|
}
|
|
}
|
|
|
|
// Sort by similarity descending and limit
|
|
return results
|
|
.sort((a, b) => b.similarity - a.similarity)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Prune old trajectories to stay within the configured limit.
|
|
* Removes oldest trajectories first (LRU), but prefers keeping
|
|
* trajectories with feedback.
|
|
*
|
|
* @returns Number of trajectories pruned
|
|
*/
|
|
prune(): number {
|
|
const targetSize = Math.floor(this.config.maxTrajectories * 0.9); // Keep 90% after pruning
|
|
const toRemove = this.trajectoryOrder.length - targetSize;
|
|
|
|
if (toRemove <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Separate trajectories into those with and without feedback
|
|
const withFeedback: string[] = [];
|
|
const withoutFeedback: string[] = [];
|
|
|
|
for (const id of this.trajectoryOrder) {
|
|
const trajectory = this.trajectories.get(id);
|
|
if (!trajectory) continue;
|
|
|
|
if (trajectory.feedback !== null) {
|
|
withFeedback.push(id);
|
|
} else {
|
|
withoutFeedback.push(id);
|
|
}
|
|
}
|
|
|
|
// Remove trajectories without feedback first (oldest first)
|
|
let removed = 0;
|
|
const toDelete: string[] = [];
|
|
|
|
for (const id of withoutFeedback) {
|
|
if (removed >= toRemove) break;
|
|
toDelete.push(id);
|
|
removed++;
|
|
}
|
|
|
|
// If still need to remove more, remove old feedback trajectories
|
|
if (removed < toRemove) {
|
|
for (const id of withFeedback) {
|
|
if (removed >= toRemove) break;
|
|
toDelete.push(id);
|
|
removed++;
|
|
}
|
|
}
|
|
|
|
// Perform deletion - use Set for O(1) lookups instead of O(n) array.includes
|
|
const toDeleteSet = new Set(toDelete);
|
|
for (const id of toDelete) {
|
|
this.trajectories.delete(id);
|
|
}
|
|
this.trajectoryOrder = this.trajectoryOrder.filter((id) => !toDeleteSet.has(id));
|
|
|
|
this.logger.info?.(
|
|
`trajectory: pruned ${removed} trajectories (remaining: ${this.trajectories.size})`,
|
|
);
|
|
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Clear all trajectories.
|
|
*/
|
|
clear(): void {
|
|
this.trajectories.clear();
|
|
this.trajectoryOrder = [];
|
|
this.logger.info?.("trajectory: cleared all trajectories");
|
|
}
|
|
|
|
/**
|
|
* Get statistics about recorded trajectories.
|
|
*/
|
|
getStats(): TrajectoryStats {
|
|
let trajectoriesWithFeedback = 0;
|
|
let totalFeedback = 0;
|
|
let oldestTimestamp: number | null = null;
|
|
let newestTimestamp: number | null = null;
|
|
|
|
for (const trajectory of this.trajectories.values()) {
|
|
if (trajectory.feedback !== null) {
|
|
trajectoriesWithFeedback++;
|
|
totalFeedback += trajectory.feedback;
|
|
}
|
|
|
|
if (oldestTimestamp === null || trajectory.timestamp < oldestTimestamp) {
|
|
oldestTimestamp = trajectory.timestamp;
|
|
}
|
|
if (newestTimestamp === null || trajectory.timestamp > newestTimestamp) {
|
|
newestTimestamp = trajectory.timestamp;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalTrajectories: this.trajectories.size,
|
|
trajectoriesWithFeedback,
|
|
averageFeedbackScore: trajectoriesWithFeedback > 0
|
|
? totalFeedback / trajectoriesWithFeedback
|
|
: 0,
|
|
oldestTimestamp,
|
|
newestTimestamp,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Export trajectories for persistence or analysis.
|
|
*
|
|
* @param options - Filter options for export
|
|
* @returns Array of trajectory objects
|
|
*/
|
|
export(options: GetTrajectoriesOptions = {}): Trajectory[] {
|
|
return this.getRecent({ ...options, limit: this.config.maxTrajectories });
|
|
}
|
|
|
|
/**
|
|
* Import trajectories from a previous export.
|
|
*
|
|
* @param trajectories - Array of trajectories to import
|
|
* @returns Number of trajectories imported
|
|
*/
|
|
import(trajectories: Trajectory[]): number {
|
|
let imported = 0;
|
|
|
|
for (const trajectory of trajectories) {
|
|
// Skip if already exists
|
|
if (this.trajectories.has(trajectory.id)) {
|
|
continue;
|
|
}
|
|
|
|
this.trajectories.set(trajectory.id, trajectory);
|
|
this.trajectoryOrder.push(trajectory.id);
|
|
imported++;
|
|
}
|
|
|
|
// Sort trajectory order by timestamp
|
|
this.trajectoryOrder.sort((a, b) => {
|
|
const tA = this.trajectories.get(a)?.timestamp ?? 0;
|
|
const tB = this.trajectories.get(b)?.timestamp ?? 0;
|
|
return tA - tB;
|
|
});
|
|
|
|
// Prune if needed
|
|
if (this.trajectoryOrder.length > this.config.maxTrajectories) {
|
|
this.prune();
|
|
}
|
|
|
|
this.logger.info?.(`trajectory: imported ${imported} trajectories`);
|
|
return imported;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Private Helpers
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Calculate cosine similarity between two vectors.
|
|
*/
|
|
private cosineSimilarity(a: number[], b: number[]): number {
|
|
if (a.length !== b.length || a.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
let dotProduct = 0;
|
|
let normA = 0;
|
|
let normB = 0;
|
|
|
|
for (let i = 0; i < a.length; 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;
|
|
}
|
|
}
|