Files
wifi-densepose/npm/packages/ruvbot/tests/unit/domain/memory.test.ts
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

711 lines
18 KiB
TypeScript

/**
* Memory Domain Entity - Unit Tests
*
* Tests for Memory storage, retrieval, and vector operations
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMemory, createVectorMemory, type Memory, type MemoryMetadata } from '../../factories';
// Memory Entity Types
interface MemoryEntry {
id: string;
tenantId: string;
sessionId: string | null;
type: 'short-term' | 'long-term' | 'vector' | 'episodic';
key: string;
value: unknown;
embedding: Float32Array | null;
metadata: MemoryEntryMetadata;
}
interface MemoryEntryMetadata {
createdAt: Date;
updatedAt: Date;
expiresAt: Date | null;
accessCount: number;
importance: number;
tags: string[];
}
interface VectorSearchResult {
entry: MemoryEntry;
score: number;
distance: number;
}
// Mock Memory Store class for testing
class MemoryStore {
private entries: Map<string, MemoryEntry> = new Map();
private indexByKey: Map<string, Set<string>> = new Map();
private indexByTenant: Map<string, Set<string>> = new Map();
private indexBySession: Map<string, Set<string>> = new Map();
private readonly dimension: number;
constructor(dimension: number = 384) {
this.dimension = dimension;
}
async set(entry: Omit<MemoryEntry, 'metadata'> & { metadata?: Partial<MemoryEntryMetadata> }): Promise<MemoryEntry> {
const fullEntry: MemoryEntry = {
...entry,
metadata: {
createdAt: entry.metadata?.createdAt || new Date(),
updatedAt: new Date(),
expiresAt: entry.metadata?.expiresAt || null,
accessCount: entry.metadata?.accessCount || 0,
importance: entry.metadata?.importance || 0.5,
tags: entry.metadata?.tags || []
}
};
// Validate embedding dimension
if (fullEntry.embedding && fullEntry.embedding.length !== this.dimension) {
throw new Error(`Embedding dimension mismatch: expected ${this.dimension}, got ${fullEntry.embedding.length}`);
}
this.entries.set(entry.id, fullEntry);
this.updateIndexes(fullEntry);
return fullEntry;
}
async get(id: string): Promise<MemoryEntry | null> {
const entry = this.entries.get(id);
if (entry) {
entry.metadata.accessCount++;
entry.metadata.updatedAt = new Date();
}
return entry || null;
}
async getByKey(key: string, tenantId: string): Promise<MemoryEntry | null> {
const ids = this.indexByKey.get(key);
if (!ids) return null;
for (const id of ids) {
const entry = this.entries.get(id);
if (entry && entry.tenantId === tenantId) {
entry.metadata.accessCount++;
return entry;
}
}
return null;
}
async delete(id: string): Promise<boolean> {
const entry = this.entries.get(id);
if (!entry) return false;
this.removeFromIndexes(entry);
return this.entries.delete(id);
}
async deleteByKey(key: string, tenantId: string): Promise<boolean> {
const entry = await this.getByKey(key, tenantId);
if (!entry) return false;
return this.delete(entry.id);
}
async listByTenant(tenantId: string, limit: number = 100): Promise<MemoryEntry[]> {
const ids = this.indexByTenant.get(tenantId);
if (!ids) return [];
const entries: MemoryEntry[] = [];
for (const id of ids) {
const entry = this.entries.get(id);
if (entry) entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
async listBySession(sessionId: string, limit: number = 100): Promise<MemoryEntry[]> {
const ids = this.indexBySession.get(sessionId);
if (!ids) return [];
const entries: MemoryEntry[] = [];
for (const id of ids) {
const entry = this.entries.get(id);
if (entry) entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
async search(query: Float32Array, tenantId: string, topK: number = 10): Promise<VectorSearchResult[]> {
if (query.length !== this.dimension) {
throw new Error(`Query dimension mismatch: expected ${this.dimension}, got ${query.length}`);
}
const results: VectorSearchResult[] = [];
const tenantIds = this.indexByTenant.get(tenantId);
if (!tenantIds) return [];
for (const id of tenantIds) {
const entry = this.entries.get(id);
if (entry?.embedding) {
const score = this.cosineSimilarity(query, entry.embedding);
results.push({
entry,
score,
distance: 1 - score
});
}
}
return results
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
async expire(): Promise<number> {
const now = new Date();
let expiredCount = 0;
for (const [id, entry] of this.entries) {
if (entry.metadata.expiresAt && entry.metadata.expiresAt < now) {
this.delete(id);
expiredCount++;
}
}
return expiredCount;
}
async clear(tenantId?: string): Promise<number> {
if (tenantId) {
const ids = this.indexByTenant.get(tenantId);
if (!ids) return 0;
let deletedCount = 0;
for (const id of Array.from(ids)) {
if (this.delete(id)) deletedCount++;
}
return deletedCount;
}
const count = this.entries.size;
this.entries.clear();
this.indexByKey.clear();
this.indexByTenant.clear();
this.indexBySession.clear();
return count;
}
size(): number {
return this.entries.size;
}
sizeByTenant(tenantId: string): number {
return this.indexByTenant.get(tenantId)?.size || 0;
}
private updateIndexes(entry: MemoryEntry): void {
// Key index
let keySet = this.indexByKey.get(entry.key);
if (!keySet) {
keySet = new Set();
this.indexByKey.set(entry.key, keySet);
}
keySet.add(entry.id);
// Tenant index
let tenantSet = this.indexByTenant.get(entry.tenantId);
if (!tenantSet) {
tenantSet = new Set();
this.indexByTenant.set(entry.tenantId, tenantSet);
}
tenantSet.add(entry.id);
// Session index
if (entry.sessionId) {
let sessionSet = this.indexBySession.get(entry.sessionId);
if (!sessionSet) {
sessionSet = new Set();
this.indexBySession.set(entry.sessionId, sessionSet);
}
sessionSet.add(entry.id);
}
}
private removeFromIndexes(entry: MemoryEntry): void {
this.indexByKey.get(entry.key)?.delete(entry.id);
this.indexByTenant.get(entry.tenantId)?.delete(entry.id);
if (entry.sessionId) {
this.indexBySession.get(entry.sessionId)?.delete(entry.id);
}
}
private cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
}
// Tests
describe('Memory Store', () => {
let store: MemoryStore;
beforeEach(() => {
store = new MemoryStore(384);
});
describe('Basic Operations', () => {
it('should set and get memory entry', async () => {
const entry = await store.set({
id: 'mem-001',
tenantId: 'tenant-001',
sessionId: null,
type: 'long-term',
key: 'test-key',
value: { data: 'test' },
embedding: null
});
const retrieved = await store.get('mem-001');
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe('mem-001');
expect(retrieved?.value).toEqual({ data: 'test' });
});
it('should return null for non-existent entry', async () => {
const entry = await store.get('non-existent');
expect(entry).toBeNull();
});
it('should increment access count on get', async () => {
await store.set({
id: 'mem-001',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'test',
value: 'test',
embedding: null
});
await store.get('mem-001');
await store.get('mem-001');
const entry = await store.get('mem-001');
expect(entry?.metadata.accessCount).toBe(3);
});
it('should delete entry', async () => {
await store.set({
id: 'mem-001',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'test',
value: 'test',
embedding: null
});
const deleted = await store.delete('mem-001');
const entry = await store.get('mem-001');
expect(deleted).toBe(true);
expect(entry).toBeNull();
});
it('should return false when deleting non-existent entry', async () => {
const deleted = await store.delete('non-existent');
expect(deleted).toBe(false);
});
});
describe('Key-based Operations', () => {
it('should get entry by key and tenant', async () => {
await store.set({
id: 'mem-001',
tenantId: 'tenant-001',
sessionId: null,
type: 'long-term',
key: 'unique-key',
value: 'value1',
embedding: null
});
await store.set({
id: 'mem-002',
tenantId: 'tenant-002',
sessionId: null,
type: 'long-term',
key: 'unique-key',
value: 'value2',
embedding: null
});
const entry1 = await store.getByKey('unique-key', 'tenant-001');
const entry2 = await store.getByKey('unique-key', 'tenant-002');
expect(entry1?.value).toBe('value1');
expect(entry2?.value).toBe('value2');
});
it('should delete by key', async () => {
await store.set({
id: 'mem-001',
tenantId: 'tenant-001',
sessionId: null,
type: 'long-term',
key: 'to-delete',
value: 'test',
embedding: null
});
const deleted = await store.deleteByKey('to-delete', 'tenant-001');
const entry = await store.getByKey('to-delete', 'tenant-001');
expect(deleted).toBe(true);
expect(entry).toBeNull();
});
});
describe('Listing Operations', () => {
beforeEach(async () => {
for (let i = 0; i < 5; i++) {
await store.set({
id: `mem-${i}`,
tenantId: 'tenant-001',
sessionId: 'session-001',
type: 'short-term',
key: `key-${i}`,
value: `value-${i}`,
embedding: null
});
}
for (let i = 5; i < 8; i++) {
await store.set({
id: `mem-${i}`,
tenantId: 'tenant-002',
sessionId: 'session-002',
type: 'short-term',
key: `key-${i}`,
value: `value-${i}`,
embedding: null
});
}
});
it('should list entries by tenant', async () => {
const entries = await store.listByTenant('tenant-001');
expect(entries).toHaveLength(5);
entries.forEach(e => expect(e.tenantId).toBe('tenant-001'));
});
it('should list entries by session', async () => {
const entries = await store.listBySession('session-001');
expect(entries).toHaveLength(5);
entries.forEach(e => expect(e.sessionId).toBe('session-001'));
});
it('should respect limit parameter', async () => {
const entries = await store.listByTenant('tenant-001', 3);
expect(entries).toHaveLength(3);
});
it('should return empty array for unknown tenant', async () => {
const entries = await store.listByTenant('unknown');
expect(entries).toEqual([]);
});
});
describe('Vector Operations', () => {
const createRandomEmbedding = (dim: number): Float32Array => {
const arr = new Float32Array(dim);
let norm = 0;
for (let i = 0; i < dim; i++) {
arr[i] = Math.random() - 0.5;
norm += arr[i] * arr[i];
}
norm = Math.sqrt(norm);
for (let i = 0; i < dim; i++) {
arr[i] /= norm;
}
return arr;
};
it('should search by vector similarity', async () => {
const embedding1 = createRandomEmbedding(384);
const embedding2 = createRandomEmbedding(384);
const embedding3 = createRandomEmbedding(384);
await store.set({
id: 'vec-1',
tenantId: 'tenant-001',
sessionId: null,
type: 'vector',
key: 'doc-1',
value: { text: 'Document 1' },
embedding: embedding1
});
await store.set({
id: 'vec-2',
tenantId: 'tenant-001',
sessionId: null,
type: 'vector',
key: 'doc-2',
value: { text: 'Document 2' },
embedding: embedding2
});
await store.set({
id: 'vec-3',
tenantId: 'tenant-001',
sessionId: null,
type: 'vector',
key: 'doc-3',
value: { text: 'Document 3' },
embedding: embedding3
});
const results = await store.search(embedding1, 'tenant-001', 2);
expect(results).toHaveLength(2);
expect(results[0].entry.id).toBe('vec-1'); // Most similar to itself
expect(results[0].score).toBeCloseTo(1, 5);
});
it('should throw error for dimension mismatch on set', async () => {
const wrongDimensionEmbedding = new Float32Array(256);
await expect(store.set({
id: 'vec-wrong',
tenantId: 'tenant-001',
sessionId: null,
type: 'vector',
key: 'wrong',
value: {},
embedding: wrongDimensionEmbedding
})).rejects.toThrow('dimension mismatch');
});
it('should throw error for dimension mismatch on search', async () => {
const wrongDimensionQuery = new Float32Array(256);
await expect(store.search(wrongDimensionQuery, 'tenant-001'))
.rejects.toThrow('dimension mismatch');
});
it('should only search within tenant', async () => {
const embedding = createRandomEmbedding(384);
await store.set({
id: 'vec-1',
tenantId: 'tenant-001',
sessionId: null,
type: 'vector',
key: 'doc-1',
value: {},
embedding
});
await store.set({
id: 'vec-2',
tenantId: 'tenant-002',
sessionId: null,
type: 'vector',
key: 'doc-2',
value: {},
embedding
});
const results = await store.search(embedding, 'tenant-001');
expect(results).toHaveLength(1);
expect(results[0].entry.tenantId).toBe('tenant-001');
});
});
describe('Expiration', () => {
it('should expire entries', async () => {
const pastDate = new Date(Date.now() - 1000);
const futureDate = new Date(Date.now() + 100000);
await store.set({
id: 'expired',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'expired',
value: 'test',
embedding: null,
metadata: { expiresAt: pastDate }
});
await store.set({
id: 'not-expired',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'not-expired',
value: 'test',
embedding: null,
metadata: { expiresAt: futureDate }
});
const expiredCount = await store.expire();
expect(expiredCount).toBe(1);
expect(await store.get('expired')).toBeNull();
expect(await store.get('not-expired')).not.toBeNull();
});
});
describe('Clear Operations', () => {
beforeEach(async () => {
await store.set({
id: 'mem-1',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'key-1',
value: 'test',
embedding: null
});
await store.set({
id: 'mem-2',
tenantId: 'tenant-002',
sessionId: null,
type: 'short-term',
key: 'key-2',
value: 'test',
embedding: null
});
});
it('should clear all entries', async () => {
const cleared = await store.clear();
expect(cleared).toBe(2);
expect(store.size()).toBe(0);
});
it('should clear entries by tenant', async () => {
const cleared = await store.clear('tenant-001');
expect(cleared).toBe(1);
expect(store.sizeByTenant('tenant-001')).toBe(0);
expect(store.sizeByTenant('tenant-002')).toBe(1);
});
});
describe('Size Operations', () => {
it('should return total size', async () => {
await store.set({
id: 'mem-1',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'k1',
value: 'v1',
embedding: null
});
await store.set({
id: 'mem-2',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'k2',
value: 'v2',
embedding: null
});
expect(store.size()).toBe(2);
});
it('should return size by tenant', async () => {
await store.set({
id: 'mem-1',
tenantId: 'tenant-001',
sessionId: null,
type: 'short-term',
key: 'k1',
value: 'v1',
embedding: null
});
await store.set({
id: 'mem-2',
tenantId: 'tenant-002',
sessionId: null,
type: 'short-term',
key: 'k2',
value: 'v2',
embedding: null
});
expect(store.sizeByTenant('tenant-001')).toBe(1);
expect(store.sizeByTenant('tenant-002')).toBe(1);
});
});
});
describe('Memory Factory Integration', () => {
let store: MemoryStore;
beforeEach(() => {
store = new MemoryStore(384);
});
it('should create memory from factory data', async () => {
const factoryMemory = createMemory({
key: 'factory-key',
value: { factory: 'data' },
type: 'long-term'
});
const entry = await store.set({
id: factoryMemory.id,
tenantId: factoryMemory.tenantId,
sessionId: factoryMemory.sessionId,
type: factoryMemory.type,
key: factoryMemory.key,
value: factoryMemory.value,
embedding: factoryMemory.embedding
});
expect(entry.key).toBe('factory-key');
expect(entry.type).toBe('long-term');
});
it('should create vector memory from factory data', async () => {
const factoryMemory = createVectorMemory(384, {
key: 'vector-key',
value: { text: 'Test document' }
});
const entry = await store.set({
id: factoryMemory.id,
tenantId: factoryMemory.tenantId,
sessionId: factoryMemory.sessionId,
type: factoryMemory.type,
key: factoryMemory.key,
value: factoryMemory.value,
embedding: factoryMemory.embedding
});
expect(entry.embedding).not.toBeNull();
expect(entry.embedding?.length).toBe(384);
expect(entry.type).toBe('vector');
});
});