603 lines
22 KiB
JavaScript
603 lines
22 KiB
JavaScript
/**
|
|
* Pattern Learner
|
|
*
|
|
* Implements pattern extraction, matching, and evolution for
|
|
* continuous learning from agent experiences.
|
|
*
|
|
* Performance Targets:
|
|
* - Pattern matching: <1ms
|
|
* - Pattern extraction: <5ms
|
|
* - Evolution step: <2ms
|
|
*/
|
|
/**
|
|
* Default Pattern Learner configuration
|
|
*/
|
|
const DEFAULT_CONFIG = {
|
|
maxPatterns: 1000,
|
|
matchThreshold: 0.7,
|
|
minUsagesForStable: 5,
|
|
qualityThreshold: 0.5,
|
|
enableClustering: true,
|
|
numClusters: 50,
|
|
evolutionLearningRate: 0.1,
|
|
};
|
|
/**
|
|
* Pattern Learner - Manages pattern extraction, matching, and evolution
|
|
*/
|
|
export class PatternLearner {
|
|
config;
|
|
patterns = new Map();
|
|
clusters = [];
|
|
patternToCluster = new Map();
|
|
// Performance tracking
|
|
matchCount = 0;
|
|
totalMatchTime = 0;
|
|
extractionCount = 0;
|
|
totalExtractionTime = 0;
|
|
evolutionCount = 0;
|
|
totalEvolutionTime = 0;
|
|
// Event listeners
|
|
eventListeners = new Set();
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
}
|
|
// ==========================================================================
|
|
// Pattern Matching
|
|
// ==========================================================================
|
|
/**
|
|
* Find matching patterns for a query embedding
|
|
* Target: <1ms
|
|
*/
|
|
findMatches(queryEmbedding, k = 3) {
|
|
const startTime = performance.now();
|
|
if (this.patterns.size === 0) {
|
|
return [];
|
|
}
|
|
let candidates;
|
|
// Use clustering for faster search if enabled and clusters exist
|
|
if (this.config.enableClustering && this.clusters.length > 0) {
|
|
candidates = this.getCandidatesFromClusters(queryEmbedding);
|
|
}
|
|
else {
|
|
candidates = Array.from(this.patterns.values());
|
|
}
|
|
// Compute similarities
|
|
const matches = [];
|
|
for (const pattern of candidates) {
|
|
const similarity = this.cosineSimilarity(queryEmbedding, pattern.embedding);
|
|
if (similarity >= this.config.matchThreshold) {
|
|
matches.push({
|
|
pattern,
|
|
similarity,
|
|
confidence: this.computeMatchConfidence(pattern, similarity),
|
|
latencyMs: 0,
|
|
});
|
|
}
|
|
}
|
|
// Sort by similarity
|
|
matches.sort((a, b) => b.similarity - a.similarity);
|
|
const result = matches.slice(0, k);
|
|
// Track performance
|
|
const elapsed = performance.now() - startTime;
|
|
this.matchCount++;
|
|
this.totalMatchTime += elapsed;
|
|
// Warn if over target
|
|
if (elapsed > 1) {
|
|
console.warn(`Pattern matching exceeded target: ${elapsed.toFixed(2)}ms > 1ms`);
|
|
}
|
|
return result;
|
|
}
|
|
/**
|
|
* Find best single match
|
|
*/
|
|
findBestMatch(queryEmbedding) {
|
|
const matches = this.findMatches(queryEmbedding, 1);
|
|
return matches.length > 0 ? matches[0] : null;
|
|
}
|
|
// ==========================================================================
|
|
// Pattern Extraction
|
|
// ==========================================================================
|
|
/**
|
|
* Extract a pattern from a trajectory
|
|
* Target: <5ms
|
|
*/
|
|
extractPattern(trajectory, memory) {
|
|
const startTime = performance.now();
|
|
// Validate trajectory
|
|
if (!trajectory.isComplete || trajectory.qualityScore < this.config.qualityThreshold) {
|
|
return null;
|
|
}
|
|
// Check for duplicates
|
|
const embedding = this.computePatternEmbedding(trajectory);
|
|
const existing = this.findSimilarPattern(embedding, 0.95);
|
|
if (existing) {
|
|
// Update existing pattern instead
|
|
this.updatePatternFromTrajectory(existing, trajectory);
|
|
return existing;
|
|
}
|
|
// Create new pattern
|
|
const pattern = {
|
|
patternId: `pat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
name: this.generatePatternName(trajectory),
|
|
domain: trajectory.domain,
|
|
embedding,
|
|
strategy: this.extractStrategy(trajectory),
|
|
successRate: trajectory.qualityScore,
|
|
usageCount: 1,
|
|
qualityHistory: [trajectory.qualityScore],
|
|
evolutionHistory: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
// Store pattern
|
|
this.patterns.set(pattern.patternId, pattern);
|
|
// Update clusters if enabled
|
|
if (this.config.enableClustering) {
|
|
this.assignToCluster(pattern);
|
|
}
|
|
// Prune if over capacity
|
|
if (this.patterns.size > this.config.maxPatterns) {
|
|
this.prunePatterns();
|
|
}
|
|
// Track performance
|
|
const elapsed = performance.now() - startTime;
|
|
this.extractionCount++;
|
|
this.totalExtractionTime += elapsed;
|
|
return pattern;
|
|
}
|
|
/**
|
|
* Extract patterns from multiple trajectories in batch
|
|
*/
|
|
extractPatternsBatch(trajectories) {
|
|
const patterns = [];
|
|
for (const trajectory of trajectories) {
|
|
const pattern = this.extractPattern(trajectory);
|
|
if (pattern) {
|
|
patterns.push(pattern);
|
|
}
|
|
}
|
|
// Rebuild clusters after batch extraction
|
|
if (this.config.enableClustering && patterns.length > 10) {
|
|
this.rebuildClusters();
|
|
}
|
|
return patterns;
|
|
}
|
|
// ==========================================================================
|
|
// Pattern Evolution
|
|
// ==========================================================================
|
|
/**
|
|
* Evolve a pattern based on new experience
|
|
* Target: <2ms
|
|
*/
|
|
evolvePattern(patternId, quality, context) {
|
|
const startTime = performance.now();
|
|
const pattern = this.patterns.get(patternId);
|
|
if (!pattern)
|
|
return;
|
|
const previousQuality = pattern.successRate;
|
|
const lr = this.config.evolutionLearningRate;
|
|
// Update quality history
|
|
pattern.qualityHistory.push(quality);
|
|
if (pattern.qualityHistory.length > 100) {
|
|
pattern.qualityHistory = pattern.qualityHistory.slice(-100);
|
|
}
|
|
// Exponential moving average for success rate
|
|
pattern.successRate = pattern.successRate * (1 - lr) + quality * lr;
|
|
pattern.usageCount++;
|
|
pattern.updatedAt = Date.now();
|
|
// Record evolution
|
|
const evolutionType = this.determineEvolutionType(previousQuality, pattern.successRate);
|
|
pattern.evolutionHistory.push({
|
|
timestamp: Date.now(),
|
|
type: evolutionType,
|
|
previousQuality,
|
|
newQuality: pattern.successRate,
|
|
description: context || 'Updated from new experience',
|
|
});
|
|
// Keep evolution history bounded
|
|
if (pattern.evolutionHistory.length > 50) {
|
|
pattern.evolutionHistory = pattern.evolutionHistory.slice(-50);
|
|
}
|
|
// Emit event
|
|
this.emitEvent({
|
|
type: 'pattern_evolved',
|
|
patternId,
|
|
evolutionType,
|
|
});
|
|
// Track performance
|
|
const elapsed = performance.now() - startTime;
|
|
this.evolutionCount++;
|
|
this.totalEvolutionTime += elapsed;
|
|
}
|
|
/**
|
|
* Merge two similar patterns
|
|
*/
|
|
mergePatterns(patternId1, patternId2) {
|
|
const p1 = this.patterns.get(patternId1);
|
|
const p2 = this.patterns.get(patternId2);
|
|
if (!p1 || !p2)
|
|
return null;
|
|
// Keep the higher quality pattern as base
|
|
const [keep, remove] = p1.successRate >= p2.successRate ? [p1, p2] : [p2, p1];
|
|
// Merge embeddings (weighted average)
|
|
const totalUsage = keep.usageCount + remove.usageCount;
|
|
const w1 = keep.usageCount / totalUsage;
|
|
const w2 = remove.usageCount / totalUsage;
|
|
for (let i = 0; i < keep.embedding.length; i++) {
|
|
keep.embedding[i] = keep.embedding[i] * w1 + remove.embedding[i] * w2;
|
|
}
|
|
// Merge statistics
|
|
keep.usageCount += remove.usageCount;
|
|
keep.qualityHistory.push(...remove.qualityHistory);
|
|
keep.successRate = keep.qualityHistory.reduce((a, b) => a + b, 0) / keep.qualityHistory.length;
|
|
// Record merge
|
|
keep.evolutionHistory.push({
|
|
timestamp: Date.now(),
|
|
type: 'merge',
|
|
previousQuality: p1.successRate,
|
|
newQuality: keep.successRate,
|
|
description: `Merged with pattern ${remove.patternId}`,
|
|
});
|
|
// Remove the merged pattern
|
|
this.patterns.delete(remove.patternId);
|
|
this.patternToCluster.delete(remove.patternId);
|
|
return keep;
|
|
}
|
|
/**
|
|
* Split a pattern into more specific sub-patterns
|
|
*/
|
|
splitPattern(patternId, numSplits = 2) {
|
|
const pattern = this.patterns.get(patternId);
|
|
if (!pattern || numSplits < 2)
|
|
return [];
|
|
const splits = [];
|
|
for (let i = 0; i < numSplits; i++) {
|
|
// Create variation of embedding with noise
|
|
const newEmbedding = new Float32Array(pattern.embedding.length);
|
|
for (let j = 0; j < newEmbedding.length; j++) {
|
|
const noise = (Math.random() - 0.5) * 0.1;
|
|
newEmbedding[j] = pattern.embedding[j] + noise;
|
|
}
|
|
const newPattern = {
|
|
patternId: `pat_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`,
|
|
name: `${pattern.name}_split_${i}`,
|
|
domain: pattern.domain,
|
|
embedding: newEmbedding,
|
|
strategy: pattern.strategy,
|
|
successRate: pattern.successRate * 0.9, // Slight penalty for uncertainty
|
|
usageCount: 0,
|
|
qualityHistory: [],
|
|
evolutionHistory: [{
|
|
timestamp: Date.now(),
|
|
type: 'split',
|
|
previousQuality: pattern.successRate,
|
|
newQuality: pattern.successRate * 0.9,
|
|
description: `Split from pattern ${patternId}`,
|
|
}],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.patterns.set(newPattern.patternId, newPattern);
|
|
splits.push(newPattern);
|
|
}
|
|
// Remove original pattern
|
|
this.patterns.delete(patternId);
|
|
this.patternToCluster.delete(patternId);
|
|
// Rebuild clusters
|
|
if (this.config.enableClustering) {
|
|
this.rebuildClusters();
|
|
}
|
|
return splits;
|
|
}
|
|
// ==========================================================================
|
|
// Pattern Access
|
|
// ==========================================================================
|
|
/**
|
|
* Get all patterns
|
|
*/
|
|
getPatterns() {
|
|
return Array.from(this.patterns.values());
|
|
}
|
|
/**
|
|
* Get pattern by ID
|
|
*/
|
|
getPattern(patternId) {
|
|
return this.patterns.get(patternId);
|
|
}
|
|
/**
|
|
* Get patterns by domain
|
|
*/
|
|
getPatternsByDomain(domain) {
|
|
return Array.from(this.patterns.values()).filter(p => p.domain === domain);
|
|
}
|
|
/**
|
|
* Get stable patterns (sufficient usage)
|
|
*/
|
|
getStablePatterns() {
|
|
return Array.from(this.patterns.values())
|
|
.filter(p => p.usageCount >= this.config.minUsagesForStable);
|
|
}
|
|
// ==========================================================================
|
|
// Statistics
|
|
// ==========================================================================
|
|
getStats() {
|
|
const patterns = Array.from(this.patterns.values());
|
|
return {
|
|
totalPatterns: this.patterns.size,
|
|
stablePatterns: patterns.filter(p => p.usageCount >= this.config.minUsagesForStable).length,
|
|
avgSuccessRate: patterns.length > 0
|
|
? patterns.reduce((s, p) => s + p.successRate, 0) / patterns.length
|
|
: 0,
|
|
avgUsageCount: patterns.length > 0
|
|
? patterns.reduce((s, p) => s + p.usageCount, 0) / patterns.length
|
|
: 0,
|
|
numClusters: this.clusters.length,
|
|
avgMatchTimeMs: this.matchCount > 0 ? this.totalMatchTime / this.matchCount : 0,
|
|
avgExtractionTimeMs: this.extractionCount > 0 ? this.totalExtractionTime / this.extractionCount : 0,
|
|
avgEvolutionTimeMs: this.evolutionCount > 0 ? this.totalEvolutionTime / this.evolutionCount : 0,
|
|
};
|
|
}
|
|
// ==========================================================================
|
|
// Event System
|
|
// ==========================================================================
|
|
addEventListener(listener) {
|
|
this.eventListeners.add(listener);
|
|
}
|
|
removeEventListener(listener) {
|
|
this.eventListeners.delete(listener);
|
|
}
|
|
emitEvent(event) {
|
|
for (const listener of this.eventListeners) {
|
|
try {
|
|
listener(event);
|
|
}
|
|
catch (error) {
|
|
console.error('Error in PatternLearner event listener:', error);
|
|
}
|
|
}
|
|
}
|
|
// ==========================================================================
|
|
// Private Helper Methods
|
|
// ==========================================================================
|
|
cosineSimilarity(a, b) {
|
|
if (a.length !== b.length)
|
|
return 0;
|
|
let dot = 0, normA = 0, normB = 0;
|
|
for (let i = 0; i < a.length; i++) {
|
|
dot += a[i] * b[i];
|
|
normA += a[i] * a[i];
|
|
normB += b[i] * b[i];
|
|
}
|
|
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
return denom > 0 ? dot / denom : 0;
|
|
}
|
|
computeMatchConfidence(pattern, similarity) {
|
|
// Combine similarity with pattern reliability
|
|
const usageWeight = Math.min(pattern.usageCount / 10, 1);
|
|
const qualityWeight = pattern.successRate;
|
|
return similarity * (1 - usageWeight * 0.2 - qualityWeight * 0.2) +
|
|
usageWeight * 0.1 +
|
|
qualityWeight * 0.1;
|
|
}
|
|
getCandidatesFromClusters(queryEmbedding) {
|
|
// Find nearest clusters
|
|
const clusterScores = [];
|
|
for (const cluster of this.clusters) {
|
|
const score = this.cosineSimilarity(queryEmbedding, cluster.centroid);
|
|
clusterScores.push({ cluster, score });
|
|
}
|
|
clusterScores.sort((a, b) => b.score - a.score);
|
|
// Get patterns from top 3 clusters
|
|
const candidates = [];
|
|
for (const { cluster } of clusterScores.slice(0, 3)) {
|
|
for (const patternId of cluster.patternIds) {
|
|
const pattern = this.patterns.get(patternId);
|
|
if (pattern) {
|
|
candidates.push(pattern);
|
|
}
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
findSimilarPattern(embedding, threshold) {
|
|
for (const pattern of this.patterns.values()) {
|
|
const sim = this.cosineSimilarity(embedding, pattern.embedding);
|
|
if (sim >= threshold) {
|
|
return pattern;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
updatePatternFromTrajectory(pattern, trajectory) {
|
|
// Update quality
|
|
pattern.qualityHistory.push(trajectory.qualityScore);
|
|
if (pattern.qualityHistory.length > 100) {
|
|
pattern.qualityHistory = pattern.qualityHistory.slice(-100);
|
|
}
|
|
// EMA for success rate
|
|
const lr = this.config.evolutionLearningRate;
|
|
pattern.successRate = pattern.successRate * (1 - lr) + trajectory.qualityScore * lr;
|
|
pattern.usageCount++;
|
|
pattern.updatedAt = Date.now();
|
|
}
|
|
computePatternEmbedding(trajectory) {
|
|
if (trajectory.steps.length === 0) {
|
|
return new Float32Array(768);
|
|
}
|
|
const dim = trajectory.steps[0].stateAfter.length;
|
|
const embedding = new Float32Array(dim);
|
|
// Weighted average (higher weight for later steps)
|
|
let totalWeight = 0;
|
|
for (let i = 0; i < trajectory.steps.length; i++) {
|
|
const weight = (i + 1) / trajectory.steps.length;
|
|
totalWeight += weight;
|
|
for (let j = 0; j < dim; j++) {
|
|
embedding[j] += trajectory.steps[i].stateAfter[j] * weight;
|
|
}
|
|
}
|
|
for (let j = 0; j < dim; j++) {
|
|
embedding[j] /= totalWeight;
|
|
}
|
|
return embedding;
|
|
}
|
|
generatePatternName(trajectory) {
|
|
const domain = trajectory.domain;
|
|
const quality = trajectory.qualityScore > 0.7 ? 'high' : 'mid';
|
|
const steps = trajectory.steps.length > 5 ? 'complex' : 'simple';
|
|
return `${domain}_${quality}_${steps}_${Date.now() % 10000}`;
|
|
}
|
|
extractStrategy(trajectory) {
|
|
const actions = trajectory.steps.map(s => s.action);
|
|
if (actions.length === 0)
|
|
return 'empty';
|
|
if (actions.length <= 3)
|
|
return actions.join(' -> ');
|
|
return `${actions.slice(0, 2).join(' -> ')} ... ${actions[actions.length - 1]}`;
|
|
}
|
|
assignToCluster(pattern) {
|
|
if (this.clusters.length === 0) {
|
|
// Create first cluster
|
|
this.clusters.push({
|
|
clusterId: 0,
|
|
centroid: new Float32Array(pattern.embedding),
|
|
patternIds: new Set([pattern.patternId]),
|
|
});
|
|
this.patternToCluster.set(pattern.patternId, 0);
|
|
return;
|
|
}
|
|
// Find nearest cluster
|
|
let bestCluster = 0;
|
|
let bestSim = -1;
|
|
for (let i = 0; i < this.clusters.length; i++) {
|
|
const sim = this.cosineSimilarity(pattern.embedding, this.clusters[i].centroid);
|
|
if (sim > bestSim) {
|
|
bestSim = sim;
|
|
bestCluster = i;
|
|
}
|
|
}
|
|
// Create new cluster if not similar enough and under limit
|
|
if (bestSim < 0.7 && this.clusters.length < this.config.numClusters) {
|
|
const newId = this.clusters.length;
|
|
this.clusters.push({
|
|
clusterId: newId,
|
|
centroid: new Float32Array(pattern.embedding),
|
|
patternIds: new Set([pattern.patternId]),
|
|
});
|
|
this.patternToCluster.set(pattern.patternId, newId);
|
|
}
|
|
else {
|
|
// Add to existing cluster and update centroid
|
|
const cluster = this.clusters[bestCluster];
|
|
cluster.patternIds.add(pattern.patternId);
|
|
this.patternToCluster.set(pattern.patternId, bestCluster);
|
|
this.updateClusterCentroid(cluster);
|
|
}
|
|
}
|
|
updateClusterCentroid(cluster) {
|
|
const dim = cluster.centroid.length;
|
|
const newCentroid = new Float32Array(dim);
|
|
let count = 0;
|
|
for (const patternId of cluster.patternIds) {
|
|
const pattern = this.patterns.get(patternId);
|
|
if (pattern) {
|
|
for (let i = 0; i < dim; i++) {
|
|
newCentroid[i] += pattern.embedding[i];
|
|
}
|
|
count++;
|
|
}
|
|
}
|
|
if (count > 0) {
|
|
for (let i = 0; i < dim; i++) {
|
|
newCentroid[i] /= count;
|
|
}
|
|
cluster.centroid = newCentroid;
|
|
}
|
|
}
|
|
rebuildClusters() {
|
|
if (this.patterns.size === 0) {
|
|
this.clusters = [];
|
|
this.patternToCluster.clear();
|
|
return;
|
|
}
|
|
const patterns = Array.from(this.patterns.values());
|
|
const k = Math.min(this.config.numClusters, Math.ceil(patterns.length / 5));
|
|
const dim = patterns[0].embedding.length;
|
|
// Initialize clusters with random patterns
|
|
this.clusters = [];
|
|
this.patternToCluster.clear();
|
|
const indices = new Set();
|
|
while (indices.size < k && indices.size < patterns.length) {
|
|
indices.add(Math.floor(Math.random() * patterns.length));
|
|
}
|
|
let clusterId = 0;
|
|
for (const idx of indices) {
|
|
this.clusters.push({
|
|
clusterId: clusterId++,
|
|
centroid: new Float32Array(patterns[idx].embedding),
|
|
patternIds: new Set(),
|
|
});
|
|
}
|
|
// K-means iterations
|
|
for (let iter = 0; iter < 10; iter++) {
|
|
// Clear assignments
|
|
for (const cluster of this.clusters) {
|
|
cluster.patternIds.clear();
|
|
}
|
|
// Assign patterns to nearest cluster
|
|
for (const pattern of patterns) {
|
|
let bestCluster = 0;
|
|
let bestSim = -1;
|
|
for (let c = 0; c < this.clusters.length; c++) {
|
|
const sim = this.cosineSimilarity(pattern.embedding, this.clusters[c].centroid);
|
|
if (sim > bestSim) {
|
|
bestSim = sim;
|
|
bestCluster = c;
|
|
}
|
|
}
|
|
this.clusters[bestCluster].patternIds.add(pattern.patternId);
|
|
this.patternToCluster.set(pattern.patternId, bestCluster);
|
|
}
|
|
// Update centroids
|
|
for (const cluster of this.clusters) {
|
|
this.updateClusterCentroid(cluster);
|
|
}
|
|
}
|
|
// Remove empty clusters
|
|
this.clusters = this.clusters.filter(c => c.patternIds.size > 0);
|
|
}
|
|
prunePatterns() {
|
|
// Sort by score (quality * log(usage))
|
|
const scored = Array.from(this.patterns.entries())
|
|
.map(([id, pattern]) => ({
|
|
id,
|
|
pattern,
|
|
score: pattern.successRate * Math.log(pattern.usageCount + 1),
|
|
}))
|
|
.sort((a, b) => a.score - b.score);
|
|
// Remove lowest scoring patterns
|
|
const toRemove = scored.length - Math.floor(this.config.maxPatterns * 0.8);
|
|
for (let i = 0; i < toRemove && i < scored.length; i++) {
|
|
this.patterns.delete(scored[i].id);
|
|
this.patternToCluster.delete(scored[i].id);
|
|
}
|
|
// Rebuild clusters
|
|
if (this.config.enableClustering) {
|
|
this.rebuildClusters();
|
|
}
|
|
}
|
|
determineEvolutionType(prev, curr) {
|
|
const delta = curr - prev;
|
|
if (delta > 0.05)
|
|
return 'improvement';
|
|
if (delta < -0.15)
|
|
return 'prune';
|
|
return 'improvement';
|
|
}
|
|
}
|
|
/**
|
|
* Factory function for creating PatternLearner
|
|
*/
|
|
export function createPatternLearner(config) {
|
|
return new PatternLearner(config);
|
|
}
|
|
//# sourceMappingURL=pattern-learner.js.map
|