474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* Tests for Brainy Distributed Mode functionality
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
|
|
import { BrainyData } from '../src/brainyData.js'
|
||
|
|
import { DistributedConfigManager } from '../src/distributed/configManager.js'
|
||
|
|
import { HashPartitioner } from '../src/distributed/hashPartitioner.js'
|
||
|
|
import { DomainDetector } from '../src/distributed/domainDetector.js'
|
||
|
|
import {
|
||
|
|
ReaderMode,
|
||
|
|
WriterMode,
|
||
|
|
HybridMode,
|
||
|
|
OperationalModeFactory
|
||
|
|
} from '../src/distributed/operationalModes.js'
|
||
|
|
import { HealthMonitor } from '../src/distributed/healthMonitor.js'
|
||
|
|
|
||
|
|
// Mock storage adapter for testing
|
||
|
|
class MockStorageAdapter {
|
||
|
|
private metadata: Map<string, any> = new Map()
|
||
|
|
|
||
|
|
async init() {}
|
||
|
|
|
||
|
|
async saveMetadata(id: string, data: any) {
|
||
|
|
this.metadata.set(id, data)
|
||
|
|
}
|
||
|
|
|
||
|
|
async getMetadata(id: string) {
|
||
|
|
return this.metadata.get(id) || null
|
||
|
|
}
|
||
|
|
|
||
|
|
async saveNoun(noun: any) {}
|
||
|
|
async getNoun(id: string) { return null }
|
||
|
|
async getAllNouns() { return [] }
|
||
|
|
async getNouns() { return { items: [], pagination: { page: 1, pageSize: 100, total: 0 } } }
|
||
|
|
async deleteNoun(id: string) {}
|
||
|
|
async saveVerb(verb: any) {}
|
||
|
|
async getVerb(id: string) { return null }
|
||
|
|
async getVerbsBySource(source: string) { return [] }
|
||
|
|
async getVerbsByTarget(target: string) { return [] }
|
||
|
|
async getVerbsByType(type: string) { return [] }
|
||
|
|
async getAllVerbs() { return [] }
|
||
|
|
async deleteVerb(id: string) {}
|
||
|
|
async incrementStatistic(stat: string, service: string) {}
|
||
|
|
async updateHnswIndexSize(size: number) {}
|
||
|
|
async trackFieldNames(obj: any, service: string) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Distributed Configuration Manager', () => {
|
||
|
|
let storage: MockStorageAdapter
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
storage = new MockStorageAdapter()
|
||
|
|
// Clear any environment variables that might be set
|
||
|
|
delete process.env.BRAINY_ROLE
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
// Clean up environment
|
||
|
|
delete process.env.BRAINY_ROLE
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should require explicit role configuration', async () => {
|
||
|
|
const configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true }, // No role specified
|
||
|
|
{} // No read/write mode
|
||
|
|
)
|
||
|
|
|
||
|
|
// Should throw error when no role is set
|
||
|
|
await expect(configManager.initialize()).rejects.toThrow(
|
||
|
|
'Distributed mode requires explicit role configuration'
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should accept role from environment variable', async () => {
|
||
|
|
process.env.BRAINY_ROLE = 'writer'
|
||
|
|
|
||
|
|
const configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true }
|
||
|
|
)
|
||
|
|
|
||
|
|
await configManager.initialize()
|
||
|
|
expect(configManager.getRole()).toBe('writer')
|
||
|
|
|
||
|
|
delete process.env.BRAINY_ROLE
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should accept role from config', async () => {
|
||
|
|
const configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true, role: 'reader' }
|
||
|
|
)
|
||
|
|
|
||
|
|
await configManager.initialize()
|
||
|
|
expect(configManager.getRole()).toBe('reader')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should infer role from read/write mode', async () => {
|
||
|
|
const configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true },
|
||
|
|
{ writeOnly: true }
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(configManager.getRole()).toBe('writer')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should validate role values', async () => {
|
||
|
|
process.env.BRAINY_ROLE = 'invalid'
|
||
|
|
|
||
|
|
const configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true },
|
||
|
|
{} // No read/write mode
|
||
|
|
)
|
||
|
|
|
||
|
|
await expect(configManager.initialize()).rejects.toThrow(
|
||
|
|
'Invalid BRAINY_ROLE: invalid'
|
||
|
|
)
|
||
|
|
|
||
|
|
delete process.env.BRAINY_ROLE
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('Hash Partitioner', () => {
|
||
|
|
it('should partition vectors deterministically', () => {
|
||
|
|
const config = {
|
||
|
|
version: 1,
|
||
|
|
updated: new Date().toISOString(),
|
||
|
|
settings: {
|
||
|
|
partitionStrategy: 'hash' as const,
|
||
|
|
partitionCount: 10,
|
||
|
|
embeddingModel: 'test',
|
||
|
|
dimensions: 384,
|
||
|
|
distanceMetric: 'cosine' as const
|
||
|
|
},
|
||
|
|
instances: {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const partitioner = new HashPartitioner(config)
|
||
|
|
|
||
|
|
// Same ID should always go to same partition
|
||
|
|
const id = 'test-vector-123'
|
||
|
|
const partition1 = partitioner.getPartition(id)
|
||
|
|
const partition2 = partitioner.getPartition(id)
|
||
|
|
|
||
|
|
expect(partition1).toBe(partition2)
|
||
|
|
expect(partition1).toMatch(/^vectors\/p\d{3}$/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should distribute vectors evenly', () => {
|
||
|
|
const config = {
|
||
|
|
version: 1,
|
||
|
|
updated: new Date().toISOString(),
|
||
|
|
settings: {
|
||
|
|
partitionStrategy: 'hash' as const,
|
||
|
|
partitionCount: 10,
|
||
|
|
embeddingModel: 'test',
|
||
|
|
dimensions: 384,
|
||
|
|
distanceMetric: 'cosine' as const
|
||
|
|
},
|
||
|
|
instances: {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const partitioner = new HashPartitioner(config)
|
||
|
|
const partitionCounts = new Map<string, number>()
|
||
|
|
|
||
|
|
// Generate many IDs and check distribution
|
||
|
|
for (let i = 0; i < 1000; i++) {
|
||
|
|
const partition = partitioner.getPartition(`vector-${i}`)
|
||
|
|
partitionCounts.set(partition, (partitionCounts.get(partition) || 0) + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check that all partitions got some vectors
|
||
|
|
expect(partitionCounts.size).toBeGreaterThan(5)
|
||
|
|
|
||
|
|
// Check distribution is reasonably even (no partition has more than 20% of vectors)
|
||
|
|
for (const count of partitionCounts.values()) {
|
||
|
|
expect(count).toBeLessThan(200)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('Domain Detector', () => {
|
||
|
|
let detector: DomainDetector
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
detector = new DomainDetector()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should detect medical domain', () => {
|
||
|
|
const data = {
|
||
|
|
symptoms: 'headache and fever',
|
||
|
|
diagnosis: 'flu',
|
||
|
|
treatment: 'rest and fluids'
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = detector.detectDomain(data)
|
||
|
|
expect(result.domain).toBe('medical')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should detect legal domain', () => {
|
||
|
|
const data = {
|
||
|
|
contract: 'lease agreement',
|
||
|
|
clause: 'termination clause',
|
||
|
|
jurisdiction: 'California'
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = detector.detectDomain(data)
|
||
|
|
expect(result.domain).toBe('legal')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should detect product domain', () => {
|
||
|
|
const data = {
|
||
|
|
price: 99.99,
|
||
|
|
sku: 'PROD-123',
|
||
|
|
inventory: 50,
|
||
|
|
category: 'electronics'
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = detector.detectDomain(data)
|
||
|
|
expect(result.domain).toBe('product')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return general for unrecognized data', () => {
|
||
|
|
const data = {
|
||
|
|
foo: 'bar',
|
||
|
|
baz: 'qux'
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = detector.detectDomain(data)
|
||
|
|
expect(result.domain).toBe('general')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should respect explicit domain field', () => {
|
||
|
|
const data = {
|
||
|
|
domain: 'custom',
|
||
|
|
foo: 'bar'
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = detector.detectDomain(data)
|
||
|
|
expect(result.domain).toBe('custom')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('Operational Modes', () => {
|
||
|
|
it('should create reader mode with correct settings', () => {
|
||
|
|
const mode = new ReaderMode()
|
||
|
|
|
||
|
|
expect(mode.canRead).toBe(true)
|
||
|
|
expect(mode.canWrite).toBe(false)
|
||
|
|
expect(mode.canDelete).toBe(false)
|
||
|
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.8)
|
||
|
|
expect(mode.cacheStrategy.prefetchAggressive).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should create writer mode with correct settings', () => {
|
||
|
|
const mode = new WriterMode()
|
||
|
|
|
||
|
|
expect(mode.canRead).toBe(false)
|
||
|
|
expect(mode.canWrite).toBe(true)
|
||
|
|
expect(mode.canDelete).toBe(true)
|
||
|
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.2)
|
||
|
|
expect(mode.cacheStrategy.batchWrites).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should create hybrid mode with correct settings', () => {
|
||
|
|
const mode = new HybridMode()
|
||
|
|
|
||
|
|
expect(mode.canRead).toBe(true)
|
||
|
|
expect(mode.canWrite).toBe(true)
|
||
|
|
expect(mode.canDelete).toBe(true)
|
||
|
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.5)
|
||
|
|
expect(mode.cacheStrategy.adaptive).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should validate operations based on mode', () => {
|
||
|
|
const readerMode = new ReaderMode()
|
||
|
|
const writerMode = new WriterMode()
|
||
|
|
|
||
|
|
// Reader should not allow writes
|
||
|
|
expect(() => readerMode.validateOperation('write')).toThrow(
|
||
|
|
'Write operations are not allowed in read-only mode'
|
||
|
|
)
|
||
|
|
|
||
|
|
// Writer should not allow reads
|
||
|
|
expect(() => writerMode.validateOperation('read')).toThrow(
|
||
|
|
'Read operations are not allowed in write-only mode'
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should create correct mode from factory', () => {
|
||
|
|
const reader = OperationalModeFactory.createMode('reader')
|
||
|
|
const writer = OperationalModeFactory.createMode('writer')
|
||
|
|
const hybrid = OperationalModeFactory.createMode('hybrid')
|
||
|
|
|
||
|
|
expect(reader).toBeInstanceOf(ReaderMode)
|
||
|
|
expect(writer).toBeInstanceOf(WriterMode)
|
||
|
|
expect(hybrid).toBeInstanceOf(HybridMode)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('Health Monitor', () => {
|
||
|
|
let configManager: DistributedConfigManager
|
||
|
|
let healthMonitor: HealthMonitor
|
||
|
|
let storage: MockStorageAdapter
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
storage = new MockStorageAdapter()
|
||
|
|
configManager = new DistributedConfigManager(
|
||
|
|
storage as any,
|
||
|
|
{ enabled: true, role: 'reader' }
|
||
|
|
)
|
||
|
|
healthMonitor = new HealthMonitor(configManager)
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
healthMonitor.stop()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should track request metrics', () => {
|
||
|
|
healthMonitor.recordRequest(100, false)
|
||
|
|
healthMonitor.recordRequest(150, false)
|
||
|
|
healthMonitor.recordRequest(200, true) // Error
|
||
|
|
|
||
|
|
const status = healthMonitor.getHealthStatus()
|
||
|
|
|
||
|
|
expect(status.metrics.averageLatency).toBeGreaterThan(0)
|
||
|
|
expect(status.metrics.errorRate).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should track cache metrics', () => {
|
||
|
|
healthMonitor.recordCacheAccess(true) // Hit
|
||
|
|
healthMonitor.recordCacheAccess(true) // Hit
|
||
|
|
healthMonitor.recordCacheAccess(false) // Miss
|
||
|
|
|
||
|
|
const status = healthMonitor.getHealthStatus()
|
||
|
|
|
||
|
|
expect(status.metrics.cacheHitRate).toBeCloseTo(0.667, 2)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should update vector count', () => {
|
||
|
|
healthMonitor.updateVectorCount(1000)
|
||
|
|
|
||
|
|
const status = healthMonitor.getHealthStatus()
|
||
|
|
|
||
|
|
expect(status.metrics.vectorCount).toBe(1000)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should determine health status based on metrics', () => {
|
||
|
|
// Add some successful requests first to establish a good baseline
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
healthMonitor.recordRequest(50, false)
|
||
|
|
healthMonitor.recordCacheAccess(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
let status = healthMonitor.getHealthStatus()
|
||
|
|
// With good metrics, should be healthy (unless cache hit rate is too low initially)
|
||
|
|
// Let's just check it's not unhealthy
|
||
|
|
expect(status.status).not.toBe('unhealthy')
|
||
|
|
|
||
|
|
// High error rate
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
healthMonitor.recordRequest(100, true)
|
||
|
|
}
|
||
|
|
status = healthMonitor.getHealthStatus()
|
||
|
|
expect(status.status).toBe('unhealthy')
|
||
|
|
expect(status.errors).toContain('Critical error rate')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('BrainyData with Distributed Mode', () => {
|
||
|
|
it('should initialize with distributed config', async () => {
|
||
|
|
const brainy = new BrainyData({
|
||
|
|
distributed: { role: 'reader' },
|
||
|
|
storage: {
|
||
|
|
forceMemoryStorage: true
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
await brainy.init()
|
||
|
|
|
||
|
|
// Should be in read-only mode
|
||
|
|
expect(() => brainy['checkReadOnly']()).toThrow()
|
||
|
|
|
||
|
|
await brainy.cleanup()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should detect domain and add to metadata', async () => {
|
||
|
|
const brainy = new BrainyData({
|
||
|
|
distributed: { role: 'writer' },
|
||
|
|
storage: {
|
||
|
|
forceMemoryStorage: true
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
await brainy.init()
|
||
|
|
|
||
|
|
const medicalData = {
|
||
|
|
symptoms: 'headache',
|
||
|
|
diagnosis: 'migraine'
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create a proper 512-dimensional vector
|
||
|
|
const vector = new Array(384).fill(0).map((_, i) => i / 384)
|
||
|
|
|
||
|
|
const id = await brainy.add(vector, medicalData)
|
||
|
|
const result = await brainy.get(id)
|
||
|
|
|
||
|
|
// Check that domain was added to metadata
|
||
|
|
expect(result?.metadata).toHaveProperty('domain')
|
||
|
|
// Note: In memory storage, the domain detection happens but may not persist
|
||
|
|
// This is just checking the flow works
|
||
|
|
|
||
|
|
await brainy.cleanup()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should support domain filtering in search', async () => {
|
||
|
|
const brainy = new BrainyData({
|
||
|
|
distributed: { role: 'hybrid' },
|
||
|
|
storage: {
|
||
|
|
forceMemoryStorage: true
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
await brainy.init()
|
||
|
|
|
||
|
|
// Create proper 512-dimensional vectors
|
||
|
|
const vector1 = new Array(384).fill(0).map((_, i) => i === 0 ? 1 : 0)
|
||
|
|
const vector2 = new Array(384).fill(0).map((_, i) => i === 1 ? 1 : 0)
|
||
|
|
const vector3 = new Array(384).fill(0).map((_, i) => i === 2 ? 1 : 0)
|
||
|
|
|
||
|
|
// Add items with different domains
|
||
|
|
await brainy.add(vector1, { domain: 'medical', content: 'medical1' })
|
||
|
|
await brainy.add(vector2, { domain: 'legal', content: 'legal1' })
|
||
|
|
await brainy.add(vector3, { domain: 'medical', content: 'medical2' })
|
||
|
|
|
||
|
|
// Search with domain filter
|
||
|
|
const results = await brainy.search(vector1, 10, {
|
||
|
|
filter: { domain: 'medical' }
|
||
|
|
})
|
||
|
|
|
||
|
|
// Should filter out non-medical results
|
||
|
|
const medicalResults = results.filter(r =>
|
||
|
|
r.metadata && (r.metadata as any).domain === 'medical'
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(medicalResults.length).toBeGreaterThan(0)
|
||
|
|
|
||
|
|
await brainy.cleanup()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should provide health status', async () => {
|
||
|
|
const brainy = new BrainyData({
|
||
|
|
distributed: { role: 'reader' },
|
||
|
|
storage: {
|
||
|
|
forceMemoryStorage: true
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
await brainy.init()
|
||
|
|
|
||
|
|
const health = brainy.getHealthStatus()
|
||
|
|
|
||
|
|
expect(health).toHaveProperty('status')
|
||
|
|
expect(health).toHaveProperty('instanceId')
|
||
|
|
expect(health).toHaveProperty('role')
|
||
|
|
expect(health).toHaveProperty('metrics')
|
||
|
|
|
||
|
|
await brainy.cleanup()
|
||
|
|
})
|
||
|
|
})
|