tasq/node_modules/@claude-flow/memory/dist/learning-bridge.test.js

578 lines
26 KiB
JavaScript

/**
* Tests for LearningBridge
*
* TDD London School (mock-first) tests for the bridge that connects
* AutoMemoryBridge insights to the NeuralLearningSystem.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { LearningBridge } from './learning-bridge.js';
// ===== Mock Neural System =====
function createMockNeuralSystem() {
return {
initialize: vi.fn().mockResolvedValue(undefined),
beginTask: vi.fn().mockReturnValue('traj-1'),
recordStep: vi.fn(),
completeTask: vi.fn().mockResolvedValue(undefined),
findPatterns: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined),
};
}
function createNeuralLoader(neural) {
return async () => neural;
}
function createFailingNeuralLoader() {
return async () => { throw new Error('Module not found'); };
}
// ===== Mock Backend =====
function createMockBackend() {
const storedEntries = [];
return {
storedEntries,
initialize: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockImplementation(async (entry) => {
storedEntries.push(entry);
}),
get: vi.fn().mockResolvedValue(null),
getByKey: vi.fn().mockResolvedValue(null),
update: vi.fn().mockImplementation(async (id, upd) => {
const entry = storedEntries.find(e => e.id === id);
if (!entry)
return null;
if (upd.metadata)
entry.metadata = { ...entry.metadata, ...upd.metadata };
return entry;
}),
delete: vi.fn().mockResolvedValue(true),
query: vi.fn().mockResolvedValue([]),
search: vi.fn().mockResolvedValue([]),
bulkInsert: vi.fn().mockResolvedValue(undefined),
bulkDelete: vi.fn().mockResolvedValue(0),
count: vi.fn().mockResolvedValue(0),
listNamespaces: vi.fn().mockResolvedValue([]),
clearNamespace: vi.fn().mockResolvedValue(0),
getStats: vi.fn().mockResolvedValue({
totalEntries: 0, entriesByNamespace: {}, entriesByType: {},
memoryUsage: 0, avgQueryTime: 0, avgSearchTime: 0,
}),
healthCheck: vi.fn().mockResolvedValue({
status: 'healthy',
components: {
storage: { status: 'healthy', latency: 0 },
index: { status: 'healthy', latency: 0 },
cache: { status: 'healthy', latency: 0 },
},
timestamp: Date.now(), issues: [], recommendations: [],
}),
};
}
// ===== Test Fixtures =====
function createTestInsight(overrides = {}) {
return {
category: 'debugging',
summary: 'HNSW index requires initialization before search',
source: 'agent:tester',
confidence: 0.95,
...overrides,
};
}
function createTestEntry(overrides = {}) {
const now = Date.now();
return {
id: 'entry-1',
key: 'insight:debugging:12345:0',
content: 'HNSW index requires initialization',
type: 'semantic',
namespace: 'learnings',
tags: ['insight', 'debugging'],
metadata: { confidence: 0.8, category: 'debugging' },
accessLevel: 'private',
createdAt: now,
updatedAt: now,
version: 1,
references: [],
accessCount: 0,
lastAccessedAt: now,
...overrides,
};
}
// ===== Tests =====
describe('LearningBridge', () => {
let bridge;
let backend;
let neural;
beforeEach(() => {
backend = createMockBackend();
neural = createMockNeuralSystem();
bridge = new LearningBridge(backend, { neuralLoader: createNeuralLoader(neural) });
});
afterEach(() => {
bridge.destroy();
});
// ===== constructor =====
describe('constructor', () => {
it('should create with default config', () => {
const b = new LearningBridge(backend);
const stats = b.getStats();
expect(stats.totalTrajectories).toBe(0);
expect(stats.neuralAvailable).toBe(false);
b.destroy();
});
it('should create with custom config', () => {
const custom = new LearningBridge(backend, {
sonaMode: 'research',
confidenceDecayRate: 0.01,
accessBoostAmount: 0.05,
maxConfidence: 0.9,
minConfidence: 0.2,
ewcLambda: 5000,
consolidationThreshold: 20,
});
expect(custom.getStats().totalTrajectories).toBe(0);
custom.destroy();
});
it('should respect enabled=false', async () => {
const disabled = new LearningBridge(backend, {
enabled: false,
neuralLoader: createNeuralLoader(neural),
});
await disabled.onInsightRecorded(createTestInsight(), 'entry-1');
expect(neural.beginTask).not.toHaveBeenCalled();
disabled.destroy();
});
});
// ===== onInsightRecorded =====
describe('onInsightRecorded', () => {
it('should store trajectory when neural available', async () => {
const insight = createTestInsight();
await bridge.onInsightRecorded(insight, 'entry-1');
expect(neural.beginTask).toHaveBeenCalledWith(insight.summary, 'general');
expect(neural.recordStep).toHaveBeenCalledWith('traj-1', expect.objectContaining({
action: 'record:debugging',
reward: 0.95,
}));
expect(bridge.getStats().totalTrajectories).toBe(1);
});
it('should emit insight:learning-started event', async () => {
const handler = vi.fn();
bridge.on('insight:learning-started', handler);
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(handler).toHaveBeenCalledWith({ entryId: 'entry-1', category: 'debugging' });
});
it('should no-op when disabled', async () => {
const disabled = new LearningBridge(backend, {
enabled: false,
neuralLoader: createNeuralLoader(neural),
});
await disabled.onInsightRecorded(createTestInsight(), 'entry-1');
expect(neural.beginTask).not.toHaveBeenCalled();
disabled.destroy();
});
it('should no-op when destroyed', async () => {
bridge.destroy();
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(neural.beginTask).not.toHaveBeenCalled();
});
it('should handle neural unavailable gracefully', async () => {
const safeBridge = new LearningBridge(backend, {
neuralLoader: createFailingNeuralLoader(),
});
const handler = vi.fn();
safeBridge.on('insight:learning-started', handler);
await safeBridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(handler).toHaveBeenCalled();
expect(safeBridge.getStats().neuralAvailable).toBe(false);
safeBridge.destroy();
});
it('should pass hash embedding as stateEmbedding', async () => {
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
const stepArg = neural.recordStep.mock.calls[0][1];
expect(stepArg.stateEmbedding).toBeInstanceOf(Float32Array);
expect(stepArg.stateEmbedding.length).toBe(768);
});
it('should create unique trajectory per entry', async () => {
neural.beginTask.mockReturnValueOnce('traj-1').mockReturnValueOnce('traj-2');
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
await bridge.onInsightRecorded(createTestInsight({ summary: 'Second insight' }), 'entry-2');
expect(bridge.getStats().totalTrajectories).toBe(2);
expect(bridge.getStats().activeTrajectories).toBe(2);
});
it('should survive beginTask throwing', async () => {
neural.beginTask.mockImplementationOnce(() => { throw new Error('fail'); });
const handler = vi.fn();
bridge.on('insight:learning-started', handler);
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(handler).toHaveBeenCalled();
});
});
// ===== onInsightAccessed =====
describe('onInsightAccessed', () => {
it('should boost confidence by accessBoostAmount', async () => {
const entry = createTestEntry({ metadata: { confidence: 0.5 } });
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
expect(backend.update).toHaveBeenCalledWith('entry-1', {
metadata: expect.objectContaining({ confidence: 0.53 }),
});
});
it('should cap confidence at maxConfidence', async () => {
const entry = createTestEntry({ metadata: { confidence: 0.99 } });
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
const updateCall = backend.update.mock.calls[0];
expect(updateCall[1].metadata.confidence).toBeLessThanOrEqual(1.0);
});
it('should handle missing entry gracefully', async () => {
backend.get.mockResolvedValueOnce(null);
await bridge.onInsightAccessed('nonexistent');
expect(backend.update).not.toHaveBeenCalled();
});
it('should emit insight:accessed event', async () => {
const entry = createTestEntry({ metadata: { confidence: 0.7 } });
backend.get.mockResolvedValueOnce(entry);
const handler = vi.fn();
bridge.on('insight:accessed', handler);
await bridge.onInsightAccessed('entry-1');
expect(handler).toHaveBeenCalledWith({
entryId: 'entry-1',
newConfidence: expect.any(Number),
});
});
it('should update entry metadata in backend preserving existing fields', async () => {
const entry = createTestEntry({
metadata: { confidence: 0.6, category: 'debugging', extra: 'preserved' },
});
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
const updateCall = backend.update.mock.calls[0][1];
expect(updateCall.metadata.category).toBe('debugging');
expect(updateCall.metadata.extra).toBe('preserved');
expect(updateCall.metadata.confidence).toBeCloseTo(0.63, 5);
});
it('should record neural step when trajectory exists', async () => {
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
vi.clearAllMocks();
const entry = createTestEntry();
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
expect(neural.recordStep).toHaveBeenCalledWith('traj-1', {
action: 'access',
reward: 0.03,
});
});
it('should not record neural step without trajectory', async () => {
const entry = createTestEntry();
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
expect(neural.recordStep).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'access' }));
});
it('should use default 0.5 when metadata lacks confidence', async () => {
const entry = createTestEntry({ metadata: {} });
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
const updateCall = backend.update.mock.calls[0][1];
expect(updateCall.metadata.confidence).toBeCloseTo(0.53, 5);
});
it('should no-op when disabled', async () => {
const disabled = new LearningBridge(backend, { enabled: false });
await disabled.onInsightAccessed('entry-1');
expect(backend.get).not.toHaveBeenCalled();
disabled.destroy();
});
it('should track boost stats', async () => {
const entry = createTestEntry({ metadata: { confidence: 0.5 } });
backend.get.mockResolvedValue(entry);
await bridge.onInsightAccessed('entry-1');
await bridge.onInsightAccessed('entry-1');
expect(bridge.getStats().avgConfidenceBoost).toBeCloseTo(0.03, 5);
});
});
// ===== consolidate =====
describe('consolidate', () => {
async function seedTrajectories(count) {
for (let i = 0; i < count; i++) {
neural.beginTask.mockReturnValueOnce(`traj-${i}`);
await bridge.onInsightRecorded(createTestInsight({ summary: `Insight ${i}` }), `entry-${i}`);
}
}
it('should complete active trajectories', async () => {
await seedTrajectories(10);
const result = await bridge.consolidate();
expect(result.trajectoriesCompleted).toBe(10);
expect(result.patternsLearned).toBe(10);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
it('should return early when below threshold', async () => {
await seedTrajectories(2);
const result = await bridge.consolidate();
expect(result.trajectoriesCompleted).toBe(0);
expect(neural.completeTask).not.toHaveBeenCalled();
});
it('should return early when neural unavailable', async () => {
const safeBridge = new LearningBridge(backend, {
neuralLoader: createFailingNeuralLoader(),
});
const result = await safeBridge.consolidate();
expect(result.trajectoriesCompleted).toBe(0);
safeBridge.destroy();
});
it('should clear completed trajectories', async () => {
await seedTrajectories(10);
expect(bridge.getStats().activeTrajectories).toBe(10);
await bridge.consolidate();
expect(bridge.getStats().activeTrajectories).toBe(0);
});
it('should emit consolidation:completed event', async () => {
await seedTrajectories(10);
const handler = vi.fn();
bridge.on('consolidation:completed', handler);
await bridge.consolidate();
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
trajectoriesCompleted: 10,
patternsLearned: 10,
}));
});
it('should track stats correctly', async () => {
await seedTrajectories(10);
await bridge.consolidate();
const stats = bridge.getStats();
expect(stats.completedTrajectories).toBe(10);
expect(stats.totalConsolidations).toBe(1);
});
it('should handle completeTask failure for individual trajectories', async () => {
await seedTrajectories(10);
let callCount = 0;
neural.completeTask.mockImplementation(async () => {
callCount++;
if (callCount === 3)
throw new Error('Neural failure');
});
const result = await bridge.consolidate();
expect(result.trajectoriesCompleted).toBe(9);
});
it('should respect custom consolidationThreshold', async () => {
const customBridge = new LearningBridge(backend, {
consolidationThreshold: 2,
neuralLoader: createNeuralLoader(neural),
});
neural.beginTask.mockReturnValueOnce('traj-1').mockReturnValueOnce('traj-2');
await customBridge.onInsightRecorded(createTestInsight(), 'e-1');
await customBridge.onInsightRecorded(createTestInsight({ summary: 'S2' }), 'e-2');
const result = await customBridge.consolidate();
expect(result.trajectoriesCompleted).toBe(2);
customBridge.destroy();
});
});
// ===== decayConfidences =====
describe('decayConfidences', () => {
it('should decay entries older than 1 hour', async () => {
const twoHoursAgo = Date.now() - 2 * 3_600_000;
const entry = createTestEntry({
id: 'old-entry', updatedAt: twoHoursAgo, metadata: { confidence: 0.9 },
});
backend.query.mockResolvedValueOnce([entry]);
const count = await bridge.decayConfidences('learnings');
expect(count).toBe(1);
const newConf = backend.update.mock.calls[0][1].metadata.confidence;
expect(newConf).toBeCloseTo(0.89, 2);
});
it('should respect minConfidence floor', async () => {
const longAgo = Date.now() - 200 * 3_600_000;
const entry = createTestEntry({
id: 'ancient', updatedAt: longAgo, metadata: { confidence: 0.5 },
});
backend.query.mockResolvedValueOnce([entry]);
await bridge.decayConfidences('learnings');
const newConf = backend.update.mock.calls[0][1].metadata.confidence;
expect(newConf).toBeGreaterThanOrEqual(0.1);
});
it('should skip recent entries', async () => {
const entry = createTestEntry({
id: 'recent', updatedAt: Date.now() - 30 * 60_000, metadata: { confidence: 0.8 },
});
backend.query.mockResolvedValueOnce([entry]);
const count = await bridge.decayConfidences('learnings');
expect(count).toBe(0);
expect(backend.update).not.toHaveBeenCalled();
});
it('should return count of decayed entries', async () => {
const old = Date.now() - 2 * 3_600_000;
const entries = [
createTestEntry({ id: 'e1', updatedAt: old, metadata: { confidence: 0.9 } }),
createTestEntry({ id: 'e2', updatedAt: old, metadata: { confidence: 0.7 } }),
createTestEntry({ id: 'e3', updatedAt: Date.now(), metadata: { confidence: 0.5 } }),
];
backend.query.mockResolvedValueOnce(entries);
const count = await bridge.decayConfidences('learnings');
expect(count).toBe(2);
});
it('should handle empty namespace', async () => {
backend.query.mockResolvedValueOnce([]);
const count = await bridge.decayConfidences('empty-ns');
expect(count).toBe(0);
});
it('should handle query failure gracefully', async () => {
backend.query.mockRejectedValueOnce(new Error('DB error'));
const count = await bridge.decayConfidences('broken');
expect(count).toBe(0);
});
it('should track total decays in stats', async () => {
const old = Date.now() - 5 * 3_600_000;
backend.query.mockResolvedValueOnce([
createTestEntry({ id: 'e1', updatedAt: old, metadata: { confidence: 0.9 } }),
]);
await bridge.decayConfidences('learnings');
expect(bridge.getStats().totalDecays).toBe(1);
});
});
// ===== findSimilarPatterns =====
describe('findSimilarPatterns', () => {
it('should return patterns when neural available', async () => {
neural.findPatterns.mockResolvedValueOnce([
{ content: 'Pattern A', similarity: 0.9, category: 'debugging', confidence: 0.8 },
{ content: 'Pattern B', similarity: 0.7, category: 'performance', confidence: 0.6 },
]);
const patterns = await bridge.findSimilarPatterns('test query');
expect(patterns).toHaveLength(2);
expect(patterns[0].content).toBe('Pattern A');
expect(patterns[0].similarity).toBe(0.9);
expect(patterns[1].category).toBe('performance');
});
it('should return empty when neural unavailable', async () => {
const safeBridge = new LearningBridge(backend, {
neuralLoader: createFailingNeuralLoader(),
});
const patterns = await safeBridge.findSimilarPatterns('test');
expect(patterns).toHaveLength(0);
safeBridge.destroy();
});
it('should map results to PatternMatch format', async () => {
neural.findPatterns.mockResolvedValueOnce([
{ data: 'Raw data', score: 0.85, reward: 0.7 },
]);
const patterns = await bridge.findSimilarPatterns('test');
expect(patterns).toHaveLength(1);
expect(patterns[0].content).toBe('Raw data');
expect(patterns[0].similarity).toBe(0.85);
expect(patterns[0].confidence).toBe(0.7);
expect(patterns[0].category).toBe('unknown');
});
it('should pass k parameter to neural', async () => {
await bridge.findSimilarPatterns('test', 3);
expect(neural.findPatterns).toHaveBeenCalledWith(expect.any(Float32Array), 3);
});
it('should handle findPatterns throwing', async () => {
neural.findPatterns.mockRejectedValueOnce(new Error('Neural error'));
const patterns = await bridge.findSimilarPatterns('test');
expect(patterns).toHaveLength(0);
});
it('should handle non-array result from findPatterns', async () => {
neural.findPatterns.mockResolvedValueOnce(null);
const patterns = await bridge.findSimilarPatterns('test');
expect(patterns).toHaveLength(0);
});
});
// ===== getStats =====
describe('getStats', () => {
it('should return correct initial stats', () => {
const stats = bridge.getStats();
expect(stats.totalTrajectories).toBe(0);
expect(stats.completedTrajectories).toBe(0);
expect(stats.activeTrajectories).toBe(0);
expect(stats.totalConsolidations).toBe(0);
expect(stats.totalDecays).toBe(0);
expect(stats.avgConfidenceBoost).toBe(0);
});
it('should reflect operations in stats', async () => {
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
let stats = bridge.getStats();
expect(stats.totalTrajectories).toBe(1);
expect(stats.activeTrajectories).toBe(1);
expect(stats.neuralAvailable).toBe(true);
const entry = createTestEntry({ metadata: { confidence: 0.5 } });
backend.get.mockResolvedValueOnce(entry);
await bridge.onInsightAccessed('entry-1');
stats = bridge.getStats();
expect(stats.avgConfidenceBoost).toBeCloseTo(0.03, 5);
});
it('should show neuralAvailable=false before init', () => {
const fresh = new LearningBridge(backend);
expect(fresh.getStats().neuralAvailable).toBe(false);
fresh.destroy();
});
});
// ===== destroy =====
describe('destroy', () => {
it('should set destroyed state', async () => {
bridge.destroy();
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(neural.beginTask).not.toHaveBeenCalled();
});
it('should clear trajectories', async () => {
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(bridge.getStats().activeTrajectories).toBe(1);
bridge.destroy();
expect(bridge.getStats().activeTrajectories).toBe(0);
});
it('should make subsequent onInsightRecorded no-op', async () => {
bridge.destroy();
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
expect(bridge.getStats().totalTrajectories).toBe(0);
});
it('should make subsequent onInsightAccessed no-op', async () => {
bridge.destroy();
await bridge.onInsightAccessed('entry-1');
expect(backend.get).not.toHaveBeenCalled();
});
it('should make subsequent consolidate no-op', async () => {
bridge.destroy();
const result = await bridge.consolidate();
expect(result.trajectoriesCompleted).toBe(0);
});
it('should make subsequent decayConfidences no-op', async () => {
bridge.destroy();
const count = await bridge.decayConfidences('learnings');
expect(count).toBe(0);
});
it('should make subsequent findSimilarPatterns no-op', async () => {
bridge.destroy();
const patterns = await bridge.findSimilarPatterns('test');
expect(patterns).toHaveLength(0);
});
it('should call neural cleanup if available', async () => {
// Trigger neural init
await bridge.onInsightRecorded(createTestInsight(), 'entry-1');
vi.clearAllMocks();
bridge.destroy();
expect(neural.cleanup).toHaveBeenCalled();
});
it('should remove all event listeners', () => {
bridge.on('insight:learning-started', () => { });
bridge.on('consolidation:completed', () => { });
bridge.destroy();
expect(bridge.listenerCount('insight:learning-started')).toBe(0);
expect(bridge.listenerCount('consolidation:completed')).toBe(0);
});
});
// ===== Neural init caching =====
describe('neural initialization', () => {
it('should only attempt neural load once', async () => {
const loaderFn = vi.fn().mockResolvedValue(neural);
const b = new LearningBridge(backend, { neuralLoader: loaderFn });
await b.onInsightRecorded(createTestInsight(), 'entry-1');
await b.onInsightRecorded(createTestInsight({ summary: 'Second' }), 'entry-2');
expect(loaderFn).toHaveBeenCalledTimes(1);
b.destroy();
});
it('should cache failed init and not retry', async () => {
const loaderFn = vi.fn().mockRejectedValue(new Error('fail'));
const b = new LearningBridge(backend, { neuralLoader: loaderFn });
await b.onInsightRecorded(createTestInsight(), 'entry-1');
await b.onInsightRecorded(createTestInsight(), 'entry-2');
expect(loaderFn).toHaveBeenCalledTimes(1);
expect(b.getStats().neuralAvailable).toBe(false);
b.destroy();
});
});
});
//# sourceMappingURL=learning-bridge.test.js.map