diff --git a/.aios-core/core/execution/predictive-pipeline.js b/.aios-core/core/execution/predictive-pipeline.js new file mode 100644 index 000000000..b6a94488e --- /dev/null +++ b/.aios-core/core/execution/predictive-pipeline.js @@ -0,0 +1,2 @@ +// Retrocompatible wrapper — canonical source in .aiox-core/ +module.exports = require('../../../.aiox-core/core/execution/predictive-pipeline'); diff --git a/.aiox-core/core/execution/predictive-pipeline.js b/.aiox-core/core/execution/predictive-pipeline.js new file mode 100644 index 000000000..2b74be81c --- /dev/null +++ b/.aiox-core/core/execution/predictive-pipeline.js @@ -0,0 +1,1283 @@ +/** + * Predictive Pipeline + * + * Predicts task outcomes BEFORE execution using historical data patterns. + * Estimates success probability, expected duration, resource needs, and + * potential failure points based on weighted k-NN with feature similarity. + * + * Pipeline stages: preprocess → match → predict → score → recommend + * + * @module core/execution/predictive-pipeline + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Pipeline stage names in execution order + */ +const PipelineStage = { + PREPROCESS: 'preprocess', + MATCH: 'match', + PREDICT: 'predict', + SCORE: 'score', + RECOMMEND: 'recommend', +}; + +/** + * Risk level thresholds + */ +const RiskLevel = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CRITICAL: 'critical', +}; + +/** + * Default configuration values + */ +const DEFAULTS = { + kNeighbors: 5, + minSamplesForPrediction: 3, + anomalyThreshold: 0.3, + ewmaAlpha: 0.3, + highRiskThreshold: 0.6, + maxOutcomes: 10000, + confidenceSampleCap: 20, +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PIPELINE +// ═══════════════════════════════════════════════════════════════════════════════ + +class PredictivePipeline extends EventEmitter { + /** + * @param {string} projectRoot - Root directory for persistence + * @param {Object} [options] - Configuration options + * @param {number} [options.kNeighbors=5] - Number of neighbors for k-NN + * @param {number} [options.minSamplesForPrediction=3] - Minimum outcomes for prediction + * @param {number} [options.anomalyThreshold=0.3] - Max similarity for anomaly detection + * @param {number} [options.ewmaAlpha=0.3] - EWMA smoothing factor (0-1) + * @param {number} [options.highRiskThreshold=0.6] - Risk score above which high-risk is emitted + * @param {number} [options.maxOutcomes=10000] - Maximum stored outcomes before auto-prune + */ + constructor(projectRoot, options = {}) { + super(); + + this.projectRoot = projectRoot ?? process.cwd(); + this.kNeighbors = options.kNeighbors ?? DEFAULTS.kNeighbors; + this.minSamplesForPrediction = options.minSamplesForPrediction ?? DEFAULTS.minSamplesForPrediction; + this.anomalyThreshold = options.anomalyThreshold ?? DEFAULTS.anomalyThreshold; + this.ewmaAlpha = options.ewmaAlpha ?? DEFAULTS.ewmaAlpha; + this.highRiskThreshold = options.highRiskThreshold ?? DEFAULTS.highRiskThreshold; + this.maxOutcomes = options.maxOutcomes ?? DEFAULTS.maxOutcomes; + this.confidenceSampleCap = options.confidenceSampleCap ?? DEFAULTS.confidenceSampleCap; + + // Persistence paths + this._dataDir = path.join(this.projectRoot, '.aiox', 'predictions'); + this._outcomesPath = path.join(this._dataDir, 'outcomes.json'); + this._modelPath = path.join(this._dataDir, 'model.json'); + + // In-memory state + this._outcomes = []; + this._model = this._emptyModel(); + this._loaded = false; + + // Serialized write chain + this._writeChain = Promise.resolve(); + + // Stage metrics + this._stageMetrics = {}; + for (const stage of Object.values(PipelineStage)) { + this._stageMetrics[stage] = { calls: 0, totalMs: 0, errors: 0 }; + } + + // Global stats + this._stats = { + predictions: 0, + outcomesRecorded: 0, + anomaliesDetected: 0, + retrains: 0, + }; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // DATA LOADING + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Ensure data is loaded from disk (lazy, idempotent) + * @private + */ + _ensureLoaded() { + if (this._loaded) return; + this._loadSync(); + this._loaded = true; + } + + /** + * Load outcomes and model from disk synchronously + * @private + */ + _loadSync() { + try { + if (fs.existsSync(this._outcomesPath)) { + const raw = fs.readFileSync(this._outcomesPath, 'utf8'); + const parsed = JSON.parse(raw); + this._outcomes = Array.isArray(parsed) ? parsed : []; + } + } catch { + this._outcomes = []; + } + + try { + if (fs.existsSync(this._modelPath)) { + const raw = fs.readFileSync(this._modelPath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + this._model = { ...this._emptyModel(), ...parsed }; + } + } + } catch { + this._model = this._emptyModel(); + } + } + + /** + * @private + * @returns {Object} Empty model structure + */ + _emptyModel() { + return { + taskTypeStats: {}, + agentStats: {}, + strategyStats: {}, + lastRetrain: null, + version: 1, + }; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Serialize a write operation through the write chain + * @private + * @param {Function} writeFn - Async function that performs the write + * @returns {Promise} + */ + _enqueueWrite(writeFn) { + this._writeChain = this._writeChain.then(() => writeFn()).catch((err) => { + this._emitSafeError({ type: 'persistence', error: err }); + throw err; + }); + return this._writeChain; + } + + /** + * Persist outcomes to disk + * @private + * @returns {Promise} + */ + _persistOutcomes() { + return this._enqueueWrite(async () => { + this._ensureDataDir(); + fs.writeFileSync(this._outcomesPath, JSON.stringify(this._outcomes, null, 2)); + }); + } + + /** + * Persist model to disk + * @private + * @returns {Promise} + */ + _persistModel() { + return this._enqueueWrite(async () => { + this._ensureDataDir(); + fs.writeFileSync(this._modelPath, JSON.stringify(this._model, null, 2)); + }); + } + + /** + * Ensure the data directory exists + * @private + */ + _ensureDataDir() { + if (!fs.existsSync(this._dataDir)) { + fs.mkdirSync(this._dataDir, { recursive: true }); + } + } + + // ═════════════════════════════════════════════════════════════════════════════ + // SAFE ERROR EMIT + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Emit error event only when listeners are present. + * Avoids unhandled EventEmitter 'error' exceptions. + * @private + * @param {Object} payload + */ + _emitSafeError(payload) { + if (this.listenerCount('error') > 0) { + this.emit('error', payload); + return; + } + // Silently degrade — no listeners attached + } + + // ═════════════════════════════════════════════════════════════════════════════ + // DEEP CLONE + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Deep clone with structuredClone, JSON fallback + * @private + * @param {*} obj + * @returns {*} + */ + _deepClone(obj) { + try { + return structuredClone(obj); + } catch { + return JSON.parse(JSON.stringify(obj)); + } + } + + // ═════════════════════════════════════════════════════════════════════════════ + // RECORD OUTCOMES + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Record the actual outcome of a task for future predictions + * @param {Object} outcome - Outcome data + * @param {string} outcome.taskType - Type of task executed + * @param {string} [outcome.agent] - Agent that executed the task + * @param {string} [outcome.strategy] - Strategy used + * @param {number} outcome.duration - Execution duration in ms + * @param {boolean} outcome.success - Whether the task succeeded + * @param {number} [outcome.complexity] - Task complexity (1-10) + * @param {number} [outcome.contextSize] - Size of context provided + * @param {Object} [outcome.resources] - Resources consumed (memory, cpu, apiCalls) + * @param {Object} [outcome.metadata] - Additional metadata + * @returns {Promise} The stored outcome record + */ + async recordOutcome(outcome) { + this._ensureLoaded(); + + if (!outcome || !outcome.taskType) { + throw new Error('outcome.taskType is required'); + } + if (typeof outcome.duration !== 'number' || outcome.duration < 0) { + throw new Error('outcome.duration must be a non-negative number'); + } + if (typeof outcome.success !== 'boolean') { + throw new Error('outcome.success must be a boolean'); + } + + const record = { + id: this._generateId(), + taskType: outcome.taskType, + agent: outcome.agent ?? null, + strategy: outcome.strategy ?? null, + duration: outcome.duration, + success: outcome.success, + complexity: outcome.complexity ?? 5, + contextSize: outcome.contextSize ?? 0, + resources: outcome.resources ?? null, + metadata: outcome.metadata ?? null, + timestamp: Date.now(), + }; + + this._outcomes.push(record); + this._stats.outcomesRecorded++; + + // Update model stats + this._updateModelStats(record); + + // Auto-prune if exceeding max + if (this._outcomes.length > this.maxOutcomes) { + const excess = this._outcomes.length - this.maxOutcomes; + this._outcomes.splice(0, excess); + this._recalculateModelStats(); + } + + await this._persistOutcomes(); + await this._persistModel(); + + this.emit('outcome-recorded', { id: record.id, taskType: record.taskType }); + + return this._deepClone(record); + } + + /** + * Update aggregated model statistics from a new outcome + * @private + * @param {Object} record + */ + _updateModelStats(record) { + // Task type stats + if (!this._model.taskTypeStats[record.taskType]) { + this._model.taskTypeStats[record.taskType] = { + count: 0, successes: 0, totalDuration: 0, durations: [], + }; + } + const ts = this._model.taskTypeStats[record.taskType]; + ts.count++; + if (record.success) ts.successes++; + ts.totalDuration += record.duration; + ts.durations.push(record.duration); + // Keep only last 100 durations for memory + if (ts.durations.length > 100) ts.durations.shift(); + + // Agent stats + if (record.agent) { + if (!this._model.agentStats[record.agent]) { + this._model.agentStats[record.agent] = { count: 0, successes: 0, totalDuration: 0 }; + } + const as = this._model.agentStats[record.agent]; + as.count++; + if (record.success) as.successes++; + as.totalDuration += record.duration; + } + + // Strategy stats + if (record.strategy) { + if (!this._model.strategyStats[record.strategy]) { + this._model.strategyStats[record.strategy] = { count: 0, successes: 0, totalDuration: 0 }; + } + const ss = this._model.strategyStats[record.strategy]; + ss.count++; + if (record.success) ss.successes++; + ss.totalDuration += record.duration; + } + } + + // ═════════════════════════════════════════════════════════════════════════════ + /** + * Recalculate all model stats from current outcomes. + * Called after splice/prune to keep stats consistent. + * @private + */ + _recalculateModelStats() { + this._model.taskTypeStats = {}; + this._model.agentStats = {}; + this._model.strategyStats = {}; + for (const outcome of this._outcomes) { + this._updateModelStats(outcome); + } + } + + // FEATURE VECTORS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Extract a feature vector from a task spec or outcome + * @private + * @param {Object} task + * @returns {Object} Feature vector + */ + _extractFeatures(task) { + const complexity = Number(task.complexity); + const contextSize = Number(task.contextSize); + const agentExperience = this._getAgentExperience(task.agent); + return { + taskType: task.taskType ?? 'unknown', + complexity: Number.isFinite(complexity) ? complexity : 5, + agentExperience: Number.isFinite(agentExperience) ? agentExperience : 0, + contextSize: Number.isFinite(contextSize) ? contextSize : 0, + }; + } + + /** + * Get the number of past outcomes for an agent + * @private + * @param {string|null} agent + * @returns {number} + */ + _getAgentExperience(agent) { + if (!agent) return 0; + return this._model.agentStats[agent]?.count ?? 0; + } + + /** + * Compute similarity between two feature vectors. + * Uses exact match for categorical (taskType) and cosine-like + * similarity for numeric features. + * @private + * @param {Object} a - Feature vector + * @param {Object} b - Feature vector + * @returns {number} Similarity score in [0, 1] + */ + _computeSimilarity(a, b) { + // Categorical: taskType exact match contributes 0.4 weight + const typeMatch = a.taskType === b.taskType ? 1.0 : 0.0; + + // Numeric features: normalized distance → similarity + const numericA = [a.complexity, a.agentExperience, a.contextSize]; + const numericB = [b.complexity, b.agentExperience, b.contextSize]; + + const cosineSim = this._cosineSimilarity(numericA, numericB); + + // Weighted combination: 40% categorical, 60% numeric + return 0.4 * typeMatch + 0.6 * cosineSim; + } + + /** + * Cosine similarity between two numeric vectors + * @private + * @param {number[]} a + * @param {number[]} b + * @returns {number} Similarity in [0, 1] + */ + _cosineSimilarity(a, b) { + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + normA = Math.sqrt(normA); + normB = Math.sqrt(normB); + + if (normA === 0 || normB === 0) return 0; + + return dotProduct / (normA * normB); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PIPELINE STAGES + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Execute a pipeline stage with timing + * @private + * @param {string} stageName + * @param {Function} fn + * @returns {*} Stage result + */ + _runStage(stageName, fn) { + const start = Date.now(); + try { + const result = fn(); + this._stageMetrics[stageName].calls++; + this._stageMetrics[stageName].totalMs += Date.now() - start; + return result; + } catch (err) { + this._stageMetrics[stageName].errors++; + this._stageMetrics[stageName].totalMs += Date.now() - start; + throw err; + } + } + + /** + * Stage 1: Preprocess — extract and validate features + * @private + * @param {Object} taskSpec + * @returns {Object} Preprocessed features + */ + _stagePreprocess(taskSpec) { + return this._runStage(PipelineStage.PREPROCESS, () => { + if (!taskSpec || !taskSpec.taskType) { + throw new Error('taskSpec.taskType is required for prediction'); + } + return this._extractFeatures(taskSpec); + }); + } + + /** + * Stage 2: Match — find k nearest neighbors + * @private + * @param {Object} features + * @returns {Object[]} Nearest neighbors with similarity scores + */ + _stageMatch(features) { + return this._runStage(PipelineStage.MATCH, () => { + const scored = this._outcomes.map((outcome) => { + const outFeatures = this._extractFeatures(outcome); + const similarity = this._computeSimilarity(features, outFeatures); + return { outcome, similarity }; + }); + + // Sort by similarity descending + scored.sort((a, b) => b.similarity - a.similarity); + + return scored.slice(0, this.kNeighbors); + }); + } + + /** + * Stage 3: Predict — compute predictions from matched neighbors + * @private + * @param {Object[]} neighbors - k nearest neighbors + * @param {Object} features - Original features + * @returns {Object} Raw predictions + */ + _stagePredict(neighbors, features) { + return this._runStage(PipelineStage.PREDICT, () => { + if (neighbors.length < (this.minSamplesForPrediction || 3)) { + return this._defaultPrediction(features); + } + + // Weighted success probability + let weightSum = 0; + let successWeight = 0; + let durationEwma = 0; + let durationValues = []; + let resourceEstimates = { memory: 0, cpu: 0, apiCalls: 0 }; + let resourceCount = 0; + + for (const { outcome, similarity } of neighbors) { + const weight = similarity; + weightSum += weight; + if (outcome.success) successWeight += weight; + + durationValues.push(outcome.duration); + + if (outcome.resources) { + resourceEstimates.memory += (outcome.resources.memory ?? 0) * weight; + resourceEstimates.cpu += (outcome.resources.cpu ?? 0) * weight; + resourceEstimates.apiCalls += (outcome.resources.apiCalls ?? 0) * weight; + resourceCount += weight; + } + } + + const successProbability = weightSum > 0 ? successWeight / weightSum : 0.5; + + // EWMA for duration + durationEwma = this._computeEwma(durationValues.reverse()); + + // Normalize resources + if (resourceCount > 0) { + resourceEstimates.memory /= resourceCount; + resourceEstimates.cpu /= resourceCount; + resourceEstimates.apiCalls /= resourceCount; + } + + return { + successProbability, + estimatedDuration: Math.round(durationEwma), + resources: resourceEstimates, + sampleSize: neighbors.length, + avgSimilarity: weightSum / neighbors.length, + }; + }); + } + + /** + * Stage 4: Score — compute confidence and detect anomalies + * @private + * @param {Object} prediction - Raw predictions + * @param {Object[]} neighbors - k nearest neighbors + * @param {Object} features - Original features + * @returns {Object} Scored prediction + */ + _stageScore(prediction, neighbors, features) { + return this._runStage(PipelineStage.SCORE, () => { + const durations = neighbors.map((n) => n.outcome.duration); + const cv = this._coefficientOfVariation(durations); + + // Confidence: min(sampleSize / cap, 1.0) * (1 - cv) + const sampleFactor = Math.min(prediction.sampleSize / this.confidenceSampleCap, 1.0); + const varianceFactor = Math.max(1 - cv, 0); + const confidence = sampleFactor * varianceFactor; + + // Anomaly detection + const isAnomaly = prediction.avgSimilarity < this.anomalyThreshold; + if (isAnomaly) { + this._stats.anomaliesDetected++; + this.emit('anomaly-detected', { + taskType: features.taskType, + avgSimilarity: prediction.avgSimilarity, + }); + } + + return { + ...prediction, + confidence: Math.round(confidence * 1000) / 1000, + coefficientOfVariation: Math.round(cv * 1000) / 1000, + isAnomaly, + }; + }); + } + + /** + * Stage 5: Recommend — suggest agent and strategy + * @private + * @param {Object} scored - Scored prediction + * @param {Object} features - Original features + * @returns {Object} Final prediction with recommendations + */ + _stageRecommend(scored, features) { + return this._runStage(PipelineStage.RECOMMEND, () => { + const agentRec = this._findBestAgent(features.taskType); + const strategyRec = this._findBestStrategy(features.taskType); + + return { + ...scored, + recommendedAgent: agentRec, + recommendedStrategy: strategyRec, + }; + }); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PUBLIC API + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Predict the outcome of a task before execution + * @param {Object} taskSpec - Task specification + * @param {string} taskSpec.taskType - Type of task + * @param {number} [taskSpec.complexity] - Task complexity (1-10) + * @param {string} [taskSpec.agent] - Agent to execute + * @param {number} [taskSpec.contextSize] - Size of context + * @returns {Object} Prediction result + */ + predict(taskSpec) { + this._ensureLoaded(); + + const features = this._stagePreprocess(taskSpec); + const neighbors = this._stageMatch(features); + const raw = this._stagePredict(neighbors, features); + const scored = this._stageScore(raw, neighbors, features); + const final = this._stageRecommend(scored, features); + + this._stats.predictions++; + + const result = { + taskType: features.taskType, + ...final, + riskLevel: this._computeRiskLevel(final), + timestamp: Date.now(), + }; + + this.emit('prediction', result); + + // Emit high-risk event + if (this._riskScore(final) >= this.highRiskThreshold) { + this.emit('high-risk-detected', result); + } + + return result; + } + + /** + * Predict outcomes for multiple tasks in batch + * @param {Object[]} taskSpecs - Array of task specifications + * @returns {Object[]} Array of prediction results + */ + predictBatch(taskSpecs) { + if (!Array.isArray(taskSpecs)) { + throw new Error('taskSpecs must be an array'); + } + return taskSpecs.map((spec) => this.predict(spec)); + } + + /** + * Find tasks similar to the given specification + * @param {Object} taskSpec - Task specification + * @param {Object} [opts] - Options + * @param {number} [opts.limit=10] - Maximum results to return + * @param {number} [opts.minSimilarity=0] - Minimum similarity threshold + * @returns {Object[]} Similar tasks with similarity scores + */ + findSimilarTasks(taskSpec, opts = {}) { + this._ensureLoaded(); + + const limit = opts.limit ?? 10; + const minSimilarity = opts.minSimilarity ?? 0; + const features = this._extractFeatures(taskSpec); + + const scored = this._outcomes.map((outcome) => { + const outFeatures = this._extractFeatures(outcome); + const similarity = this._computeSimilarity(features, outFeatures); + return { ...this._deepClone(outcome), similarity }; + }); + + return scored + .filter((s) => s.similarity >= minSimilarity) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, limit); + } + + /** + * Get the strength of a pattern for a given task type + * @param {string} taskType - The task type to query + * @returns {Object} Pattern strength info + */ + getPatternStrength(taskType) { + this._ensureLoaded(); + + const stats = this._model.taskTypeStats[taskType]; + if (!stats) { + return { taskType, sampleSize: 0, strength: 0, successRate: 0, avgDuration: 0 }; + } + + const cv = this._coefficientOfVariation(stats.durations); + const sampleFactor = Math.min(stats.count / this.confidenceSampleCap, 1.0); + const strength = sampleFactor * Math.max(1 - cv, 0); + + return { + taskType, + sampleSize: stats.count, + strength: Math.round(strength * 1000) / 1000, + successRate: stats.count > 0 ? Math.round((stats.successes / stats.count) * 1000) / 1000 : 0, + avgDuration: stats.count > 0 ? Math.round(stats.totalDuration / stats.count) : 0, + }; + } + + /** + * Assess the risk of executing a task + * @param {Object} taskSpec - Task specification + * @returns {Object} Risk assessment + */ + assessRisk(taskSpec) { + this._ensureLoaded(); + + const features = this._extractFeatures(taskSpec); + const neighbors = this._stageMatch(features); + const factors = []; + + // Factor 1: Low sample size + const typeStats = this._model.taskTypeStats[features.taskType]; + const sampleSize = typeStats?.count ?? 0; + if (sampleSize < this.minSamplesForPrediction) { + factors.push({ + factor: 'low-sample-size', + description: `Only ${sampleSize} historical outcomes for task type "${features.taskType}"`, + severity: sampleSize === 0 ? 'high' : 'medium', + }); + } + + // Factor 2: High variance + if (typeStats && typeStats.durations.length >= 2) { + const cv = this._coefficientOfVariation(typeStats.durations); + if (cv > 0.5) { + factors.push({ + factor: 'high-variance', + description: `Duration coefficient of variation is ${(cv * 100).toFixed(1)}%`, + severity: cv > 1.0 ? 'high' : 'medium', + }); + } + } + + // Factor 3: New task type + if (!typeStats) { + factors.push({ + factor: 'new-task-type', + description: `Task type "${features.taskType}" has no historical data`, + severity: 'high', + }); + } + + // Factor 4: Low success rate + if (typeStats && typeStats.count >= this.minSamplesForPrediction) { + const successRate = typeStats.successes / typeStats.count; + if (successRate < 0.5) { + factors.push({ + factor: 'low-success-rate', + description: `Historical success rate is ${(successRate * 100).toFixed(1)}%`, + severity: successRate < 0.25 ? 'high' : 'medium', + }); + } + } + + // Factor 5: Low similarity to known tasks (anomaly) + if (neighbors.length > 0) { + const avgSim = neighbors.reduce((s, n) => s + n.similarity, 0) / neighbors.length; + if (avgSim < this.anomalyThreshold) { + factors.push({ + factor: 'anomaly', + description: `Task has low similarity (${(avgSim * 100).toFixed(1)}%) to known patterns`, + severity: 'high', + }); + } + } + + // Factor 6: Overloaded agent + if (taskSpec.agent) { + const agentStats = this._model.agentStats[taskSpec.agent]; + if (agentStats && agentStats.count > 0) { + const agentSuccessRate = agentStats.successes / agentStats.count; + if (agentSuccessRate < 0.5) { + factors.push({ + factor: 'agent-low-success', + description: `Agent "${taskSpec.agent}" has ${(agentSuccessRate * 100).toFixed(1)}% success rate`, + severity: 'medium', + }); + } + } + } + + const riskScore = this._computeRiskScoreFromFactors(factors); + const riskLevel = this._riskLevelFromScore(riskScore); + + return { + taskType: features.taskType, + riskScore: Math.round(riskScore * 1000) / 1000, + riskLevel, + factors, + mitigations: this._suggestMitigations(factors), + }; + } + + /** + * Recommend the best agent for a task type + * @param {Object} taskSpec - Task specification + * @returns {Object} Agent recommendation + */ + recommendAgent(taskSpec) { + this._ensureLoaded(); + + if (!taskSpec || !taskSpec.taskType) { + throw new Error('taskSpec.taskType is required'); + } + + const best = this._findBestAgent(taskSpec.taskType); + return { + taskType: taskSpec.taskType, + recommendation: best, + }; + } + + /** + * Recommend the best strategy for a task type + * @param {Object} taskSpec - Task specification + * @returns {Object} Strategy recommendation + */ + recommendStrategy(taskSpec) { + this._ensureLoaded(); + + if (!taskSpec || !taskSpec.taskType) { + throw new Error('taskSpec.taskType is required'); + } + + const best = this._findBestStrategy(taskSpec.taskType); + return { + taskType: taskSpec.taskType, + recommendation: best, + }; + } + + /** + * Get the pipeline stages in order + * @returns {string[]} Ordered stage names + */ + getPipelineStages() { + return Object.values(PipelineStage); + } + + /** + * Get metrics for a specific pipeline stage + * @param {string} stageName - Stage name + * @returns {Object|null} Stage metrics or null if not found + */ + getStageMetrics(stageName) { + const metrics = this._stageMetrics[stageName]; + if (!metrics) return null; + + return { + stage: stageName, + calls: metrics.calls, + totalMs: metrics.totalMs, + avgMs: metrics.calls > 0 ? Math.round(metrics.totalMs / metrics.calls * 100) / 100 : 0, + errors: metrics.errors, + }; + } + + /** + * Get overall model accuracy based on recorded outcomes + * @returns {Object} Model accuracy info + */ + getModelAccuracy() { + this._ensureLoaded(); + + const totalTasks = Object.values(this._model.taskTypeStats).reduce((s, t) => s + t.count, 0); + const totalSuccesses = Object.values(this._model.taskTypeStats).reduce((s, t) => s + t.successes, 0); + + const perType = {}; + for (const [type, stats] of Object.entries(this._model.taskTypeStats)) { + perType[type] = { + count: stats.count, + successRate: stats.count > 0 ? Math.round((stats.successes / stats.count) * 1000) / 1000 : 0, + avgDuration: stats.count > 0 ? Math.round(stats.totalDuration / stats.count) : 0, + }; + } + + return { + totalOutcomes: totalTasks, + overallSuccessRate: totalTasks > 0 ? Math.round((totalSuccesses / totalTasks) * 1000) / 1000 : 0, + perTaskType: perType, + lastRetrain: this._model.lastRetrain, + retrains: this._stats.retrains, + }; + } + + /** + * Retrain the model by recalculating all statistics from outcomes + * @returns {Promise} Retrain result + */ + async retrain() { + this._ensureLoaded(); + + // Reset model + this._model = this._emptyModel(); + + // Rebuild from outcomes + for (const outcome of this._outcomes) { + this._updateModelStats(outcome); + } + + this._model.lastRetrain = Date.now(); + this._model.version++; + this._stats.retrains++; + + await this._persistModel(); + + this.emit('model-retrained', { + version: this._model.version, + outcomeCount: this._outcomes.length, + taskTypes: Object.keys(this._model.taskTypeStats).length, + }); + + return { + version: this._model.version, + outcomeCount: this._outcomes.length, + taskTypes: Object.keys(this._model.taskTypeStats).length, + }; + } + + /** + * Prune old outcomes + * @param {Object} [options] - Prune options + * @param {number} [options.olderThan] - Remove outcomes older than this timestamp + * @returns {Promise} Prune result + */ + async prune(options = {}) { + this._ensureLoaded(); + + const before = this._outcomes.length; + + if (options.olderThan) { + this._outcomes = this._outcomes.filter((o) => o.timestamp >= options.olderThan); + } + + const removed = before - this._outcomes.length; + + if (removed > 0) { + // Retrain after pruning + this._model = this._emptyModel(); + for (const outcome of this._outcomes) { + this._updateModelStats(outcome); + } + this._model.lastRetrain = Date.now(); + + await this._persistOutcomes(); + await this._persistModel(); + } + + return { removed, remaining: this._outcomes.length }; + } + + /** + * Get general statistics + * @returns {Object} Stats summary + */ + getStats() { + this._ensureLoaded(); + + return { + outcomes: this._outcomes.length, + taskTypes: Object.keys(this._model.taskTypeStats).length, + agents: Object.keys(this._model.agentStats).length, + strategies: Object.keys(this._model.strategyStats).length, + predictions: this._stats.predictions, + outcomesRecorded: this._stats.outcomesRecorded, + anomaliesDetected: this._stats.anomaliesDetected, + retrains: this._stats.retrains, + modelVersion: this._model.version, + }; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // HELPERS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Compute EWMA (Exponentially Weighted Moving Average) from values + * @private + * @param {number[]} values + * @returns {number} + */ + _computeEwma(values) { + if (values.length === 0) return 0; + if (values.length === 1) return values[0]; + + let ewma = values[0]; + for (let i = 1; i < values.length; i++) { + ewma = this.ewmaAlpha * values[i] + (1 - this.ewmaAlpha) * ewma; + } + return ewma; + } + + /** + * Compute coefficient of variation (stddev / mean) + * @private + * @param {number[]} values + * @returns {number} + */ + _coefficientOfVariation(values) { + if (values.length < 2) return 0; + + const mean = values.reduce((s, v) => s + v, 0) / values.length; + if (mean === 0) return 0; + + const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length; + const stddev = Math.sqrt(variance); + + return stddev / Math.abs(mean); + } + + /** + * Default prediction when no matching outcomes exist + * @private + * @param {Object} features + * @returns {Object} + */ + _defaultPrediction(features) { + return { + successProbability: 0.5, + estimatedDuration: 0, + resources: { memory: 0, cpu: 0, apiCalls: 0 }, + sampleSize: 0, + avgSimilarity: 0, + }; + } + + /** + * Compute a numeric risk score from prediction data + * @private + * @param {Object} prediction + * @returns {number} Risk score in [0, 1] + */ + _riskScore(prediction) { + let score = 0; + + // Low success probability + score += (1 - prediction.successProbability) * 0.4; + + // Low confidence + score += (1 - (prediction.confidence ?? 0)) * 0.3; + + // Anomaly + if (prediction.isAnomaly) score += 0.2; + + // High variance + score += Math.min((prediction.coefficientOfVariation ?? 0), 1) * 0.1; + + return Math.min(score, 1.0); + } + + /** + * Compute risk level from prediction + * @private + * @param {Object} prediction + * @returns {string} Risk level + */ + _computeRiskLevel(prediction) { + const score = this._riskScore(prediction); + return this._riskLevelFromScore(score); + } + + /** + * Map a numeric score to a risk level + * @private + * @param {number} score + * @returns {string} + */ + _riskLevelFromScore(score) { + if (score >= 0.8) return RiskLevel.CRITICAL; + if (score >= 0.6) return RiskLevel.HIGH; + if (score >= 0.3) return RiskLevel.MEDIUM; + return RiskLevel.LOW; + } + + /** + * Compute risk score from risk factors array + * @private + * @param {Object[]} factors + * @returns {number} + */ + _computeRiskScoreFromFactors(factors) { + if (factors.length === 0) return 0; + + let score = 0; + for (const f of factors) { + if (f.severity === 'high') score += 0.25; + else if (f.severity === 'medium') score += 0.15; + else score += 0.05; + } + + return Math.min(score, 1.0); + } + + /** + * Find the best agent for a task type (highest success rate with enough samples) + * @private + * @param {string} taskType + * @returns {Object|null} Agent recommendation or null + */ + _findBestAgent(taskType) { + const agentPerformance = {}; + + for (const outcome of this._outcomes) { + if (outcome.taskType !== taskType || !outcome.agent) continue; + + if (!agentPerformance[outcome.agent]) { + agentPerformance[outcome.agent] = { count: 0, successes: 0, totalDuration: 0 }; + } + const ap = agentPerformance[outcome.agent]; + ap.count++; + if (outcome.success) ap.successes++; + ap.totalDuration += outcome.duration; + } + + let best = null; + let bestScore = -1; + + for (const [agent, perf] of Object.entries(agentPerformance)) { + if (perf.count < this.minSamplesForPrediction) continue; + + const successRate = perf.successes / perf.count; + // Score: success rate weighted by sample confidence + const sampleConfidence = Math.min(perf.count / this.confidenceSampleCap, 1.0); + const score = successRate * sampleConfidence; + + if (score > bestScore) { + bestScore = score; + best = { + agent, + successRate: Math.round(successRate * 1000) / 1000, + sampleSize: perf.count, + avgDuration: Math.round(perf.totalDuration / perf.count), + score: Math.round(score * 1000) / 1000, + }; + } + } + + return best; + } + + /** + * Find the best strategy for a task type + * @private + * @param {string} taskType + * @returns {Object|null} Strategy recommendation or null + */ + _findBestStrategy(taskType) { + const stratPerformance = {}; + + for (const outcome of this._outcomes) { + if (outcome.taskType !== taskType || !outcome.strategy) continue; + + if (!stratPerformance[outcome.strategy]) { + stratPerformance[outcome.strategy] = { count: 0, successes: 0, totalDuration: 0 }; + } + const sp = stratPerformance[outcome.strategy]; + sp.count++; + if (outcome.success) sp.successes++; + sp.totalDuration += outcome.duration; + } + + let best = null; + let bestScore = -1; + + for (const [strategy, perf] of Object.entries(stratPerformance)) { + if (perf.count < this.minSamplesForPrediction) continue; + + const successRate = perf.successes / perf.count; + const sampleConfidence = Math.min(perf.count / this.confidenceSampleCap, 1.0); + const score = successRate * sampleConfidence; + + if (score > bestScore) { + bestScore = score; + best = { + strategy, + successRate: Math.round(successRate * 1000) / 1000, + sampleSize: perf.count, + avgDuration: Math.round(perf.totalDuration / perf.count), + score: Math.round(score * 1000) / 1000, + }; + } + } + + return best; + } + + /** + * Suggest mitigations for risk factors + * @private + * @param {Object[]} factors + * @returns {string[]} + */ + _suggestMitigations(factors) { + const mitigations = []; + + for (const f of factors) { + switch (f.factor) { + case 'low-sample-size': + mitigations.push('Run a pilot execution to gather baseline data'); + break; + case 'high-variance': + mitigations.push('Break task into smaller, more predictable sub-tasks'); + break; + case 'new-task-type': + mitigations.push('Start with a dry-run or sandbox execution'); + break; + case 'low-success-rate': + mitigations.push('Review historical failures and adjust strategy before execution'); + break; + case 'anomaly': + mitigations.push('Manual review recommended — task does not match known patterns'); + break; + case 'agent-low-success': + mitigations.push('Consider using a different agent with higher success rate'); + break; + default: + mitigations.push('Monitor execution closely'); + } + } + + return mitigations; + } + + /** + * Generate a unique ID + * @private + * @returns {string} + */ + _generateId() { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `pred_${ts}_${rand}`; + } +} + +module.exports = PredictivePipeline; +module.exports.PredictivePipeline = PredictivePipeline; +module.exports.PipelineStage = PipelineStage; +module.exports.RiskLevel = RiskLevel; +module.exports.DEFAULTS = DEFAULTS; diff --git a/.aiox-core/core/memory/decision-memory.js b/.aiox-core/core/memory/decision-memory.js new file mode 100644 index 000000000..e783c5f17 --- /dev/null +++ b/.aiox-core/core/memory/decision-memory.js @@ -0,0 +1,564 @@ +#!/usr/bin/env node + +/** + * AIOX Decision Memory + * + * Story: 9.5 - Decision Memory + * Epic: Epic 9 - Persistent Memory Layer + * + * Cross-session decision tracking system. Records agent decisions, + * their outcomes, and confidence levels to enable learning from + * past experience. Implements Phase 2 of the Agent Immortality + * Protocol (#482) — Persistence layer. + * + * Features: + * - AC1: decision-memory.js in .aiox-core/core/memory/ + * - AC2: Persists in .aiox/decisions.json + * - AC3: Records decision context, rationale, and outcome + * - AC4: Categories: architecture, delegation, tooling, recovery, workflow + * - AC5: Command *decision {description} records manually + * - AC6: Command *decisions lists recent decisions with outcomes + * - AC7: Injects relevant past decisions before similar tasks + * - AC8: Confidence scoring with decay over time + * - AC9: Pattern detection across decisions (recurring success/failure) + * + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); +const { atomicWriteSync } = require('../synapse/utils/atomic-write'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +const CONFIG = { + decisionsJsonPath: '.aiox/decisions.json', + + // Confidence decay: decisions lose relevance over time + confidenceDecayDays: 30, + minConfidence: 0.1, + + // Pattern detection + patternThreshold: 3, // Same decision pattern 3x = recognized pattern + maxDecisions: 500, // Cap stored decisions + + // Context injection + maxInjectedDecisions: 5, // Max decisions injected per task + similarityThreshold: 0.3, // Minimum keyword overlap for relevance + + version: '1.0.0', + schemaVersion: 'aiox-decision-memory-v1', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const DecisionCategory = { + ARCHITECTURE: 'architecture', + DELEGATION: 'delegation', + TOOLING: 'tooling', + RECOVERY: 'recovery', + WORKFLOW: 'workflow', + TESTING: 'testing', + DEPLOYMENT: 'deployment', + GENERAL: 'general', +}; + +const Outcome = { + SUCCESS: 'success', + PARTIAL: 'partial', + FAILURE: 'failure', + PENDING: 'pending', +}; + +const Events = { + DECISION_RECORDED: 'decision:recorded', + OUTCOME_UPDATED: 'outcome:updated', + PATTERN_DETECTED: 'pattern:detected', + DECISIONS_INJECTED: 'decisions:injected', +}; + +const CATEGORY_KEYWORDS = { + [DecisionCategory.ARCHITECTURE]: [ + 'architecture', 'design', 'pattern', 'module', 'refactor', + 'structure', 'layer', 'abstraction', 'interface', 'separation', + ], + [DecisionCategory.DELEGATION]: [ + 'delegate', 'assign', 'agent', 'handoff', 'route', + 'dispatch', 'spawn', 'subagent', 'orchestrat', + ], + [DecisionCategory.TOOLING]: [ + 'tool', 'cli', 'command', 'script', 'build', + 'lint', 'format', 'bundle', 'compile', + ], + [DecisionCategory.RECOVERY]: [ + 'recover', 'retry', 'fallback', 'circuit', 'heal', + 'restart', 'rollback', 'backup', 'restore', + ], + [DecisionCategory.WORKFLOW]: [ + 'workflow', 'pipeline', 'ci', 'deploy', 'release', + 'merge', 'branch', 'review', 'approve', + ], + [DecisionCategory.TESTING]: [ + 'test', 'spec', 'coverage', 'assert', 'mock', + 'fixture', 'snapshot', 'jest', 'unit', 'integration', + ], + [DecisionCategory.DEPLOYMENT]: [ + 'deploy', 'release', 'publish', 'ship', 'staging', + 'production', 'rollout', 'canary', 'blue-green', + ], +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// DECISION MEMORY +// ═══════════════════════════════════════════════════════════════════════════════════ + +class DecisionMemory extends EventEmitter { + /** + * @param {Object} options + * @param {string} [options.projectRoot] - Project root directory + * @param {Object} [options.config] - Override default config + */ + constructor(options = {}) { + super(); + this.projectRoot = options.projectRoot || process.cwd(); + this.config = { ...CONFIG, ...options.config }; + this.decisions = []; + this.patterns = []; + this._loaded = false; + } + + /** + * Load decisions from disk + * @returns {Promise} + */ + async load() { + const filePath = path.resolve(this.projectRoot, this.config.decisionsJsonPath); + + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw); + + if (data.schemaVersion === this.config.schemaVersion) { + this.decisions = data.decisions || []; + this.patterns = data.patterns || []; + } + } + } catch { + // Corrupted file — start fresh + this.decisions = []; + this.patterns = []; + } + + this._loaded = true; + } + + /** + * Save decisions to disk + * @returns {Promise} + */ + async save() { + await this._ensureLoaded(); + + const filePath = path.resolve(this.projectRoot, this.config.decisionsJsonPath); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + updatedAt: new Date().toISOString(), + stats: this.getStats(), + decisions: this.decisions.slice(-this.config.maxDecisions), + patterns: this.patterns, + }; + + atomicWriteSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } + + /** + * Ensure decisions are loaded from disk before mutating state + * @private + */ + async _ensureLoaded() { + if (!this._loaded) { + await this.load(); + } + } + + /** + * Record a new decision (AC3, AC5) + * @param {Object} decision + * @param {string} decision.description - What was decided + * @param {string} [decision.rationale] - Why this decision was made + * @param {string[]} [decision.alternatives] - Other options considered + * @param {string} [decision.category] - Decision category + * @param {string} [decision.taskContext] - Related task/story + * @param {string} [decision.agentId] - Agent that made the decision + * @returns {Object} The recorded decision + */ + async recordDecision({ + description, + rationale = '', + alternatives = [], + category = null, + taskContext = '', + agentId = 'unknown', + }) { + await this._ensureLoaded(); + + if (!description) { + throw new Error('Decision description is required'); + } + + const decision = { + id: this._generateId(), + description, + rationale, + alternatives, + category: category || this._detectCategory(description), + taskContext, + agentId, + outcome: Outcome.PENDING, + confidence: 1.0, + keywords: this._extractKeywords(description), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + outcomeNotes: '', + }; + + this.decisions.push(decision); + this._detectPatterns(decision); + this.emit(Events.DECISION_RECORDED, decision); + + return decision; + } + + /** + * Update the outcome of a decision (AC3) + * @param {string} decisionId - Decision ID + * @param {string} outcome - 'success' | 'partial' | 'failure' + * @param {string} [notes] - Outcome notes + * @returns {Object|null} Updated decision + */ + updateOutcome(decisionId, outcome, notes = '') { + const decision = this.decisions.find(d => d.id === decisionId); + if (!decision) return null; + + if (!Object.values(Outcome).includes(outcome)) { + throw new Error(`Invalid outcome: ${outcome}. Use: ${Object.values(Outcome).join(', ')}`); + } + + decision.outcome = outcome; + decision.outcomeNotes = notes; + decision.updatedAt = new Date().toISOString(); + + // Adjust confidence based on outcome + if (outcome === Outcome.SUCCESS) { + decision.confidence = Math.min(1.0, decision.confidence + 0.1); + } else if (outcome === Outcome.FAILURE) { + decision.confidence = Math.max(this.config.minConfidence, decision.confidence - 0.3); + } + + this.emit(Events.OUTCOME_UPDATED, decision); + return decision; + } + + /** + * Get relevant past decisions for a task context (AC7) + * @param {string} taskDescription - Current task description + * @param {Object} [options] + * @param {number} [options.limit] - Max results + * @param {string} [options.category] - Filter by category + * @param {boolean} [options.successOnly] - Only successful decisions + * @returns {Object[]} Relevant decisions sorted by relevance + */ + getRelevantDecisions(taskDescription, options = {}) { + const limit = options.limit || this.config.maxInjectedDecisions; + const taskKeywords = this._extractKeywords(taskDescription); + + let candidates = this.decisions.filter(d => d.outcome !== Outcome.PENDING); + + if (options.category) { + candidates = candidates.filter(d => d.category === options.category); + } + + if (options.successOnly) { + candidates = candidates.filter(d => d.outcome === Outcome.SUCCESS); + } + + // Score by keyword similarity + confidence with time decay + const scored = candidates.map(d => { + const similarity = this._keywordSimilarity(taskKeywords, d.keywords); + const decayed = this._applyTimeDecay(d.confidence, d.createdAt); + const outcomeBonus = d.outcome === Outcome.SUCCESS ? 0.2 : + d.outcome === Outcome.FAILURE ? 0.1 : 0; // Failures are also valuable to learn from + + return { + decision: d, + score: (similarity * 0.6) + (decayed * 0.25) + (outcomeBonus * 0.15), + }; + }); + + return scored + .filter(s => s.score >= this.config.similarityThreshold) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(s => ({ + ...s.decision, + relevanceScore: Math.round(s.score * 100) / 100, + })); + } + + /** + * Inject relevant decisions as context for a task (AC7) + * @param {string} taskDescription - Task description + * @returns {string} Formatted context block + */ + injectDecisionContext(taskDescription) { + const relevant = this.getRelevantDecisions(taskDescription); + + if (relevant.length === 0) return ''; + + const lines = [ + '## 📋 Relevant Past Decisions', + '', + ]; + + for (const d of relevant) { + const outcomeIcon = d.outcome === Outcome.SUCCESS ? '✅' : + d.outcome === Outcome.FAILURE ? '❌' : '⚠️'; + + lines.push(`### ${outcomeIcon} ${d.description}`); + if (d.rationale) lines.push(`**Rationale:** ${d.rationale}`); + if (d.outcomeNotes) lines.push(`**Outcome:** ${d.outcomeNotes}`); + lines.push(`**Category:** ${d.category} | **Confidence:** ${Math.round(this._applyTimeDecay(d.confidence, d.createdAt) * 100)}%`); + lines.push(''); + } + + this.emit(Events.DECISIONS_INJECTED, { task: taskDescription, count: relevant.length }); + return lines.join('\n'); + } + + /** + * Get recognized patterns (AC9) + * @returns {Object[]} Detected patterns + */ + getPatterns() { + return [...this.patterns]; + } + + /** + * Get statistics + * @returns {Object} Stats + */ + getStats() { + const total = this.decisions.length; + const byOutcome = {}; + const byCategory = {}; + + for (const d of this.decisions) { + byOutcome[d.outcome] = (byOutcome[d.outcome] || 0) + 1; + byCategory[d.category] = (byCategory[d.category] || 0) + 1; + } + + const successRate = total > 0 + ? (byOutcome[Outcome.SUCCESS] || 0) / Math.max(1, total - (byOutcome[Outcome.PENDING] || 0)) + : 0; + + return { + total, + byOutcome, + byCategory, + patterns: this.patterns.length, + successRate: Math.round(successRate * 100), + }; + } + + /** + * List recent decisions (AC6) + * @param {Object} [options] + * @param {number} [options.limit] - Max results + * @param {string} [options.category] - Filter by category + * @returns {Object[]} Recent decisions + */ + listDecisions(options = {}) { + const limit = options.limit || 20; + let results = [...this.decisions]; + + if (options.category) { + results = results.filter(d => d.category === options.category); + } + + return results.slice(-limit).reverse(); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Auto-detect category from description + * @param {string} text + * @returns {string} Category + * @private + */ + _detectCategory(text) { + const lower = text.toLowerCase(); + let bestCategory = DecisionCategory.GENERAL; + let bestScore = 0; + + for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) { + const score = keywords.reduce((count, kw) => + count + (lower.includes(kw) ? 1 : 0), 0); + + if (score > bestScore) { + bestScore = score; + bestCategory = category; + } + } + + return bestCategory; + } + + /** + * Extract keywords from text + * @param {string} text + * @returns {string[]} Keywords + * @private + */ + _extractKeywords(text) { + const stopWords = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', + 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', + 'would', 'could', 'should', 'may', 'might', 'can', 'shall', + 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', + 'as', 'into', 'through', 'during', 'before', 'after', 'and', + 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', + 'neither', 'each', 'every', 'all', 'any', 'few', 'more', + 'most', 'other', 'some', 'such', 'no', 'only', 'own', 'same', + 'than', 'too', 'very', 'just', 'because', 'que', 'para', + 'com', 'por', 'uma', 'como', 'mais', 'dos', 'das', 'nos', + ]); + + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 2 && !stopWords.has(w)) + .slice(0, 20); + } + + /** + * Calculate keyword similarity between two keyword sets + * @param {string[]} keywords1 + * @param {string[]} keywords2 + * @returns {number} Similarity score 0-1 + * @private + */ + _keywordSimilarity(keywords1, keywords2) { + if (keywords1.length === 0 || keywords2.length === 0) return 0; + + const set1 = new Set(keywords1); + const set2 = new Set(keywords2); + const intersection = [...set1].filter(k => set2.has(k)).length; + const union = new Set([...set1, ...set2]).size; + + return union > 0 ? intersection / union : 0; + } + + /** + * Apply time-based confidence decay + * @param {number} confidence - Original confidence + * @param {string} createdAt - ISO date string + * @returns {number} Decayed confidence + * @private + */ + _applyTimeDecay(confidence, createdAt) { + const ageMs = Date.now() - new Date(createdAt).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const decayFactor = Math.max( + this.config.minConfidence, + 1 - (ageDays / this.config.confidenceDecayDays) * 0.5, + ); + + return confidence * decayFactor; + } + + /** + * Detect recurring patterns in decisions (AC9) + * @param {Object} newDecision - The new decision to check against + * @private + */ + _detectPatterns(newDecision) { + const similar = this.decisions.filter(d => + d.id !== newDecision.id && + d.category === newDecision.category && + this._keywordSimilarity(d.keywords, newDecision.keywords) > 0.4, + ); + + if (similar.length >= this.config.patternThreshold - 1) { + const outcomes = similar.map(d => d.outcome).filter(o => o !== Outcome.PENDING); + const successCount = outcomes.filter(o => o === Outcome.SUCCESS).length; + const failureCount = outcomes.filter(o => o === Outcome.FAILURE).length; + + const pattern = { + id: `pattern-${this.patterns.length + 1}`, + category: newDecision.category, + description: `Recurring ${newDecision.category} decision: "${newDecision.description}"`, + occurrences: similar.length + 1, + successRate: outcomes.length > 0 ? successCount / outcomes.length : 0, + recommendation: successCount > failureCount + ? 'This approach has historically worked well. Consider reusing.' + : 'This approach has historically underperformed. Consider alternatives.', + detectedAt: new Date().toISOString(), + relatedDecisionIds: [...similar.map(d => d.id), newDecision.id], + }; + + // Avoid duplicate patterns + const exists = this.patterns.some(p => + p.category === pattern.category && + this._keywordSimilarity( + this._extractKeywords(p.description), + this._extractKeywords(pattern.description), + ) > 0.6, + ); + + if (!exists) { + this.patterns.push(pattern); + this.emit(Events.PATTERN_DETECTED, pattern); + } + } + } + + /** + * Generate unique decision ID + * @returns {string} + * @private + */ + _generateId() { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `dec-${timestamp}-${random}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = { + DecisionMemory, + DecisionCategory, + Outcome, + Events, + CONFIG, +}; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index f28b9cece..d4dc45633 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-06T11:56:08.042Z" +generated_at: "2026-03-10T14:29:16.009Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -420,6 +420,10 @@ files: hash: sha256:58ecd92f5de9c688f28cf952ae6cc5ee07ddf14dc89fb0ea13b2f0a527e29fae type: core size: 11590 + - path: core/execution/predictive-pipeline.js + hash: sha256:a37f0ce4907299a78aaf33d842f8a366e93e698df42dfe90fbde2e60b57d167f + type: core + size: 41437 - path: core/execution/rate-limit-manager.js hash: sha256:1b6e2ca99cf59a9dfa5a4e48109d0a47f36262efcc73e69f11a1c0c727d48abb type: core @@ -788,6 +792,10 @@ files: hash: sha256:895ec75f6a303edf4cffa0ab7adbb8a4876f62626cc0d7178420efd5758f21a9 type: core size: 8850 + - path: core/memory/decision-memory.js + hash: sha256:7c9189410dffa771db9866e40f1ccb4b8c3bac5d57b4a20f2ef99235ae71ad42 + type: core + size: 18911 - path: core/memory/gotchas-memory.js hash: sha256:0063eff42caf0dda759c0390ac323e7b102f5507f27b8beb7b0acc2fbec407c4 type: core @@ -2583,19 +2591,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2611,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2623,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2631,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3375,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3391,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3403,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3515,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3699,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3751,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3815,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3923,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3931,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3983,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3995,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/execution/predictive-pipeline.test.js b/tests/core/execution/predictive-pipeline.test.js new file mode 100644 index 000000000..cc6419977 --- /dev/null +++ b/tests/core/execution/predictive-pipeline.test.js @@ -0,0 +1,1139 @@ +/** + * Predictive Pipeline Tests + * + * Covers: outcome recording, prediction, batch prediction, similarity search, + * pattern strength, risk assessment, agent/strategy recommendations, + * pipeline stages, model accuracy, retrain, prune, persistence, events, + * anomaly detection, confidence scoring, and edge cases. + */ + +const fs = require('fs'); +const path = require('path'); +const { + PredictivePipeline, + PipelineStage, + RiskLevel, + DEFAULTS, +} = require('../../../.aiox-core/core/execution/predictive-pipeline'); + +// Helper: create a temporary directory +function makeTmpDir() { + const dir = path.join(__dirname, `__tmp_pred_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +// Helper: remove directory recursively +function rmDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +// Helper: seed pipeline with outcomes +async function seedOutcomes(pipeline, count, overrides = {}) { + const outcomes = []; + for (let i = 0; i < count; i++) { + const o = await pipeline.recordOutcome({ + taskType: overrides.taskType ?? 'build', + agent: overrides.agent ?? 'agent-1', + strategy: overrides.strategy ?? 'default', + duration: overrides.duration ?? 1000 + i * 100, + success: overrides.success ?? (i % 5 !== 0), // 80% success + complexity: overrides.complexity ?? 5, + contextSize: overrides.contextSize ?? 100, + resources: overrides.resources ?? { memory: 256, cpu: 0.5, apiCalls: 3 }, + metadata: overrides.metadata ?? null, + }); + outcomes.push(o); + } + return outcomes; +} + +describe('PredictivePipeline', () => { + let tmpDir; + let pipeline; + + beforeEach(() => { + tmpDir = makeTmpDir(); + pipeline = new PredictivePipeline(tmpDir); + }); + + afterEach(() => { + rmDir(tmpDir); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONSTANTS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('Constants', () => { + it('should export PipelineStage with all stages', () => { + expect(PipelineStage.PREPROCESS).toBe('preprocess'); + expect(PipelineStage.MATCH).toBe('match'); + expect(PipelineStage.PREDICT).toBe('predict'); + expect(PipelineStage.SCORE).toBe('score'); + expect(PipelineStage.RECOMMEND).toBe('recommend'); + }); + + it('should export RiskLevel with all levels', () => { + expect(RiskLevel.LOW).toBe('low'); + expect(RiskLevel.MEDIUM).toBe('medium'); + expect(RiskLevel.HIGH).toBe('high'); + expect(RiskLevel.CRITICAL).toBe('critical'); + }); + + it('should export DEFAULTS', () => { + expect(DEFAULTS.kNeighbors).toBe(5); + expect(DEFAULTS.minSamplesForPrediction).toBe(3); + expect(DEFAULTS.anomalyThreshold).toBe(0.3); + expect(DEFAULTS.ewmaAlpha).toBe(0.3); + expect(DEFAULTS.highRiskThreshold).toBe(0.6); + expect(DEFAULTS.maxOutcomes).toBe(10000); + expect(DEFAULTS.confidenceSampleCap).toBe(20); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════ + + describe('constructor', () => { + it('should create instance with defaults', () => { + expect(pipeline).toBeInstanceOf(PredictivePipeline); + expect(pipeline.kNeighbors).toBe(5); + expect(pipeline.minSamplesForPrediction).toBe(3); + expect(pipeline.anomalyThreshold).toBe(0.3); + expect(pipeline.ewmaAlpha).toBe(0.3); + }); + + it('should extend EventEmitter', () => { + const { EventEmitter } = require('events'); + expect(pipeline).toBeInstanceOf(EventEmitter); + }); + + it('should accept custom options with nullish coalescing', () => { + const custom = new PredictivePipeline(tmpDir, { + kNeighbors: 10, + minSamplesForPrediction: 5, + anomalyThreshold: 0.5, + ewmaAlpha: 0.5, + highRiskThreshold: 0.8, + maxOutcomes: 500, + }); + expect(custom.kNeighbors).toBe(10); + expect(custom.minSamplesForPrediction).toBe(5); + expect(custom.anomalyThreshold).toBe(0.5); + expect(custom.ewmaAlpha).toBe(0.5); + expect(custom.highRiskThreshold).toBe(0.8); + expect(custom.maxOutcomes).toBe(500); + }); + + it('should handle 0 as a valid option value (not replaced by default)', () => { + const custom = new PredictivePipeline(tmpDir, { + kNeighbors: 0, + anomalyThreshold: 0, + }); + // nullish coalescing: 0 is NOT nullish, so it should be kept + expect(custom.kNeighbors).toBe(0); + expect(custom.anomalyThreshold).toBe(0); + }); + + it('should default projectRoot to cwd when null', () => { + const p = new PredictivePipeline(null); + expect(p.projectRoot).toBe(process.cwd()); + }); + + it('should initialize empty stage metrics', () => { + for (const stage of Object.values(PipelineStage)) { + const m = pipeline.getStageMetrics(stage); + expect(m.calls).toBe(0); + expect(m.totalMs).toBe(0); + expect(m.errors).toBe(0); + } + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // RECORD OUTCOME + // ═══════════════════════════════════════════════════════════════════════════ + + describe('recordOutcome', () => { + it('should record a valid outcome', async () => { + const result = await pipeline.recordOutcome({ + taskType: 'build', + agent: 'agent-1', + strategy: 'parallel', + duration: 5000, + success: true, + complexity: 7, + contextSize: 200, + resources: { memory: 512, cpu: 0.8, apiCalls: 5 }, + metadata: { note: 'test' }, + }); + + expect(result.id).toMatch(/^pred_/); + expect(result.taskType).toBe('build'); + expect(result.agent).toBe('agent-1'); + expect(result.duration).toBe(5000); + expect(result.success).toBe(true); + expect(result.complexity).toBe(7); + expect(result.timestamp).toBeGreaterThan(0); + }); + + it('should throw when taskType is missing', async () => { + await expect(pipeline.recordOutcome({ duration: 100, success: true })) + .rejects.toThrow('outcome.taskType is required'); + }); + + it('should throw when duration is negative', async () => { + await expect(pipeline.recordOutcome({ taskType: 'x', duration: -1, success: true })) + .rejects.toThrow('outcome.duration must be a non-negative number'); + }); + + it('should throw when duration is not a number', async () => { + await expect(pipeline.recordOutcome({ taskType: 'x', duration: 'fast', success: true })) + .rejects.toThrow('outcome.duration must be a non-negative number'); + }); + + it('should throw when success is not a boolean', async () => { + await expect(pipeline.recordOutcome({ taskType: 'x', duration: 100, success: 1 })) + .rejects.toThrow('outcome.success must be a boolean'); + }); + + it('should use defaults for optional fields', async () => { + const result = await pipeline.recordOutcome({ + taskType: 'test', + duration: 100, + success: true, + }); + + expect(result.agent).toBeNull(); + expect(result.strategy).toBeNull(); + expect(result.complexity).toBe(5); + expect(result.contextSize).toBe(0); + expect(result.resources).toBeNull(); + expect(result.metadata).toBeNull(); + }); + + it('should emit outcome-recorded event', async () => { + const spy = jest.fn(); + pipeline.on('outcome-recorded', spy); + + await pipeline.recordOutcome({ + taskType: 'deploy', + duration: 2000, + success: true, + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].taskType).toBe('deploy'); + }); + + it('should persist outcomes to disk', async () => { + await pipeline.recordOutcome({ + taskType: 'build', + duration: 1000, + success: true, + }); + + const outcomesPath = path.join(tmpDir, '.aiox', 'predictions', 'outcomes.json'); + expect(fs.existsSync(outcomesPath)).toBe(true); + + const data = JSON.parse(fs.readFileSync(outcomesPath, 'utf8')); + expect(data).toHaveLength(1); + expect(data[0].taskType).toBe('build'); + }); + + it('should auto-prune when exceeding maxOutcomes', async () => { + const small = new PredictivePipeline(tmpDir, { maxOutcomes: 5 }); + + for (let i = 0; i < 8; i++) { + await small.recordOutcome({ + taskType: 'build', + duration: 100 * (i + 1), + success: true, + }); + } + + const stats = small.getStats(); + expect(stats.outcomes).toBeLessThanOrEqual(5); + }); + + it('should accept duration of 0', async () => { + const result = await pipeline.recordOutcome({ + taskType: 'noop', + duration: 0, + success: true, + }); + expect(result.duration).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PREDICT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('predict', () => { + it('should return a prediction with all fields', async () => { + await seedOutcomes(pipeline, 10); + + const result = pipeline.predict({ taskType: 'build', complexity: 5 }); + + expect(result).toHaveProperty('taskType', 'build'); + expect(result).toHaveProperty('successProbability'); + expect(result).toHaveProperty('estimatedDuration'); + expect(result).toHaveProperty('resources'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('isAnomaly'); + expect(result).toHaveProperty('riskLevel'); + expect(result).toHaveProperty('recommendedAgent'); + expect(result).toHaveProperty('recommendedStrategy'); + expect(result).toHaveProperty('timestamp'); + }); + + it('should throw when taskType is missing', () => { + expect(() => pipeline.predict({})).toThrow('taskSpec.taskType is required'); + }); + + it('should throw when taskSpec is null', () => { + expect(() => pipeline.predict(null)).toThrow('taskSpec.taskType is required'); + }); + + it('should return default prediction when no outcomes exist', () => { + const result = pipeline.predict({ taskType: 'unknown' }); + + expect(result.successProbability).toBe(0.5); + expect(result.sampleSize).toBe(0); + expect(result.estimatedDuration).toBe(0); + }); + + it('should emit prediction event', async () => { + await seedOutcomes(pipeline, 5); + + const spy = jest.fn(); + pipeline.on('prediction', spy); + + pipeline.predict({ taskType: 'build' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should emit high-risk-detected for risky tasks', () => { + // No history → low confidence → potentially high risk + const spy = jest.fn(); + pipeline.on('high-risk-detected', spy); + + // Predict with no history, anomaly threshold set low + const p = new PredictivePipeline(tmpDir, { highRiskThreshold: 0.3 }); + p.on('high-risk-detected', spy); + p.predict({ taskType: 'never-seen-before' }); + + // Should have been called at least once (either from pipeline or p) + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('should predict higher success rate for successful task types', async () => { + // All successful + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'easy-task', + duration: 500, + success: true, + agent: 'agent-1', + complexity: 3, + }); + } + + // All failures + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'hard-task', + duration: 5000, + success: false, + agent: 'agent-1', + complexity: 9, + }); + } + + const easyPred = pipeline.predict({ taskType: 'easy-task', complexity: 3, agent: 'agent-1' }); + const hardPred = pipeline.predict({ taskType: 'hard-task', complexity: 9, agent: 'agent-1' }); + + expect(easyPred.successProbability).toBeGreaterThan(hardPred.successProbability); + }); + + it('should increment predictions stat', async () => { + await seedOutcomes(pipeline, 5); + + pipeline.predict({ taskType: 'build' }); + pipeline.predict({ taskType: 'build' }); + + expect(pipeline.getStats().predictions).toBe(2); + }); + + it('should include risk level in prediction', async () => { + await seedOutcomes(pipeline, 10); + + const result = pipeline.predict({ taskType: 'build' }); + + expect([RiskLevel.LOW, RiskLevel.MEDIUM, RiskLevel.HIGH, RiskLevel.CRITICAL]) + .toContain(result.riskLevel); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PREDICT BATCH + // ═══════════════════════════════════════════════════════════════════════════ + + describe('predictBatch', () => { + it('should predict for multiple tasks', async () => { + await seedOutcomes(pipeline, 10); + + const results = pipeline.predictBatch([ + { taskType: 'build' }, + { taskType: 'deploy' }, + { taskType: 'test' }, + ]); + + expect(results).toHaveLength(3); + expect(results[0].taskType).toBe('build'); + expect(results[1].taskType).toBe('deploy'); + expect(results[2].taskType).toBe('test'); + }); + + it('should throw when input is not an array', () => { + expect(() => pipeline.predictBatch('not-an-array')).toThrow('taskSpecs must be an array'); + }); + + it('should return empty array for empty input', async () => { + const results = pipeline.predictBatch([]); + expect(results).toEqual([]); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // FIND SIMILAR TASKS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('findSimilarTasks', () => { + it('should find similar tasks ordered by similarity', async () => { + await seedOutcomes(pipeline, 10); + await seedOutcomes(pipeline, 5, { taskType: 'deploy', complexity: 8 }); + + const similar = pipeline.findSimilarTasks({ taskType: 'build', complexity: 5 }); + + expect(similar.length).toBeGreaterThan(0); + // First result should be most similar + if (similar.length > 1) { + expect(similar[0].similarity).toBeGreaterThanOrEqual(similar[1].similarity); + } + }); + + it('should respect limit option', async () => { + await seedOutcomes(pipeline, 20); + + const similar = pipeline.findSimilarTasks({ taskType: 'build' }, { limit: 3 }); + + expect(similar.length).toBeLessThanOrEqual(3); + }); + + it('should respect minSimilarity option', async () => { + await seedOutcomes(pipeline, 10); + + const similar = pipeline.findSimilarTasks( + { taskType: 'build' }, + { minSimilarity: 0.9 }, + ); + + for (const s of similar) { + expect(s.similarity).toBeGreaterThanOrEqual(0.9); + } + }); + + it('should return empty when no outcomes exist', () => { + const similar = pipeline.findSimilarTasks({ taskType: 'build' }); + expect(similar).toEqual([]); + }); + + it('should include similarity score in results', async () => { + await seedOutcomes(pipeline, 5); + + const similar = pipeline.findSimilarTasks({ taskType: 'build' }); + + for (const s of similar) { + expect(typeof s.similarity).toBe('number'); + expect(s.similarity).toBeGreaterThanOrEqual(0); + expect(s.similarity).toBeLessThanOrEqual(1); + } + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PATTERN STRENGTH + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getPatternStrength', () => { + it('should return zero strength for unknown task type', () => { + const result = pipeline.getPatternStrength('unknown'); + + expect(result.taskType).toBe('unknown'); + expect(result.sampleSize).toBe(0); + expect(result.strength).toBe(0); + expect(result.successRate).toBe(0); + expect(result.avgDuration).toBe(0); + }); + + it('should return correct stats for recorded task type', async () => { + await seedOutcomes(pipeline, 10, { taskType: 'lint', duration: 500, success: true }); + + const result = pipeline.getPatternStrength('lint'); + + expect(result.taskType).toBe('lint'); + expect(result.sampleSize).toBe(10); + expect(result.successRate).toBe(1.0); + expect(result.avgDuration).toBe(500); + expect(result.strength).toBeGreaterThan(0); + }); + + it('should increase strength with more samples', async () => { + await seedOutcomes(pipeline, 3, { taskType: 'test', duration: 1000, success: true }); + const strengthSmall = pipeline.getPatternStrength('test').strength; + + await seedOutcomes(pipeline, 17, { taskType: 'test', duration: 1000, success: true }); + const strengthLarge = pipeline.getPatternStrength('test').strength; + + expect(strengthLarge).toBeGreaterThanOrEqual(strengthSmall); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // RISK ASSESSMENT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('assessRisk', () => { + it('should identify risk for new task type', () => { + const risk = pipeline.assessRisk({ taskType: 'brand-new' }); + + expect(risk.taskType).toBe('brand-new'); + expect(risk.riskLevel).toBeTruthy(); + expect(risk.factors.length).toBeGreaterThan(0); + expect(risk.factors.some((f) => f.factor === 'new-task-type')).toBe(true); + }); + + it('should identify low sample size risk', async () => { + await pipeline.recordOutcome({ taskType: 'rare', duration: 100, success: true }); + + const risk = pipeline.assessRisk({ taskType: 'rare' }); + + expect(risk.factors.some((f) => f.factor === 'low-sample-size')).toBe(true); + }); + + it('should identify high variance risk', async () => { + // Record outcomes with wildly varying durations + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'volatile', + duration: i % 2 === 0 ? 100 : 10000, + success: true, + }); + } + + const risk = pipeline.assessRisk({ taskType: 'volatile' }); + + expect(risk.factors.some((f) => f.factor === 'high-variance')).toBe(true); + }); + + it('should identify low success rate risk', async () => { + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'flaky', + duration: 1000, + success: i >= 8, // only 2/10 succeed + }); + } + + const risk = pipeline.assessRisk({ taskType: 'flaky' }); + + expect(risk.factors.some((f) => f.factor === 'low-success-rate')).toBe(true); + }); + + it('should include mitigations for each risk factor', async () => { + const risk = pipeline.assessRisk({ taskType: 'brand-new' }); + + expect(risk.mitigations.length).toBeGreaterThan(0); + expect(risk.mitigations.length).toBe(risk.factors.length); + }); + + it('should return low risk for well-known successful task', async () => { + await seedOutcomes(pipeline, 25, { taskType: 'stable', duration: 1000, success: true }); + + const risk = pipeline.assessRisk({ taskType: 'stable' }); + + expect(risk.riskScore).toBeLessThan(0.5); + }); + + it('should detect agent-low-success factor', async () => { + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'task-a', + agent: 'bad-agent', + duration: 1000, + success: false, + }); + } + + const risk = pipeline.assessRisk({ taskType: 'task-a', agent: 'bad-agent' }); + + expect(risk.factors.some((f) => f.factor === 'agent-low-success')).toBe(true); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // RECOMMENDATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('recommendAgent', () => { + it('should recommend best agent for task type', async () => { + // Agent A: high success + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'compile', + agent: 'agent-fast', + duration: 500, + success: true, + }); + } + // Agent B: low success + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'compile', + agent: 'agent-slow', + duration: 3000, + success: i > 7, + }); + } + + const rec = pipeline.recommendAgent({ taskType: 'compile' }); + + expect(rec.taskType).toBe('compile'); + expect(rec.recommendation).not.toBeNull(); + expect(rec.recommendation.agent).toBe('agent-fast'); + }); + + it('should return null recommendation when no agents qualify', () => { + const rec = pipeline.recommendAgent({ taskType: 'unknown-task' }); + + expect(rec.recommendation).toBeNull(); + }); + + it('should throw when taskType is missing', () => { + expect(() => pipeline.recommendAgent({})).toThrow('taskSpec.taskType is required'); + }); + }); + + describe('recommendStrategy', () => { + it('should recommend best strategy for task type', async () => { + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'build', + strategy: 'parallel', + duration: 500, + success: true, + }); + } + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'build', + strategy: 'sequential', + duration: 2000, + success: i > 5, + }); + } + + const rec = pipeline.recommendStrategy({ taskType: 'build' }); + + expect(rec.recommendation).not.toBeNull(); + expect(rec.recommendation.strategy).toBe('parallel'); + }); + + it('should return null recommendation when no strategies qualify', () => { + const rec = pipeline.recommendStrategy({ taskType: 'empty' }); + + expect(rec.recommendation).toBeNull(); + }); + + it('should throw when taskType is missing', () => { + expect(() => pipeline.recommendStrategy({})).toThrow('taskSpec.taskType is required'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PIPELINE STAGES + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getPipelineStages', () => { + it('should return all 5 stages in order', () => { + const stages = pipeline.getPipelineStages(); + + expect(stages).toEqual([ + 'preprocess', 'match', 'predict', 'score', 'recommend', + ]); + }); + }); + + describe('getStageMetrics', () => { + it('should return null for unknown stage', () => { + expect(pipeline.getStageMetrics('nonexistent')).toBeNull(); + }); + + it('should track stage calls after prediction', async () => { + await seedOutcomes(pipeline, 5); + pipeline.predict({ taskType: 'build' }); + + for (const stage of Object.values(PipelineStage)) { + const m = pipeline.getStageMetrics(stage); + expect(m.calls).toBe(1); + expect(m.avgMs).toBeGreaterThanOrEqual(0); + } + }); + + it('should accumulate metrics across multiple predictions', async () => { + await seedOutcomes(pipeline, 5); + + pipeline.predict({ taskType: 'build' }); + pipeline.predict({ taskType: 'build' }); + pipeline.predict({ taskType: 'build' }); + + const m = pipeline.getStageMetrics(PipelineStage.PREPROCESS); + expect(m.calls).toBe(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // MODEL ACCURACY + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getModelAccuracy', () => { + it('should return empty accuracy when no outcomes', () => { + const acc = pipeline.getModelAccuracy(); + + expect(acc.totalOutcomes).toBe(0); + expect(acc.overallSuccessRate).toBe(0); + expect(Object.keys(acc.perTaskType)).toHaveLength(0); + }); + + it('should compute correct accuracy after outcomes', async () => { + // 8 successes, 2 failures + for (let i = 0; i < 10; i++) { + await pipeline.recordOutcome({ + taskType: 'build', + duration: 1000, + success: i < 8, + }); + } + + const acc = pipeline.getModelAccuracy(); + + expect(acc.totalOutcomes).toBe(10); + expect(acc.overallSuccessRate).toBe(0.8); + expect(acc.perTaskType.build.count).toBe(10); + expect(acc.perTaskType.build.successRate).toBe(0.8); + }); + + it('should report per-task-type accuracy', async () => { + await seedOutcomes(pipeline, 5, { taskType: 'build', success: true }); + await seedOutcomes(pipeline, 5, { taskType: 'deploy', success: false }); + + const acc = pipeline.getModelAccuracy(); + + expect(acc.perTaskType.build.successRate).toBe(1.0); + expect(acc.perTaskType.deploy.successRate).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // RETRAIN + // ═══════════════════════════════════════════════════════════════════════════ + + describe('retrain', () => { + it('should rebuild model from outcomes', async () => { + await seedOutcomes(pipeline, 10); + + const result = await pipeline.retrain(); + + expect(result.version).toBe(2); + expect(result.outcomeCount).toBe(10); + expect(result.taskTypes).toBe(1); + }); + + it('should emit model-retrained event', async () => { + await seedOutcomes(pipeline, 5); + + const spy = jest.fn(); + pipeline.on('model-retrained', spy); + + await pipeline.retrain(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].version).toBe(2); + }); + + it('should increment retrains stat', async () => { + await seedOutcomes(pipeline, 5); + + await pipeline.retrain(); + await pipeline.retrain(); + + expect(pipeline.getStats().retrains).toBe(2); + }); + + it('should persist retrained model', async () => { + await seedOutcomes(pipeline, 5); + await pipeline.retrain(); + + const modelPath = path.join(tmpDir, '.aiox', 'predictions', 'model.json'); + expect(fs.existsSync(modelPath)).toBe(true); + + const model = JSON.parse(fs.readFileSync(modelPath, 'utf8')); + expect(model.lastRetrain).toBeGreaterThan(0); + expect(model.version).toBe(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PRUNE + // ═══════════════════════════════════════════════════════════════════════════ + + describe('prune', () => { + it('should remove outcomes older than threshold', async () => { + // Record some outcomes + await seedOutcomes(pipeline, 5); + + const stats = pipeline.getStats(); + expect(stats.outcomes).toBe(5); + + // Prune everything recorded so far + const result = await pipeline.prune({ olderThan: Date.now() + 1 }); + + expect(result.removed).toBe(5); + expect(result.remaining).toBe(0); + }); + + it('should not remove anything when no olderThan specified', async () => { + await seedOutcomes(pipeline, 5); + + const result = await pipeline.prune(); + + expect(result.removed).toBe(0); + expect(result.remaining).toBe(5); + }); + + it('should retrain model after pruning', async () => { + await seedOutcomes(pipeline, 10); + + await pipeline.prune({ olderThan: Date.now() + 1 }); + + const acc = pipeline.getModelAccuracy(); + expect(acc.totalOutcomes).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // STATS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getStats', () => { + it('should return comprehensive stats', async () => { + await seedOutcomes(pipeline, 5); + pipeline.predict({ taskType: 'build' }); + + const stats = pipeline.getStats(); + + expect(stats.outcomes).toBe(5); + expect(stats.taskTypes).toBe(1); + expect(stats.agents).toBe(1); + expect(stats.strategies).toBe(1); + expect(stats.predictions).toBe(1); + expect(stats.outcomesRecorded).toBe(5); + expect(stats.modelVersion).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + describe('persistence', () => { + it('should survive recreation from same directory', async () => { + await seedOutcomes(pipeline, 10); + + // Create a new instance pointing to same dir + const pipeline2 = new PredictivePipeline(tmpDir); + const stats = pipeline2.getStats(); + + expect(stats.outcomes).toBe(10); + }); + + it('should create data directory when it does not exist', async () => { + const newDir = path.join(tmpDir, 'sub', 'nested'); + + const p = new PredictivePipeline(newDir); + await p.recordOutcome({ taskType: 'build', duration: 100, success: true }); + + expect(fs.existsSync(path.join(newDir, '.aiox', 'predictions', 'outcomes.json'))).toBe(true); + }); + + it('should handle corrupted outcomes file gracefully', async () => { + // Write garbage + const predDir = path.join(tmpDir, '.aiox', 'predictions'); + fs.mkdirSync(predDir, { recursive: true }); + fs.writeFileSync(path.join(predDir, 'outcomes.json'), 'NOT JSON!!!'); + + const p = new PredictivePipeline(tmpDir); + const stats = p.getStats(); + + // Should start empty, not crash + expect(stats.outcomes).toBe(0); + }); + + it('should handle corrupted model file gracefully', async () => { + const predDir = path.join(tmpDir, '.aiox', 'predictions'); + fs.mkdirSync(predDir, { recursive: true }); + fs.writeFileSync(path.join(predDir, 'model.json'), '{ broken'); + + const p = new PredictivePipeline(tmpDir); + + // Should not crash on predict + const result = p.predict({ taskType: 'test' }); + expect(result).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // ANOMALY DETECTION + // ═══════════════════════════════════════════════════════════════════════════ + + describe('anomaly detection', () => { + it('should detect anomaly for very different task', async () => { + // Seed with one type + for (let i = 0; i < 20; i++) { + await pipeline.recordOutcome({ + taskType: 'build', + complexity: 5, + contextSize: 100, + duration: 1000, + success: true, + }); + } + + const spy = jest.fn(); + pipeline.on('anomaly-detected', spy); + + // Predict for a totally different type with different features + pipeline.predict({ taskType: 'alien-task', complexity: 1, contextSize: 0 }); + + expect(spy).toHaveBeenCalled(); + expect(pipeline.getStats().anomaliesDetected).toBeGreaterThan(0); + }); + + it('should not flag anomaly for matching task', async () => { + await seedOutcomes(pipeline, 10); + + const spy = jest.fn(); + pipeline.on('anomaly-detected', spy); + + pipeline.predict({ taskType: 'build', complexity: 5, contextSize: 100 }); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONFIDENCE SCORING + // ═══════════════════════════════════════════════════════════════════════════ + + describe('confidence scoring', () => { + it('should have low confidence with few samples', async () => { + await seedOutcomes(pipeline, 2, { duration: 1000, success: true }); + + const result = pipeline.predict({ taskType: 'build' }); + + expect(result.confidence).toBeLessThan(0.5); + }); + + it('should have higher confidence with more consistent samples', async () => { + // Consistent durations — all identical + await seedOutcomes(pipeline, 20, { taskType: 'stable', duration: 1000, success: true }); + + const result = pipeline.predict({ taskType: 'stable', complexity: 5, contextSize: 100, agent: 'agent-1' }); + + // With 5 neighbors (kNeighbors=5), sampleFactor = 5/20 = 0.25 + // With zero variance, varianceFactor = 1.0, so confidence = 0.25 + expect(result.confidence).toBeGreaterThanOrEqual(0.2); + }); + + it('should have lower confidence with high variance', async () => { + for (let i = 0; i < 20; i++) { + await pipeline.recordOutcome({ + taskType: 'volatile', + duration: i % 2 === 0 ? 100 : 50000, + success: true, + complexity: 5, + contextSize: 100, + agent: 'agent-1', + }); + } + + const result = pipeline.predict({ taskType: 'volatile', complexity: 5, contextSize: 100, agent: 'agent-1' }); + + // Even though we have 20 samples, variance is extreme + expect(result.coefficientOfVariation).toBeGreaterThan(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // SAFE ERROR EMIT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('error handling', () => { + it('should emit error event when listeners present', () => { + const spy = jest.fn(); + pipeline.on('error', spy); + + // Trigger _emitSafeError directly + pipeline._emitSafeError({ type: 'test', error: new Error('boom') }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not throw when no error listeners and _emitSafeError is called', () => { + // No error listener → should not throw + expect(() => { + pipeline._emitSafeError({ type: 'test', error: new Error('boom') }); + }).not.toThrow(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // EWMA & MATH + // ═══════════════════════════════════════════════════════════════════════════ + + describe('internal math', () => { + it('should compute EWMA correctly', () => { + const result = pipeline._computeEwma([100, 200, 300]); + + // ewma(0) = 100 + // ewma(1) = 0.3*200 + 0.7*100 = 130 + // ewma(2) = 0.3*300 + 0.7*130 = 181 + expect(result).toBeCloseTo(181, 0); + }); + + it('should return 0 for empty EWMA', () => { + expect(pipeline._computeEwma([])).toBe(0); + }); + + it('should return single value for EWMA of one', () => { + expect(pipeline._computeEwma([42])).toBe(42); + }); + + it('should compute coefficient of variation', () => { + // [10, 10, 10] → cv = 0 + expect(pipeline._coefficientOfVariation([10, 10, 10])).toBe(0); + + // cv > 0 for non-uniform data + expect(pipeline._coefficientOfVariation([10, 100, 10, 100])).toBeGreaterThan(0); + }); + + it('should return 0 cv for less than 2 values', () => { + expect(pipeline._coefficientOfVariation([])).toBe(0); + expect(pipeline._coefficientOfVariation([5])).toBe(0); + }); + + it('should compute cosine similarity', () => { + // Identical vectors → 1.0 + expect(pipeline._cosineSimilarity([1, 2, 3], [1, 2, 3])).toBeCloseTo(1.0, 5); + + // Orthogonal → 0 + expect(pipeline._cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0, 5); + + // Zero vector → 0 + expect(pipeline._cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // DEEP CLONE + // ═══════════════════════════════════════════════════════════════════════════ + + describe('deep clone', () => { + it('should return independent copies', () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = pipeline._deepClone(original); + + cloned.b.c = 99; + expect(original.b.c).toBe(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // EDGE CASES + // ═══════════════════════════════════════════════════════════════════════════ + + describe('edge cases', () => { + it('should handle concurrent recordOutcome calls', async () => { + const promises = []; + for (let i = 0; i < 20; i++) { + promises.push(pipeline.recordOutcome({ + taskType: 'concurrent', + duration: 100 * i, + success: true, + })); + } + + const results = await Promise.all(promises); + const ids = new Set(results.map(r => r.id)); + expect(ids.size).toBe(20); + + const stats = pipeline.getStats(); + expect(stats.outcomes).toBe(20); + }); + + it('should generate unique IDs', async () => { + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const result = await pipeline.recordOutcome({ + taskType: 'id-test', + duration: 100, + success: true, + }); + ids.add(result.id); + } + expect(ids.size).toBe(50); + }); + + it('should handle task with all optional fields missing', async () => { + const result = await pipeline.recordOutcome({ + taskType: 'minimal', + duration: 0, + success: false, + }); + + expect(result.agent).toBeNull(); + expect(result.strategy).toBeNull(); + expect(result.complexity).toBe(5); + expect(result.contextSize).toBe(0); + }); + + it('should predict after retrain with no outcomes', async () => { + await seedOutcomes(pipeline, 5); + await pipeline.prune({ olderThan: Date.now() + 1 }); + + const result = pipeline.predict({ taskType: 'build' }); + + expect(result.sampleSize).toBe(0); + expect(result.successProbability).toBe(0.5); + }); + }); +}); diff --git a/tests/core/memory/decision-memory.test.js b/tests/core/memory/decision-memory.test.js new file mode 100644 index 000000000..5a45a3b9f --- /dev/null +++ b/tests/core/memory/decision-memory.test.js @@ -0,0 +1,493 @@ +const path = require('path'); +const fs = require('fs'); +const { + DecisionMemory, + DecisionCategory, + Outcome, + Events, + CONFIG, +} = require('../../../.aiox-core/core/memory/decision-memory'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TEST HELPERS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const TEST_ROOT = path.join(__dirname, '__fixtures__', 'decision-memory'); + +function createMemory(overrides = {}) { + return new DecisionMemory({ + projectRoot: TEST_ROOT, + config: { ...overrides }, + }); +} + +function cleanFixtures() { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('DecisionMemory', () => { + beforeEach(() => { + cleanFixtures(); + }); + + afterAll(() => { + cleanFixtures(); + const dir = path.join(TEST_ROOT, '.aiox'); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); + if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Constructor & Loading + // ───────────────────────────────────────────────────────────────────────────── + + describe('constructor', () => { + it('should create with default config', () => { + const mem = createMemory(); + expect(mem.decisions).toEqual([]); + expect(mem.patterns).toEqual([]); + expect(mem._loaded).toBe(false); + }); + + it('should accept custom config overrides', () => { + const mem = createMemory({ maxDecisions: 100 }); + expect(mem.config.maxDecisions).toBe(100); + }); + }); + + describe('load', () => { + it('should load from empty state', async () => { + const mem = createMemory(); + await mem.load(); + expect(mem._loaded).toBe(true); + expect(mem.decisions).toEqual([]); + }); + + it('should load persisted decisions', async () => { + const mem = createMemory(); + await mem.recordDecision({ description: 'Use microservices architecture' }); + await mem.save(); + + const mem2 = createMemory(); + await mem2.load(); + expect(mem2.decisions).toHaveLength(1); + expect(mem2.decisions[0].description).toBe('Use microservices architecture'); + }); + + it('should handle corrupted file gracefully', async () => { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, '{invalid json!!!', 'utf-8'); + + const mem = createMemory(); + await mem.load(); + expect(mem.decisions).toEqual([]); + }); + + it('should ignore data with wrong schema version', async () => { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ + schemaVersion: 'old-version', + decisions: [{ id: 'old', description: 'old' }], + }), 'utf-8'); + + const mem = createMemory(); + await mem.load(); + expect(mem.decisions).toEqual([]); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Recording Decisions + // ───────────────────────────────────────────────────────────────────────────── + + describe('recordDecision', () => { + it('should record a basic decision', async () => { + const mem = createMemory(); + const decision = await mem.recordDecision({ + description: 'Delegate story creation to @sm agent', + }); + + expect(decision.id).toMatch(/^dec-/); + expect(decision.description).toBe('Delegate story creation to @sm agent'); + expect(decision.outcome).toBe(Outcome.PENDING); + expect(decision.confidence).toBe(1.0); + expect(decision.createdAt).toBeDefined(); + }); + + it('should auto-detect category from description', async () => { + const mem = createMemory(); + + const arch = await mem.recordDecision({ description: 'Refactor module architecture to use layered pattern' }); + expect(arch.category).toBe(DecisionCategory.ARCHITECTURE); + + const deleg = await mem.recordDecision({ description: 'Delegate task to subagent for orchestration' }); + expect(deleg.category).toBe(DecisionCategory.DELEGATION); + + const test = await mem.recordDecision({ description: 'Add jest unit test coverage for utils' }); + expect(test.category).toBe(DecisionCategory.TESTING); + }); + + it('should use provided category over auto-detect', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ + description: 'Use TypeScript', + category: DecisionCategory.TOOLING, + }); + expect(d.category).toBe(DecisionCategory.TOOLING); + }); + + it('should extract keywords from description', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ + description: 'Use circuit breaker pattern for API resilience', + }); + expect(d.keywords).toContain('circuit'); + expect(d.keywords).toContain('breaker'); + expect(d.keywords).toContain('pattern'); + expect(d.keywords).not.toContain('for'); // stop word + }); + + it('should throw on empty description', async () => { + const mem = createMemory(); + await expect(mem.recordDecision({ description: '' })).rejects.toThrow('description is required'); + }); + + it('should emit DECISION_RECORDED event', async () => { + const mem = createMemory(); + const handler = jest.fn(); + mem.on(Events.DECISION_RECORDED, handler); + + await mem.recordDecision({ description: 'Test decision' }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].description).toBe('Test decision'); + }); + + it('should record rationale and alternatives', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ + description: 'Use PostgreSQL over MongoDB', + rationale: 'Relational data model fits better', + alternatives: ['MongoDB', 'DynamoDB', 'SQLite'], + }); + + expect(d.rationale).toBe('Relational data model fits better'); + expect(d.alternatives).toEqual(['MongoDB', 'DynamoDB', 'SQLite']); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Outcome Updates + // ───────────────────────────────────────────────────────────────────────────── + + describe('updateOutcome', () => { + it('should update outcome and notes', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ description: 'Use caching layer' }); + + const updated = mem.updateOutcome(d.id, Outcome.SUCCESS, 'Reduced latency by 40%'); + expect(updated.outcome).toBe(Outcome.SUCCESS); + expect(updated.outcomeNotes).toBe('Reduced latency by 40%'); + }); + + it('should increase confidence on success (up to cap)', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ description: 'Enable compression' }); + + // Reduce confidence first via a failure, then verify success increases it + mem.updateOutcome(d.id, Outcome.FAILURE); + const afterFailure = d.confidence; + + d.outcome = Outcome.PENDING; // reset to allow re-update + mem.updateOutcome(d.id, Outcome.SUCCESS); + expect(d.confidence).toBeGreaterThan(afterFailure); + }); + + it('should decrease confidence on failure', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ description: 'Deploy on Friday' }); + const initial = d.confidence; + + mem.updateOutcome(d.id, Outcome.FAILURE); + expect(d.confidence).toBeLessThan(initial); + }); + + it('should not go below minimum confidence', async () => { + const mem = createMemory({ minConfidence: 0.1 }); + const d = await mem.recordDecision({ description: 'Bad idea' }); + + // Multiple failures + for (let i = 0; i < 10; i++) { + d.outcome = Outcome.PENDING; + mem.updateOutcome(d.id, Outcome.FAILURE); + } + + expect(d.confidence).toBeGreaterThanOrEqual(0.1); + }); + + it('should return null for unknown decision ID', async () => { + const mem = createMemory(); + expect(mem.updateOutcome('nonexistent', Outcome.SUCCESS)).toBeNull(); + }); + + it('should throw on invalid outcome', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ description: 'test' }); + expect(() => mem.updateOutcome(d.id, 'invalid')).toThrow('Invalid outcome'); + }); + + it('should emit OUTCOME_UPDATED event', async () => { + const mem = createMemory(); + const handler = jest.fn(); + mem.on(Events.OUTCOME_UPDATED, handler); + + const d = await mem.recordDecision({ description: 'test' }); + mem.updateOutcome(d.id, Outcome.SUCCESS); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Relevance & Context Injection + // ───────────────────────────────────────────────────────────────────────────── + + describe('getRelevantDecisions', () => { + it('should find relevant decisions by keyword similarity', async () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + + const d1 = await mem.recordDecision({ description: 'Use circuit breaker for API calls' }); + mem.updateOutcome(d1.id, Outcome.SUCCESS, 'Prevented cascade failures'); + + const d2 = await mem.recordDecision({ description: 'Add database connection pooling' }); + mem.updateOutcome(d2.id, Outcome.SUCCESS); + + const relevant = mem.getRelevantDecisions('circuit breaker pattern for external API'); + expect(relevant.length).toBeGreaterThanOrEqual(1); + expect(relevant[0].description).toContain('circuit breaker'); + }); + + it('should exclude pending decisions', async () => { + const mem = createMemory(); + await mem.recordDecision({ description: 'Pending decision about testing' }); + + const relevant = mem.getRelevantDecisions('testing strategy'); + expect(relevant).toHaveLength(0); + }); + + it('should filter by category', async () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d1 = await mem.recordDecision({ description: 'Architecture decision about modules', category: DecisionCategory.ARCHITECTURE }); + mem.updateOutcome(d1.id, Outcome.SUCCESS); + const d2 = await mem.recordDecision({ description: 'Testing decision about modules', category: DecisionCategory.TESTING }); + mem.updateOutcome(d2.id, Outcome.SUCCESS); + + const relevant = mem.getRelevantDecisions('modules', { category: DecisionCategory.ARCHITECTURE }); + expect(relevant.every(d => d.category === DecisionCategory.ARCHITECTURE)).toBe(true); + }); + + it('should filter successOnly when requested', async () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d1 = await mem.recordDecision({ description: 'Good deploy strategy' }); + mem.updateOutcome(d1.id, Outcome.SUCCESS); + const d2 = await mem.recordDecision({ description: 'Bad deploy strategy' }); + mem.updateOutcome(d2.id, Outcome.FAILURE); + + const relevant = mem.getRelevantDecisions('deploy strategy', { successOnly: true }); + expect(relevant.every(d => d.outcome === Outcome.SUCCESS)).toBe(true); + }); + }); + + describe('injectDecisionContext', () => { + it('should return empty string when no relevant decisions', async () => { + const mem = createMemory(); + expect(mem.injectDecisionContext('something unrelated')).toBe(''); + }); + + it('should format relevant decisions as markdown', async () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d = await mem.recordDecision({ + description: 'Use retry with exponential backoff', + rationale: 'Prevents thundering herd', + }); + mem.updateOutcome(d.id, Outcome.SUCCESS, 'Worked perfectly'); + + const context = mem.injectDecisionContext('retry strategy for API calls'); + expect(context).toContain('Relevant Past Decisions'); + expect(context).toContain('exponential backoff'); + expect(context).toContain('✅'); + }); + + it('should emit DECISIONS_INJECTED event', async () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const handler = jest.fn(); + mem.on(Events.DECISIONS_INJECTED, handler); + + const d = await mem.recordDecision({ description: 'caching strategy for data' }); + mem.updateOutcome(d.id, Outcome.SUCCESS); + mem.injectDecisionContext('data caching approach'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Pattern Detection + // ───────────────────────────────────────────────────────────────────────────── + + describe('pattern detection', () => { + it('should detect pattern after threshold occurrences', async () => { + const mem = createMemory({ patternThreshold: 3, similarityThreshold: 0.1 }); + const handler = jest.fn(); + mem.on(Events.PATTERN_DETECTED, handler); + + await mem.recordDecision({ description: 'Use circuit breaker for service A' }); + await mem.recordDecision({ description: 'Use circuit breaker for service B' }); + await mem.recordDecision({ description: 'Use circuit breaker for service C' }); + + expect(handler).toHaveBeenCalled(); + expect(mem.getPatterns().length).toBeGreaterThanOrEqual(1); + }); + + it('should not duplicate patterns', async () => { + const mem = createMemory({ patternThreshold: 3 }); + + for (let i = 0; i < 6; i++) { + await mem.recordDecision({ description: `Use retry pattern for service ${i}` }); + } + + const patterns = mem.getPatterns(); + // Should not have multiple identical patterns + const unique = new Set(patterns.map(p => p.category)); + expect(patterns.length).toBeLessThanOrEqual(unique.size + 1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Stats & Listing + // ───────────────────────────────────────────────────────────────────────────── + + describe('getStats', () => { + it('should return correct statistics', async () => { + const mem = createMemory(); + const d1 = await mem.recordDecision({ description: 'Decision 1' }); + const d2 = await mem.recordDecision({ description: 'Decision 2' }); + const d3 = await mem.recordDecision({ description: 'Decision 3' }); + + mem.updateOutcome(d1.id, Outcome.SUCCESS); + mem.updateOutcome(d2.id, Outcome.FAILURE); + + const stats = mem.getStats(); + expect(stats.total).toBe(3); + expect(stats.byOutcome[Outcome.SUCCESS]).toBe(1); + expect(stats.byOutcome[Outcome.FAILURE]).toBe(1); + expect(stats.byOutcome[Outcome.PENDING]).toBe(1); + expect(stats.successRate).toBe(50); + }); + }); + + describe('listDecisions', () => { + it('should list decisions in reverse chronological order', async () => { + const mem = createMemory(); + await mem.recordDecision({ description: 'First' }); + await mem.recordDecision({ description: 'Second' }); + await mem.recordDecision({ description: 'Third' }); + + const list = mem.listDecisions(); + expect(list[0].description).toBe('Third'); + expect(list[2].description).toBe('First'); + }); + + it('should respect limit', async () => { + const mem = createMemory(); + for (let i = 0; i < 10; i++) { + await mem.recordDecision({ description: `Decision ${i}` }); + } + + const list = mem.listDecisions({ limit: 3 }); + expect(list).toHaveLength(3); + }); + + it('should filter by category', async () => { + const mem = createMemory(); + await mem.recordDecision({ description: 'Architecture choice', category: DecisionCategory.ARCHITECTURE }); + await mem.recordDecision({ description: 'Testing choice', category: DecisionCategory.TESTING }); + + const list = mem.listDecisions({ category: DecisionCategory.ARCHITECTURE }); + expect(list).toHaveLength(1); + expect(list[0].category).toBe(DecisionCategory.ARCHITECTURE); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Persistence + // ───────────────────────────────────────────────────────────────────────────── + + describe('save & load roundtrip', () => { + it('should persist and restore full state', async () => { + const mem = createMemory(); + const d = await mem.recordDecision({ + description: 'Use event-driven architecture', + rationale: 'Decouples components', + alternatives: ['REST', 'gRPC'], + agentId: 'cto', + }); + mem.updateOutcome(d.id, Outcome.SUCCESS, 'Clean separation achieved'); + await mem.save(); + + const mem2 = createMemory(); + await mem2.load(); + + expect(mem2.decisions).toHaveLength(1); + expect(mem2.decisions[0].description).toBe('Use event-driven architecture'); + expect(mem2.decisions[0].outcome).toBe(Outcome.SUCCESS); + expect(mem2.decisions[0].rationale).toBe('Decouples components'); + expect(mem2.decisions[0].alternatives).toEqual(['REST', 'gRPC']); + }); + + it('should cap decisions at maxDecisions on save', async () => { + const mem = createMemory({ maxDecisions: 5 }); + + for (let i = 0; i < 10; i++) { + await mem.recordDecision({ description: `Decision ${i}` }); + } + + await mem.save(); + + const mem2 = createMemory({ maxDecisions: 5 }); + await mem2.load(); + expect(mem2.decisions.length).toBeLessThanOrEqual(5); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Time Decay + // ───────────────────────────────────────────────────────────────────────────── + + describe('confidence decay', () => { + it('should decay confidence over time', () => { + const mem = createMemory({ confidenceDecayDays: 30 }); + const oldDate = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(); // 20 days ago + const decayed = mem._applyTimeDecay(1.0, oldDate); + + expect(decayed).toBeLessThan(1.0); + expect(decayed).toBeGreaterThan(0); + }); + + it('should not decay recent decisions', () => { + const mem = createMemory({ confidenceDecayDays: 30 }); + const recent = new Date().toISOString(); + const decayed = mem._applyTimeDecay(1.0, recent); + + expect(decayed).toBeCloseTo(1.0, 1); + }); + }); +});