332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
/**
|
|
* PersistentSonaCoordinator - SONA learning with RVF persistence
|
|
*
|
|
* Wraps RvfLearningStore to provide an in-memory pattern bank with
|
|
* brute-force cosine similarity, trajectory buffering, EWC tracking,
|
|
* and automatic periodic persistence to disk.
|
|
*
|
|
* This is intentionally decoupled from the ruvector SONA classes:
|
|
* it defines its own compatible types and delegates persistence to
|
|
* RvfLearningStore.
|
|
*
|
|
* @module @claude-flow/memory/persistent-sona
|
|
*/
|
|
import { RvfLearningStore, } from './rvf-learning-store.js';
|
|
// ===== Constants =====
|
|
const DEFAULT_PATTERN_THRESHOLD = 0.85;
|
|
const DEFAULT_MAX_TRAJECTORY_BUFFER = 1000;
|
|
const DEFAULT_AUTO_PERSIST_MS = 30_000;
|
|
// ===== Helpers =====
|
|
/**
|
|
* Compute cosine similarity between two number arrays.
|
|
* Returns 0 when either vector has zero magnitude.
|
|
*/
|
|
function cosineSimilarity(a, b) {
|
|
const len = Math.min(a.length, b.length);
|
|
let dot = 0;
|
|
let normA = 0;
|
|
let normB = 0;
|
|
// Process in groups of 4 for better throughput
|
|
let i = 0;
|
|
for (; i + 3 < len; i += 4) {
|
|
dot += a[i] * b[i] + a[i + 1] * b[i + 1] + a[i + 2] * b[i + 2] + a[i + 3] * b[i + 3];
|
|
normA += a[i] * a[i] + a[i + 1] * a[i + 1] + a[i + 2] * a[i + 2] + a[i + 3] * a[i + 3];
|
|
normB += b[i] * b[i] + b[i + 1] * b[i + 1] + b[i + 2] * b[i + 2] + b[i + 3] * b[i + 3];
|
|
}
|
|
for (; i < len; 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;
|
|
}
|
|
function generateId(prefix) {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
// ===== PersistentSonaCoordinator =====
|
|
/**
|
|
* Coordinates SONA learning with persistent storage.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const sona = new PersistentSonaCoordinator({
|
|
* storePath: './data/sona-learning.rvls',
|
|
* });
|
|
* await sona.initialize();
|
|
*
|
|
* // Store a pattern
|
|
* const id = sona.storePattern('query_response', embedding);
|
|
*
|
|
* // Find similar patterns
|
|
* const matches = sona.findSimilarPatterns(queryEmbedding, 5);
|
|
*
|
|
* // Record a trajectory
|
|
* sona.recordTrajectory({ id: 'traj-1', steps: [...], outcome: 'success', ... });
|
|
*
|
|
* // Periodic background learning
|
|
* const result = sona.runBackgroundLoop();
|
|
*
|
|
* await sona.shutdown();
|
|
* ```
|
|
*/
|
|
export class PersistentSonaCoordinator {
|
|
store;
|
|
patterns = new Map();
|
|
trajectoryBuffer = [];
|
|
ewcState = null;
|
|
patternThreshold;
|
|
maxTrajectoryBuffer;
|
|
verbose;
|
|
initialized = false;
|
|
constructor(config) {
|
|
this.patternThreshold = config.patternThreshold ?? DEFAULT_PATTERN_THRESHOLD;
|
|
this.maxTrajectoryBuffer = config.maxTrajectoryBuffer ?? DEFAULT_MAX_TRAJECTORY_BUFFER;
|
|
this.verbose = config.verbose ?? false;
|
|
const storeConfig = {
|
|
storePath: config.storePath,
|
|
autoPersistInterval: config.autoPersistInterval ?? DEFAULT_AUTO_PERSIST_MS,
|
|
verbose: config.verbose,
|
|
};
|
|
this.store = new RvfLearningStore(storeConfig);
|
|
}
|
|
/**
|
|
* Initialize by loading persisted state from the RVF store.
|
|
*/
|
|
async initialize() {
|
|
if (this.initialized)
|
|
return;
|
|
await this.store.initialize();
|
|
// Rebuild in-memory state from store
|
|
const patterns = await this.store.loadPatterns();
|
|
for (const p of patterns) {
|
|
this.patterns.set(p.id, p);
|
|
}
|
|
const ewc = await this.store.loadEwcState();
|
|
this.ewcState = ewc;
|
|
const trajectories = await this.store.getTrajectories();
|
|
// getTrajectories returns newest-first, reverse for chronological buffer
|
|
this.trajectoryBuffer = trajectories.reverse();
|
|
this.initialized = true;
|
|
this.log(`Initialized: ${this.patterns.size} patterns, ` +
|
|
`${this.trajectoryBuffer.length} trajectories, ` +
|
|
`EWC: ${this.ewcState ? 'yes' : 'no'}`);
|
|
}
|
|
// ===== Pattern operations =====
|
|
/**
|
|
* Store a new pattern and return its ID.
|
|
*
|
|
* @param type - Pattern type (e.g. 'query_response', 'routing')
|
|
* @param embedding - The pattern embedding vector
|
|
* @param metadata - Optional extra metadata (currently unused, reserved)
|
|
* @returns The generated pattern ID
|
|
*/
|
|
storePattern(type, embedding, metadata) {
|
|
this.ensureInitialized();
|
|
const id = generateId('pat');
|
|
const record = {
|
|
id,
|
|
type,
|
|
embedding: [...embedding],
|
|
successRate: 1.0,
|
|
useCount: 0,
|
|
lastUsed: new Date().toISOString(),
|
|
};
|
|
this.patterns.set(id, record);
|
|
// Mark for persistence on next persist() call
|
|
void this.store.savePatterns([record]).catch(() => { });
|
|
return id;
|
|
}
|
|
/**
|
|
* Find the k most similar patterns above the configured threshold.
|
|
* Uses brute-force cosine similarity (suitable for small pattern sets).
|
|
*/
|
|
findSimilarPatterns(embedding, k = 5) {
|
|
this.ensureInitialized();
|
|
const results = [];
|
|
for (const record of this.patterns.values()) {
|
|
const score = cosineSimilarity(embedding, record.embedding);
|
|
if (score >= this.patternThreshold) {
|
|
results.push({ record, score });
|
|
}
|
|
}
|
|
// Sort descending by score and take top-k
|
|
results.sort((a, b) => b.score - a.score);
|
|
return results.slice(0, k).map((r) => r.record);
|
|
}
|
|
/**
|
|
* Record a pattern usage outcome. Updates the success rate using an
|
|
* exponential moving average (alpha = 0.1).
|
|
*/
|
|
recordPatternUsage(patternId, success) {
|
|
this.ensureInitialized();
|
|
const pattern = this.patterns.get(patternId);
|
|
if (!pattern)
|
|
return;
|
|
pattern.useCount++;
|
|
pattern.lastUsed = new Date().toISOString();
|
|
const alpha = 0.1;
|
|
const outcome = success ? 1.0 : 0.0;
|
|
pattern.successRate = alpha * outcome + (1 - alpha) * pattern.successRate;
|
|
}
|
|
/**
|
|
* Remove patterns that have low success rates after sufficient usage.
|
|
*
|
|
* @returns The number of patterns pruned
|
|
*/
|
|
prunePatterns(minSuccessRate = 0.3, minUseCount = 5) {
|
|
this.ensureInitialized();
|
|
let pruned = 0;
|
|
for (const [id, pattern] of this.patterns) {
|
|
if (pattern.useCount >= minUseCount && pattern.successRate < minSuccessRate) {
|
|
this.patterns.delete(id);
|
|
pruned++;
|
|
}
|
|
}
|
|
if (pruned > 0) {
|
|
this.log(`Pruned ${pruned} low-performing patterns`);
|
|
}
|
|
return pruned;
|
|
}
|
|
// ===== Trajectory tracking =====
|
|
/**
|
|
* Buffer a completed trajectory for later processing.
|
|
* When the buffer exceeds maxTrajectoryBuffer, the oldest entries
|
|
* are evicted.
|
|
*/
|
|
recordTrajectory(trajectory) {
|
|
this.ensureInitialized();
|
|
const copy = { ...trajectory };
|
|
this.trajectoryBuffer.push(copy);
|
|
// Persist to store immediately so evicted entries are not lost
|
|
void this.store.appendTrajectory(copy).catch(() => { });
|
|
while (this.trajectoryBuffer.length > this.maxTrajectoryBuffer) {
|
|
this.trajectoryBuffer.shift();
|
|
}
|
|
}
|
|
// ===== Learning loop =====
|
|
/**
|
|
* Process buffered trajectories to extract new patterns.
|
|
* Successful and partial trajectories are mined for high-confidence
|
|
* steps; new patterns are stored if they are sufficiently different
|
|
* from existing ones.
|
|
*
|
|
* After processing, the trajectory buffer is cleared and low-performing
|
|
* patterns are pruned.
|
|
*
|
|
* @returns Summary of the learning pass
|
|
*/
|
|
runBackgroundLoop() {
|
|
this.ensureInitialized();
|
|
let patternsLearned = 0;
|
|
const trajectoriesProcessed = this.trajectoryBuffer.length;
|
|
for (const traj of this.trajectoryBuffer) {
|
|
if (traj.outcome === 'success' || traj.outcome === 'partial') {
|
|
patternsLearned += this.extractPatternsFromTrajectory(traj);
|
|
}
|
|
}
|
|
this.prunePatterns();
|
|
this.trajectoryBuffer = [];
|
|
this.log(`Background loop: ${patternsLearned} patterns learned, ` +
|
|
`${trajectoriesProcessed} trajectories processed`);
|
|
return { patternsLearned, trajectoriesProcessed };
|
|
}
|
|
// ===== Persistence =====
|
|
/**
|
|
* Flush current in-memory state to the RVF store on disk.
|
|
*/
|
|
async persist() {
|
|
const allPatterns = Array.from(this.patterns.values());
|
|
await this.store.savePatterns(allPatterns);
|
|
if (this.ewcState) {
|
|
await this.store.saveEwcState(this.ewcState);
|
|
}
|
|
// Persist any buffered trajectories that have not yet been saved
|
|
for (const traj of this.trajectoryBuffer) {
|
|
await this.store.appendTrajectory(traj);
|
|
}
|
|
await this.store.persist();
|
|
}
|
|
/**
|
|
* Persist state and shut down the store.
|
|
*/
|
|
async shutdown() {
|
|
if (!this.initialized)
|
|
return;
|
|
await this.persist();
|
|
await this.store.close();
|
|
this.initialized = false;
|
|
this.log('Shutdown complete');
|
|
}
|
|
// ===== Stats =====
|
|
/**
|
|
* Return a summary of the coordinator's current state.
|
|
*/
|
|
getStats() {
|
|
let totalSuccessRate = 0;
|
|
let count = 0;
|
|
for (const p of this.patterns.values()) {
|
|
totalSuccessRate += p.successRate;
|
|
count++;
|
|
}
|
|
return {
|
|
patterns: this.patterns.size,
|
|
avgSuccessRate: count > 0 ? totalSuccessRate / count : 0,
|
|
trajectoriesBuffered: this.trajectoryBuffer.length,
|
|
ewcTasksLearned: this.ewcState?.tasksLearned ?? 0,
|
|
};
|
|
}
|
|
// ===== Private =====
|
|
/**
|
|
* Extract patterns from a trajectory's high-confidence steps.
|
|
* A step produces a new pattern only if no sufficiently similar
|
|
* pattern already exists.
|
|
*/
|
|
extractPatternsFromTrajectory(trajectory) {
|
|
let extracted = 0;
|
|
for (const step of trajectory.steps) {
|
|
if (step.confidence < this.patternThreshold)
|
|
continue;
|
|
const embedding = this.createHashEmbedding(step.input + step.output);
|
|
const similar = this.findSimilarPatterns(embedding, 1);
|
|
if (similar.length === 0) {
|
|
this.storePattern(step.type, embedding);
|
|
extracted++;
|
|
}
|
|
}
|
|
return extracted;
|
|
}
|
|
/**
|
|
* Deterministic hash-based embedding for pattern extraction.
|
|
* This is a lightweight stand-in for a real embedding model,
|
|
* matching the approach used in SonaCoordinator.
|
|
*/
|
|
createHashEmbedding(text, dim = 64) {
|
|
const embedding = new Array(dim).fill(0);
|
|
for (let i = 0; i < text.length; i++) {
|
|
const idx = (text.charCodeAt(i) * (i + 1)) % dim;
|
|
embedding[idx] += 0.1;
|
|
}
|
|
let norm = 0;
|
|
for (let i = 0; i < dim; i++) {
|
|
norm += embedding[i] * embedding[i];
|
|
}
|
|
norm = Math.sqrt(norm) || 1;
|
|
for (let i = 0; i < dim; i++) {
|
|
embedding[i] /= norm;
|
|
}
|
|
return embedding;
|
|
}
|
|
ensureInitialized() {
|
|
if (!this.initialized) {
|
|
throw new Error('PersistentSonaCoordinator has not been initialized. Call initialize() first.');
|
|
}
|
|
}
|
|
log(message) {
|
|
if (this.verbose) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[PersistentSona] ${message}`);
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=persistent-sona.js.map
|