diff --git a/packages/agentdb/src/services/SonaTrajectoryService.js b/packages/agentdb/src/services/SonaTrajectoryService.js new file mode 100644 index 000000000..0e7b8ba45 --- /dev/null +++ b/packages/agentdb/src/services/SonaTrajectoryService.js @@ -0,0 +1,646 @@ +/** + * SonaTrajectoryService - Wraps @ruvector/sona for trajectory learning + * + * Provides trajectory recording and action prediction for agent learning. + * Uses @ruvector/sona for reinforcement learning when available, + * otherwise falls back to in-memory trajectory storage with simple + * frequency-based prediction. + * + * Usage: + * const sona = new SonaTrajectoryService(); + * await sona.initialize(); + * + * // Record agent trajectories + * await sona.recordTrajectory('coder', [ + * { state: { task: 'implement' }, action: 'write_code', reward: 0.8 }, + * { state: { task: 'test' }, action: 'run_tests', reward: 0.9 } + * ]); + * + * // Predict next action + * const prediction = await sona.predict({ task: 'implement' }); + */ +export class SonaTrajectoryService { + sona = null; + available = false; + engineType = 'js'; + hiddenDim = 256; + trajectories = new Map(); + // RL Training state + policyConfig = { + learningRate: 0.001, + gamma: 0.99, + epsilon: 0.1, + entropyCoeff: 0.01 + }; + valueConfig = { + learningRate: 0.001, + gamma: 0.99, + lambda: 0.95 + }; + replayConfig = { + bufferSize: 10000, + batchSize: 32, + priorityAlpha: 0.6, + priorityBeta: 0.4 + }; + experienceBuffer = []; + metrics = { + episodeReward: 0, + avgReward: 0, + loss: 0, + epsilon: 0.1, + iterationCount: 0 + }; + policyWeights = new Map(); + valueWeights = new Map(); + /** + * Initialize the trajectory service + * + * ADR-062 Phase 2: Tries native @ruvector/sona (NAPI-RS) first, + * falls back to in-memory trajectory storage. Reports engine type. + * + * @param options - Optional configuration { hiddenDim?: number } + * @returns true if @ruvector/sona was loaded, false if using fallback + */ + async initialize(options) { + if (options?.hiddenDim) { + this.hiddenDim = options.hiddenDim; + } + try { + const mod = await import('@ruvector/sona'); + // Resolve the SonaEngine class from the module exports + // Module exports: { default: { SonaEngine: [class] } } + const exports = mod.default || mod; + const SonaEngine = exports.SonaEngine || exports.SONA || exports.Sona; + if (SonaEngine && typeof SonaEngine === 'function') { + // Prefer withConfig for full control over learning parameters + if (typeof SonaEngine.withConfig === 'function') { + this.sona = SonaEngine.withConfig({ + hiddenDim: this.hiddenDim, + embeddingDim: this.hiddenDim, + patternClusters: 10, + qualityThreshold: 0.1, + trajectoryCapacity: 10000 + }); + } + else { + this.sona = new SonaEngine(this.hiddenDim); + } + this.available = true; + this.engineType = 'native'; + console.log(`[SonaTrajectoryService] Using native @ruvector/sona (dim=${this.hiddenDim})`); + return true; + } + console.warn('[SonaTrajectoryService] @ruvector/sona loaded but SonaEngine class not found'); + this.available = false; + this.engineType = 'js'; + return false; + } + catch { + this.available = false; + this.engineType = 'js'; + return false; + } + } + /** + * Get the active engine type: 'native' or 'js' + */ + getEngineType() { + return this.engineType; + } + /** + * Generate an embedding vector from a state object. + * + * Uses deterministic hashing to produce a consistent embedding + * from the state's string representation. This ensures that + * similar state descriptions produce similar (though not + * semantically equivalent) vectors. When an external embedding + * is provided in the step data, that is used instead. + * + * @param state - State object or string to embed + * @returns Array of numbers with length = this.hiddenDim + */ + stateToEmbedding(state) { + const text = typeof state === 'string' ? state : JSON.stringify(state); + const dim = this.hiddenDim; + const embedding = new Array(dim); + // Deterministic hash-based embedding from text content + // Uses multiple hash passes with different seeds for each dimension + let hash = 5381; + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) + hash + text.charCodeAt(i)) & 0x7fffffff; + } + for (let d = 0; d < dim; d++) { + // Mix the base hash with the dimension index + let h = hash ^ (d * 2654435761); + h = ((h >>> 16) ^ h) * 0x45d9f3b; + h = ((h >>> 16) ^ h) * 0x45d9f3b; + h = (h >>> 16) ^ h; + // Convert to float in [-1, 1] + embedding[d] = ((h & 0x7fffffff) / 0x7fffffff) * 2 - 1; + } + // L2 normalize + let norm = 0; + for (let d = 0; d < dim; d++) { + norm += embedding[d] * embedding[d]; + } + norm = Math.sqrt(norm); + if (norm > 0) { + for (let d = 0; d < dim; d++) { + embedding[d] /= norm; + } + } + return embedding; + } + /** + * Record a trajectory for an agent type + * + * When @ruvector/sona is available, records the trajectory using the + * native engine's beginTrajectory → addTrajectoryStep → endTrajectory + * pipeline for real LoRA-based learning. + * Otherwise, trajectories are stored in memory for pattern analysis. + * + * @param agentType - Type of agent (e.g., 'coder', 'reviewer') + * @param steps - Sequence of state-action-reward tuples + */ + async recordTrajectory(agentType, steps) { + if (steps.length === 0) + return; + const totalReward = steps.reduce((sum, s) => sum + s.reward, 0) / steps.length; + // Try native @ruvector/sona engine + if (this.sona && typeof this.sona.beginTrajectory === 'function') { + try { + // Generate embedding from the first step's state (represents the task) + const firstState = steps[0].state; + const queryEmbedding = firstState?.embedding || this.stateToEmbedding(firstState); + // Begin a new trajectory with the query embedding + const trajectoryId = this.sona.beginTrajectory(queryEmbedding); + // Add each step to the trajectory + for (const step of steps) { + // Generate activations and attention weights from the step state + // These represent the "neural state" at each decision point + const stepEmbedding = step.state?.embedding || this.stateToEmbedding(step.state); + const activations = stepEmbedding; + // Attention weights: use action-weighted variant of the embedding + const actionHash = this.stateToEmbedding(step.action || 'default'); + const attentionWeights = actionHash; + this.sona.addTrajectoryStep( + trajectoryId, + activations, + attentionWeights, + step.reward + ); + } + // Set the route (agent type) for this trajectory + this.sona.setTrajectoryRoute(trajectoryId, agentType); + // End trajectory with average quality score + const quality = Math.max(0, Math.min(1, totalReward)); + this.sona.endTrajectory(trajectoryId, quality); + // Tick the instant learning loop + this.sona.tick(); + } + catch (err) { + // Log but don't throw - fall through to in-memory storage + console.warn(`[SonaTrajectoryService] Native trajectory recording failed: ${err?.message || err}`); + } + } + // In-memory storage (always maintained for local analysis) + if (!this.trajectories.has(agentType)) { + this.trajectories.set(agentType, []); + } + this.trajectories.get(agentType).push({ steps, reward: totalReward }); + } + /** + * Predict the next action given a state + * + * When @ruvector/sona is available, uses findPatterns to find + * similar learned patterns and derive predictions from them. + * Otherwise, uses frequency-based prediction from stored trajectories. + * + * @param state - Current state to predict action for + * @returns Predicted action and confidence score + */ + async predict(state) { + // Try native @ruvector/sona pattern matching + if (this.sona && typeof this.sona.findPatterns === 'function') { + try { + const queryEmbedding = state?.embedding || this.stateToEmbedding(state); + const patterns = this.sona.findPatterns(queryEmbedding, 3); + if (patterns && Array.isArray(patterns) && patterns.length > 0) { + // Use the best pattern's cluster to derive a prediction + const best = patterns[0]; + // Confidence based on cluster quality and size + const confidence = Math.min(0.95, + best.avgQuality * Math.min(1, best.clusterSize / 10)); + return { + action: best.patternType || 'default', + confidence, + source: 'native-sona', + patternId: best.id, + clusterSize: best.clusterSize, + avgQuality: best.avgQuality + }; + } + } + catch { + // Fall through to frequency-based prediction + } + } + // Frequency-based fallback: find the most common action across trajectories + return this.frequencyPredict(); + } + /** + * Get trajectory patterns, optionally filtered by agent type + * + * When @ruvector/sona is available, queries the native engine for + * learned patterns using a neutral query embedding. + * Otherwise, returns stored trajectories. + * + * @param agentType - Optional agent type filter + * @returns Array of trajectory patterns + */ + async getPatterns(agentType) { + // Try native @ruvector/sona pattern retrieval + if (this.sona && typeof this.sona.findPatterns === 'function') { + try { + // Use a neutral embedding to get all patterns + const neutralEmbedding = new Array(this.hiddenDim).fill(1.0 / Math.sqrt(this.hiddenDim)); + const patterns = this.sona.findPatterns(neutralEmbedding, 50); + if (patterns && Array.isArray(patterns) && patterns.length > 0) { + return patterns; + } + } + catch { + // Fall through to in-memory trajectories + } + } + if (agentType) { + return this.trajectories.get(agentType) || []; + } + return Array.from(this.trajectories.values()).flat(); + } + /** + * Force the native engine to run a background learning cycle. + * Returns the learning result string, or null if native engine unavailable. + */ + forceLearn() { + if (this.sona && typeof this.sona.forceLearn === 'function') { + return this.sona.forceLearn(); + } + return null; + } + /** + * Get native engine statistics (if available) + */ + getNativeStats() { + if (this.sona && typeof this.sona.getStats === 'function') { + try { + const raw = this.sona.getStats(); + return typeof raw === 'string' ? JSON.parse(raw) : raw; + } + catch { + return null; + } + } + return null; + } + /** + * Check if @ruvector/sona is available + */ + isAvailable() { + return this.available; + } + /** + * Get service statistics + */ + getStats() { + const base = { + available: this.available, + engineType: this.engineType, + trajectoryCount: Array.from(this.trajectories.values()) + .reduce((sum, arr) => sum + arr.length, 0), + agentTypes: Array.from(this.trajectories.keys()) + }; + // Include native engine stats if available + const nativeStats = this.getNativeStats(); + if (nativeStats) { + base.native = nativeStats; + } + return base; + } + /** + * Clear all stored trajectories for an agent type, or all if not specified + */ + clear(agentType) { + if (agentType) { + this.trajectories.delete(agentType); + } + else { + this.trajectories.clear(); + } + } + /** + * Frequency-based action prediction from stored trajectories + */ + frequencyPredict() { + const actionCounts = new Map(); + for (const trajectories of this.trajectories.values()) { + for (const traj of trajectories) { + for (const step of traj.steps) { + const entry = actionCounts.get(step.action) || { count: 0, totalReward: 0 }; + entry.count++; + entry.totalReward += step.reward; + actionCounts.set(step.action, entry); + } + } + } + if (actionCounts.size === 0) { + return { action: 'default', confidence: 0.5 }; + } + // Find action with highest average reward + let bestAction = 'default'; + let bestAvgReward = -Infinity; + let totalActions = 0; + for (const [action, entry] of actionCounts) { + totalActions += entry.count; + const avgReward = entry.totalReward / entry.count; + if (avgReward > bestAvgReward) { + bestAvgReward = avgReward; + bestAction = action; + } + } + // Confidence based on the proportion of observations + const bestCount = actionCounts.get(bestAction)?.count || 0; + const confidence = Math.min(0.95, bestCount / Math.max(totalActions, 1)); + return { action: bestAction, confidence }; + } + // ==================== RL Training Methods ==================== + /** + * Train policy using Policy Gradient (REINFORCE with baseline) + * + * @param episodes - Array of trajectories to learn from + * @param config - Optional policy gradient configuration + * @returns Training loss + */ + async trainPolicy(episodes, config) { + if (config) { + this.policyConfig = { ...this.policyConfig, ...config }; + } + let totalLoss = 0; + let episodeCount = 0; + for (const episode of episodes) { + const returns = []; + let G = 0; + // Calculate returns (backwards) + for (let t = episode.steps.length - 1; t >= 0; t--) { + G = episode.steps[t].reward + this.policyConfig.gamma * G; + returns.unshift(G); + } + // Calculate baseline (average return) + const baseline = returns.reduce((a, b) => a + b, 0) / returns.length; + // Update policy for each step + for (let t = 0; t < episode.steps.length; t++) { + const step = episode.steps[t]; + const advantage = returns[t] - baseline; + // Get or initialize policy weights for this action + const actionKey = step.action; + if (!this.policyWeights.has(actionKey)) { + this.policyWeights.set(actionKey, [0]); + } + const weights = this.policyWeights.get(actionKey); + const gradient = advantage * this.policyConfig.learningRate; + weights[0] += gradient; + totalLoss += Math.abs(advantage); + } + episodeCount++; + } + // Update metrics + this.metrics.loss = totalLoss / Math.max(episodeCount, 1); + this.metrics.iterationCount++; + // Decay epsilon (exploration rate) + this.metrics.epsilon = Math.max(0.01, this.policyConfig.epsilon * 0.995); + this.policyConfig.epsilon = this.metrics.epsilon; + return this.metrics.loss; + } + /** + * Estimate value function using TD learning + * + * @param state - State to estimate value for + * @param reward - Observed reward + * @param nextState - Next state + * @param config - Optional value function configuration + * @returns Estimated value + */ + async estimateValue(state, reward, nextState, config) { + if (config) { + this.valueConfig = { ...this.valueConfig, ...config }; + } + const stateKey = JSON.stringify(state); + const nextStateKey = JSON.stringify(nextState); + // Get or initialize value estimates + const currentValue = this.valueWeights.get(stateKey) || 0; + const nextValue = this.valueWeights.get(nextStateKey) || 0; + // TD error: δ = r + γV(s') - V(s) + const tdError = reward + this.valueConfig.gamma * nextValue - currentValue; + // Update value function: V(s) ← V(s) + α·δ + const newValue = currentValue + this.valueConfig.learningRate * tdError; + this.valueWeights.set(stateKey, newValue); + return newValue; + } + /** + * Add experience to replay buffer with priority sampling + * + * @param state - Current state + * @param action - Action taken + * @param reward - Reward received + * @param nextState - Resulting state + * @param priority - Experience priority (default: 1.0) + */ + addExperience(state, action, reward, nextState, priority = 1.0) { + // Add to buffer + this.experienceBuffer.push({ state, action, reward, nextState, priority }); + // Maintain buffer size + if (this.experienceBuffer.length > this.replayConfig.bufferSize) { + this.experienceBuffer.shift(); + } + } + /** + * Sample batch from experience replay buffer with priority sampling + * + * @param batchSize - Optional batch size (default: from config) + * @returns Batch of experiences + */ + sampleExperience(batchSize) { + const size = batchSize || this.replayConfig.batchSize; + if (this.experienceBuffer.length === 0) { + return []; + } + // Calculate probability distribution based on priorities + const totalPriority = this.experienceBuffer.reduce((sum, exp) => sum + Math.pow(exp.priority, this.replayConfig.priorityAlpha), 0); + const batch = []; + for (let i = 0; i < Math.min(size, this.experienceBuffer.length); i++) { + // Priority sampling + let rand = Math.random() * totalPriority; + let selectedExp = this.experienceBuffer[0]; + for (const exp of this.experienceBuffer) { + rand -= Math.pow(exp.priority, this.replayConfig.priorityAlpha); + if (rand <= 0) { + selectedExp = exp; + break; + } + } + batch.push({ + state: selectedExp.state, + action: selectedExp.action, + reward: selectedExp.reward, + nextState: selectedExp.nextState + }); + } + return batch; + } + /** + * Multi-agent reinforcement learning coordination + * + * @param agentStates - Map of agent IDs to their states + * @param jointAction - Joint action taken by all agents + * @param jointReward - Shared reward + * @returns Individual rewards for each agent + */ + async multiAgentLearn(agentStates, jointAction, jointReward) { + const individualRewards = new Map(); + // Distribute reward based on contribution (simplified) + const numAgents = agentStates.size; + const baseReward = jointReward / numAgents; + for (const [agentId, state] of agentStates) { + const action = jointAction.get(agentId) || 'default'; + // Calculate individual contribution + const contribution = this.calculateContribution(agentId, state, action); + const reward = baseReward * (0.5 + contribution * 0.5); + individualRewards.set(agentId, reward); + // Record for learning + await this.recordTrajectory(agentId, [{ + state, + action, + reward + }]); + } + return individualRewards; + } + /** + * Transfer learning: apply knowledge from source task to target task + * + * @param sourceAgent - Agent type to transfer from + * @param targetAgent - Agent type to transfer to + * @param transferRatio - How much knowledge to transfer (0-1) + * @returns Success indicator + */ + async transferLearning(sourceAgent, targetAgent, transferRatio = 0.7) { + const sourcePatterns = await this.getPatterns(sourceAgent); + if (sourcePatterns.length === 0) { + return false; + } + // Transfer policy weights + for (const [actionKey, weights] of this.policyWeights) { + if (actionKey.startsWith(sourceAgent)) { + const targetKey = actionKey.replace(sourceAgent, targetAgent); + const targetWeights = this.policyWeights.get(targetKey) || [0]; + // Blend weights + for (let i = 0; i < Math.min(weights.length, targetWeights.length); i++) { + targetWeights[i] = transferRatio * weights[i] + (1 - transferRatio) * targetWeights[i]; + } + this.policyWeights.set(targetKey, targetWeights); + } + } + // Transfer value estimates + for (const [stateKey, value] of this.valueWeights) { + const state = JSON.parse(stateKey); + if (state.agentType === sourceAgent) { + const targetState = { ...state, agentType: targetAgent }; + const targetKey = JSON.stringify(targetState); + const targetValue = this.valueWeights.get(targetKey) || 0; + this.valueWeights.set(targetKey, transferRatio * value + (1 - transferRatio) * targetValue); + } + } + return true; + } + /** + * Continuous learning: update model with new experience + * + * @param state - Current state + * @param action - Action taken + * @param reward - Reward received + * @param nextState - Resulting state + * @returns Updated value estimate + */ + async continuousLearn(state, action, reward, nextState) { + // Add to experience replay + const tdError = Math.abs(reward - (this.valueWeights.get(JSON.stringify(state)) || 0)); + this.addExperience(state, action, reward, nextState, tdError + 1); + // Update value function + const value = await this.estimateValue(state, reward, nextState); + // Update policy if we have enough experiences + if (this.experienceBuffer.length >= this.replayConfig.batchSize) { + const batch = this.sampleExperience(); + // Create mini-episode from batch + const miniEpisode = { + steps: batch.map(exp => ({ + state: exp.state, + action: exp.action, + reward: exp.reward + })), + reward: batch.reduce((sum, exp) => sum + exp.reward, 0) / batch.length + }; + await this.trainPolicy([miniEpisode]); + } + // Update metrics + this.metrics.episodeReward += reward; + this.metrics.avgReward = (this.metrics.avgReward * this.metrics.iterationCount + reward) / (this.metrics.iterationCount + 1); + return value; + } + /** + * Get current RL metrics + */ + getRLMetrics() { + return { ...this.metrics }; + } + /** + * Reset RL state (for new training session) + */ + resetRL() { + this.policyWeights.clear(); + this.valueWeights.clear(); + this.experienceBuffer = []; + this.metrics = { + episodeReward: 0, + avgReward: 0, + loss: 0, + epsilon: this.policyConfig.epsilon, + iterationCount: 0 + }; + } + /** + * Configure RL parameters + */ + configureRL(config) { + if (config.policy) { + this.policyConfig = { ...this.policyConfig, ...config.policy }; + } + if (config.value) { + this.valueConfig = { ...this.valueConfig, ...config.value }; + } + if (config.replay) { + this.replayConfig = { ...this.replayConfig, ...config.replay }; + } + } + /** + * Calculate agent contribution to joint reward (simplified) + */ + calculateContribution(agentId, state, action) { + // Simple heuristic: higher value states = higher contribution + const stateKey = JSON.stringify(state); + const stateValue = this.valueWeights.get(stateKey) || 0; + // Normalize to [0, 1] + return Math.max(0, Math.min(1, (stateValue + 1) / 2)); + } +} +//# sourceMappingURL=SonaTrajectoryService.js.map diff --git a/packages/agentdb/tests/sona-trajectory-native-api.test.mjs b/packages/agentdb/tests/sona-trajectory-native-api.test.mjs new file mode 100644 index 000000000..642d7cad6 --- /dev/null +++ b/packages/agentdb/tests/sona-trajectory-native-api.test.mjs @@ -0,0 +1,318 @@ +/** + * Test suite for SonaTrajectoryService patch + * + * Validates that the patched service correctly: + * 1. Initializes the native SonaEngine (not just stores the module) + * 2. Records trajectories using the correct native API + * 3. Triggers learning after sufficient trajectories + * 4. Returns patterns via findPatterns for predictions + * 5. Falls back gracefully to JS when native unavailable + * 6. Maintains backward compatibility with existing callers + */ + +// Import the patched service +import { SonaTrajectoryService } from '../src/SonaTrajectoryService.js'; + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (condition) { + passed++; + console.log(` PASS: ${message}`); + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function section(name) { + console.log(`\n=== ${name} ===`); +} + +async function runTests() { + // ========================================================================= + section('TEST 1: Initialization - Native Engine'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + const result = await sona.initialize({ hiddenDim: 32 }); + + assert(result === true, 'initialize() returns true when native available'); + assert(sona.available === true, 'available is true'); + assert(sona.engineType === 'native', 'engineType is "native"'); + assert(sona.sona !== null, 'sona engine instance is not null'); + assert(typeof sona.sona.beginTrajectory === 'function', 'engine has beginTrajectory method'); + assert(typeof sona.sona.addTrajectoryStep === 'function', 'engine has addTrajectoryStep method'); + assert(typeof sona.sona.endTrajectory === 'function', 'engine has endTrajectory method'); + assert(typeof sona.sona.findPatterns === 'function', 'engine has findPatterns method'); + assert(typeof sona.sona.forceLearn === 'function', 'engine has forceLearn method'); + assert(sona.sona.isEnabled() === true, 'engine is enabled'); + } + + // ========================================================================= + section('TEST 2: Record Single Trajectory'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + // Record a trajectory in the same format callers use + await sona.recordTrajectory('coder', [ + { state: { task: 'implement auth' }, action: 'write_code', reward: 0.8 }, + { state: { task: 'test auth' }, action: 'run_tests', reward: 0.9 } + ]); + + const nativeStats = sona.getNativeStats(); + assert(nativeStats !== null, 'getNativeStats returns data'); + assert(nativeStats.trajectories_buffered >= 0, 'trajectories are tracked in native engine'); + assert(nativeStats.trajectories_dropped === 0, 'no trajectories dropped'); + + // Also check in-memory storage (backward compat) + const stats = sona.getStats(); + assert(stats.trajectoryCount === 1, 'in-memory trajectory count is 1'); + assert(stats.agentTypes.includes('coder'), 'agent type "coder" recorded'); + assert(stats.engineType === 'native', 'stats show native engine'); + assert(stats.native !== undefined, 'stats include native sub-stats'); + } + + // ========================================================================= + section('TEST 3: Record 120 Trajectories and Trigger Learning'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + const agents = ['coder', 'tester', 'reviewer']; + + // Record 120 trajectories (above 100 minimum threshold) + for (let i = 0; i < 120; i++) { + const agentType = agents[i % 3]; + await sona.recordTrajectory(agentType, [ + { state: { task: `task-${i}`, type: agentType }, action: `action-${agentType}`, reward: 0.7 + Math.random() * 0.3 }, + { state: { task: `task-${i}-step2`, type: agentType }, action: `verify-${agentType}`, reward: 0.8 + Math.random() * 0.2 } + ]); + } + + const statsBefore = sona.getNativeStats(); + console.log(` [info] Before learning: ${JSON.stringify(statsBefore)}`); + + // Force learning + const learnResult = sona.forceLearn(); + console.log(` [info] forceLearn result: ${learnResult}`); + + const statsAfter = sona.getNativeStats(); + console.log(` [info] After learning: ${JSON.stringify(statsAfter)}`); + + assert(statsAfter.patterns_stored > 0, `patterns_stored > 0 (got ${statsAfter.patterns_stored})`); + assert(learnResult.includes('completed'), 'forceLearn reports "completed"'); + assert(statsAfter.trajectories_buffered === 0, 'buffer is drained after learning'); + } + + // ========================================================================= + section('TEST 4: Pattern Search After Learning'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + // Build up trajectories with distinct patterns + for (let i = 0; i < 120; i++) { + const agentType = i % 2 === 0 ? 'coder' : 'reviewer'; + await sona.recordTrajectory(agentType, [ + { state: { task: `impl-${i}`, domain: agentType }, action: agentType, reward: 0.85 } + ]); + } + + sona.forceLearn(); + + const nativeStats = sona.getNativeStats(); + console.log(` [info] Native patterns after learn: ${nativeStats.patterns_stored}`); + + // Test native pattern retrieval via predict (which uses findPatterns internally) + const prediction = await sona.predict({ task: 'impl-test', domain: 'coder' }); + assert(prediction !== null, 'prediction after learning returns result'); + + if (nativeStats.patterns_stored > 0) { + // Native patterns available - getPatterns should return JsLearnedPattern objects + const patterns = await sona.getPatterns(); + assert(Array.isArray(patterns), 'getPatterns returns array'); + assert(patterns.length > 0, `getPatterns returns patterns (got ${patterns.length})`); + if (patterns.length > 0) { + const p = patterns[0]; + assert(p.id !== undefined, 'pattern has id'); + assert(Array.isArray(p.centroid), 'pattern has centroid array'); + assert(typeof p.clusterSize === 'number', 'pattern has clusterSize'); + assert(typeof p.avgQuality === 'number', 'pattern has avgQuality'); + assert(p.avgQuality > 0, `pattern avgQuality > 0 (got ${p.avgQuality})`); + } + } else { + // Native didn't store patterns, falls back to in-memory + const patterns = await sona.getPatterns(); + assert(Array.isArray(patterns), 'getPatterns falls back to in-memory array'); + assert(patterns.length === 120, `in-memory fallback has all 120 trajectories (got ${patterns.length})`); + } + } + + // ========================================================================= + section('TEST 5: Predict After Learning'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + // Record enough trajectories + for (let i = 0; i < 120; i++) { + await sona.recordTrajectory('coder', [ + { state: { task: 'implement feature' }, action: 'write_code', reward: 0.9 } + ]); + } + sona.forceLearn(); + + // Test prediction + const prediction = await sona.predict({ task: 'implement feature' }); + assert(prediction !== null, 'predict returns a result'); + assert(typeof prediction.action === 'string', 'prediction has action string'); + assert(typeof prediction.confidence === 'number', 'prediction has confidence number'); + console.log(` [info] Prediction: ${JSON.stringify(prediction)}`); + + // If native patterns available, should come from native + const nativeStats = sona.getNativeStats(); + if (nativeStats.patterns_stored > 0) { + assert(prediction.source === 'native-sona', 'prediction source is native-sona'); + } + } + + // ========================================================================= + section('TEST 6: stateToEmbedding Determinism'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + sona.hiddenDim = 32; + + const emb1 = sona.stateToEmbedding({ task: 'implement auth' }); + const emb2 = sona.stateToEmbedding({ task: 'implement auth' }); + const emb3 = sona.stateToEmbedding({ task: 'fix bug' }); + + assert(emb1.length === 32, 'embedding has correct dimensions'); + assert(JSON.stringify(emb1) === JSON.stringify(emb2), 'same input produces same embedding'); + assert(JSON.stringify(emb1) !== JSON.stringify(emb3), 'different input produces different embedding'); + + // Check L2 normalization + const norm = Math.sqrt(emb1.reduce((s, v) => s + v * v, 0)); + assert(Math.abs(norm - 1.0) < 0.001, `embedding is L2 normalized (norm=${norm.toFixed(6)})`); + } + + // ========================================================================= + section('TEST 7: Backward Compatibility - In-Memory Always Maintained'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + await sona.recordTrajectory('coder', [ + { state: { task: 'x' }, action: 'a', reward: 0.5 } + ]); + await sona.recordTrajectory('tester', [ + { state: { task: 'y' }, action: 'b', reward: 0.7 } + ]); + + // Even with native engine, in-memory storage should work + const stats = sona.getStats(); + assert(stats.trajectoryCount === 2, 'in-memory has both trajectories'); + assert(stats.agentTypes.length === 2, 'both agent types tracked'); + + // Frequency predict should still work + const freq = sona.frequencyPredict(); + assert(typeof freq.action === 'string', 'frequencyPredict still works'); + assert(typeof freq.confidence === 'number', 'frequencyPredict returns confidence'); + } + + // ========================================================================= + section('TEST 8: forceLearn and getNativeStats New Methods'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + const learnResult = sona.forceLearn(); + assert(typeof learnResult === 'string', 'forceLearn returns a string'); + + const nativeStats = sona.getNativeStats(); + assert(nativeStats !== null, 'getNativeStats returns data'); + assert(typeof nativeStats.trajectories_buffered === 'number', 'native stats has trajectories_buffered'); + assert(typeof nativeStats.patterns_stored === 'number', 'native stats has patterns_stored'); + assert(typeof nativeStats.instant_enabled === 'boolean', 'native stats has instant_enabled'); + assert(typeof nativeStats.background_enabled === 'boolean', 'native stats has background_enabled'); + } + + // ========================================================================= + section('TEST 9: Error Recovery - Bad Steps Don\'t Crash'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + // These should not throw + await sona.recordTrajectory('coder', []); + await sona.recordTrajectory('coder', [ + { state: null, action: 'test', reward: 0.5 } + ]); + await sona.recordTrajectory('coder', [ + { state: undefined, action: undefined, reward: 0 } + ]); + + assert(true, 'bad inputs handled without crash'); + + // Predict with bad state should still work + const pred = await sona.predict(null); + assert(pred !== null, 'predict with null state returns result'); + } + + // ========================================================================= + section('TEST 10: RL Methods Still Work (Backward Compat)'); + // ========================================================================= + { + const sona = new SonaTrajectoryService(); + await sona.initialize({ hiddenDim: 32 }); + + // These existing RL methods should still function + const loss = await sona.trainPolicy([{ + steps: [{ state: {}, action: 'a', reward: 1 }], + reward: 1 + }]); + assert(typeof loss === 'number', 'trainPolicy returns a number'); + + const value = await sona.estimateValue({}, 1.0, {}); + assert(typeof value === 'number', 'estimateValue returns a number'); + + sona.addExperience({}, 'a', 1.0, {}); + const batch = sona.sampleExperience(1); + assert(batch.length === 1, 'sampleExperience returns batch'); + + const rlMetrics = sona.getRLMetrics(); + assert(typeof rlMetrics.loss === 'number', 'getRLMetrics works'); + + sona.configureRL({ policy: { learningRate: 0.01 } }); + assert(sona.policyConfig.learningRate === 0.01, 'configureRL updates config'); + + sona.resetRL(); + assert(sona.experienceBuffer.length === 0, 'resetRL clears buffer'); + } + + // ========================================================================= + // Summary + // ========================================================================= + console.log(`\n${'='.repeat(50)}`); + console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${passed + failed} assertions`); + console.log(`${'='.repeat(50)}`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test runner crashed:', err); + process.exit(1); +});