/** * 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