smart-coding-mcp 2.3.0 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,31 @@
1
1
  import { parentPort, workerData } from "worker_threads";
2
2
  import { pipeline, layer_norm } from "@huggingface/transformers";
3
+ import { existsSync, rmSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
3
6
 
4
7
  let embedder = null;
5
8
  const VALID_DIMENSIONS = [64, 128, 256, 512, 768];
6
9
 
10
+ /**
11
+ * Clear the HuggingFace transformers cache for a specific model
12
+ * Used for auto-recovery from corrupted model files
13
+ */
14
+ function clearModelCache(modelName) {
15
+ try {
16
+ const transformersPath = dirname(fileURLToPath(import.meta.resolve('@huggingface/transformers')));
17
+ const cacheDir = join(transformersPath, '.cache', ...modelName.split('/'));
18
+ if (existsSync(cacheDir)) {
19
+ console.error(`[Worker] Clearing corrupted cache: ${cacheDir}`);
20
+ rmSync(cacheDir, { recursive: true, force: true });
21
+ return true;
22
+ }
23
+ } catch (e) {
24
+ console.error(`[Worker] Failed to clear cache: ${e.message}`);
25
+ }
26
+ return false;
27
+ }
28
+
7
29
  // Initialize the embedding model once when worker starts
8
30
  async function initializeEmbedder() {
9
31
  if (!embedder) {
@@ -11,12 +33,27 @@ async function initializeEmbedder() {
11
33
  const dimension = workerData.embeddingDimension || 256;
12
34
  const targetDim = VALID_DIMENSIONS.includes(dimension) ? dimension : 256;
13
35
  const isNomic = modelName.includes('nomic');
14
-
15
- const extractor = await pipeline("feature-extraction", modelName);
36
+
37
+ // Load model with auto-recovery for corrupted files
38
+ let extractor;
39
+ try {
40
+ extractor = await pipeline("feature-extraction", modelName);
41
+ } catch (err) {
42
+ if (err.message && err.message.includes('Protobuf parsing failed')) {
43
+ console.error(`[Worker] Corrupted model detected, attempting auto-recovery...`);
44
+ if (clearModelCache(modelName)) {
45
+ extractor = await pipeline("feature-extraction", modelName);
46
+ } else {
47
+ throw err;
48
+ }
49
+ } else {
50
+ throw err;
51
+ }
52
+ }
16
53
 
17
54
  if (isNomic) {
18
55
  // MRL embedder with dimension slicing
19
- embedder = async function(text, options = {}) {
56
+ embedder = async function(text) {
20
57
  let embeddings = await extractor(text, { pooling: 'mean' });
21
58
  embeddings = layer_norm(embeddings, [embeddings.dims[1]])
22
59
  .slice(null, [0, targetDim])
@@ -26,7 +63,7 @@ async function initializeEmbedder() {
26
63
  embedder.dimension = targetDim;
27
64
  } else {
28
65
  // Legacy embedder (MiniLM etc.)
29
- embedder = async function(text, options = {}) {
66
+ embedder = async function(text) {
30
67
  return await extractor(text, { pooling: 'mean', normalize: true });
31
68
  };
32
69
  embedder.dimension = 384;
@@ -1,15 +1,50 @@
1
1
  /**
2
2
  * MRL (Matryoshka Representation Learning) Embedder
3
- *
3
+ *
4
4
  * Provides flexible embedding dimensions (64, 128, 256, 512, 768) using
5
5
  * nomic-embed-text-v1.5 with layer normalization and dimension slicing.
6
6
  */
7
7
 
8
8
  import { pipeline, layer_norm } from '@huggingface/transformers';
9
+ import { existsSync, rmSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
9
12
 
10
13
  // Valid MRL dimensions for nomic-embed-text-v1.5
11
14
  const VALID_DIMENSIONS = [64, 128, 256, 512, 768];
12
15
 
16
+ /**
17
+ * Clear the HuggingFace transformers cache for a specific model
18
+ * Used for auto-recovery from corrupted model files
19
+ */
20
+ function clearModelCache(modelName) {
21
+ try {
22
+ // Find the transformers package location
23
+ // import.meta.resolve may return .../dist/index.js, so check parent too
24
+ const resolvedPath = dirname(fileURLToPath(import.meta.resolve('@huggingface/transformers')));
25
+ const possibleRoots = [resolvedPath, dirname(resolvedPath)];
26
+
27
+ for (const root of possibleRoots) {
28
+ // Try different cache path patterns
29
+ const cachePaths = [
30
+ join(root, '.cache', modelName.replace('/', '-')), // nomic-ai-nomic-embed-text-v1.5
31
+ join(root, '.cache', ...modelName.split('/')) // nomic-ai/nomic-embed-text-v1.5
32
+ ];
33
+
34
+ for (const cacheDir of cachePaths) {
35
+ if (existsSync(cacheDir)) {
36
+ console.error(`[MRL] Clearing corrupted cache: ${cacheDir}`);
37
+ rmSync(cacheDir, { recursive: true, force: true });
38
+ return true;
39
+ }
40
+ }
41
+ }
42
+ } catch (e) {
43
+ console.error(`[MRL] Failed to clear cache: ${e.message}`);
44
+ }
45
+ return false;
46
+ }
47
+
13
48
  /**
14
49
  * Create an MRL-enabled embedder with configurable output dimensions
15
50
  *
@@ -35,34 +70,94 @@ export async function createMRLEmbedder(modelName, options = {}) {
35
70
  // Detect best device if auto
36
71
  const finalDevice = device === 'auto' ? detectBestDevice() : device;
37
72
 
38
- // Create the feature extraction pipeline
73
+ // Create the feature extraction pipeline with auto-recovery for corrupted models
39
74
  const pipelineOptions = {};
40
75
  if (finalDevice === 'webgpu') {
41
76
  pipelineOptions.device = 'webgpu';
42
77
  }
43
-
44
- const extractor = await pipeline('feature-extraction', modelName, pipelineOptions);
45
-
78
+
79
+ let extractor;
80
+
81
+ // Helper to detect corruption errors
82
+ function isCorruptionError(err) {
83
+ if (!err.message) return false;
84
+ return err.message.includes('Protobuf parsing failed') ||
85
+ err.message.includes('Invalid model') ||
86
+ err.message.includes('ONNX') && err.message.includes('corrupt');
87
+ }
88
+
89
+ // Helper to load/reload the extractor
90
+ async function loadExtractor(clearCache = false) {
91
+ if (clearCache) {
92
+ console.error(`[MRL] Corrupted model detected, attempting auto-recovery...`);
93
+ clearModelCache(modelName);
94
+ }
95
+ return await pipeline('feature-extraction', modelName, pipelineOptions);
96
+ }
97
+
98
+ try {
99
+ extractor = await loadExtractor();
100
+ } catch (err) {
101
+ if (isCorruptionError(err)) {
102
+ extractor = await loadExtractor(true);
103
+ } else {
104
+ throw err;
105
+ }
106
+ }
107
+
46
108
  console.error(`[MRL] Model loaded on ${finalDevice}`);
47
-
109
+
110
+ // Fallback embedder for when MRL model fails at runtime
111
+ let fallbackEmbedder = null;
112
+
48
113
  /**
49
114
  * Embed text with MRL dimension slicing
50
115
  * Compatible with existing embedder(text, options) signature
116
+ * Includes runtime auto-recovery for corrupted models with fallback
51
117
  */
52
118
  async function embed(text, embedOptions = {}) {
53
- // Generate full 768d embedding
54
- let embeddings = await extractor(text, { pooling: 'mean' });
55
-
56
- // Apply MRL: layer_norm -> slice -> normalize
57
- embeddings = layer_norm(embeddings, [embeddings.dims[1]])
58
- .slice(null, [0, targetDim])
59
- .normalize(2, -1);
60
-
61
- // Return in format compatible with existing code (has .data property)
62
- return {
63
- data: embeddings.data,
64
- dims: [embeddings.dims[0], targetDim]
65
- };
119
+ // If we've fallen back to legacy, use it
120
+ if (fallbackEmbedder) {
121
+ return await fallbackEmbedder(text, embedOptions);
122
+ }
123
+
124
+ async function doEmbed() {
125
+ // Generate full 768d embedding
126
+ let embeddings = await extractor(text, { pooling: 'mean' });
127
+
128
+ // Apply MRL: layer_norm -> slice -> normalize
129
+ embeddings = layer_norm(embeddings, [embeddings.dims[1]])
130
+ .slice(null, [0, targetDim])
131
+ .normalize(2, -1);
132
+
133
+ // Return in format compatible with existing code (has .data property)
134
+ return {
135
+ data: embeddings.data,
136
+ dims: [embeddings.dims[0], targetDim]
137
+ };
138
+ }
139
+
140
+ try {
141
+ return await doEmbed();
142
+ } catch (err) {
143
+ // Runtime corruption detection - try reload first
144
+ if (isCorruptionError(err)) {
145
+ console.error(`[MRL] Runtime corruption detected, attempting reload...`);
146
+ try {
147
+ extractor = await loadExtractor(true);
148
+ return await doEmbed();
149
+ } catch (reloadErr) {
150
+ // Reload failed - fall back to legacy model
151
+ console.error(`[MRL] Reload failed, falling back to legacy model...`);
152
+ const { createLegacyEmbedder } = await import('./mrl-embedder.js');
153
+ fallbackEmbedder = await createLegacyEmbedder();
154
+ embed.dimension = fallbackEmbedder.dimension;
155
+ embed.modelName = fallbackEmbedder.modelName;
156
+ return await fallbackEmbedder(text, embedOptions);
157
+ }
158
+ }
159
+ throw err;
160
+ }
66
161
  }
67
162
 
68
163
  // Attach metadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-coding-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.3",
4
4
  "description": "An extensible MCP server that enhances coding productivity with AI-powered features including semantic code search, intelligent indexing, and more, using local LLMs",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for Model Cache Auto-Recovery
3
+ *
4
+ * Tests the auto-recovery mechanism for corrupted ONNX model files:
5
+ * - Cache directory detection and clearing
6
+ * - Protobuf parsing error detection
7
+ * - Retry after cache clear
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { tmpdir } from 'os';
15
+
16
+ // Create a test cache directory structure
17
+ const testCacheBase = join(tmpdir(), 'smart-coding-mcp-test-cache');
18
+
19
+ describe('Model Cache Recovery', () => {
20
+ beforeEach(() => {
21
+ // Clean up before each test
22
+ if (existsSync(testCacheBase)) {
23
+ rmSync(testCacheBase, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ afterEach(() => {
28
+ // Clean up after each test
29
+ if (existsSync(testCacheBase)) {
30
+ rmSync(testCacheBase, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ describe('Cache Directory Detection', () => {
35
+ it('should detect cache directory with forward slash model names', () => {
36
+ // Create a mock cache structure
37
+ const cacheDir = join(testCacheBase, 'nomic-ai', 'nomic-embed-text-v1.5', 'onnx');
38
+ mkdirSync(cacheDir, { recursive: true });
39
+ writeFileSync(join(cacheDir, 'model.onnx'), 'corrupted data');
40
+
41
+ expect(existsSync(cacheDir)).toBe(true);
42
+
43
+ // Simulate cache clear logic
44
+ const modelName = 'nomic-ai/nomic-embed-text-v1.5';
45
+ const cachePath = join(testCacheBase, ...modelName.split('/'));
46
+
47
+ if (existsSync(cachePath)) {
48
+ rmSync(cachePath, { recursive: true, force: true });
49
+ }
50
+
51
+ expect(existsSync(cachePath)).toBe(false);
52
+ });
53
+
54
+ it('should handle non-existent cache gracefully', () => {
55
+ const nonExistentPath = join(testCacheBase, 'does-not-exist');
56
+
57
+ // Should not throw
58
+ let cleared = false;
59
+ if (existsSync(nonExistentPath)) {
60
+ rmSync(nonExistentPath, { recursive: true, force: true });
61
+ cleared = true;
62
+ }
63
+
64
+ expect(cleared).toBe(false);
65
+ });
66
+
67
+ it('should clear nested model cache directories', () => {
68
+ // Create nested structure mimicking real cache
69
+ const modelDir = join(testCacheBase, 'nomic-ai', 'nomic-embed-text-v1.5');
70
+ const onnxDir = join(modelDir, 'onnx');
71
+ const tokenizerDir = join(modelDir, 'tokenizer');
72
+
73
+ mkdirSync(onnxDir, { recursive: true });
74
+ mkdirSync(tokenizerDir, { recursive: true });
75
+
76
+ writeFileSync(join(onnxDir, 'model.onnx'), 'corrupted');
77
+ writeFileSync(join(tokenizerDir, 'tokenizer.json'), '{}');
78
+
79
+ expect(existsSync(onnxDir)).toBe(true);
80
+ expect(existsSync(tokenizerDir)).toBe(true);
81
+
82
+ // Clear the model directory (not just onnx)
83
+ rmSync(modelDir, { recursive: true, force: true });
84
+
85
+ expect(existsSync(modelDir)).toBe(false);
86
+ expect(existsSync(onnxDir)).toBe(false);
87
+ expect(existsSync(tokenizerDir)).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe('Error Detection', () => {
92
+ it('should identify Protobuf parsing errors as recoverable', () => {
93
+ const recoverableErrors = [
94
+ 'Protobuf parsing failed',
95
+ 'Load model from /path/to/model.onnx failed:Protobuf parsing failed.',
96
+ 'Error: Protobuf parsing failed'
97
+ ];
98
+
99
+ for (const errorMsg of recoverableErrors) {
100
+ const isRecoverable = errorMsg.includes('Protobuf parsing failed');
101
+ expect(isRecoverable).toBe(true);
102
+ }
103
+ });
104
+
105
+ it('should not identify other errors as recoverable', () => {
106
+ const nonRecoverableErrors = [
107
+ 'Network error',
108
+ 'File not found',
109
+ 'Out of memory',
110
+ 'Invalid model format',
111
+ 'ONNX runtime error'
112
+ ];
113
+
114
+ for (const errorMsg of nonRecoverableErrors) {
115
+ const isRecoverable = errorMsg.includes('Protobuf parsing failed');
116
+ expect(isRecoverable).toBe(false);
117
+ }
118
+ });
119
+ });
120
+
121
+ describe('Recovery Flow', () => {
122
+ it('should simulate recovery sequence', async () => {
123
+ // Create corrupted cache
124
+ const cacheDir = join(testCacheBase, 'nomic-ai', 'nomic-embed-text-v1.5');
125
+ mkdirSync(join(cacheDir, 'onnx'), { recursive: true });
126
+ writeFileSync(join(cacheDir, 'onnx', 'model.onnx'), 'corrupted protobuf data');
127
+
128
+ let loadAttempts = 0;
129
+ let cacheCleared = false;
130
+
131
+ // Simulate the recovery flow
132
+ const mockLoadModel = async () => {
133
+ loadAttempts++;
134
+ if (loadAttempts === 1 && !cacheCleared) {
135
+ throw new Error('Load model from /path/model.onnx failed:Protobuf parsing failed.');
136
+ }
137
+ return { success: true };
138
+ };
139
+
140
+ const mockClearCache = () => {
141
+ if (existsSync(cacheDir)) {
142
+ rmSync(cacheDir, { recursive: true, force: true });
143
+ cacheCleared = true;
144
+ return true;
145
+ }
146
+ return false;
147
+ };
148
+
149
+ // First attempt should fail
150
+ let result;
151
+ try {
152
+ result = await mockLoadModel();
153
+ } catch (err) {
154
+ if (err.message.includes('Protobuf parsing failed')) {
155
+ // Clear cache and retry
156
+ mockClearCache();
157
+ result = await mockLoadModel();
158
+ }
159
+ }
160
+
161
+ expect(loadAttempts).toBe(2);
162
+ expect(cacheCleared).toBe(true);
163
+ expect(result.success).toBe(true);
164
+ expect(existsSync(cacheDir)).toBe(false);
165
+ });
166
+
167
+ it('should propagate non-recoverable errors', async () => {
168
+ const mockLoadModel = async () => {
169
+ throw new Error('Network connection failed');
170
+ };
171
+
172
+ await expect(async () => {
173
+ try {
174
+ await mockLoadModel();
175
+ } catch (err) {
176
+ if (err.message.includes('Protobuf parsing failed')) {
177
+ // Would clear cache and retry, but this error is different
178
+ }
179
+ throw err;
180
+ }
181
+ }).rejects.toThrow('Network connection failed');
182
+ });
183
+
184
+ it('should handle cache clear failure gracefully', async () => {
185
+ let loadAttempts = 0;
186
+
187
+ const mockLoadModel = async () => {
188
+ loadAttempts++;
189
+ throw new Error('Protobuf parsing failed');
190
+ };
191
+
192
+ const mockClearCache = () => {
193
+ // Simulate cache clear failure (e.g., permission denied)
194
+ return false;
195
+ };
196
+
197
+ await expect(async () => {
198
+ try {
199
+ await mockLoadModel();
200
+ } catch (err) {
201
+ if (err.message.includes('Protobuf parsing failed')) {
202
+ if (!mockClearCache()) {
203
+ // Cache clear failed, re-throw original error
204
+ throw err;
205
+ }
206
+ } else {
207
+ throw err;
208
+ }
209
+ }
210
+ }).rejects.toThrow('Protobuf parsing failed');
211
+
212
+ expect(loadAttempts).toBe(1);
213
+ });
214
+ });
215
+
216
+ describe('Path Resolution', () => {
217
+ it('should handle model names with organization prefix', () => {
218
+ const modelName = 'nomic-ai/nomic-embed-text-v1.5';
219
+ const parts = modelName.split('/');
220
+
221
+ expect(parts).toEqual(['nomic-ai', 'nomic-embed-text-v1.5']);
222
+ expect(parts.length).toBe(2);
223
+ });
224
+
225
+ it('should handle model names without organization prefix', () => {
226
+ const modelName = 'all-MiniLM-L6-v2';
227
+ const parts = modelName.split('/');
228
+
229
+ expect(parts).toEqual(['all-MiniLM-L6-v2']);
230
+ expect(parts.length).toBe(1);
231
+ });
232
+
233
+ it('should build correct cache paths', () => {
234
+ const baseDir = '/some/cache/path';
235
+ const modelName = 'nomic-ai/nomic-embed-text-v1.5';
236
+
237
+ const cachePath = join(baseDir, ...modelName.split('/'));
238
+
239
+ expect(cachePath).toBe('/some/cache/path/nomic-ai/nomic-embed-text-v1.5');
240
+ });
241
+ });
242
+ });
@@ -7,7 +7,7 @@
7
7
  * - Semantic similarity at different dimensions
8
8
  */
9
9
 
10
- import { describe, it, expect, beforeAll } from 'vitest';
10
+ import { describe, it, expect, beforeAll, vi } from 'vitest';
11
11
  import { createMRLEmbedder, createLegacyEmbedder, createEmbedder, VALID_DIMENSIONS } from '../lib/mrl-embedder.js';
12
12
  import { cosineSimilarity } from '../lib/utils.js';
13
13
 
@@ -101,8 +101,114 @@ describe('createEmbedder Factory', () => {
101
101
  embeddingModel: 'Xenova/all-MiniLM-L6-v2',
102
102
  device: 'cpu'
103
103
  };
104
-
104
+
105
105
  const embedder = await createEmbedder(config);
106
106
  expect(embedder.dimension).toBe(384);
107
107
  }, 120000);
108
108
  });
109
+
110
+ describe('Auto-Recovery Logic', () => {
111
+ describe('Corruption Error Detection', () => {
112
+ it('should detect Protobuf parsing errors', async () => {
113
+ // We test this indirectly through the createEmbedder fallback behavior
114
+ // When MRL fails, it should fall back to legacy
115
+ const config = {
116
+ embeddingModel: 'nomic-ai/nomic-embed-text-v1.5',
117
+ embeddingDimension: 256,
118
+ device: 'cpu'
119
+ };
120
+
121
+ // This should succeed (model loads or recovers)
122
+ const embedder = await createEmbedder(config);
123
+ expect(embedder).toBeDefined();
124
+ expect(typeof embedder).toBe('function');
125
+ }, 120000);
126
+ });
127
+
128
+ describe('Runtime Recovery', () => {
129
+ let embedder;
130
+
131
+ beforeAll(async () => {
132
+ embedder = await createMRLEmbedder('nomic-ai/nomic-embed-text-v1.5', { dimension: 256 });
133
+ }, 120000);
134
+
135
+ it('should successfully embed after model is loaded', async () => {
136
+ const result = await embedder('test recovery');
137
+ expect(result.data).toBeDefined();
138
+ expect(result.dims[1]).toBe(256);
139
+ });
140
+
141
+ it('should have correct metadata after successful embedding', () => {
142
+ expect(embedder.dimension).toBe(256);
143
+ expect(embedder.modelName).toBe('nomic-ai/nomic-embed-text-v1.5');
144
+ });
145
+
146
+ it('should handle multiple sequential embeddings', async () => {
147
+ const texts = ['first text', 'second text', 'third text'];
148
+
149
+ for (const text of texts) {
150
+ const result = await embedder(text);
151
+ expect(result.data).toBeDefined();
152
+ expect(Array.from(result.data).length).toBe(256);
153
+ }
154
+ });
155
+ });
156
+
157
+ describe('Fallback Behavior', () => {
158
+ it('createEmbedder should fall back to legacy when MRL fails completely', async () => {
159
+ // Test that the factory handles failures gracefully
160
+ // Using a known-working legacy model
161
+ const config = {
162
+ embeddingModel: 'Xenova/all-MiniLM-L6-v2',
163
+ device: 'cpu'
164
+ };
165
+
166
+ const embedder = await createEmbedder(config);
167
+ expect(embedder.dimension).toBe(384);
168
+ expect(embedder.modelName).toBe('Xenova/all-MiniLM-L6-v2');
169
+
170
+ // Verify it actually works
171
+ const result = await embedder('fallback test');
172
+ expect(result.data).toBeDefined();
173
+ }, 120000);
174
+
175
+ it('legacy embedder should produce valid embeddings', async () => {
176
+ const embedder = await createLegacyEmbedder();
177
+
178
+ expect(embedder.dimension).toBe(384);
179
+ expect(embedder.modelName).toBe('Xenova/all-MiniLM-L6-v2');
180
+
181
+ const result = await embedder('legacy embedding test');
182
+ const vector = Array.from(result.data);
183
+
184
+ expect(vector.length).toBe(384);
185
+
186
+ // Check it's normalized
187
+ const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
188
+ expect(magnitude).toBeCloseTo(1, 2);
189
+ }, 120000);
190
+ });
191
+ });
192
+
193
+ describe('Auto-Recovery with Mocked Pipeline', () => {
194
+ it('should handle corruption and recovery flow', async () => {
195
+ // This test verifies the recovery logic exists and embedder is resilient
196
+ const embedder = await createMRLEmbedder('nomic-ai/nomic-embed-text-v1.5', { dimension: 128 });
197
+
198
+ // Verify embedder works
199
+ const result1 = await embedder('before corruption test');
200
+ expect(result1.dims[1]).toBe(128);
201
+
202
+ // Run multiple embeddings to ensure stability
203
+ const results = await Promise.all([
204
+ embedder('concurrent test 1'),
205
+ embedder('concurrent test 2'),
206
+ embedder('concurrent test 3')
207
+ ]);
208
+
209
+ results.forEach(result => {
210
+ expect(result.dims[1]).toBe(128);
211
+ expect(Array.from(result.data).length).toBe(128);
212
+ });
213
+ }, 120000);
214
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Real Integration Tests for MRL Auto-Recovery
3
+ *
4
+ * Tests embedder stability and error handling with real models.
5
+ * Destructive corruption tests are in mrl-recovery.test.js (mocked).
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { existsSync, readdirSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { createMRLEmbedder, createLegacyEmbedder, createEmbedder } from '../lib/mrl-embedder.js';
13
+
14
+ // Find the transformers cache directory
15
+ function getTransformersCacheDir() {
16
+ const transformersPath = dirname(fileURLToPath(import.meta.resolve('@huggingface/transformers')));
17
+ const cacheInParent = join(dirname(transformersPath), '.cache');
18
+ if (existsSync(cacheInParent)) return cacheInParent;
19
+ return join(transformersPath, '.cache');
20
+ }
21
+
22
+ describe('MRL Embedder Integration', () => {
23
+ const modelName = 'nomic-ai/nomic-embed-text-v1.5';
24
+ let embedder;
25
+
26
+ beforeAll(async () => {
27
+ console.log('[Test] Loading MRL embedder...');
28
+ embedder = await createMRLEmbedder(modelName, { dimension: 256 });
29
+ console.log('[Test] MRL embedder loaded');
30
+ }, 300000);
31
+
32
+ it('should create embedder with correct metadata', () => {
33
+ expect(embedder.dimension).toBe(256);
34
+ expect(embedder.modelName).toBe(modelName);
35
+ expect(embedder.device).toBe('cpu');
36
+ });
37
+
38
+ it('should produce correct dimension embeddings', async () => {
39
+ const result = await embedder('test embedding');
40
+ expect(result.data).toBeDefined();
41
+ expect(result.dims[1]).toBe(256);
42
+ expect(Array.from(result.data).length).toBe(256);
43
+ });
44
+
45
+ it('should produce normalized vectors', async () => {
46
+ const result = await embedder('normalized test');
47
+ const vector = Array.from(result.data);
48
+ const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
49
+ expect(magnitude).toBeCloseTo(1, 3);
50
+ });
51
+
52
+ it('should handle multiple sequential embeddings', async () => {
53
+ const texts = ['first', 'second', 'third', 'fourth', 'fifth'];
54
+
55
+ for (const text of texts) {
56
+ const result = await embedder(text);
57
+ expect(result.data).toBeDefined();
58
+ expect(Array.from(result.data).length).toBe(256);
59
+ }
60
+ });
61
+
62
+ it('should handle concurrent embeddings', async () => {
63
+ const results = await Promise.all([
64
+ embedder('concurrent 1'),
65
+ embedder('concurrent 2'),
66
+ embedder('concurrent 3')
67
+ ]);
68
+
69
+ results.forEach(result => {
70
+ expect(result.data).toBeDefined();
71
+ expect(result.dims[1]).toBe(256);
72
+ });
73
+ });
74
+
75
+ it('should handle various input types', async () => {
76
+ const inputs = [
77
+ 'normal text',
78
+ 'a'.repeat(500), // long text
79
+ 'special: @#$%^&*()',
80
+ 'unicode: 你好世界',
81
+ ' whitespace '
82
+ ];
83
+
84
+ for (const input of inputs) {
85
+ const result = await embedder(input);
86
+ expect(result.data).toBeDefined();
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('Legacy Embedder Integration', () => {
92
+ let embedder;
93
+
94
+ beforeAll(async () => {
95
+ embedder = await createLegacyEmbedder();
96
+ }, 120000);
97
+
98
+ it('should create legacy embedder with correct metadata', () => {
99
+ expect(embedder.dimension).toBe(384);
100
+ expect(embedder.modelName).toBe('Xenova/all-MiniLM-L6-v2');
101
+ });
102
+
103
+ it('should produce 384d embeddings', async () => {
104
+ const result = await embedder('legacy test');
105
+ expect(Array.from(result.data).length).toBe(384);
106
+ });
107
+ });
108
+
109
+ describe('Factory Function', () => {
110
+ it('should create MRL embedder for nomic model', async () => {
111
+ const embedder = await createEmbedder({
112
+ embeddingModel: 'nomic-ai/nomic-embed-text-v1.5',
113
+ embeddingDimension: 128,
114
+ device: 'cpu'
115
+ });
116
+
117
+ expect(embedder.dimension).toBe(128);
118
+ expect(embedder.modelName).toContain('nomic');
119
+ }, 300000);
120
+
121
+ it('should create legacy embedder for MiniLM', async () => {
122
+ const embedder = await createEmbedder({
123
+ embeddingModel: 'Xenova/all-MiniLM-L6-v2',
124
+ device: 'cpu'
125
+ });
126
+
127
+ expect(embedder.dimension).toBe(384);
128
+ expect(embedder.modelName).toBe('Xenova/all-MiniLM-L6-v2');
129
+ }, 120000);
130
+ });
131
+
132
+ describe('Cache Location Verification', () => {
133
+ it('should find transformers cache directory', () => {
134
+ const cacheDir = getTransformersCacheDir();
135
+ expect(existsSync(cacheDir)).toBe(true);
136
+ console.log(`[Test] Cache directory: ${cacheDir}`);
137
+ });
138
+
139
+ it('should have model files in cache', () => {
140
+ const cacheDir = getTransformersCacheDir();
141
+ const modelDir = join(cacheDir, 'nomic-ai', 'nomic-embed-text-v1.5', 'onnx');
142
+
143
+ if (existsSync(modelDir)) {
144
+ const files = readdirSync(modelDir);
145
+ expect(files.some(f => f.endsWith('.onnx'))).toBe(true);
146
+ console.log(`[Test] Model files: ${files.join(', ')}`);
147
+ } else {
148
+ console.log('[Test] Model directory not found (may need download)');
149
+ }
150
+ });
151
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Tests for MRL Embedder Auto-Recovery
3
+ *
4
+ * Uses mocked pipeline to test corruption detection and recovery:
5
+ * - Startup corruption → clear cache → reload
6
+ * - Runtime corruption → reload → retry
7
+ * - Runtime corruption → reload fails → fallback to legacy
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+
12
+ // Mock the transformers pipeline
13
+ const mockPipeline = vi.fn();
14
+ const mockLayerNorm = vi.fn();
15
+
16
+ vi.mock('@huggingface/transformers', () => ({
17
+ pipeline: (...args) => mockPipeline(...args),
18
+ layer_norm: (...args) => mockLayerNorm(...args)
19
+ }));
20
+
21
+ // Mock fs for cache clearing
22
+ vi.mock('fs', () => ({
23
+ existsSync: vi.fn(() => true),
24
+ rmSync: vi.fn()
25
+ }));
26
+
27
+ describe('MRL Auto-Recovery (Mocked)', () => {
28
+ let callCount = 0;
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ callCount = 0;
33
+
34
+ // Default mock implementation for layer_norm
35
+ mockLayerNorm.mockImplementation((tensor) => ({
36
+ slice: () => ({
37
+ normalize: () => ({
38
+ data: new Float32Array(256).fill(0.1),
39
+ dims: [1, 256]
40
+ })
41
+ }),
42
+ dims: tensor.dims
43
+ }));
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.resetModules();
48
+ });
49
+
50
+ describe('Startup Recovery', () => {
51
+ it('should recover from corruption on first load', async () => {
52
+ // First call throws corruption, second succeeds
53
+ mockPipeline
54
+ .mockRejectedValueOnce(new Error('Protobuf parsing failed'))
55
+ .mockResolvedValueOnce(async () => ({
56
+ data: new Float32Array(768).fill(0.1),
57
+ dims: [1, 768]
58
+ }));
59
+
60
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
61
+ const embedder = await createMRLEmbedder('test-model', { dimension: 256 });
62
+
63
+ expect(mockPipeline).toHaveBeenCalledTimes(2);
64
+ expect(embedder).toBeDefined();
65
+ });
66
+
67
+ it('should throw if recovery also fails with non-corruption error', async () => {
68
+ mockPipeline.mockRejectedValue(new Error('Network error'));
69
+
70
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
71
+
72
+ await expect(createMRLEmbedder('test-model', { dimension: 256 }))
73
+ .rejects.toThrow('Network error');
74
+ });
75
+ });
76
+
77
+ describe('Runtime Recovery', () => {
78
+ it('should reload model on runtime corruption and retry', async () => {
79
+ let embedCallCount = 0;
80
+ const mockExtractor = vi.fn().mockImplementation(async () => {
81
+ embedCallCount++;
82
+ if (embedCallCount === 1) {
83
+ throw new Error('Protobuf parsing failed');
84
+ }
85
+ return {
86
+ data: new Float32Array(768).fill(0.1),
87
+ dims: [1, 768]
88
+ };
89
+ });
90
+
91
+ mockPipeline.mockResolvedValue(mockExtractor);
92
+
93
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
94
+ const embedder = await createMRLEmbedder('test-model', { dimension: 256 });
95
+
96
+ // First embed triggers corruption, then reload and retry
97
+ const result = await embedder('test text');
98
+
99
+ expect(result.data).toBeDefined();
100
+ // Pipeline called: 1 (init) + 1 (reload after corruption) = 2
101
+ expect(mockPipeline).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it('should detect various corruption error messages', async () => {
105
+ const corruptionErrors = [
106
+ 'Protobuf parsing failed',
107
+ 'Invalid model format',
108
+ 'ONNX model is corrupt'
109
+ ];
110
+
111
+ for (const errorMsg of corruptionErrors) {
112
+ vi.resetModules();
113
+ vi.clearAllMocks();
114
+
115
+ let throwError = true;
116
+ const mockExtractor = vi.fn().mockImplementation(async () => {
117
+ if (throwError) {
118
+ throwError = false;
119
+ throw new Error(errorMsg);
120
+ }
121
+ return {
122
+ data: new Float32Array(768).fill(0.1),
123
+ dims: [1, 768]
124
+ };
125
+ });
126
+
127
+ mockPipeline.mockResolvedValue(mockExtractor);
128
+ mockLayerNorm.mockImplementation((tensor) => ({
129
+ slice: () => ({
130
+ normalize: () => ({
131
+ data: new Float32Array(256).fill(0.1),
132
+ dims: [1, 256]
133
+ })
134
+ }),
135
+ dims: tensor.dims
136
+ }));
137
+
138
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
139
+ const embedder = await createMRLEmbedder('test-model', { dimension: 256 });
140
+
141
+ const result = await embedder('test');
142
+ expect(result.data).toBeDefined();
143
+ }
144
+ });
145
+ });
146
+
147
+ describe('Fallback to Legacy', () => {
148
+ it('should fall back to legacy when reload also fails', async () => {
149
+ let pipelineCallCount = 0;
150
+ let embedCallCount = 0;
151
+
152
+ // Mock extractor that fails on first embed call
153
+ const corruptExtractor = vi.fn().mockImplementation(async () => {
154
+ embedCallCount++;
155
+ if (embedCallCount === 1) {
156
+ throw new Error('Protobuf parsing failed');
157
+ }
158
+ return {
159
+ data: new Float32Array(768).fill(0.1),
160
+ dims: [1, 768]
161
+ };
162
+ });
163
+
164
+ // Mock legacy extractor that works
165
+ const legacyExtractor = vi.fn().mockResolvedValue({
166
+ data: new Float32Array(384).fill(0.2),
167
+ dims: [1, 384]
168
+ });
169
+
170
+ mockPipeline.mockImplementation(async (task, model) => {
171
+ pipelineCallCount++;
172
+ if (pipelineCallCount <= 2) {
173
+ if (pipelineCallCount === 2) {
174
+ // Reload attempt fails
175
+ throw new Error('Network timeout');
176
+ }
177
+ return corruptExtractor;
178
+ }
179
+ // Third call is legacy model
180
+ return legacyExtractor;
181
+ });
182
+
183
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
184
+ const embedder = await createMRLEmbedder('nomic-ai/nomic-embed-text-v1.5', { dimension: 256 });
185
+
186
+ // This should trigger: corruption → reload fail → fallback to legacy
187
+ const result = await embedder('test text');
188
+
189
+ expect(result.data).toBeDefined();
190
+ // Dimension should update to legacy (384)
191
+ expect(embedder.dimension).toBe(384);
192
+ });
193
+
194
+ it('should use fallback for subsequent calls after switching', async () => {
195
+ let pipelineCallCount = 0;
196
+ let embedCallCount = 0;
197
+
198
+ const corruptExtractor = vi.fn().mockImplementation(async () => {
199
+ embedCallCount++;
200
+ throw new Error('Protobuf parsing failed');
201
+ });
202
+
203
+ const legacyExtractor = vi.fn().mockResolvedValue({
204
+ data: new Float32Array(384).fill(0.2),
205
+ dims: [1, 384]
206
+ });
207
+
208
+ mockPipeline.mockImplementation(async (task, model) => {
209
+ pipelineCallCount++;
210
+ if (model.includes('nomic')) {
211
+ if (pipelineCallCount >= 2) {
212
+ throw new Error('Model unavailable');
213
+ }
214
+ return corruptExtractor;
215
+ }
216
+ return legacyExtractor;
217
+ });
218
+
219
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
220
+ const embedder = await createMRLEmbedder('nomic-ai/nomic-embed-text-v1.5', { dimension: 256 });
221
+
222
+ // First call triggers fallback
223
+ await embedder('first');
224
+
225
+ // Subsequent calls should use legacy directly
226
+ const legacyCallsBefore = legacyExtractor.mock.calls.length;
227
+ await embedder('second');
228
+ await embedder('third');
229
+
230
+ expect(legacyExtractor.mock.calls.length).toBe(legacyCallsBefore + 2);
231
+ });
232
+ });
233
+
234
+ describe('Non-Corruption Errors', () => {
235
+ it('should throw non-corruption errors without recovery attempt', async () => {
236
+ const mockExtractor = vi.fn().mockRejectedValue(new Error('Out of memory'));
237
+ mockPipeline.mockResolvedValue(mockExtractor);
238
+
239
+ const { createMRLEmbedder } = await import('../lib/mrl-embedder.js');
240
+ const embedder = await createMRLEmbedder('test-model', { dimension: 256 });
241
+
242
+ await expect(embedder('test')).rejects.toThrow('Out of memory');
243
+
244
+ // Should not have attempted reload (only initial load)
245
+ expect(mockPipeline).toHaveBeenCalledTimes(1);
246
+ });
247
+ });
248
+ });