mirror of
https://github.com/Heretek-AI/heretek-openclaw-core.git
synced 2026-07-01 14:17:57 -04:00
feat: Add BFT consensus, curiosity v2, tiered memory
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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<Object>} 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 };
|
||||
@@ -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<Array>} 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<Array>} 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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<Object>} 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<Object>} 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 };
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user