ai-functions
Version:
Core AI primitives for building intelligent applications
713 lines (558 loc) • 19.9 kB
text/typescript
/**
* Tests for caching layer for embeddings and generations
*
* TDD: RED Phase - These tests should fail initially
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
// Cache storage interface and implementations
CacheStorage,
MemoryCache,
// Specialized caches
EmbeddingCache,
GenerationCache,
// Cache wrapper
withCache,
// Utilities
hashKey,
createCacheKey,
// Types
CacheEntry,
CacheOptions,
CacheStats,
} from '../src/index.js'
describe('CacheStorage interface', () => {
describe('MemoryCache', () => {
let cache: MemoryCache<string>
beforeEach(() => {
cache = new MemoryCache<string>()
})
it('stores and retrieves values', async () => {
await cache.set('key1', 'value1')
const result = await cache.get('key1')
expect(result).toBe('value1')
})
it('returns undefined for missing keys', async () => {
const result = await cache.get('nonexistent')
expect(result).toBeUndefined()
})
it('checks if key exists', async () => {
await cache.set('key1', 'value1')
expect(await cache.has('key1')).toBe(true)
expect(await cache.has('nonexistent')).toBe(false)
})
it('deletes keys', async () => {
await cache.set('key1', 'value1')
await cache.delete('key1')
expect(await cache.has('key1')).toBe(false)
})
it('clears all entries', async () => {
await cache.set('key1', 'value1')
await cache.set('key2', 'value2')
await cache.clear()
expect(await cache.has('key1')).toBe(false)
expect(await cache.has('key2')).toBe(false)
})
it('returns cache size', async () => {
expect(await cache.size()).toBe(0)
await cache.set('key1', 'value1')
expect(await cache.size()).toBe(1)
await cache.set('key2', 'value2')
expect(await cache.size()).toBe(2)
})
it('lists all keys', async () => {
await cache.set('key1', 'value1')
await cache.set('key2', 'value2')
const keys = await cache.keys()
expect(keys).toContain('key1')
expect(keys).toContain('key2')
})
})
describe('MemoryCache with TTL', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('expires entries after TTL', async () => {
const cache = new MemoryCache<string>({ defaultTTL: 1000 }) // 1 second TTL
await cache.set('key1', 'value1')
expect(await cache.get('key1')).toBe('value1')
// Advance time past TTL
vi.advanceTimersByTime(1500)
expect(await cache.get('key1')).toBeUndefined()
})
it('allows per-key TTL override', async () => {
const cache = new MemoryCache<string>({ defaultTTL: 1000 })
await cache.set('key1', 'value1', { ttl: 500 })
await cache.set('key2', 'value2', { ttl: 2000 })
vi.advanceTimersByTime(750)
expect(await cache.get('key1')).toBeUndefined() // Expired
expect(await cache.get('key2')).toBe('value2') // Still valid
})
it('supports sliding window TTL refresh on access', async () => {
const cache = new MemoryCache<string>({ defaultTTL: 1000, slidingExpiration: true })
await cache.set('key1', 'value1')
// Access before expiry to refresh TTL
vi.advanceTimersByTime(800)
expect(await cache.get('key1')).toBe('value1') // Refreshes TTL
// Another 800ms should still be valid because we refreshed
vi.advanceTimersByTime(800)
expect(await cache.get('key1')).toBe('value1')
// Without access for full TTL, it should expire
vi.advanceTimersByTime(1100)
expect(await cache.get('key1')).toBeUndefined()
})
it('cleans up expired entries automatically', async () => {
const cache = new MemoryCache<string>({
defaultTTL: 1000,
cleanupInterval: 500
})
await cache.set('key1', 'value1')
expect(await cache.size()).toBe(1)
vi.advanceTimersByTime(1500)
// After cleanup, size should be 0
expect(await cache.size()).toBe(0)
cache.dispose() // Clean up timer
})
})
describe('MemoryCache with LRU eviction', () => {
it('evicts least recently used entries when max size reached', async () => {
const cache = new MemoryCache<string>({ maxSize: 3 })
await cache.set('key1', 'value1')
await cache.set('key2', 'value2')
await cache.set('key3', 'value3')
// Access key1 to make it recently used
await cache.get('key1')
// Add key4, should evict key2 (least recently used)
await cache.set('key4', 'value4')
expect(await cache.size()).toBe(3)
expect(await cache.has('key1')).toBe(true)
expect(await cache.has('key2')).toBe(false) // Evicted
expect(await cache.has('key3')).toBe(true)
expect(await cache.has('key4')).toBe(true)
})
})
})
describe('Cache key generation', () => {
describe('hashKey', () => {
it('generates consistent hashes for same input', () => {
const hash1 = hashKey('hello world')
const hash2 = hashKey('hello world')
expect(hash1).toBe(hash2)
})
it('generates different hashes for different inputs', () => {
const hash1 = hashKey('hello')
const hash2 = hashKey('world')
expect(hash1).not.toBe(hash2)
})
it('handles objects by serializing them', () => {
const hash1 = hashKey({ a: 1, b: 2 })
const hash2 = hashKey({ a: 1, b: 2 })
const hash3 = hashKey({ b: 2, a: 1 }) // Different key order
expect(hash1).toBe(hash2)
// Sorted keys should produce same hash
expect(hash1).toBe(hash3)
})
it('handles arrays', () => {
const hash1 = hashKey([1, 2, 3])
const hash2 = hashKey([1, 2, 3])
const hash3 = hashKey([3, 2, 1])
expect(hash1).toBe(hash2)
expect(hash1).not.toBe(hash3)
})
})
describe('createCacheKey', () => {
it('creates content-addressable keys for embeddings', () => {
const key1 = createCacheKey('embedding', { content: 'hello world', model: 'text-embedding-3-small' })
const key2 = createCacheKey('embedding', { content: 'hello world', model: 'text-embedding-3-small' })
const key3 = createCacheKey('embedding', { content: 'different', model: 'text-embedding-3-small' })
expect(key1).toBe(key2)
expect(key1).not.toBe(key3)
expect(key1).toContain('embedding:')
})
it('creates parameter-aware keys for generations', () => {
const key1 = createCacheKey('generation', {
prompt: 'Write a poem',
model: 'sonnet',
temperature: 0.7
})
const key2 = createCacheKey('generation', {
prompt: 'Write a poem',
model: 'sonnet',
temperature: 0.7
})
const key3 = createCacheKey('generation', {
prompt: 'Write a poem',
model: 'sonnet',
temperature: 0.9 // Different temperature
})
expect(key1).toBe(key2)
expect(key1).not.toBe(key3)
expect(key1).toContain('generation:')
})
it('includes schema version in structured output keys', () => {
const key1 = createCacheKey('generation', {
prompt: 'Extract data',
model: 'sonnet',
schemaVersion: 'v1'
})
const key2 = createCacheKey('generation', {
prompt: 'Extract data',
model: 'sonnet',
schemaVersion: 'v2'
})
expect(key1).not.toBe(key2)
})
})
})
describe('EmbeddingCache', () => {
let embeddingCache: EmbeddingCache
beforeEach(() => {
embeddingCache = new EmbeddingCache()
})
it('stores and retrieves embeddings by content hash', async () => {
const embedding = [0.1, 0.2, 0.3, 0.4, 0.5]
await embeddingCache.set('hello world', embedding, { model: 'text-embedding-3-small' })
const result = await embeddingCache.get('hello world', { model: 'text-embedding-3-small' })
expect(result).toEqual(embedding)
})
it('returns undefined for cache miss', async () => {
const result = await embeddingCache.get('unknown text', { model: 'text-embedding-3-small' })
expect(result).toBeUndefined()
})
it('differentiates by model', async () => {
const embedding1 = [0.1, 0.2, 0.3]
const embedding2 = [0.4, 0.5, 0.6]
await embeddingCache.set('hello', embedding1, { model: 'model-a' })
await embeddingCache.set('hello', embedding2, { model: 'model-b' })
expect(await embeddingCache.get('hello', { model: 'model-a' })).toEqual(embedding1)
expect(await embeddingCache.get('hello', { model: 'model-b' })).toEqual(embedding2)
})
it('caches batch embeddings', async () => {
const texts = ['doc1', 'doc2', 'doc3']
const embeddings = [
[0.1, 0.2],
[0.3, 0.4],
[0.5, 0.6]
]
await embeddingCache.setMany(texts, embeddings, { model: 'text-embedding-3-small' })
expect(await embeddingCache.get('doc1', { model: 'text-embedding-3-small' })).toEqual([0.1, 0.2])
expect(await embeddingCache.get('doc2', { model: 'text-embedding-3-small' })).toEqual([0.3, 0.4])
expect(await embeddingCache.get('doc3', { model: 'text-embedding-3-small' })).toEqual([0.5, 0.6])
})
it('returns partial hits for batch lookups', async () => {
// Pre-populate some embeddings
await embeddingCache.set('doc1', [0.1, 0.2], { model: 'model-a' })
await embeddingCache.set('doc3', [0.5, 0.6], { model: 'model-a' })
const result = await embeddingCache.getMany(['doc1', 'doc2', 'doc3'], { model: 'model-a' })
expect(result.hits).toEqual({
'doc1': [0.1, 0.2],
'doc3': [0.5, 0.6]
})
expect(result.misses).toEqual(['doc2'])
})
it('provides cache statistics', async () => {
await embeddingCache.set('doc1', [0.1], { model: 'model-a' })
await embeddingCache.get('doc1', { model: 'model-a' }) // Hit
await embeddingCache.get('doc2', { model: 'model-a' }) // Miss
const stats = embeddingCache.getStats()
expect(stats.hits).toBe(1)
expect(stats.misses).toBe(1)
expect(stats.hitRate).toBeCloseTo(0.5)
expect(stats.size).toBe(1)
})
})
describe('GenerationCache', () => {
let generationCache: GenerationCache
beforeEach(() => {
generationCache = new GenerationCache()
})
it('caches generation results by prompt and parameters', async () => {
const result = { text: 'Hello, world!' }
await generationCache.set({
prompt: 'Say hello',
model: 'sonnet',
temperature: 0.7
}, result)
const cached = await generationCache.get({
prompt: 'Say hello',
model: 'sonnet',
temperature: 0.7
})
expect(cached).toEqual(result)
})
it('returns undefined for cache miss', async () => {
const cached = await generationCache.get({
prompt: 'Unknown prompt',
model: 'sonnet'
})
expect(cached).toBeUndefined()
})
it('differentiates by temperature', async () => {
const result1 = { text: 'Deterministic response' }
const result2 = { text: 'Creative response' }
await generationCache.set({
prompt: 'Write something',
model: 'sonnet',
temperature: 0
}, result1)
await generationCache.set({
prompt: 'Write something',
model: 'sonnet',
temperature: 1
}, result2)
expect(await generationCache.get({
prompt: 'Write something',
model: 'sonnet',
temperature: 0
})).toEqual(result1)
expect(await generationCache.get({
prompt: 'Write something',
model: 'sonnet',
temperature: 1
})).toEqual(result2)
})
it('differentiates by model', async () => {
await generationCache.set({
prompt: 'Hello',
model: 'sonnet'
}, { text: 'Sonnet response' })
await generationCache.set({
prompt: 'Hello',
model: 'opus'
}, { text: 'Opus response' })
expect(await generationCache.get({
prompt: 'Hello',
model: 'sonnet'
})).toEqual({ text: 'Sonnet response' })
expect(await generationCache.get({
prompt: 'Hello',
model: 'opus'
})).toEqual({ text: 'Opus response' })
})
it('includes system prompt in cache key', async () => {
await generationCache.set({
prompt: 'Hello',
model: 'sonnet',
system: 'You are a pirate'
}, { text: 'Ahoy!' })
await generationCache.set({
prompt: 'Hello',
model: 'sonnet',
system: 'You are a robot'
}, { text: 'Beep boop' })
expect(await generationCache.get({
prompt: 'Hello',
model: 'sonnet',
system: 'You are a pirate'
})).toEqual({ text: 'Ahoy!' })
})
it('supports schema versioning for structured outputs', async () => {
await generationCache.set({
prompt: 'Extract user',
model: 'sonnet',
schemaVersion: 'v1'
}, { object: { name: 'John' } })
await generationCache.set({
prompt: 'Extract user',
model: 'sonnet',
schemaVersion: 'v2'
}, { object: { name: 'John', age: 30 } })
expect(await generationCache.get({
prompt: 'Extract user',
model: 'sonnet',
schemaVersion: 'v1'
})).toEqual({ object: { name: 'John' } })
expect(await generationCache.get({
prompt: 'Extract user',
model: 'sonnet',
schemaVersion: 'v2'
})).toEqual({ object: { name: 'John', age: 30 } })
})
it('supports cache bypass option', async () => {
await generationCache.set({
prompt: 'Hello',
model: 'sonnet'
}, { text: 'Cached response' })
// With bypass, should return undefined
const bypassResult = await generationCache.get({
prompt: 'Hello',
model: 'sonnet'
}, { bypass: true })
expect(bypassResult).toBeUndefined()
// Without bypass, should return cached
const cachedResult = await generationCache.get({
prompt: 'Hello',
model: 'sonnet'
})
expect(cachedResult).toEqual({ text: 'Cached response' })
})
it('provides cache statistics', async () => {
await generationCache.set({ prompt: 'test', model: 'sonnet' }, { text: 'result' })
await generationCache.get({ prompt: 'test', model: 'sonnet' }) // Hit
await generationCache.get({ prompt: 'other', model: 'sonnet' }) // Miss
const stats = generationCache.getStats()
expect(stats.hits).toBe(1)
expect(stats.misses).toBe(1)
expect(stats.hitRate).toBeCloseTo(0.5)
})
})
describe('withCache wrapper', () => {
let cache: MemoryCache<string>
beforeEach(() => {
cache = new MemoryCache<string>()
})
it('wraps an async function with caching', async () => {
let callCount = 0
const expensiveOperation = async (input: string) => {
callCount++
return `Result for ${input}`
}
const cachedOperation = withCache(cache, expensiveOperation, {
keyFn: (input) => input
})
// First call - should execute function
const result1 = await cachedOperation('test')
expect(result1).toBe('Result for test')
expect(callCount).toBe(1)
// Second call with same input - should use cache
const result2 = await cachedOperation('test')
expect(result2).toBe('Result for test')
expect(callCount).toBe(1) // Still 1, didn't call again
// Different input - should execute function
const result3 = await cachedOperation('other')
expect(result3).toBe('Result for other')
expect(callCount).toBe(2)
})
it('supports custom key generation', async () => {
const fn = async (a: number, b: number) => a + b
const cachedFn = withCache(cache, fn, {
keyFn: (a, b) => `sum:${a}:${b}`
})
await cachedFn(1, 2)
await cachedFn(1, 2)
expect(await cache.has('sum:1:2')).toBe(true)
})
it('respects TTL option', async () => {
vi.useFakeTimers()
let callCount = 0
const fn = async () => {
callCount++
return 'result'
}
const ttlCache = new MemoryCache<string>({ defaultTTL: 1000 })
const cachedFn = withCache(ttlCache, fn, { keyFn: () => 'key' })
await cachedFn()
expect(callCount).toBe(1)
await cachedFn()
expect(callCount).toBe(1) // Cached
vi.advanceTimersByTime(1500)
await cachedFn()
expect(callCount).toBe(2) // Cache expired, called again
vi.useRealTimers()
})
it('handles errors without caching them', async () => {
let callCount = 0
const failingFn = async () => {
callCount++
throw new Error('Failed')
}
const cachedFn = withCache(cache, failingFn, { keyFn: () => 'key' })
await expect(cachedFn()).rejects.toThrow('Failed')
expect(callCount).toBe(1)
// Should retry since error wasn't cached
await expect(cachedFn()).rejects.toThrow('Failed')
expect(callCount).toBe(2)
// Nothing should be in cache
expect(await cache.has('key')).toBe(false)
})
it('supports bypass option', async () => {
let callCount = 0
const fn = async () => {
callCount++
return `result-${callCount}`
}
const cachedFn = withCache(cache, fn, { keyFn: () => 'key' })
const result1 = await cachedFn()
expect(result1).toBe('result-1')
// Force fresh result
const result2 = await cachedFn.bypass()
expect(result2).toBe('result-2')
expect(callCount).toBe(2)
// Cache should be updated with new result
const result3 = await cachedFn()
expect(result3).toBe('result-2')
expect(callCount).toBe(2)
})
})
describe('Cache entry metadata', () => {
it('tracks creation time', async () => {
const cache = new MemoryCache<string>()
const before = Date.now()
await cache.set('key', 'value')
const entry = await cache.getEntry('key')
expect(entry).toBeDefined()
expect(entry!.createdAt).toBeGreaterThanOrEqual(before)
expect(entry!.createdAt).toBeLessThanOrEqual(Date.now())
})
it('tracks access time', async () => {
vi.useFakeTimers()
const cache = new MemoryCache<string>()
await cache.set('key', 'value')
const entry1 = await cache.getEntry('key')
const createdAt = entry1!.createdAt
vi.advanceTimersByTime(1000)
await cache.get('key')
const entry2 = await cache.getEntry('key')
expect(entry2!.lastAccessedAt).toBeGreaterThan(createdAt)
vi.useRealTimers()
})
it('tracks access count', async () => {
const cache = new MemoryCache<string>()
await cache.set('key', 'value')
await cache.get('key')
await cache.get('key')
await cache.get('key')
const entry = await cache.getEntry('key')
expect(entry!.accessCount).toBe(3)
})
})
describe('Distributed cache interface', () => {
// These tests verify the interface contracts for Redis-like backends
it('CacheStorage interface is properly typed', () => {
// This is a compile-time check
const mockStorage: CacheStorage<string> = {
get: async () => undefined,
set: async () => {},
has: async () => false,
delete: async () => {},
clear: async () => {},
size: async () => 0,
keys: async () => []
}
expect(mockStorage).toBeDefined()
})
it('MemoryCache implements CacheStorage', () => {
const cache = new MemoryCache<string>()
// Verify all methods exist
expect(typeof cache.get).toBe('function')
expect(typeof cache.set).toBe('function')
expect(typeof cache.has).toBe('function')
expect(typeof cache.delete).toBe('function')
expect(typeof cache.clear).toBe('function')
expect(typeof cache.size).toBe('function')
expect(typeof cache.keys).toBe('function')
})
})
describe('Cache serialization', () => {
it('serializes cache entries for distributed storage', async () => {
const cache = new MemoryCache<{ data: number[] }>()
await cache.set('vectors', { data: [0.1, 0.2, 0.3] })
const entry = await cache.getEntry('vectors')
const serialized = JSON.stringify(entry)
const deserialized = JSON.parse(serialized)
expect(deserialized.value.data).toEqual([0.1, 0.2, 0.3])
})
})