From f4aed38ecbbaeb5d2bc692510e1a47b03a2950a4 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Sat, 28 Mar 2026 12:28:17 +0100 Subject: [PATCH 1/2] Handle embedding fallback dimension drift --- src/embedding/provider.ts | 199 ++++++-- src/memory/observations.ts | 54 ++- src/store/orama-store.ts | 102 ++-- tests/embedding/provider.test.ts | 112 ++++- .../http-embedding-fallback.test.ts | 440 ++++++++++++++++++ tests/memory/vector-stability.test.ts | 90 ++++ tests/search/orama-store-semantic.test.ts | 45 ++ 7 files changed, 949 insertions(+), 93 deletions(-) create mode 100644 tests/integration/http-embedding-fallback.test.ts diff --git a/src/embedding/provider.ts b/src/embedding/provider.ts index eb6012d..4ac90cc 100644 --- a/src/embedding/provider.ts +++ b/src/embedding/provider.ts @@ -42,6 +42,9 @@ export interface EmbeddingProvider { let provider: EmbeddingProvider | null = null; let initPromise: Promise | null = null; +type EmbeddingMode = 'off' | 'fastembed' | 'transformers' | 'api' | 'auto'; +type ProviderKind = 'api' | 'fastembed' | 'transformers' | 'unknown'; + /** * Tracks whether the last init attempt resulted in a temporary failure * (mode != 'off' but provider returned null). When true, the next @@ -53,7 +56,7 @@ let lastInitWasTemporaryFailure = false; * Get configured embedding mode from environment. * Default is 'off' to minimize resource usage. */ -function getEmbeddingMode(): 'off' | 'fastembed' | 'transformers' | 'api' | 'auto' { +function getEmbeddingMode(): EmbeddingMode { // Unified: env vars > config.json > 'off' try { const { getEmbeddingMode: cfgMode } = require('../config.js'); @@ -89,6 +92,127 @@ function hasAPIEmbeddingConfig(): boolean { } } +function getProviderKind(candidate: EmbeddingProvider): ProviderKind { + if (candidate.name.startsWith('api-')) return 'api'; + if (candidate.name.startsWith('fastembed-')) return 'fastembed'; + if (candidate.name.startsWith('transformers-')) return 'transformers'; + return 'unknown'; +} + +function isTemporaryEmbeddingFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + /embedding api timeout/i.test(error.message) || + /embedding api error \((402|429|5\d\d)\)/i.test(error.message) || + /quota exceeded/i.test(error.message) || + /account balance/i.test(error.message) || + /fetch failed/i.test(error.message) || + /econnreset/i.test(error.message) || + /econnrefused/i.test(error.message) || + /temporarily unavailable/i.test(error.message) + ); +} + +function markTemporaryFailure(reason: unknown): void { + provider = null; + initPromise = null; + lastInitWasTemporaryFailure = true; + lastFailureTimestamp = Date.now(); + const message = reason instanceof Error ? reason.message : String(reason); + console.error(`[memorix] Embedding provider temporarily unavailable at runtime: ${message}`); +} + +async function createFastEmbedProvider(): Promise { + try { + const { FastEmbedProvider } = await import('./fastembed-provider.js'); + return await FastEmbedProvider.create(); + } catch (e) { + console.error(`[memorix] Failed to load fastembed: ${e instanceof Error ? e.message : e}`); + console.error('[memorix] Install with: npm install fastembed'); + return null; + } +} + +async function createTransformersProvider(): Promise { + try { + const { TransformersProvider } = await import('./transformers-provider.js'); + return await TransformersProvider.create(); + } catch (e) { + console.error(`[memorix] Failed to load transformers: ${e instanceof Error ? e.message : e}`); + console.error('[memorix] Install with: npm install @huggingface/transformers'); + return null; + } +} + +async function createAPIProvider(): Promise { + try { + const { APIEmbeddingProvider } = await import('./api-provider.js'); + return await APIEmbeddingProvider.create(); + } catch (e) { + console.error(`[memorix] Failed to init API embedding: ${e instanceof Error ? e.message : e}`); + return null; + } +} + +async function createLocalFallbackProvider(): Promise { + const fastembed = await createFastEmbedProvider(); + if (fastembed) return fastembed; + + const transformers = await createTransformersProvider(); + if (transformers) return transformers; + + return null; +} + +function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider { + const kind = getProviderKind(candidate); + + return { + name: candidate.name, + dimensions: candidate.dimensions, + async embed(text: string): Promise { + try { + return await candidate.embed(text); + } catch (error) { + if (kind === 'api' && getEmbeddingMode() === 'auto' && isTemporaryEmbeddingFailure(error)) { + console.error('[memorix] API embedding temporarily unavailable — switching to local fallback provider'); + const fallback = await createLocalFallbackProvider(); + if (fallback) { + provider = wrapProvider(fallback); + console.error(`[memorix] Embedding fallback activated: ${provider.name} (${provider.dimensions}d)`); + return provider.embed(text); + } + } + + if (isTemporaryEmbeddingFailure(error)) { + markTemporaryFailure(error); + } + throw error; + } + }, + async embedBatch(texts: string[]): Promise { + try { + return await candidate.embedBatch(texts); + } catch (error) { + if (kind === 'api' && getEmbeddingMode() === 'auto' && isTemporaryEmbeddingFailure(error)) { + console.error('[memorix] API embedding temporarily unavailable — switching to local fallback provider'); + const fallback = await createLocalFallbackProvider(); + if (fallback) { + provider = wrapProvider(fallback); + console.error(`[memorix] Embedding fallback activated: ${provider.name} (${provider.dimensions}d)`); + return provider.embedBatch(texts); + } + } + + if (isTemporaryEmbeddingFailure(error)) { + markTemporaryFailure(error); + } + throw error; + } + }, + }; +} + /** Minimum interval between retry attempts after a temporary failure (ms). */ const RETRY_COOLDOWN_MS = 30_000; let lastFailureTimestamp = 0; @@ -133,73 +257,46 @@ export async function getEmbeddingProvider(): Promise // Explicit fastembed if (mode === 'fastembed') { - try { - const { FastEmbedProvider } = await import('./fastembed-provider.js'); - provider = await FastEmbedProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); - return provider; - } catch (e) { - console.error(`[memorix] Failed to load fastembed: ${e instanceof Error ? e.message : e}`); - console.error('[memorix] Install with: npm install fastembed'); - return null; - } + const initialized = await createFastEmbedProvider(); + if (!initialized) return null; + provider = wrapProvider(initialized); + console.error(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`); + return provider; } // Explicit transformers if (mode === 'transformers') { - try { - const { TransformersProvider } = await import('./transformers-provider.js'); - provider = await TransformersProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); - return provider; - } catch (e) { - console.error(`[memorix] Failed to load transformers: ${e instanceof Error ? e.message : e}`); - console.error('[memorix] Install with: npm install @huggingface/transformers'); - return null; - } + const initialized = await createTransformersProvider(); + if (!initialized) return null; + provider = wrapProvider(initialized); + console.error(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`); + return provider; } // API mode: remote embedding via OpenAI-compatible endpoint if (mode === 'api') { - try { - const { APIEmbeddingProvider } = await import('./api-provider.js'); - provider = await APIEmbeddingProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); - return provider; - } catch (e) { - console.error(`[memorix] Failed to init API embedding: ${e instanceof Error ? e.message : e}`); - return null; - } + const initialized = await createAPIProvider(); + if (!initialized) return null; + provider = wrapProvider(initialized); + console.error(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`); + return provider; } // Auto mode: try configured API first, then local fallbacks if (hasAPIEmbeddingConfig()) { - try { - const { APIEmbeddingProvider } = await import('./api-provider.js'); - provider = await APIEmbeddingProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); + const initialized = await createAPIProvider(); + if (initialized) { + provider = wrapProvider(initialized); + console.error(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`); return provider; - } catch (e) { - console.error(`[memorix] API embedding unavailable in auto mode: ${e instanceof Error ? e.message : e}`); } } - try { - const { FastEmbedProvider } = await import('./fastembed-provider.js'); - provider = await FastEmbedProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); - return provider; - } catch { - // fastembed not installed — try next - } - - try { - const { TransformersProvider } = await import('./transformers-provider.js'); - provider = await TransformersProvider.create(); - console.error(`[memorix] Embedding provider: ${provider!.name} (${provider!.dimensions}d)`); + const localFallback = await createLocalFallbackProvider(); + if (localFallback) { + provider = wrapProvider(localFallback); + console.error(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`); return provider; - } catch { - // transformers not installed — degrade to fulltext } console.error('[memorix] No embedding provider available — using BM25 fulltext search'); diff --git a/src/memory/observations.ts b/src/memory/observations.ts index a13f83f..cacc9b4 100644 --- a/src/memory/observations.ts +++ b/src/memory/observations.ts @@ -16,13 +16,14 @@ import { resetDb, generateEmbedding, batchGenerateEmbeddings, + getVectorDimensions, makeOramaObservationId, } from '../store/orama-store.js'; import { saveObservationsJson, loadObservationsJson, saveIdCounter, loadIdCounter } from '../store/persistence.js'; import { withFileLock } from '../store/file-lock.js'; import { countTextTokens } from '../compact/token-budget.js'; import { extractEntities, enrichConcepts } from './entity-extractor.js'; -import { isEmbeddingExplicitlyDisabled } from '../embedding/provider.js'; +import { getEmbeddingProvider, isEmbeddingExplicitlyDisabled } from '../embedding/provider.js'; /** In-memory observation list (loaded from persistence on init) */ let observations: Observation[] = []; @@ -35,6 +36,12 @@ let projectDir: string | null = null; const vectorMissingIds = new Set(); let vectorBackfillRunning = false; +function isVectorCompatibleWithCurrentIndex(embedding: number[] | null): boolean { + if (!embedding) return false; + const vectorDimensions = getVectorDimensions(); + return vectorDimensions === null || embedding.length === vectorDimensions; +} + /** * Initialize the observations manager with a project directory. */ @@ -236,6 +243,13 @@ export async function storeObservation(input: { const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' '); generateEmbedding(searchableText).then(async (embedding) => { if (embedding) { + if (!isVectorCompatibleWithCurrentIndex(embedding)) { + const vectorDimensions = getVectorDimensions(); + console.error( + `[memorix] Embedding dimension mismatch for obs-${obsId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`, + ); + return; + } try { const { removeObservation: removeObs } = await import('../store/orama-store.js'); await removeObs(makeOramaObservationId(input.projectId, obsId)); @@ -560,17 +574,27 @@ export async function reindexObservations(): Promise { // Reset the Orama index to ensure clean reindex (idempotent) await resetDb(); + vectorMissingIds.clear(); // Batch-generate all embeddings at once (much faster than individual calls) - let embeddings: (number[] | null)[] = []; - try { + let embeddings: (number[] | null)[] = observations.map(() => null); + const provider = await getEmbeddingProvider(); + const canBatchEmbedAtStartup = provider !== null && !provider.name.startsWith('api-'); + + if (provider && !canBatchEmbedAtStartup) { + console.error('[memorix] Startup reindex: skipping synchronous API embeddings; background backfill will hydrate vectors'); + } + + if (canBatchEmbedAtStartup) { + try { const texts = observations.map(obs => [obs.title, obs.narrative, ...obs.facts].join(' '), ); embeddings = await batchGenerateEmbeddings(texts); // Batch embedding failed — fall back to no embeddings - } catch { - // Batch embedding failed; fall back to no embeddings. + } catch { + // Batch embedding failed; fall back to no embeddings. + } } let count = 0; @@ -578,6 +602,13 @@ export async function reindexObservations(): Promise { const obs = observations[i]; try { const embedding = embeddings[i] ?? null; + const compatibleEmbedding = isVectorCompatibleWithCurrentIndex(embedding) ? embedding : null; + if (embedding && !compatibleEmbedding) { + const vectorDimensions = getVectorDimensions(); + console.error( + `[memorix] Startup reindex embedding mismatch for obs-${obs.id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (queued for backfill)`, + ); + } const docId = makeOramaObservationId(obs.projectId, obs.id); const doc: MemorixDocument = { id: docId, @@ -596,9 +627,12 @@ export async function reindexObservations(): Promise { lastAccessedAt: '', status: obs.status ?? 'active', source: obs.source ?? 'agent', - ...(embedding ? { embedding } : {}), + ...(compatibleEmbedding ? { embedding: compatibleEmbedding } : {}), }; await insertObservation(doc); + if (!compatibleEmbedding && !isEmbeddingExplicitlyDisabled()) { + vectorMissingIds.add(obs.id); + } count++; } catch (err) { console.error(`[memorix] Failed to reindex observation #${obs.id}: ${err}`); @@ -668,6 +702,14 @@ export async function backfillVectorEmbeddings(): Promise<{ try { const embedding = await generateEmbedding(text); if (embedding) { + if (!isVectorCompatibleWithCurrentIndex(embedding)) { + const vectorDimensions = getVectorDimensions(); + console.error( + `[memorix] Backfill embedding mismatch for obs-${id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in queue)`, + ); + failed++; + continue; + } const oramaId = makeOramaObservationId(obs.projectId, obs.id); try { const { removeObservation: removeObs } = await import('../store/orama-store.js'); diff --git a/src/store/orama-store.ts b/src/store/orama-store.ts index 990b5d0..e58f50f 100644 --- a/src/store/orama-store.ts +++ b/src/store/orama-store.ts @@ -18,6 +18,7 @@ import { maybeExpandSearchQuery } from '../search/query-expansion.js'; let db: AnyOrama | null = null; let embeddingEnabled = false; +let embeddingDimensions: number | null = null; const NON_CJK_HYBRID_SIMILARITY = 0.45; const lastSearchModeByProject = new Map(); const SEARCH_MODE_DEFAULT_KEY = '__global__'; @@ -99,6 +100,19 @@ function isCommandStyleEntry(title: string): boolean { return COMMAND_STYLE_TITLE.test(title); } +function isVectorDimensionMismatchError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + /declared as a \d+-dimensional vector, but got a \d+-dimensional vector/i.test(error.message) || + /dimension mismatch/i.test(error.message) + ); +} + +function stripVectorSearchParams(params: Record): Record { + const { mode, vector, similarity, hybridWeights, ...rest } = params; + return rest; +} + /** * Initialize or return the Orama database instance. * Schema conditionally includes vector field based on embedding provider. @@ -110,6 +124,7 @@ export async function getDb(): Promise { // Check if embedding provider is available const provider = await getEmbeddingProvider(); embeddingEnabled = provider !== null; + embeddingDimensions = provider?.dimensions ?? null; const baseSchema = { id: 'string' as const, @@ -131,7 +146,7 @@ export async function getDb(): Promise { }; // Dynamic vector dimensions based on provider (384 for local, 1024+ for API) - const dims = provider?.dimensions ?? 384; + const dims = embeddingDimensions ?? 384; const schema = embeddingEnabled ? { ...baseSchema, embedding: `vector[${dims}]` as const } : baseSchema; @@ -147,6 +162,7 @@ export async function getDb(): Promise { export async function resetDb(): Promise { db = null; embeddingEnabled = false; + embeddingDimensions = null; lastSearchModeByProject.clear(); } @@ -157,6 +173,14 @@ export function isEmbeddingEnabled(): boolean { return embeddingEnabled; } +/** + * Current vector dimensions for the active Orama index. + * Returns null when vector search is disabled for this process. + */ +export function getVectorDimensions(): number | null { + return embeddingEnabled ? embeddingDimensions : null; +} + /** * Generate embedding for text content using the available provider. * Returns null if no provider is available. @@ -333,32 +357,43 @@ export async function searchObservations(options: SearchOptions): Promise((_, reject) => - setTimeout(() => reject(new Error(`Embedding timeout after ${EMBEDDING_TIMEOUT_MS}ms`)), EMBEDDING_TIMEOUT_MS) - ); - queryVector = await Promise.race([embedPromise, timeoutPromise]); - mark('embedding'); - // Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well - const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length; - const isCJKHeavy = cjkRatio > 0.3; - lastSearchModeByProject.set(modeKey, 'hybrid'); - searchParams = { - ...searchParams, - mode: 'hybrid', - vector: { - value: queryVector, - property: 'embedding', - }, - // English paraphrase queries were getting clipped just below 0.5 - // even when vector-only search could already find the right memory. - similarity: isCJKHeavy ? 0.3 : NON_CJK_HYBRID_SIMILARITY, - hybridWeights: isCJKHeavy - ? { text: 0.2, vector: 0.8 } // CJK: trust vector over BM25 - : { text: 0.6, vector: 0.4 }, - }; + const activeVectorDimensions = getVectorDimensions(); + if (activeVectorDimensions !== null && provider.dimensions !== activeVectorDimensions) { + lastSearchModeByProject.set( + modeKey, + `fulltext (embedding dimension mismatch: provider ${provider.dimensions}d vs index ${activeVectorDimensions}d)`, + ); + console.error( + `[memorix] Embedding provider dimension mismatch (${provider.dimensions}d provider vs ${activeVectorDimensions}d index); using fulltext search`, + ); + } else { + // Embedding timeout: 15 seconds + const EMBEDDING_TIMEOUT_MS = 15000; + const embedPromise = provider.embed(expandedEmbeddingQuery!); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Embedding timeout after ${EMBEDDING_TIMEOUT_MS}ms`)), EMBEDDING_TIMEOUT_MS) + ); + queryVector = await Promise.race([embedPromise, timeoutPromise]); + mark('embedding'); + // Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well + const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length; + const isCJKHeavy = cjkRatio > 0.3; + lastSearchModeByProject.set(modeKey, 'hybrid'); + searchParams = { + ...searchParams, + mode: 'hybrid', + vector: { + value: queryVector, + property: 'embedding', + }, + // English paraphrase queries were getting clipped just below 0.5 + // even when vector-only search could already find the right memory. + similarity: isCJKHeavy ? 0.3 : NON_CJK_HYBRID_SIMILARITY, + hybridWeights: isCJKHeavy + ? { text: 0.2, vector: 0.8 } // CJK: trust vector over BM25 + : { text: 0.6, vector: 0.4 }, + }; + } } } catch (error) { // Fallback to fulltext if embedding fails or times out @@ -368,7 +403,18 @@ export async function searchObservations(options: SearchOptions): Promise { - throw new Error('fastembed not installed (mocked)'); -}); -vi.mock('../../src/embedding/transformers-provider.js', () => { - throw new Error('transformers not installed (mocked)'); -}); +const mockFastEmbedCreate = vi.fn(); +const mockTransformersCreate = vi.fn(); const mockApiProviderCreate = vi.fn(); +vi.mock('../../src/embedding/fastembed-provider.js', () => ({ + FastEmbedProvider: { + create: mockFastEmbedCreate, + }, +})); +vi.mock('../../src/embedding/transformers-provider.js', () => ({ + TransformersProvider: { + create: mockTransformersCreate, + }, +})); vi.mock('../../src/embedding/api-provider.js', () => ({ APIEmbeddingProvider: { create: mockApiProviderCreate, @@ -38,6 +43,10 @@ beforeEach(() => { savedEnv[key] = process.env[key]; delete process.env[key]; } + mockFastEmbedCreate.mockReset(); + mockFastEmbedCreate.mockRejectedValue(new Error('fastembed not installed (mocked)')); + mockTransformersCreate.mockReset(); + mockTransformersCreate.mockRejectedValue(new Error('transformers not installed (mocked)')); mockApiProviderCreate.mockReset(); resetProvider(); resetDb(); @@ -132,7 +141,94 @@ describe('Embedding Provider', () => { const provider = await getEmbeddingProvider(); - expect(provider).toBe(apiProvider); + expect(provider?.name).toBe(apiProvider.name); + expect(provider?.dimensions).toBe(apiProvider.dimensions); + expect(mockApiProviderCreate).toHaveBeenCalledTimes(1); + }); + + it('falls back to fastembed when the API provider fails at runtime', async () => { + process.env.MEMORIX_EMBEDDING = 'auto'; + process.env.MEMORIX_EMBEDDING_API_KEY = 'api-key'; + process.env.MEMORIX_EMBEDDING_BASE_URL = 'https://embeddings.example/v1'; + process.env.MEMORIX_EMBEDDING_MODEL = 'text-embedding-3-small'; + + const apiProvider = { + name: 'api-text-embedding-3-small', + dimensions: 1536, + embed: vi.fn().mockRejectedValue(new Error('Embedding API error (429): quota exceeded')), + embedBatch: vi.fn().mockRejectedValue(new Error('Embedding API error (429): quota exceeded')), + }; + const fastembedProvider = { + name: 'fastembed-bge-small-en-v1.5', + dimensions: 384, + embed: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]), + embedBatch: vi.fn().mockResolvedValue([[0.1, 0.2, 0.3]]), + }; + mockApiProviderCreate.mockResolvedValue(apiProvider); + mockFastEmbedCreate.mockResolvedValue(fastembedProvider); + + const provider = await getEmbeddingProvider(); + expect(provider).not.toBeNull(); + + await expect(provider!.embed('runtime failure query')).resolves.toEqual([0.1, 0.2, 0.3]); + + const currentProvider = await getEmbeddingProvider(); + expect(currentProvider?.name).toBe('fastembed-bge-small-en-v1.5'); + expect(mockFastEmbedCreate).toHaveBeenCalledTimes(1); + }); + + it('treats quota-exceeded 402 responses as temporary API failures in auto mode', async () => { + process.env.MEMORIX_EMBEDDING = 'auto'; + process.env.MEMORIX_EMBEDDING_API_KEY = 'api-key'; + process.env.MEMORIX_EMBEDDING_BASE_URL = 'https://embeddings.example/v1'; + process.env.MEMORIX_EMBEDDING_MODEL = 'text-embedding-3-small'; + + const apiProvider = { + name: 'api-text-embedding-3-small', + dimensions: 1536, + embed: vi.fn().mockRejectedValue(new Error('Embedding API error (402): quota exceeded and account balance is $0.0')), + embedBatch: vi.fn().mockRejectedValue(new Error('Embedding API error (402): quota exceeded and account balance is $0.0')), + }; + const fastembedProvider = { + name: 'fastembed-bge-small-en-v1.5', + dimensions: 384, + embed: vi.fn().mockResolvedValue([0.4, 0.5, 0.6]), + embedBatch: vi.fn().mockResolvedValue([[0.4, 0.5, 0.6]]), + }; + mockApiProviderCreate.mockResolvedValue(apiProvider); + mockFastEmbedCreate.mockResolvedValue(fastembedProvider); + + const provider = await getEmbeddingProvider(); + expect(provider).not.toBeNull(); + + await expect(provider!.embed('quota exceeded query')).resolves.toEqual([0.4, 0.5, 0.6]); + + const currentProvider = await getEmbeddingProvider(); + expect(currentProvider?.name).toBe('fastembed-bge-small-en-v1.5'); + }); + }); + + describe('strict api mode runtime degradation', () => { + it('opens a cooldown circuit after runtime API failures in strict api mode', async () => { + process.env.MEMORIX_EMBEDDING = 'api'; + process.env.MEMORIX_EMBEDDING_API_KEY = 'api-key'; + process.env.MEMORIX_EMBEDDING_BASE_URL = 'https://embeddings.example/v1'; + process.env.MEMORIX_EMBEDDING_MODEL = 'text-embedding-3-small'; + + const apiProvider = { + name: 'api-text-embedding-3-small', + dimensions: 1536, + embed: vi.fn().mockRejectedValue(new Error('Embedding API error (429): quota exceeded')), + embedBatch: vi.fn().mockRejectedValue(new Error('Embedding API error (429): quota exceeded')), + }; + mockApiProviderCreate.mockResolvedValue(apiProvider); + + const provider = await getEmbeddingProvider(); + expect(provider).not.toBeNull(); + await expect(provider!.embed('strict api query')).rejects.toThrow('429'); + + const nextProvider = await getEmbeddingProvider(); + expect(nextProvider).toBeNull(); expect(mockApiProviderCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/integration/http-embedding-fallback.test.ts b/tests/integration/http-embedding-fallback.test.ts new file mode 100644 index 0000000..f84fbcf --- /dev/null +++ b/tests/integration/http-embedding-fallback.test.ts @@ -0,0 +1,440 @@ +/** + * HTTP Embedding Fallback Regression Test + * + * Reproduces the real HTTP/MCP failure path: + * 1. Session binds to a project while API embeddings are active (1536d index). + * 2. A later semantic search hits a quota error at runtime. + * 3. provider.ts falls back to a local provider with different dimensions (384d). + * 4. The HTTP tool call must degrade to fulltext instead of breaking the session. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const mockApiProviderCreate = vi.fn(); +const mockFastEmbedCreate = vi.fn(); +const mockTransformersCreate = vi.fn(); + +vi.mock('../../src/embedding/api-provider.js', () => ({ + APIEmbeddingProvider: { + create: mockApiProviderCreate, + }, +})); + +vi.mock('../../src/embedding/fastembed-provider.js', () => ({ + FastEmbedProvider: { + create: mockFastEmbedCreate, + }, +})); + +vi.mock('../../src/embedding/transformers-provider.js', () => ({ + TransformersProvider: { + create: mockTransformersCreate, + }, +})); + +vi.mock('../../src/llm/provider.js', () => ({ + initLLM: () => null, + isLLMEnabled: () => false, + getLLMConfig: () => null, +})); + +import { resetProvider } from '../../src/embedding/provider.js'; +import { resetDb } from '../../src/store/orama-store.js'; +import { resetConfigCache } from '../../src/config.js'; + +let StreamableHTTPServerTransport: any; +let isInitializeRequest: any; +let createMemorixServer: any; +let CallToolResultSchema: any; + +const TEST_PORT = 13212; +const BASE_URL = `http://127.0.0.1:${TEST_PORT}`; +const EMBEDDING_ENV_KEYS = [ + 'MEMORIX_API_KEY', + 'MEMORIX_EMBEDDING', + 'MEMORIX_EMBEDDING_API_KEY', + 'MEMORIX_EMBEDDING_BASE_URL', + 'MEMORIX_EMBEDDING_MODEL', + 'MEMORIX_LLM_API_KEY', + 'OPENAI_API_KEY', +]; + +let httpServer: Server; +let tempHomeDir: string; +let testDir: string; +let projectDir: string; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalHomePath = process.env.HOMEPATH; +const savedEnv: Record = {}; +const sessions = new Map(); + +function makeVector(dimensions: number, value: number): number[] { + return Array.from({ length: dimensions }, () => value); +} + +async function createFakeGitRepo(root: string, remote: string) { + await fs.mkdir(path.join(root, '.git'), { recursive: true }); + await fs.writeFile( + path.join(root, '.git', 'config'), + `[remote "origin"]\n\turl = ${remote}\n`, + 'utf8', + ); +} + +async function mcpPost(body: unknown, sessionId?: string): Promise<{ status: number; headers: Headers; text: string; json?: any }> { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + }; + if (sessionId) headers['Mcp-Session-Id'] = sessionId; + + const res = await fetch(`${BASE_URL}/mcp`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + const text = await res.text(); + let json: any; + + const dataLines = text.split('\n').filter(line => line.startsWith('data:')); + if (dataLines.length > 0) { + try { + json = JSON.parse(dataLines[0].replace('data: ', '')); + } catch { + // ignore non-JSON event data + } + } + + if (!json && res.headers.get('content-type')?.includes('application/json')) { + try { + json = JSON.parse(text); + } catch { + // ignore non-JSON body + } + } + + return { status: res.status, headers: res.headers, text, json }; +} + +async function initSession(): Promise { + const res = await mcpPost({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-agent', version: '1.0' }, + }, + id: 1, + }); + + const sid = res.headers.get('mcp-session-id'); + if (!sid) throw new Error('No session ID returned'); + + await fetch(`${BASE_URL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Mcp-Session-Id': sid, + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + }); + + return sid; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Condition not met within ${timeoutMs}ms`); + } + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +beforeAll(async () => { + tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memorix-http-embed-home-')); + process.env.HOME = tempHomeDir; + process.env.USERPROFILE = tempHomeDir; + process.env.HOMEPATH = tempHomeDir; + + const streamMod = await import('@modelcontextprotocol/sdk/server/streamableHttp.js'); + StreamableHTTPServerTransport = streamMod.StreamableHTTPServerTransport; + const typesMod = await import('@modelcontextprotocol/sdk/types.js'); + isInitializeRequest = typesMod.isInitializeRequest; + CallToolResultSchema = typesMod.CallToolResultSchema; + const serverMod = await import('../../src/server.js'); + createMemorixServer = serverMod.createMemorixServer; + + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memorix-http-embed-test-')); + projectDir = path.join(testDir, 'project-a'); + await fs.mkdir(projectDir, { recursive: true }); + await createFakeGitRepo(projectDir, 'https://github.com/AVIDS2/http-embed-project.git'); + + httpServer = createServer(async (req, res) => { + const origin = req.headers['origin']; + const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; + if (origin && allowedOrigin.test(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Last-Event-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://localhost:${TEST_PORT}`); + if (url.pathname !== '/mcp') { + res.writeHead(404); + res.end('Not found'); + return; + } + + try { + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')); + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId)!.transport.handleRequest(req, res, body); + return; + } + + if (!sessionId && isInitializeRequest(body)) { + let createdState: { transport: any; server: any; switchProject: any } | null = null; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + if (createdState) sessions.set(sid, createdState); + }, + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) sessions.delete(sid); + }; + + const { server, switchProject } = await createMemorixServer( + testDir, + undefined, + undefined, + { + allowUntrackedFallback: false, + deferProjectInitUntilBound: true, + }, + ); + createdState = { transport, server, switchProject }; + await server.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request' }, id: null })); + return; + } + + if (req.method === 'GET' || req.method === 'DELETE') { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !sessions.has(sessionId)) { + res.writeHead(400); + res.end('Invalid session'); + return; + } + await sessions.get(sessionId)!.transport.handleRequest(req, res); + return; + } + + res.writeHead(405); + res.end('Method not allowed'); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: String(error) }, id: null })); + } + } + }); + + await new Promise((resolve) => { + httpServer.listen(TEST_PORT, '127.0.0.1', () => resolve()); + }); +}, 30_000); + +beforeEach(() => { + for (const key of EMBEDDING_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + + process.env.MEMORIX_EMBEDDING = 'auto'; + process.env.MEMORIX_EMBEDDING_API_KEY = 'api-key'; + process.env.MEMORIX_EMBEDDING_BASE_URL = 'https://embeddings.example/v1'; + process.env.MEMORIX_EMBEDDING_MODEL = 'text-embedding-3-small'; + + resetProvider(); + resetDb(); + resetConfigCache(); + + const apiEmbed = vi.fn() + .mockResolvedValueOnce(makeVector(1536, 0.01)) + .mockRejectedValueOnce(new Error('Embedding API error (402): quota exceeded and account balance is $0.0')); + const apiEmbedBatch = vi.fn(async (texts: string[]) => texts.map(() => makeVector(1536, 0.01))); + const fallbackEmbed = vi.fn().mockResolvedValue(makeVector(384, 0.25)); + const fallbackEmbedBatch = vi.fn(async (texts: string[]) => texts.map(() => makeVector(384, 0.25))); + + mockApiProviderCreate.mockReset(); + mockApiProviderCreate.mockResolvedValue({ + name: 'api-text-embedding-3-small', + dimensions: 1536, + embed: apiEmbed, + embedBatch: apiEmbedBatch, + }); + + mockFastEmbedCreate.mockReset(); + mockFastEmbedCreate.mockResolvedValue({ + name: 'fastembed-bge-small-en-v1.5', + dimensions: 384, + embed: fallbackEmbed, + embedBatch: fallbackEmbedBatch, + }); + + mockTransformersCreate.mockReset(); + mockTransformersCreate.mockResolvedValue(null); +}); + +afterEach(() => { + resetProvider(); + resetDb(); + resetConfigCache(); + for (const key of EMBEDDING_ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +afterAll(async () => { + for (const [, state] of sessions) { + try { + await state.transport.close(); + } catch { + // ignore session close errors in cleanup + } + } + sessions.clear(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + process.env.HOMEPATH = originalHomePath; +}, 30_000); + +describe('HTTP embedding fallback regression', () => { + it('keeps HTTP search alive when API quota fallback changes embedding dimensions mid-session', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const sessionId = await initSession(); + + const startRes = await mcpPost({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'memorix_session_start', + arguments: { agent: 'http-embed-fallback', projectRoot: projectDir }, + }, + id: 101, + }, sessionId); + + const startResult = CallToolResultSchema.parse(startRes.json?.result); + expect(startResult.isError).toBeFalsy(); + + const storeRes = await mcpPost({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'memorix_store', + arguments: { + entityName: 'http-embed-fallback', + type: 'problem-solution', + title: 'HTTP quota fallback memory', + narrative: 'The HTTP session should keep working when embedding quota errors switch providers at runtime.', + }, + }, + id: 102, + }, sessionId); + + const storeResult = CallToolResultSchema.parse(storeRes.json?.result); + expect(storeResult.isError).toBeFalsy(); + + await waitFor(() => { + const provider = mockApiProviderCreate.mock.results[0]?.value; + return Boolean(provider); + }); + + const apiEmbed = (await mockApiProviderCreate.mock.results[0]!.value).embed as ReturnType; + await waitFor(() => apiEmbed.mock.calls.length >= 1); + await new Promise(resolve => setTimeout(resolve, 50)); + + const searchRes = await mcpPost({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'memorix_search', + arguments: { + query: 'why did the HTTP quota fallback memory break after the embedding provider switched at runtime', + }, + }, + id: 103, + }, sessionId); + + expect(searchRes.status).toBe(200); + const searchResult = CallToolResultSchema.parse(searchRes.json?.result); + expect(searchResult.isError).toBeFalsy(); + const searchText = searchResult.content.map((part: any) => part.text ?? '').join('\n'); + expect(searchText).toContain('HTTP quota fallback memory'); + expect(apiEmbed).toHaveBeenCalledTimes(2); + expect(mockFastEmbedCreate).toHaveBeenCalledTimes(1); + + const followUpRes = await mcpPost({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'memorix_search', + arguments: { + query: 'HTTP quota fallback memory', + }, + }, + id: 104, + }, sessionId); + + const followUpResult = CallToolResultSchema.parse(followUpRes.json?.result); + expect(followUpResult.isError).toBeFalsy(); + + const logs = errorSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(logs).toContain('API embedding temporarily unavailable — switching to local fallback provider'); + expect(logs).toContain('Embedding fallback activated: fastembed-bge-small-en-v1.5 (384d)'); + expect( + logs.includes('Vector search dimension mismatch detected, retrying without embeddings') || + logs.includes('Embedding provider dimension mismatch (384d provider vs 1536d index); using fulltext search'), + ).toBe(true); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/tests/memory/vector-stability.test.ts b/tests/memory/vector-stability.test.ts index 64fa7f7..08d4ad3 100644 --- a/tests/memory/vector-stability.test.ts +++ b/tests/memory/vector-stability.test.ts @@ -14,6 +14,7 @@ vi.mock('../../src/store/orama-store.js', () => ({ resetDb: vi.fn().mockResolvedValue(undefined), generateEmbedding: vi.fn().mockResolvedValue(null), batchGenerateEmbeddings: vi.fn().mockResolvedValue([]), + getVectorDimensions: vi.fn().mockReturnValue(384), makeOramaObservationId: (projectId: string, id: number) => `obs-${projectId}-${id}`, })); @@ -136,6 +137,28 @@ describe('Vector Stability', () => { expect(missingIds).toContain(observation.id); }); + it('keeps obs in vectorMissingIds when fallback embedding dimensions do not match the current index', async () => { + const oramaStore = await import('../../src/store/orama-store.js'); + vi.mocked(oramaStore.generateEmbedding).mockResolvedValue([0.1, 0.2, 0.3]); + vi.mocked(oramaStore.getVectorDimensions).mockReturnValue(4096); + + const { storeObservation, getVectorMissingIds } = await import('../../src/memory/observations.js'); + const { observation } = await storeObservation({ + entityName: 'dimension-mismatch-test', + type: 'discovery', + title: 'Fallback dimensions differ from index', + narrative: 'This should stay queued until a compatible provider is available again', + projectId: 'test/vector-stability', + }); + + await new Promise(r => setTimeout(r, 100)); + + const missingIds = getVectorMissingIds(); + expect(missingIds).toContain(observation.id); + expect(oramaStore.removeObservation).not.toHaveBeenCalled(); + expect(oramaStore.insertObservation).toHaveBeenCalledTimes(1); + }); + it('removes obs from vectorMissingIds when embedding is explicitly disabled', async () => { const oramaStore = await import('../../src/store/orama-store.js'); const embeddingProvider = await import('../../src/embedding/provider.js'); @@ -196,4 +219,71 @@ describe('Vector Stability', () => { // Must still be in the missing set for next retry expect(getVectorMissingIds()).toContain(observation.id); }); + + it('backfill keeps items queued when the generated embedding dimensions do not match the index', async () => { + const oramaStore = await import('../../src/store/orama-store.js'); + + vi.mocked(oramaStore.generateEmbedding).mockResolvedValue([0.1, 0.2, 0.3]); + vi.mocked(oramaStore.getVectorDimensions).mockReturnValue(4096); + + const { storeObservation, backfillVectorEmbeddings, getVectorMissingIds } = + await import('../../src/memory/observations.js'); + + const { observation } = await storeObservation({ + entityName: 'backfill-dimension-mismatch', + type: 'gotcha', + title: 'Backfill dimension mismatch', + narrative: 'Backfill should not inject vectors into an incompatible index', + projectId: 'test/vector-stability', + }); + + await new Promise(r => setTimeout(r, 100)); + expect(getVectorMissingIds()).toContain(observation.id); + + const result = await backfillVectorEmbeddings(); + + expect(result.attempted).toBeGreaterThanOrEqual(1); + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(getVectorMissingIds()).toContain(observation.id); + }); + + it('reindexObservations skips synchronous batch embedding when the active provider is remote API', async () => { + const oramaStore = await import('../../src/store/orama-store.js'); + const embeddingProvider = await import('../../src/embedding/provider.js'); + const persistence = await import('../../src/store/persistence.js'); + + vi.mocked(persistence.loadObservationsJson).mockResolvedValue([ + { + id: 41, + entityName: 'startup-reindex', + type: 'discovery', + title: 'Remote API startup reindex', + narrative: 'Should not block MCP startup on remote embedding backfill', + facts: [], + filesModified: [], + concepts: [], + tokens: 10, + createdAt: new Date().toISOString(), + projectId: 'test/vector-stability', + status: 'active', + source: 'agent', + }, + ]); + vi.mocked(embeddingProvider.getEmbeddingProvider).mockResolvedValue({ + name: 'api-Qwen-Qwen3-Embedding-8B', + dimensions: 4096, + embed: vi.fn(), + embedBatch: vi.fn(), + }); + + const { initObservations, reindexObservations, getVectorMissingIds } = + await import('../../src/memory/observations.js'); + + await initObservations('/tmp/memorix-vector-stability'); + const count = await reindexObservations(); + + expect(count).toBe(1); + expect(oramaStore.batchGenerateEmbeddings).not.toHaveBeenCalled(); + expect(getVectorMissingIds()).toContain(41); + }); }); diff --git a/tests/search/orama-store-semantic.test.ts b/tests/search/orama-store-semantic.test.ts index 522afd0..1e5eb6d 100644 --- a/tests/search/orama-store-semantic.test.ts +++ b/tests/search/orama-store-semantic.test.ts @@ -284,4 +284,49 @@ describe('orama-store semantic hybrid search', () => { expect(entries[0]?.id).toBe(30); expect(entries[0]?.title).toContain('semantic retrieval weak'); }); + + it('falls back to fulltext when the active provider dimensions no longer match the vector index', async () => { + const { + insertObservation, + makeOramaObservationId, + searchObservations, + getLastSearchMode, + } = await import('../../src/store/orama-store.js'); + + const now = new Date().toISOString(); + + await insertObservation({ + id: makeOramaObservationId('AVIDS2/memorix', 40), + observationId: 40, + entityName: 'dimension-mismatch', + type: 'gotcha', + title: 'Dimension mismatch falls back to fulltext', + narrative: 'Lexical search should still work when vector dimensions stop matching.', + facts: 'The index was created with API vectors but the active fallback provider uses another size.', + filesModified: 'src/store/orama-store.ts', + concepts: 'semantic-search\nfallback\nmemorix', + tokens: 32, + createdAt: now, + projectId: 'AVIDS2/memorix', + accessCount: 0, + lastAccessedAt: now, + status: 'active', + source: 'agent', + embedding: [0.6, 0.8], + }); + + mockProvider.dimensions = 3; + mockProvider.embed = vi.fn(async () => [0.2, 0.3, 0.5]); + mockProvider.embedBatch = vi.fn(async () => [[0.2, 0.3, 0.5]]); + + const entries = await searchObservations({ + query: 'dimension mismatch fulltext fallback', + projectId: 'AVIDS2/memorix', + limit: 5, + }); + + expect(entries.length).toBeGreaterThan(0); + expect(entries[0]?.id).toBe(40); + expect(getLastSearchMode('AVIDS2/memorix')).toContain('dimension mismatch'); + }); }); From 06cd7adc618fc0bdff4e4e0ae1ee7855a7b5572f Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Sat, 28 Mar 2026 13:24:58 +0100 Subject: [PATCH 2/2] Fix reindex embedding test expectations --- tests/memory/reindex-embeddings.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/memory/reindex-embeddings.test.ts b/tests/memory/reindex-embeddings.test.ts index a463280..bb755ac 100644 --- a/tests/memory/reindex-embeddings.test.ts +++ b/tests/memory/reindex-embeddings.test.ts @@ -5,6 +5,9 @@ const mockBatchGenerateEmbeddings = vi.fn(); const mockInsertObservation = vi.fn(); const mockLoadObservationsJson = vi.fn(); const mockLoadIdCounter = vi.fn(); +const mockGetVectorDimensions = vi.fn(); +const mockGetEmbeddingProvider = vi.fn(); +const mockIsEmbeddingExplicitlyDisabled = vi.fn(); vi.mock('../../src/store/orama-store.js', () => ({ insertObservation: mockInsertObservation, @@ -12,9 +15,16 @@ vi.mock('../../src/store/orama-store.js', () => ({ resetDb: mockResetDb, generateEmbedding: vi.fn(), batchGenerateEmbeddings: mockBatchGenerateEmbeddings, + getVectorDimensions: mockGetVectorDimensions, makeOramaObservationId: (projectId: string, observationId: number) => `${projectId}:${observationId}`, })); +vi.mock('../../src/embedding/provider.js', () => ({ + getEmbeddingProvider: mockGetEmbeddingProvider, + isEmbeddingExplicitlyDisabled: mockIsEmbeddingExplicitlyDisabled, + resetProvider: vi.fn(), +})); + vi.mock('../../src/store/persistence.js', () => ({ saveObservationsJson: vi.fn(), loadObservationsJson: mockLoadObservationsJson, @@ -43,6 +53,17 @@ describe('reindexObservations', () => { mockInsertObservation.mockReset(); mockLoadObservationsJson.mockReset(); mockLoadIdCounter.mockReset(); + mockGetVectorDimensions.mockReset(); + mockGetEmbeddingProvider.mockReset(); + mockIsEmbeddingExplicitlyDisabled.mockReset(); + mockGetVectorDimensions.mockReturnValue(null); + mockGetEmbeddingProvider.mockResolvedValue({ + name: 'fastembed-bge-small-en-v1.5', + dimensions: 384, + embed: vi.fn(), + embedBatch: vi.fn(), + }); + mockIsEmbeddingExplicitlyDisabled.mockReturnValue(false); }); it('rebuilds historical observations with batch embeddings after reset', async () => {