468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* TypeAwareHNSWIndex Unit Tests
|
||
|
|
*
|
||
|
|
* Comprehensive test suite for Phase 2 Type-Aware HNSW implementation.
|
||
|
|
* Tests cover:
|
||
|
|
* - Lazy initialization
|
||
|
|
* - Type routing (single/multi/all types)
|
||
|
|
* - Edge cases (empty array, null, invalid type)
|
||
|
|
* - Error handling
|
||
|
|
* - Memory isolation
|
||
|
|
* - Statistics
|
||
|
|
* - Configuration
|
||
|
|
*
|
||
|
|
* Total: 25 unit tests
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||
|
|
import { TypeAwareHNSWIndex } from '../src/hnsw/typeAwareHNSWIndex.js'
|
||
|
|
import type { NounType } from '../src/types/graphTypes.js'
|
||
|
|
import { euclideanDistance } from '../src/utils/index.js'
|
||
|
|
import { MemoryStorage } from '../src/storage/adapters/memoryStorage.js'
|
||
|
|
|
||
|
|
describe('TypeAwareHNSWIndex', () => {
|
||
|
|
let index: TypeAwareHNSWIndex
|
||
|
|
let storage: MemoryStorage
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
storage = new MemoryStorage()
|
||
|
|
index = new TypeAwareHNSWIndex(
|
||
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||
|
|
euclideanDistance,
|
||
|
|
{ storage }
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 1. LAZY INITIALIZATION
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Lazy Initialization', () => {
|
||
|
|
it('should not create indexes upfront', () => {
|
||
|
|
expect(index.getActiveTypes()).toHaveLength(0)
|
||
|
|
expect(index.size()).toBe(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should create index only when first entity added', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 2, 3] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(index.getActiveTypes()).toContain('person')
|
||
|
|
expect(index.getActiveTypes()).toHaveLength(1)
|
||
|
|
expect(index.sizeForType('person' as NounType)).toBe(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should create separate indexes for different types', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 2, 3] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [4, 5, 6] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(index.getActiveTypes()).toHaveLength(2)
|
||
|
|
expect(index.getActiveTypes()).toContain('person')
|
||
|
|
expect(index.getActiveTypes()).toContain('document')
|
||
|
|
expect(index.sizeForType('person' as NounType)).toBe(1)
|
||
|
|
expect(index.sizeForType('document' as NounType)).toBe(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should not create index for types with no entities', () => {
|
||
|
|
expect(index.sizeForType('event' as NounType)).toBe(0)
|
||
|
|
expect(index.getActiveTypes()).not.toContain('event')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 2. TYPE ROUTING
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Type Routing', () => {
|
||
|
|
beforeEach(async () => {
|
||
|
|
// Add entities of different types
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-2', vector: [1, 0.1, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'event-1', vector: [0, 0, 1] },
|
||
|
|
'event' as NounType
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should search single type only (fast path)', async () => {
|
||
|
|
const results = await index.search([1, 0, 0], 2, 'person' as NounType)
|
||
|
|
|
||
|
|
expect(results).toHaveLength(2)
|
||
|
|
expect(results[0][0]).toBe('person-1') // Exact match
|
||
|
|
expect(results[1][0]).toBe('person-2') // Close match
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should search multiple types', async () => {
|
||
|
|
const results = await index.search(
|
||
|
|
[1, 0, 0],
|
||
|
|
3,
|
||
|
|
['person', 'document'] as NounType[]
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(results).toHaveLength(3)
|
||
|
|
const ids = results.map((r) => r[0])
|
||
|
|
expect(ids).toContain('person-1')
|
||
|
|
expect(ids).toContain('person-2')
|
||
|
|
expect(ids).toContain('doc-1')
|
||
|
|
expect(ids).not.toContain('event-1') // Not searched
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should search all types when type not specified', async () => {
|
||
|
|
const results = await index.search([1, 0, 0], 4)
|
||
|
|
|
||
|
|
expect(results).toHaveLength(4)
|
||
|
|
const ids = results.map((r) => r[0])
|
||
|
|
expect(ids).toContain('person-1')
|
||
|
|
expect(ids).toContain('person-2')
|
||
|
|
expect(ids).toContain('doc-1')
|
||
|
|
expect(ids).toContain('event-1')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return results sorted by distance', async () => {
|
||
|
|
const results = await index.search([1, 0, 0], 4)
|
||
|
|
|
||
|
|
// Distances should be increasing
|
||
|
|
for (let i = 0; i < results.length - 1; i++) {
|
||
|
|
expect(results[i][1]).toBeLessThanOrEqual(results[i + 1][1])
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 3. EDGE CASE HANDLING
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Edge Cases', () => {
|
||
|
|
it('should handle empty array in search() (fall through to all types)', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const results = await index.search([1, 0, 0], 10, [] as NounType[])
|
||
|
|
|
||
|
|
// Should search all types (fallback behavior)
|
||
|
|
expect(results).toHaveLength(1)
|
||
|
|
expect(results[0][0]).toBe('person-1')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should throw on null item in addItem()', async () => {
|
||
|
|
await expect(
|
||
|
|
index.addItem(null as any, 'person' as NounType)
|
||
|
|
).rejects.toThrow('Invalid VectorDocument: item or vector is null/undefined')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should throw on undefined vector in addItem()', async () => {
|
||
|
|
await expect(
|
||
|
|
index.addItem({ id: 'test' } as any, 'person' as NounType)
|
||
|
|
).rejects.toThrow('Invalid VectorDocument: item or vector is null/undefined')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should throw on null type in addItem()', async () => {
|
||
|
|
await expect(
|
||
|
|
index.addItem({ id: 'test', vector: [1, 2, 3] }, null as any)
|
||
|
|
).rejects.toThrow('Type is required for type-aware indexing')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should throw on invalid type string', async () => {
|
||
|
|
await expect(
|
||
|
|
index.addItem(
|
||
|
|
{ id: 'test', vector: [1, 2, 3] },
|
||
|
|
'not-a-valid-noun-type-at-all' as any
|
||
|
|
)
|
||
|
|
).rejects.toThrow('Invalid NounType')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should handle search with no results', async () => {
|
||
|
|
const results = await index.search([1, 2, 3], 10, 'person' as NounType)
|
||
|
|
|
||
|
|
expect(results).toHaveLength(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should handle removeItem() for non-existent type', async () => {
|
||
|
|
const removed = await index.removeItem('test-id', 'person' as NounType)
|
||
|
|
|
||
|
|
expect(removed).toBe(false)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 4. ADD/REMOVE/SEARCH OPERATIONS
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Operations', () => {
|
||
|
|
it('should add item and return ID', async () => {
|
||
|
|
const id = await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 2, 3] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(id).toBe('person-1')
|
||
|
|
expect(index.size()).toBe(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should remove item from correct type', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 2, 3] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [4, 5, 6] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const removed = await index.removeItem('person-1', 'person' as NounType)
|
||
|
|
|
||
|
|
expect(removed).toBe(true)
|
||
|
|
expect(index.sizeForType('person' as NounType)).toBe(0)
|
||
|
|
expect(index.sizeForType('document' as NounType)).toBe(1) // Unchanged
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should search with filter function', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-2', vector: [1, 0.1, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const filter = async (id: string) => id === 'person-1'
|
||
|
|
const results = await index.search(
|
||
|
|
[1, 0, 0],
|
||
|
|
2,
|
||
|
|
'person' as NounType,
|
||
|
|
filter
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(results).toHaveLength(1)
|
||
|
|
expect(results[0][0]).toBe('person-1')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 5. MEMORY ISOLATION
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Memory Isolation', () => {
|
||
|
|
it('should maintain separate memory for each type', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
// Clear person type only
|
||
|
|
index.clearType('person' as NounType)
|
||
|
|
|
||
|
|
expect(index.sizeForType('person' as NounType)).toBe(0)
|
||
|
|
expect(index.sizeForType('document' as NounType)).toBe(1) // Unchanged
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should clear all indexes', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
index.clear()
|
||
|
|
|
||
|
|
expect(index.size()).toBe(0)
|
||
|
|
expect(index.getActiveTypes()).toHaveLength(0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 6. SIZE AND STATISTICS
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Size and Statistics', () => {
|
||
|
|
it('should return total size across all types', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-2', vector: [1, 0.1, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(index.size()).toBe(3)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return size for specific type', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-2', vector: [1, 0.1, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
expect(index.sizeForType('person' as NounType)).toBe(2)
|
||
|
|
expect(index.sizeForType('document' as NounType)).toBe(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return comprehensive statistics', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const stats = index.getStats()
|
||
|
|
|
||
|
|
expect(stats.totalNodes).toBe(2)
|
||
|
|
expect(stats.typeCount).toBe(2)
|
||
|
|
expect(stats.typeStats.has('person' as NounType)).toBe(true)
|
||
|
|
expect(stats.typeStats.has('document' as NounType)).toBe(true)
|
||
|
|
expect(stats.memoryReductionPercent).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return stats for specific type', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const stats = index.getStatsForType('person' as NounType)
|
||
|
|
|
||
|
|
expect(stats).not.toBeNull()
|
||
|
|
expect(stats!.nodeCount).toBe(1)
|
||
|
|
expect(stats!.memoryMB).toBeGreaterThanOrEqual(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return null stats for non-existent type', () => {
|
||
|
|
const stats = index.getStatsForType('person' as NounType)
|
||
|
|
|
||
|
|
expect(stats).toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should calculate memory reduction percentage', async () => {
|
||
|
|
// Add multiple entities to make calculation meaningful
|
||
|
|
for (let i = 0; i < 100; i++) {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: `person-${i}`, vector: [Math.random(), Math.random(), Math.random()] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const stats = index.getStats()
|
||
|
|
|
||
|
|
expect(stats.totalNodes).toBe(100)
|
||
|
|
expect(stats.estimatedMonolithicMemoryMB).toBeGreaterThan(0)
|
||
|
|
expect(stats.memoryReductionPercent).toBeGreaterThanOrEqual(0)
|
||
|
|
expect(stats.memoryReductionPercent).toBeLessThanOrEqual(100)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should handle stats with empty indexes', () => {
|
||
|
|
const stats = index.getStats()
|
||
|
|
|
||
|
|
expect(stats.totalNodes).toBe(0)
|
||
|
|
expect(stats.typeCount).toBe(0)
|
||
|
|
expect(stats.totalMemoryMB).toBe(0)
|
||
|
|
expect(stats.memoryReductionPercent).toBe(0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 7. CONFIGURATION
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Configuration', () => {
|
||
|
|
it('should return HNSW configuration', () => {
|
||
|
|
const config = index.getConfig()
|
||
|
|
|
||
|
|
expect(config.M).toBe(4)
|
||
|
|
expect(config.efConstruction).toBe(50)
|
||
|
|
expect(config.efSearch).toBe(20)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return distance function', () => {
|
||
|
|
const distFn = index.getDistanceFunction()
|
||
|
|
|
||
|
|
expect(distFn).toBe(euclideanDistance)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should get parallelization setting', () => {
|
||
|
|
const parallel = index.getUseParallelization()
|
||
|
|
|
||
|
|
expect(parallel).toBe(true) // Default
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should set parallelization for all indexes', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
index.setUseParallelization(false)
|
||
|
|
|
||
|
|
expect(index.getUseParallelization()).toBe(false)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ===================================================================
|
||
|
|
// 8. ACTIVE TYPES
|
||
|
|
// ===================================================================
|
||
|
|
|
||
|
|
describe('Active Types', () => {
|
||
|
|
it('should return list of types with entities', async () => {
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'person-1', vector: [1, 0, 0] },
|
||
|
|
'person' as NounType
|
||
|
|
)
|
||
|
|
await index.addItem(
|
||
|
|
{ id: 'doc-1', vector: [0, 1, 0] },
|
||
|
|
'document' as NounType
|
||
|
|
)
|
||
|
|
|
||
|
|
const activeTypes = index.getActiveTypes()
|
||
|
|
|
||
|
|
expect(activeTypes).toHaveLength(2)
|
||
|
|
expect(activeTypes).toContain('person')
|
||
|
|
expect(activeTypes).toContain('document')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return empty array when no types have entities', () => {
|
||
|
|
const activeTypes = index.getActiveTypes()
|
||
|
|
|
||
|
|
expect(activeTypes).toHaveLength(0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|