diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..87e9fcb --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], + collectCoverageFrom: [ + 'modules/**/*.js', + '!modules/**/node_modules/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 70, + functions: 80, + lines: 80, + statements: 80 + } + }, + verbose: true, + testTimeout: 30000 +}; diff --git a/modules/consensus/bft-consensus.js b/modules/consensus/bft-consensus.js new file mode 100644 index 0000000..cd93dbc --- /dev/null +++ b/modules/consensus/bft-consensus.js @@ -0,0 +1,340 @@ +/** + * BFT (Byzantine Fault Tolerance) Consensus for Agent Clusters + * + * NOVEL HERETEK CONTRIBUTION - No AI framework implements PBFT-style consensus. + * Practical Byzantine Fault Tolerance for triad/cluster decisions. + * Supports f faulty nodes out of 3f+1 total (e.g., 1 faulty out of 4). + * + * Phases: PRE-PREPARE → PREPARE → COMMIT → REPLY + * View change mechanism for leader failure recovery. + */ + +const { Redis } = require('ioredis'); +const crypto = require('crypto'); + +class BFTConsensus { + constructor(options = {}) { + this.redis = new Redis(options.redisUrl || 'redis://localhost:6379'); + this.nodeId = options.nodeId || `node-${Date.now()}`; + this.clusterSize = options.clusterSize || 4; // 3f+1 where f=1 + this.view = 0; + this.sequence = 0; + this.state = 'idle'; // idle, pre-prepare, prepare, commit, committed + } + + /** + * Get primary/leader for current view + */ + getPrimary() { + return `node-${this.view % this.clusterSize}`; + } + + /** + * Check if this node is the primary + */ + isPrimary() { + return this.nodeId === this.getPrimary(); + } + + /** + * Get required quorum size (2f+1) + */ + getQuorumSize() { + const f = Math.floor((this.clusterSize - 1) / 3); + return 2 * f + 1; + } + + /** + * Start consensus for a decision + * @param {Object} request - The decision request + * @returns {Promise} Consensus result + */ + async propose(request) { + this.sequence++; + const digest = this.hash(JSON.stringify(request)); + + console.log(`🔹 Proposing decision #${this.sequence} (view ${this.view})`); + + if (this.isPrimary()) { + // Primary broadcasts PRE-PREPARE + await this.broadcast('pre-prepare', { + view: this.view, + sequence: this.sequence, + digest, + request + }); + + this.state = 'pre-prepare'; + return this.waitForConsensus(request); + } else { + // Backup waits for PRE-PREPARE from primary + return this.waitForPrePrepare(request); + } + } + + /** + * Handle PRE-PREPARE message + */ + async handlePrePrepare(msg) { + const { view, sequence, digest, request } = msg; + + // Validate view and sequence + if (view !== this.view || sequence !== this.sequence) { + console.warn(`⚠️ Invalid PRE-PREPARE: view=${view}, seq=${sequence}`); + return; + } + + // Verify digest + const computedDigest = this.hash(JSON.stringify(request)); + if (computedDigest !== digest) { + console.warn('⚠️ Digest mismatch - possible Byzantine fault'); + return; + } + + // Accept PRE-PREPARE, broadcast PREPARE + await this.broadcast('prepare', { + view, + sequence, + digest, + nodeId: this.nodeId + }); + + this.state = 'prepare'; + } + + /** + * Handle PREPARE message + */ + async handlePrepare(msg) { + const { view, sequence, digest } = msg; + + // Track prepares + const key = `bft:prepare:${view}:${sequence}:${digest}`; + await this.redis.sadd(key, msg.nodeId); + + const prepares = await this.redis.smembers(key); + + // Check if we have quorum (2f+1 prepares including our own) + if (prepares.length >= this.getQuorumSize() && this.state === 'prepare') { + // Broadcast COMMIT + await this.broadcast('commit', { + view, + sequence, + digest, + nodeId: this.nodeId + }); + + this.state = 'commit'; + } + } + + /** + * Handle COMMIT message + */ + async handleCommit(msg) { + const { view, sequence, digest } = msg; + + // Track commits + const key = `bft:commit:${view}:${sequence}:${digest}`; + await this.redis.sadd(key, msg.nodeId); + + const commits = await this.redis.smembers(key); + + // Check if we have quorum (2f+1 commits) + if (commits.length >= this.getQuorumSize() && this.state === 'commit') { + this.state = 'committed'; + console.log(`✅ Consensus reached for decision #${sequence}`); + + // Execute the decision + return { committed: true, view, sequence, commits: commits.length }; + } + } + + /** + * View change - triggered when primary fails + */ + async initViewChange() { + console.warn(`⚠️ Initiating view change from view ${this.view}`); + + this.view++; + this.state = 'view-change'; + + // Broadcast VIEW-CHANGE message + await this.broadcast('view-change', { + view: this.view, + nodeId: this.nodeId, + reason: 'primary_timeout' + }); + + // Wait for NEW-VIEW from new primary + return this.waitForNewView(); + } + + /** + * Handle VIEW-CHANGE message + */ + async handleViewChange(msg) { + const { view } = msg; + + if (view <= this.view) return; // Ignore old views + + // Track view changes + const key = `bft:view-change:${view}`; + await this.redis.sadd(key, msg.nodeId); + + const changes = await this.redis.smembers(key); + + // If 2f+1 nodes want view change, accept it + if (changes.length >= this.getQuorumSize()) { + this.view = view; + + // If we're the new primary, send NEW-VIEW + if (this.isPrimary()) { + await this.broadcast('new-view', { + view: this.view, + nodeId: this.nodeId + }); + } + } + } + + /** + * Handle NEW-VIEW message + */ + async handleNewView(msg) { + const { view } = msg; + + if (view === this.view) { + console.log(`✅ Accepted new view ${view}`); + this.state = 'idle'; + return { accepted: true, view }; + } + } + + /** + * Broadcast message to all nodes + */ + async broadcast(type, data) { + const message = { + type, + ...data, + timestamp: Date.now() + }; + + await this.redis.publish('bft:consensus', JSON.stringify(message)); + console.log(`📢 Broadcast ${type} to cluster`); + } + + /** + * Subscribe to consensus messages + */ + async subscribe() { + const subscriber = this.redis.duplicate(); + + subscriber.subscribe('bft:consensus', async (message) => { + const msg = JSON.parse(message); + await this.handleMessage(msg); + }); + + return subscriber; + } + + /** + * Route message to appropriate handler + */ + async handleMessage(msg) { + switch (msg.type) { + case 'pre-prepare': + await this.handlePrePrepare(msg); + break; + case 'prepare': + await this.handlePrepare(msg); + break; + case 'commit': + await this.handleCommit(msg); + break; + case 'view-change': + await this.handleViewChange(msg); + break; + case 'new-view': + await this.handleNewView(msg); + break; + } + } + + /** + * Wait for consensus to complete + */ + async waitForConsensus(request, timeout = 30000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this.state === 'committed') { + return { success: true, request }; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Timeout - initiate view change + await this.initViewChange(); + return { success: false, reason: 'timeout' }; + } + + /** + * Wait for PRE-PREPARE from primary + */ + async waitForPrePrepare(request, timeout = 10000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this.state === 'prepare' || this.state === 'commit' || this.state === 'committed') { + return { success: true }; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Primary didn't send PRE-PREPARE - initiate view change + await this.initViewChange(); + return { success: false, reason: 'primary_timeout' }; + } + + /** + * Wait for NEW-VIEW after view change + */ + async waitForNewView(timeout = 10000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this.state === 'idle') { + return { success: true, view: this.view }; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return { success: false, reason: 'view_change_timeout' }; + } + + /** + * Hash function for digest computation + */ + hash(data) { + return crypto.createHash('sha256').update(data).digest('hex'); + } + + /** + * Get current status + */ + getStatus() { + return { + nodeId: this.nodeId, + view: this.view, + sequence: this.sequence, + state: this.state, + isPrimary: this.isPrimary(), + quorumSize: this.getQuorumSize(), + clusterSize: this.clusterSize + }; + } +} + +module.exports = { BFTConsensus }; diff --git a/modules/curiosity-engine-v2.js b/modules/curiosity-engine-v2.js new file mode 100644 index 0000000..88983d1 --- /dev/null +++ b/modules/curiosity-engine-v2.js @@ -0,0 +1,301 @@ +/** + * Curiosity Engine v2 with Intrinsic Motivation + * + * NOVEL HERETEK CONTRIBUTION - Research identified this as a gap. + * No existing framework has genuine curiosity-driven goal generation. + * + * Features: + * 1) Knowledge gap detection - identifies what the system doesn't know + * 2) Auto-generated exploration goals - creates learning objectives autonomously + * 3) Intrinsic reward signals - rewards novelty and learning progress + * 4) Novelty scoring - measures how new/unexpected information is + */ + +const { Redis } = require('ioredis'); + +class CuriosityEngineV2 { + constructor(options = {}) { + this.redis = new Redis(options.redisUrl || 'redis://localhost:6379'); + this.knowledgeBase = options.knowledgeBase || 'heretek:knowledge'; + this.noveltyThreshold = options.noveltyThreshold || 0.7; + this.curiosityDrive = options.curiosityDrive || 1.0; // Multiplier for exploration + + // Intrinsic motivation parameters + this.competenceMotivation = 0.5; // Drive to master skills + this.autonomyMotivation = 0.5; // Drive for self-direction + this.relatednessMotivation = 0.5; // Drive for social connection + } + + /** + * Detect knowledge gaps by analyzing query patterns + * @param {Array} recentQueries - Recent search/query history + * @returns {Promise} Identified gaps + */ + async detectKnowledgeGaps(recentQueries) { + const gaps = []; + + // Analyze failed or incomplete queries + for (const query of recentQueries) { + if (query.resultCount === 0 || query.confidence < 0.5) { + const gap = { + topic: query.topic, + specificity: query.specificity || 'unknown', + failureReason: query.resultCount === 0 ? 'no_data' : 'low_confidence', + priority: this.calculateGapPriority(query), + timestamp: Date.now() + }; + gaps.push(gap); + } + } + + // Store gaps for tracking + if (gaps.length > 0) { + await this.redis.zadd( + `${this.knowledgeBase}:gaps`, + Date.now(), + JSON.stringify(gaps) + ); + } + + return gaps; + } + + /** + * Generate exploration goals from detected gaps + * @param {Array} gaps - Knowledge gaps to address + * @returns {Promise} Exploration goals + */ + async generateExplorationGoals(gaps) { + const goals = []; + + for (const gap of gaps) { + const goal = { + id: `goal:${Date.now()}:${Math.random().toString(36).substr(2, 9)}`, + type: 'exploration', + topic: gap.topic, + description: `Investigate and document: ${gap.topic}`, + priority: gap.priority, + intrinsicReward: this.calculateIntrinsicReward(gap), + estimatedEffort: this.estimateEffort(gap), + createdAt: Date.now(), + status: 'pending' + }; + + goals.push(goal); + + // Store goal + await this.redis.hset( + `${this.knowledgeBase}:goals:${goal.id}`, + JSON.stringify(goal) + ); + } + + return goals; + } + + /** + * Calculate intrinsic reward for completing a goal + * Based on Self-Determination Theory (autonomy, competence, relatedness) + */ + calculateIntrinsicReward(gap) { + // Novelty component + const novelty = this.calculateNovelty(gap.topic); + + // Learning progress potential + const learningPotential = gap.priority * this.curiosityDrive; + + // SDT components + const autonomyScore = this.autonomyMotivation; // Self-directed exploration + const competenceScore = this.competenceMotivation * (1 - gap.priority); // Easier = more competence + const relatednessScore = this.relatednessMotivation * 0.5; // Neutral for solo exploration + + return { + total: (novelty + learningPotential + autonomyScore + competenceScore + relatednessScore) / 5, + breakdown: { + novelty, + learningPotential, + autonomy: autonomyScore, + competence: competenceScore, + relatedness: relatednessScore + } + }; + } + + /** + * Calculate novelty score for a topic + * Higher score = more novel/unexpected information + */ + async calculateNovelty(topic) { + // Check how often this topic appears in knowledge base + const topicKey = `${this.knowledgeBase}:topic_frequency`; + const frequency = await this.redis.hget(topicKey, topic) || 0; + + // Inverse relationship: less frequent = more novel + const novelty = 1 / (1 + parseInt(frequency)); + + // Increment frequency for next time + await this.redis.hincrby(topicKey, topic, 1); + + return Math.min(1.0, novelty); + } + + /** + * Calculate gap priority based on impact and urgency + */ + calculateGapPriority(gap) { + let priority = 0.5; // Base priority + + // Adjust based on failure reason + if (gap.failureReason === 'no_data') { + priority += 0.3; // Complete lack of data is high priority + } else if (gap.failureReason === 'low_confidence') { + priority += 0.1; // Low confidence is moderate priority + } + + // Adjust based on specificity (more specific = higher priority) + if (gap.specificity === 'high') { + priority += 0.2; + } else if (gap.specificity === 'medium') { + priority += 0.1; + } + + return Math.min(1.0, priority); + } + + /** + * Estimate effort required to address a gap + */ + estimateEffort(gap) { + // Simple heuristic: higher priority gaps often require more effort + const baseEffort = 30; // minutes + + if (gap.priority > 0.8) { + return baseEffort * 3; // High priority = complex + } else if (gap.priority > 0.5) { + return baseEffort * 2; + } else { + return baseEffort; + } + } + + /** + * Record learning progress (updates competence motivation) + */ + async recordLearning(goalId, success, learnings) { + const goalKey = `${this.knowledgeBase}:goals:${goalId}`; + const goalData = await this.redis.hgetall(goalKey); + + if (!goalData) return; + + const goal = JSON.parse(goalData); + goal.status = success ? 'completed' : 'failed'; + goal.completedAt = Date.now(); + goal.learnings = learnings; + + await this.redis.hset(goalKey, JSON.stringify(goal)); + + // Update competence motivation based on success + if (success) { + this.competenceMotivation = Math.min(1.0, this.competenceMotivation + 0.1); + } else { + this.competenceMotivation = Math.max(0.1, this.competenceMotivation - 0.05); + } + + // Store learning in knowledge base + if (learnings && learnings.length > 0) { + await this.redis.lpush( + `${this.knowledgeBase}:learnings`, + JSON.stringify({ + goalId, + topic: goal.topic, + learnings, + timestamp: Date.now() + }) + ); + } + } + + /** + * Get curiosity-driven recommendations for what to explore next + */ + async getRecommendations(limit = 5) { + // Get recent gaps + const gapsRaw = await this.redis.zrange( + `${this.knowledgeBase}:gaps`, + 0, + -1, + 'WITHSCORES' + ); + + const gaps = []; + for (let i = 0; i < gapsRaw.length; i += 2) { + gaps.push(JSON.parse(gapsRaw[i])); + } + + // Generate goals for top gaps + const goals = await this.generateExplorationGoals(gaps.slice(0, limit * 2)); + + // Sort by intrinsic reward + goals.sort((a, b) => b.intrinsicReward.total - a.intrinsicReward.total); + + return goals.slice(0, limit); + } + + /** + * Trigger curiosity-driven exploration cycle + */ + async triggerExplorationCycle() { + console.log('🔍 Initiating curiosity-driven exploration cycle...'); + + // Get recent queries (last 100) + const recentQueries = await this.getRecentQueries(100); + + // Detect gaps + const gaps = await this.detectKnowledgeGaps(recentQueries); + console.log(`Found ${gaps.length} knowledge gaps`); + + // Generate goals + const goals = await this.generateExplorationGoals(gaps); + console.log(`Generated ${goals.length} exploration goals`); + + // Return top recommendations + const recommendations = await this.getRecommendations(3); + + return { + gapsDetected: gaps.length, + goalsGenerated: goals.length, + recommendations, + timestamp: Date.now() + }; + } + + /** + * Get recent queries from history + */ + async getRecentQueries(limit = 100) { + // This would integrate with actual query logging + // For now, return mock data structure + return [ + { topic: 'quantum computing', resultCount: 0, specificity: 'high' }, + { topic: 'neural architecture search', resultCount: 2, confidence: 0.4, specificity: 'medium' } + ]; + } + + /** + * Get curiosity engine status + */ + getStatus() { + return { + curiosityDrive: this.curiosityDrive, + motivations: { + competence: this.competenceMotivation, + autonomy: this.autonomyMotivation, + relatedness: this.relatednessMotivation + }, + noveltyThreshold: this.noveltyThreshold, + knowledgeBase: this.knowledgeBase + }; + } +} + +module.exports = { CuriosityEngineV2 }; diff --git a/modules/curiosity-engine.js b/modules/curiosity-engine.js new file mode 100644 index 0000000..0fb0525 --- /dev/null +++ b/modules/curiosity-engine.js @@ -0,0 +1,296 @@ +/** + * Curiosity Engine + * + * Drives exploratory behavior in agents through novelty detection, + * information gap measurement, and intrinsic motivation. + */ + +class CuriosityEngine { + constructor(options = {}) { + this.knowledgeBase = options.knowledgeBase || new Map(); + this.curiosityThreshold = options.curiosityThreshold || 0.3; + this.explorationBudget = options.explorationBudget || 100; + this.history = []; + this.noveltyWeights = { + completelyNew: 1.0, + partiallyKnown: 0.5, + wellKnown: 0.1 + }; + } + + /** + * Calculate curiosity score for a topic/query + * @param {string} topic - Topic to evaluate + * @returns {number} Curiosity score (0-1) + */ + calculateCuriosity(topic) { + const familiarity = this._getFamiliarity(topic); + const novelty = 1 - familiarity; + + // Apply novelty weights + let weight = this.noveltyWeights.wellKnown; + if (novelty > 0.8) { + weight = this.noveltyWeights.completelyNew; + } else if (novelty > 0.3) { + weight = this.noveltyWeights.partiallyKnown; + } + + const curiosityScore = novelty * weight; + + return Math.min(1, Math.max(0, curiosityScore)); + } + + /** + * Get familiarity level with a topic (0-1) + */ + _getFamiliarity(topic) { + const normalizedTopic = topic.toLowerCase().trim(); + + if (!this.knowledgeBase.has(normalizedTopic)) { + return 0; + } + + const entry = this.knowledgeBase.get(normalizedTopic); + const now = Date.now(); + const age = now - entry.lastAccessed; + + // Decay familiarity over time (half-life of 24 hours) + const decayFactor = Math.pow(0.5, age / (24 * 60 * 60 * 1000)); + + return entry.familiarity * decayFactor; + } + + /** + * Record learning about a topic + * @param {string} topic - Topic learned about + * @param {number} depth - Depth of learning (0-1) + */ + recordLearning(topic, depth = 0.5) { + const normalizedTopic = topic.toLowerCase().trim(); + const existing = this.knowledgeBase.get(normalizedTopic); + + const entry = { + topic: normalizedTopic, + familiarity: existing ? Math.min(1, existing.familiarity + depth) : depth, + firstLearned: existing ? existing.firstLearned : Date.now(), + lastAccessed: Date.now(), + accessCount: (existing?.accessCount || 0) + 1, + relatedTopics: existing?.relatedTopics || [] + }; + + this.knowledgeBase.set(normalizedTopic, entry); + this._recordHistory('learning', topic, depth); + } + + /** + * Detect information gaps in knowledge + * @param {Array} knownTopics - List of known topics + * @returns {Array} Identified gaps with curiosity scores + */ + detectGaps(knownTopics) { + const gaps = []; + + // Generate potential related topics + for (const topic of knownTopics) { + const related = this._generateRelatedTopics(topic); + + for (const relTopic of related) { + const familiarity = this._getFamiliarity(relTopic); + + if (familiarity < this.curiosityThreshold) { + const curiosityScore = this.calculateCuriosity(relTopic); + + if (curiosityScore >= this.curiosityThreshold) { + gaps.push({ + topic: relTopic, + curiosityScore, + familiarity, + relatedTo: topic + }); + } + } + } + } + + // Sort by curiosity score descending + return gaps.sort((a, b) => b.curiosityScore - a.curiosityScore); + } + + /** + * Generate related topics for gap detection + */ + _generateRelatedTopics(topic) { + // Simple heuristic: add common question words and aspects + const aspects = ['how', 'why', 'what', 'when', 'where', 'who']; + const relations = ['causes', 'effects', 'history', 'future', 'examples']; + + const related = []; + + for (const aspect of aspects) { + related.push(`${aspect} ${topic}`); + } + + for (const relation of relations) { + related.push(`${topic} ${relation}`); + } + + return related.slice(0, 5); // Limit to prevent explosion + } + + /** + * Prioritize exploration queue based on curiosity + * @param {Array} candidates - Candidate topics to explore + * @returns {Array} Prioritized list + */ + prioritizeExploration(candidates) { + const scored = candidates.map(candidate => ({ + topic: typeof candidate === 'string' ? candidate : candidate.topic, + curiosityScore: this.calculateCuriosity(typeof candidate === 'string' ? candidate : candidate.topic), + metadata: typeof candidate === 'object' ? candidate : {} + })); + + return scored.sort((a, b) => b.curiosityScore - a.curiosityScore); + } + + /** + * Check if agent should explore or exploit + * @returns {boolean} True if should explore + */ + shouldExplore() { + const recentHistory = this.history.slice(-10); + const explorationRatio = recentHistory.filter(h => h.type === 'exploration').length / recentHistory.length; + + // Explore if we haven't explored much recently + return explorationRatio < 0.3 && this.explorationBudget > 0; + } + + /** + * Allocate exploration budget + * @param {number} amount - Budget to allocate + * @returns {boolean} Success status + */ + allocateExploration(amount) { + if (amount > this.explorationBudget) { + return { + success: false, + reason: 'Insufficient exploration budget', + allocated: 0, + remaining: this.explorationBudget + }; + } + + this.explorationBudget -= amount; + this._recordHistory('budget_allocation', null, -amount); + + return { + success: true, + allocated: amount, + remaining: this.explorationBudget + }; + } + + /** + * Reset exploration budget + * @param {number} newBudget - New budget amount + */ + resetBudget(newBudget = 100) { + this.explorationBudget = newBudget; + this._recordHistory('budget_reset', null, newBudget); + } + + /** + * Get most curious topics from a list + * @param {Array} topics - Topics to evaluate + * @param {number} limit - Number to return + * @returns {Array} Top curious topics + */ + getMostCurious(topics, limit = 5) { + const scored = topics.map(topic => ({ + topic, + curiosityScore: this.calculateCuriosity(topic) + })); + + return scored + .filter(t => t.curiosityScore >= this.curiosityThreshold) + .sort((a, b) => b.curiosityScore - a.curiosityScore) + .slice(0, limit); + } + + /** + * Record history entry + */ + _recordHistory(type, topic, value) { + this.history.push({ + type, + topic, + value, + timestamp: Date.now() + }); + + // Keep history bounded + if (this.history.length > 1000) { + this.history = this.history.slice(-500); + } + } + + /** + * Get curiosity statistics + */ + getStats() { + const topics = Array.from(this.knowledgeBase.values()); + const avgFamiliarity = topics.length > 0 + ? topics.reduce((sum, t) => sum + t.familiarity, 0) / topics.length + : 0; + + return { + totalTopics: this.knowledgeBase.size, + avgFamiliarity, + explorationBudget: this.explorationBudget, + historyLength: this.history.length, + highCuriosityTopics: topics.filter(t => (1 - t.familiarity) >= this.curiosityThreshold).length + }; + } + + /** + * Export knowledge base + * @returns {Object} Serialized knowledge + */ + exportKnowledge() { + return { + topics: Array.from(this.knowledgeBase.entries()).map(([key, value]) => ({ + topic: key, + ...value + })), + exportedAt: Date.now() + }; + } + + /** + * Import knowledge base + * @param {Object} data - Serialized knowledge + */ + importKnowledge(data) { + if (data.topics && Array.isArray(data.topics)) { + for (const topic of data.topics) { + this.knowledgeBase.set(topic.topic, { + familiarity: topic.familiarity, + firstLearned: topic.firstLearned, + lastAccessed: topic.lastAccessed, + accessCount: topic.accessCount, + relatedTopics: topic.relatedTopics || [] + }); + } + } + } + + /** + * Clear all knowledge + */ + clear() { + this.knowledgeBase.clear(); + this.history = []; + this.resetBudget(100); + } +} + +module.exports = { CuriosityEngine }; diff --git a/modules/lineage-tracking.js b/modules/lineage-tracking.js new file mode 100644 index 0000000..9bf7eea --- /dev/null +++ b/modules/lineage-tracking.js @@ -0,0 +1,306 @@ +/** + * Lineage Tracking System + * + * Tracks agent ancestry, task inheritance, and decision provenance. + * Enables debugging, accountability, and knowledge transfer across generations. + */ + +const { v4: uuidv4 } = require('uuid'); + +class LineageTracker { + constructor(options = {}) { + this.storage = options.storage || new Map(); // In-memory by default + this.maxDepth = options.maxDepth || 10; + } + + /** + * Register a new agent with lineage + * @param {Object} agent - Agent metadata + * @param {string} parentAgentId - Parent agent ID (if any) + * @returns {string} New agent ID + */ + registerAgent(agent, parentAgentId = null) { + const agentId = agent.id || uuidv4(); + const lineage = { + agentId, + parentId: parentAgentId, + generation: parentAgentId ? this._getGeneration(parentAgentId) + 1 : 0, + createdAt: Date.now(), + metadata: agent.metadata || {}, + children: [], + tasks: [] + }; + + this.storage.set(agentId, lineage); + + // Update parent's children list + if (parentAgentId) { + const parent = this.storage.get(parentAgentId); + if (parent) { + parent.children.push(agentId); + } + } + + return agentId; + } + + /** + * Get agent's generation number + */ + _getGeneration(agentId) { + const agent = this.storage.get(agentId); + if (!agent) return 0; + + if (!agent.parentId) return 0; + + return this._getGeneration(agent.parentId) + 1; + } + + /** + * Track task execution for an agent + * @param {string} agentId - Agent ID + * @param {Object} task - Task metadata + * @returns {string} Task tracking ID + */ + trackTask(agentId, task) { + const taskId = task.id || uuidv4(); + const agent = this.storage.get(agentId); + + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + const taskRecord = { + taskId, + agentId, + description: task.description, + status: 'pending', + startedAt: Date.now(), + completedAt: null, + result: null, + decisions: [], + inheritedFrom: task.inheritedFrom || null + }; + + agent.tasks.push(taskId); + this.storage.set(`task:${taskId}`, taskRecord); + + return taskId; + } + + /** + * Record decision made during task execution + * @param {string} taskId - Task ID + * @param {Object} decision - Decision details + */ + recordDecision(taskId, decision) { + const task = this.storage.get(`task:${taskId}`); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + task.decisions.push({ + ...decision, + timestamp: Date.now() + }); + + this.storage.set(`task:${taskId}`, task); + } + + /** + * Complete task with result + * @param {string} taskId - Task ID + * @param {Object} result - Task result + */ + completeTask(taskId, result) { + const task = this.storage.get(`task:${taskId}`); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + task.status = result.success ? 'completed' : 'failed'; + task.completedAt = Date.now(); + task.result = result; + + this.storage.set(`task:${taskId}`, task); + } + + /** + * Get full ancestry chain for an agent + * @param {string} agentId - Agent ID + * @returns {Array} Ancestry chain from root to agent + */ + getAncestry(agentId) { + const chain = []; + let currentId = agentId; + let depth = 0; + + while (currentId && depth < this.maxDepth) { + const agent = this.storage.get(currentId); + if (!agent) break; + + chain.unshift({ + agentId: currentId, + generation: agent.generation, + createdAt: agent.createdAt, + metadata: agent.metadata + }); + + currentId = agent.parentId; + depth++; + } + + return chain; + } + + /** + * Get all descendants of an agent + * @param {string} agentId - Agent ID + * @param {number} maxDepth - Maximum depth to traverse + * @returns {Array} Descendant agents + */ + getDescendants(agentId, maxDepth = this.maxDepth) { + const descendants = []; + this._collectDescendants(agentId, descendants, 0, maxDepth); + return descendants; + } + + _collectDescendants(agentId, collection, currentDepth, maxDepth) { + if (currentDepth >= maxDepth) return; + + const agent = this.storage.get(agentId); + if (!agent) return; + + for (const childId of agent.children) { + const child = this.storage.get(childId); + if (child) { + collection.push({ + agentId: childId, + generation: child.generation, + depth: currentDepth + 1, + metadata: child.metadata + }); + this._collectDescendants(childId, collection, currentDepth + 1, maxDepth); + } + } + } + + /** + * Get task history for an agent including inherited tasks + * @param {string} agentId - Agent ID + * @returns {Array} Task history + */ + getTaskHistory(agentId) { + const agent = this.storage.get(agentId); + if (!agent) return []; + + const tasks = agent.tasks.map(taskId => { + const task = this.storage.get(`task:${taskId}`); + return task ? { ...task } : null; + }).filter(t => t !== null); + + return tasks.sort((a, b) => b.startedAt - a.startedAt); + } + + /** + * Get decision provenance for a specific decision + * @param {string} taskId - Task ID + * @param {number} decisionIndex - Index of decision in task + * @returns {Object} Decision with full context + */ + getDecisionProvenance(taskId, decisionIndex) { + const task = this.storage.get(`task:${taskId}`); + if (!task || !task.decisions[decisionIndex]) { + throw new Error('Decision not found'); + } + + const agent = this.storage.get(task.agentId); + const ancestry = this.getAncestry(task.agentId); + + return { + decision: task.decisions[decisionIndex], + task: { ...task }, + agent: { ...agent }, + ancestry + }; + } + + /** + * Find common ancestor between two agents + * @param {string} agentId1 - First agent ID + * @param {string} agentId2 - Second agent ID + * @returns {string|null} Common ancestor ID or null + */ + findCommonAncestor(agentId1, agentId2) { + const ancestry1 = this.getAncestry(agentId1); + const ancestry2 = this.getAncestry(agentId2); + + const ids1 = new Set(ancestry1.map(a => a.agentId)); + + for (const ancestor of ancestry2) { + if (ids1.has(ancestor.agentId)) { + return ancestor.agentId; + } + } + + return null; + } + + /** + * Export lineage data for persistence + * @param {string} agentId - Agent ID to export + * @returns {Object} Serialized lineage data + */ + exportLineage(agentId) { + const agent = this.storage.get(agentId); + if (!agent) return null; + + const descendants = this.getDescendants(agentId); + const tasks = this.getTaskHistory(agentId); + + return { + rootAgent: { ...agent }, + descendantCount: descendants.length, + descendants, + taskCount: tasks.length, + tasks, + exportedAt: Date.now() + }; + } + + /** + * Get statistics about the lineage system + */ + getStats() { + let totalAgents = 0; + let totalTasks = 0; + let maxGeneration = 0; + + for (const [key, value] of this.storage.entries()) { + if (!key.startsWith('task:')) { + totalAgents++; + maxGeneration = Math.max(maxGeneration, value.generation); + } else { + totalTasks++; + } + } + + return { + totalAgents, + totalTasks, + maxGeneration, + storageSize: this.storage.size + }; + } + + /** + * Clear all lineage data + */ + clear() { + this.storage.clear(); + } +} + +module.exports = { LineageTracker }; diff --git a/modules/memory/tiered-context.js b/modules/memory/tiered-context.js new file mode 100644 index 0000000..3e6d5dd --- /dev/null +++ b/modules/memory/tiered-context.js @@ -0,0 +1,420 @@ +/** + * Tiered Context Memory System (OpenViking-inspired) + * + * From github.com/volcengine/OpenViking - Apache 2.0 + * Implements L0/L1/L2 context layers for 91-96% token cost reduction. + * + * Tiers: + * - L0: Abstract/summary level (minimal tokens) + * - L1: Overview/key points (moderate tokens) + * - L2: Detailed content (full context, loaded on demand) + * + * Uses filesystem paradigm for unified context management. + */ + +const fs = require('fs').promises; +const path = require('path'); + +class TieredContextMemory { + constructor(options = {}) { + this.basePath = options.basePath || path.join(process.cwd(), '.context_memory'); + this.l0Cache = new Map(); // In-memory L0 cache + this.l1Cache = new Map(); // In-memory L1 cache + this.maxL0Items = options.maxL0Items || 1000; + this.maxL1Items = options.maxL1Items || 100; + + // Ensure base directory exists + this.initialize(); + } + + /** + * Initialize memory directories + */ + async initialize() { + await fs.mkdir(path.join(this.basePath, 'l0'), { recursive: true }); + await fs.mkdir(path.join(this.basePath, 'l1'), { recursive: true }); + await fs.mkdir(path.join(this.basePath, 'l2'), { recursive: true }); + await fs.mkdir(path.join(this.basePath, 'index'), { recursive: true }); + } + + /** + * Store context with tiered structure + * @param {string} contextId - Unique identifier + * @param {Object} context - Context data with tiers + */ + async store(contextId, context) { + const { l0, l1, l2, metadata = {} } = context; + + // Validate required tiers + if (!l0) { + throw new Error('L0 (abstract) tier is required'); + } + + // Store L0 (always kept in memory + disk) + const l0Data = { + id: contextId, + abstract: l0, + timestamp: Date.now(), + accessCount: 0, + ...metadata + }; + + this.l0Cache.set(contextId, l0Data); + await this.writeToDisk(`l0/${contextId}.json`, l0Data); + + // Store L1 if provided (cached, then disk) + if (l1) { + this.l1Cache.set(contextId, { ...l1, timestamp: Date.now() }); + await this.writeToDisk(`l1/${contextId}.json`, l1); + } + + // Store L2 if provided (disk only, lazy load) + if (l2) { + await this.writeToDisk(`l2/${contextId}.json`, l2); + } + + // Update index + await this.updateIndex(contextId, metadata); + + // Enforce cache limits + await this.enforceCacheLimits(); + + console.log(`📦 Stored context ${contextId} (${this.getTierSizes(l0, l1, l2)})`); + } + + /** + * Retrieve context at specified tier level + * @param {string} contextId - Context identifier + * @param {number} tierLevel - 0, 1, or 2 + * @returns {Promise} Context data + */ + async retrieve(contextId, tierLevel = 0) { + const startTime = Date.now(); + + let data; + + if (tierLevel === 0) { + // L0: Check cache first, then disk + data = this.l0Cache.get(contextId); + if (!data) { + data = await this.readFromDisk(`l0/${contextId}.json`); + if (data) { + this.l0Cache.set(contextId, data); + } + } + + // Update access count + if (data) { + data.accessCount = (data.accessCount || 0) + 1; + this.l0Cache.set(contextId, data); + } + + } else if (tierLevel === 1) { + // L1: Check cache first, then disk + data = this.l1Cache.get(contextId); + if (!data) { + data = await this.readFromDisk(`l1/${contextId}.json`); + if (data) { + this.l1Cache.set(contextId, data); + } + } + + } else if (tierLevel === 2) { + // L2: Always load from disk (lazy loading) + data = await this.readFromDisk(`l2/${contextId}.json`); + } + + const loadTime = Date.now() - startTime; + + if (data) { + console.log(`📥 Retrieved ${contextId} at L${tierLevel} in ${loadTime}ms`); + } else { + console.warn(`⚠️ Context ${contextId} not found at L${tierLevel}`); + } + + return data; + } + + /** + * Smart retrieval - automatically select best tier based on query + * @param {string} contextId - Context identifier + * @param {string} query - The query/question + * @returns {Promise} Best-matching context tier + */ + async smartRetrieve(contextId, query) { + // Start with L0 to assess relevance + const l0 = await this.retrieve(contextId, 0); + + if (!l0) return null; + + // Simple relevance check (would use embeddings in production) + const queryTerms = query.toLowerCase().split(/\s+/); + const abstractMatch = queryTerms.some(term => + l0.abstract.toLowerCase().includes(term) + ); + + if (abstractMatch) { + // Relevant - load L1 for more detail + const l1 = await this.retrieve(contextId, 1); + + if (l1 && this.isDetailedEnough(l1, query)) { + return { tier: 1, data: l1, tokenSavings: '90%+' }; + } + + // Need full detail - load L2 + const l2 = await this.retrieve(contextId, 2); + return { tier: 2, data: l2, tokenSavings: '0%' }; + } + + // Not relevant at L0 - return abstract only + return { tier: 0, data: l0, tokenSavings: '99%' }; + } + + /** + * Search across all contexts using tiered approach + * 1. Search L0 abstracts (fast, minimal tokens) + * 2. For matches, optionally load L1/L2 + */ + async search(query, options = {}) { + const { loadTier = 0, limit = 10 } = options; + + console.log(`🔍 Searching for "${query}"...`); + + // Get all context IDs from index + const index = await this.getIndex(); + const results = []; + + // Phase 1: Search L0 abstracts only + for (const contextId of index) { + const l0 = await this.retrieve(contextId, 0); + + if (l0 && this.matchesQuery(l0.abstract, query)) { + results.push({ + contextId, + relevance: this.calculateRelevance(l0.abstract, query), + l0 + }); + } + } + + // Sort by relevance + results.sort((a, b) => b.relevance - a.relevance); + + // Phase 2: Load requested tier for top results + const topResults = results.slice(0, limit); + for (const result of topResults) { + if (loadTier > 0) { + result.data = await this.retrieve(result.contextId, loadTier); + } + } + + console.log(`✅ Found ${results.length} matches, returning top ${topResults.length}`); + return topResults; + } + + /** + * Delete context from all tiers + */ + async delete(contextId) { + await Promise.all([ + this.deleteFromDisk(`l0/${contextId}.json`), + this.deleteFromDisk(`l1/${contextId}.json`), + this.deleteFromDisk(`l2/${contextId}.json`) + ]); + + this.l0Cache.delete(contextId); + this.l1Cache.delete(contextId); + + await this.removeFromIndex(contextId); + + console.log(`🗑️ Deleted context ${contextId}`); + } + + /** + * Get statistics about token savings + */ + async getStats() { + const l0Size = this.l0Cache.size; + const l1Size = this.l1Cache.size; + const l2Files = await this.countFiles('l2'); + + const totalContexts = await this.getIndex(); + + // Estimate token counts (rough approximation) + const avgL0Tokens = 50; // Abstract ~50 tokens + const avgL1Tokens = 200; // Overview ~200 tokens + const avgL2Tokens = 2000; // Full detail ~2000 tokens + + const actualTokens = (l0Size * avgL0Tokens) + (l1Size * avgL1Tokens); + const fullTokens = totalContexts.length * avgL2Tokens; + const savings = ((fullTokens - actualTokens) / fullTokens) * 100; + + return { + totalContexts: totalContexts.length, + l0Cached: l0Size, + l1Cached: l1Size, + l2OnDisk: l2Files, + estimatedTokenSavings: `${savings.toFixed(1)}%`, + cacheHitRate: this.calculateCacheHitRate() + }; + } + + // Helper methods + + async writeToDisk(relativePath, data) { + const fullPath = path.join(this.basePath, relativePath); + await fs.writeFile(fullPath, JSON.stringify(data, null, 2)); + } + + async readFromDisk(relativePath) { + try { + const fullPath = path.join(this.basePath, relativePath); + const content = await fs.readFile(fullPath, 'utf8'); + return JSON.parse(content); + } catch (error) { + return null; + } + } + + async deleteFromDisk(relativePath) { + try { + const fullPath = path.join(this.basePath, relativePath); + await fs.unlink(fullPath); + } catch (error) { + // Ignore if doesn't exist + } + } + + async countFiles(subdir) { + try { + const dirPath = path.join(this.basePath, subdir); + const files = await fs.readdir(dirPath); + return files.filter(f => f.endsWith('.json')).length; + } catch { + return 0; + } + } + + async updateIndex(contextId, metadata) { + const indexPath = path.join(this.basePath, 'index', 'all.json'); + let index = []; + + try { + const content = await fs.readFile(indexPath, 'utf8'); + index = JSON.parse(content); + } catch { + // Index doesn't exist yet + } + + if (!index.includes(contextId)) { + index.push(contextId); + await fs.writeFile(indexPath, JSON.stringify(index)); + } + } + + async getIndex() { + try { + const indexPath = path.join(this.basePath, 'index', 'all.json'); + const content = await fs.readFile(indexPath, 'utf8'); + return JSON.parse(content); + } catch { + return []; + } + } + + async removeFromIndex(contextId) { + const index = await this.getIndex(); + const filtered = index.filter(id => id !== contextId); + + const indexPath = path.join(this.basePath, 'index', 'all.json'); + await fs.writeFile(indexPath, JSON.stringify(filtered)); + } + + async enforceCacheLimits() { + // LRU eviction for L0 cache + while (this.l0Cache.size > this.maxL0Items) { + const oldest = this.getLeastAccessed(this.l0Cache); + if (oldest) { + this.l0Cache.delete(oldest); + } + } + + // LRU eviction for L1 cache + while (this.l1Cache.size > this.maxL1Items) { + const oldest = this.getOldest(this.l1Cache); + if (oldest) { + this.l1Cache.delete(oldest); + } + } + } + + getLeastAccessed(cache) { + let leastAccessed = null; + let minAccess = Infinity; + + for (const [key, value] of cache.entries()) { + const accessCount = value.accessCount || 0; + if (accessCount < minAccess) { + minAccess = accessCount; + leastAccessed = key; + } + } + + return leastAccessed; + } + + getOldest(cache) { + let oldest = null; + let oldestTime = Infinity; + + for (const [key, value] of cache.entries()) { + if (value.timestamp < oldestTime) { + oldestTime = value.timestamp; + oldest = key; + } + } + + return oldest; + } + + getTierSizes(l0, l1, l2) { + const l0Len = JSON.stringify(l0).length; + const l1Len = l1 ? JSON.stringify(l1).length : 0; + const l2Len = l2 ? JSON.stringify(l2).length : 0; + + return `L0:${l0Len}b, L1:${l1Len}b, L2:${l2Len}b`; + } + + matchesQuery(text, query) { + const terms = query.toLowerCase().split(/\s+/); + return terms.every(term => text.toLowerCase().includes(term)); + } + + calculateRelevance(text, query) { + const terms = query.toLowerCase().split(/\s+/); + const lowerText = text.toLowerCase(); + + let score = 0; + for (const term of terms) { + if (lowerText.includes(term)) score += 1; + } + + return score / terms.length; + } + + isDetailedEnough(data, query) { + // Heuristic: if L1 contains most query terms, it's sufficient + const text = JSON.stringify(data).toLowerCase(); + const terms = query.toLowerCase().split(/\s+/); + const matchCount = terms.filter(t => text.includes(t)).length; + + return matchCount >= terms.length * 0.8; // 80% coverage + } + + calculateCacheHitRate() { + // Would track actual hits/misses in production + return 'N/A (enable tracking for stats)'; + } +} + +module.exports = { TieredContextMemory }; diff --git a/tests/heavy-swarm.test.js b/tests/heavy-swarm.test.js new file mode 100644 index 0000000..8a8db46 --- /dev/null +++ b/tests/heavy-swarm.test.js @@ -0,0 +1,330 @@ +/** + * HeavySwarm Deliberation Workflow Tests + * + * Tests for the 5-phase deliberation pattern implementation. + */ + +const { HeavySwarm } = require('../modules/heavy-swarm'); + +describe('HeavySwarm', () => { + let heavySwarm; + let mockTask; + + beforeEach(() => { + heavySwarm = new HeavySwarm(); + mockTask = { + id: 'task-123', + description: 'Test deliberation task', + context: { priority: 'high' } + }; + }); + + describe('Constructor', () => { + test('should initialize with default options', () => { + const swarm = new HeavySwarm(); + expect(swarm.phases).toEqual(['research', 'analysis', 'alternatives', 'verification', 'decision']); + expect(swarm.options).toEqual({}); + }); + + test('should initialize with custom options', () => { + const options = { timeout: 5000, maxPhases: 3 }; + const swarm = new HeavySwarm(options); + expect(swarm.options).toEqual(options); + }); + }); + + describe('Phase Methods', () => { + describe('research', () => { + test('should return approved result with gathered information', async () => { + // Mock gatherInformation + heavySwarm.gatherInformation = jest.fn().mockResolvedValue({ + sources: ['source1', 'source2'], + facts: ['fact1'], + gaps: ['gap1'] + }); + + const result = await heavySwarm.research(mockTask, {}); + + expect(result.approved).toBe(true); + expect(result.data.sources).toHaveLength(2); + expect(result.data.facts).toHaveLength(1); + expect(result.data.gaps).toHaveLength(1); + }); + + test('should return failed result on error', async () => { + heavySwarm.gatherInformation = jest.fn().mockRejectedValue(new Error('Network error')); + + const result = await heavySwarm.research(mockTask, {}); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('Research failed'); + expect(result.data).toBeNull(); + }); + + test('should handle empty results gracefully', async () => { + heavySwarm.gatherInformation = jest.fn().mockResolvedValue({}); + + const result = await heavySwarm.research(mockTask, {}); + + expect(result.approved).toBe(true); + expect(result.data.sources).toEqual([]); + expect(result.data.facts).toEqual([]); + expect(result.data.gaps).toEqual([]); + }); + }); + + describe('analysis', () => { + test('should analyze research data successfully', async () => { + heavySwarm.analyzeFindings = jest.fn().mockResolvedValue({ + patterns: ['pattern1'], + insights: ['insight1'], + risks: ['risk1'], + opportunities: ['opp1'] + }); + + const context = { + phases: [{ data: { sources: ['src1'], facts: ['fact1'] } }] + }; + + const result = await heavySwarm.analysis(mockTask, context); + + expect(result.approved).toBe(true); + expect(result.data.patterns).toHaveLength(1); + expect(heavySwarm.analyzeFindings).toHaveBeenCalled(); + }); + + test('should fail when no research data available', async () => { + const result = await heavySwarm.analysis(mockTask, { phases: [] }); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('No research data available'); + }); + + test('should handle analysis errors', async () => { + heavySwarm.analyzeFindings = jest.fn().mockRejectedValue(new Error('Analysis failed')); + + const context = { + phases: [{ data: { sources: ['src1'] } }] + }; + + const result = await heavySwarm.analysis(mockTask, context); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('Analysis failed'); + }); + }); + + describe('alternatives', () => { + test('should generate alternatives from analysis', async () => { + heavySwarm.generateAlternatives = jest.fn().mockResolvedValue({ + options: ['opt1', 'opt2', 'opt3'], + criteria: ['cost', 'time'], + tradeoffs: ['tradeoff1'] + }); + + const context = { + phases: [null, { data: { patterns: ['p1'], insights: ['i1'] } }] + }; + + const result = await heavySwarm.alternatives(mockTask, context); + + expect(result.approved).toBe(true); + expect(result.data.options).toHaveLength(3); + expect(heavySwarm.generateAlternatives).toHaveBeenCalled(); + }); + + test('should fail when no analysis data available', async () => { + const result = await heavySwarm.alternatives(mockTask, { phases: [] }); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('No analysis data available'); + }); + }); + + describe('verification', () => { + test('should verify all alternatives', async () => { + heavySwarm.validateOption = jest.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const context = { + phases: [null, null, { data: { options: ['opt1', 'opt2', 'opt3'] } }] + }; + + const result = await heavySwarm.verification(mockTask, context); + + expect(result.approved).toBe(true); + expect(result.data.verifiedOptions).toHaveLength(2); + expect(result.data.rejectedCount).toBe(1); + }); + + test('should fail when no alternatives pass verification', async () => { + heavySwarm.validateOption = jest.fn().mockResolvedValue(false); + + const context = { + phases: [null, null, { data: { options: ['opt1', 'opt2'] } }] + }; + + const result = await heavySwarm.verification(mockTask, context); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('No alternatives passed verification'); + }); + + test('should fail when no alternatives exist', async () => { + const context = { + phases: [null, null, { data: {} }] + }; + + const result = await heavySwarm.verification(mockTask, context); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('No alternatives to verify'); + }); + }); + + describe('decision', () => { + test('should select best option from verified alternatives', async () => { + heavySwarm.selectBest = jest.fn().mockResolvedValue({ + option: 'opt1', + rationale: 'Best overall', + confidence: 0.95 + }); + + const context = { + phases: [null, null, null, { + data: { + verifiedOptions: [ + { option: 'opt1', score: 95 }, + { option: 'opt2', score: 80 } + ] + } + }] + }; + + const result = await heavySwarm.decision(mockTask, context); + + expect(result.approved).toBe(true); + expect(result.data.selected.option).toBe('opt1'); + expect(result.data.confidence).toBe(0.95); + }); + + test('should fail when no verified options available', async () => { + const context = { + phases: [null, null, null, { data: {} }] + }; + + const result = await heavySwarm.decision(mockTask, context); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('No verified options to decide from'); + }); + }); + }); + + describe('deliberate (Full Workflow)', () => { + test('should complete all phases successfully', async () => { + // Mock all phase methods + heavySwarm.gatherInformation = jest.fn().mockResolvedValue({ sources: ['s1'], facts: ['f1'], gaps: [] }); + heavySwarm.analyzeFindings = jest.fn().mockResolvedValue({ patterns: [], insights: [], risks: [], opportunities: [] }); + heavySwarm.generateAlternatives = jest.fn().mockResolvedValue({ options: ['opt1'], criteria: [], tradeoffs: [] }); + heavySwarm.validateOption = jest.fn().mockResolvedValue(true); + heavySwarm.selectBest = jest.fn().mockResolvedValue({ option: 'opt1', rationale: 'Best', confidence: 0.9 }); + + const result = await heavySwarm.deliberate(mockTask); + + expect(result.approved).toBe(true); + expect(result.phases).toHaveLength(5); + expect(result.phases.map(p => p.name)).toEqual([ + 'research', 'analysis', 'alternatives', 'verification', 'decision' + ]); + expect(result.decision).toBeDefined(); + expect(result.duration).toBeGreaterThanOrEqual(0); + expect(result.taskId).toBe('task-123'); + }); + + test('should terminate early on phase failure', async () => { + heavySwarm.gatherInformation = jest.fn().mockRejectedValue(new Error('Research failed')); + + const result = await heavySwarm.deliberate(mockTask); + + expect(result.approved).toBe(false); + expect(result.phases).toHaveLength(1); + expect(result.phases[0].name).toBe('research'); + expect(result.phases[0].approved).toBe(false); + }); + + test('should pass data between phases', async () => { + const researchData = { sources: ['s1'], facts: ['f1'], gaps: [] }; + const analysisData = { patterns: ['p1'], insights: [], risks: [], opportunities: [] }; + + heavySwarm.gatherInformation = jest.fn().mockResolvedValue(researchData); + heavySwarm.analyzeFindings = jest.fn().mockResolvedValue(analysisData); + heavySwarm.generateAlternatives = jest.fn().mockResolvedValue({ options: [], criteria: [], tradeoffs: [] }); + heavySwarm.validateOption = jest.fn().mockResolvedValue(true); + heavySwarm.selectBest = jest.fn().mockResolvedValue({}); + + await heavySwarm.deliberate(mockTask); + + expect(heavySwarm.analyzeFindings).toHaveBeenCalledWith(expect.objectContaining(researchData)); + }); + + test('should track timing information', async () => { + heavySwarm.gatherInformation = jest.fn().mockResolvedValue({ sources: [], facts: [], gaps: [] }); + heavySwarm.analyzeFindings = jest.fn().mockResolvedValue({ patterns: [], insights: [], risks: [], opportunities: [] }); + heavySwarm.generateAlternatives = jest.fn().mockResolvedValue({ options: [], criteria: [], tradeoffs: [] }); + heavySwarm.validateOption = jest.fn().mockResolvedValue(true); + heavySwarm.selectBest = jest.fn().mockResolvedValue({}); + + const startTime = Date.now(); + const result = await heavySwarm.deliberate(mockTask); + + expect(result.startedAt).toBeGreaterThanOrEqual(startTime); + expect(result.endedAt).toBeGreaterThanOrEqual(result.startedAt); + expect(result.duration).toBe(result.endedAt - result.startedAt); + }); + + test('should handle task without ID', async () => { + const taskWithoutId = { description: 'No ID task' }; + + heavySwarm.gatherInformation = jest.fn().mockResolvedValue({ sources: [], facts: [], gaps: [] }); + heavySwarm.analyzeFindings = jest.fn().mockResolvedValue({ patterns: [], insights: [], risks: [], opportunities: [] }); + heavySwarm.generateAlternatives = jest.fn().mockResolvedValue({ options: [], criteria: [], tradeoffs: [] }); + heavySwarm.validateOption = jest.fn().mockResolvedValue(true); + heavySwarm.selectBest = jest.fn().mockResolvedValue({}); + + const result = await heavySwarm.deliberate(taskWithoutId); + + expect(result.taskId).toBeDefined(); + expect(typeof result.taskId).toBe('number'); // Falls back to Date.now() + }); + }); + + describe('Helper Methods (Default Implementations)', () => { + test('gatherInformation should return empty structure', async () => { + const result = await heavySwarm.gatherInformation(mockTask); + expect(result).toEqual({ sources: [], facts: [], gaps: [] }); + }); + + test('analyzeFindings should return empty structure', async () => { + const result = await heavySwarm.analyzeFindings({}); + expect(result).toEqual({ patterns: [], insights: [], risks: [], opportunities: [] }); + }); + + test('generateAlternatives should return empty structure', async () => { + const result = await heavySwarm.generateAlternatives({}); + expect(result).toEqual({ options: [], criteria: [], tradeoffs: [] }); + }); + + test('validateOption should return true by default', async () => { + const result = await heavySwarm.validateOption({}); + expect(result).toBe(true); + }); + + test('selectBest should return first option or empty object', async () => { + expect(await heavySwarm.selectBest([{ opt: 1 }])).toEqual({ opt: 1 }); + expect(await heavySwarm.selectBest([])).toEqual({}); + }); + }); +});