@ruvector/edge-net 0.5.0 → 0.5.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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * @ruvector/edge-net Model Loader
3
+ *
4
+ * Tiered model loading with:
5
+ * - Memory-aware model selection
6
+ * - Streaming chunk verification
7
+ * - Multi-source fallback (GCS → IPFS → P2P)
8
+ * - IndexedDB caching
9
+ *
10
+ * Design: Registry returns manifest only, client derives URLs from manifest.
11
+ *
12
+ * @module @ruvector/edge-net/models/loader
13
+ */
14
+
15
+ import { createHash } from 'crypto';
16
+ import { ManifestVerifier, verifyMerkleProof, computeMerkleRoot } from './integrity.js';
17
+
18
+ // ============================================================================
19
+ // MODEL TIERS
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Model tier definitions with memory requirements
24
+ */
25
+ export const MODEL_TIERS = Object.freeze({
26
+ micro: {
27
+ name: 'micro',
28
+ maxSize: 100 * 1024 * 1024, // 100MB
29
+ minMemory: 256 * 1024 * 1024, // 256MB available
30
+ description: 'Embeddings and small tasks',
31
+ priority: 1,
32
+ },
33
+ small: {
34
+ name: 'small',
35
+ maxSize: 500 * 1024 * 1024, // 500MB
36
+ minMemory: 1024 * 1024 * 1024, // 1GB available
37
+ description: 'Balanced capability',
38
+ priority: 2,
39
+ },
40
+ large: {
41
+ name: 'large',
42
+ maxSize: 1500 * 1024 * 1024, // 1.5GB
43
+ minMemory: 4096 * 1024 * 1024, // 4GB available
44
+ description: 'Full capability',
45
+ priority: 3,
46
+ },
47
+ });
48
+
49
+ /**
50
+ * Capability priorities for model selection
51
+ */
52
+ export const CAPABILITY_PRIORITIES = Object.freeze({
53
+ embed: 1, // Always prioritize embeddings
54
+ retrieve: 2, // Then retrieval
55
+ generate: 3, // Generation only when needed
56
+ code: 4, // Specialized capabilities
57
+ multilingual: 5,
58
+ });
59
+
60
+ // ============================================================================
61
+ // MEMORY DETECTION
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Detect available memory for model loading
66
+ */
67
+ export function detectAvailableMemory() {
68
+ // Browser environment
69
+ if (typeof navigator !== 'undefined' && navigator.deviceMemory) {
70
+ return navigator.deviceMemory * 1024 * 1024 * 1024;
71
+ }
72
+
73
+ // Node.js environment
74
+ if (typeof process !== 'undefined' && process.memoryUsage) {
75
+ const usage = process.memoryUsage();
76
+ // Estimate available as total minus current usage
77
+ const total = require('os').totalmem?.() || 4 * 1024 * 1024 * 1024;
78
+ return Math.max(0, total - usage.heapUsed);
79
+ }
80
+
81
+ // Default to 2GB as conservative estimate
82
+ return 2 * 1024 * 1024 * 1024;
83
+ }
84
+
85
+ /**
86
+ * Select appropriate tier based on device capabilities
87
+ */
88
+ export function selectTier(requiredCapabilities = ['embed'], preferredTier = null) {
89
+ const available = detectAvailableMemory();
90
+
91
+ // Find highest tier that fits in memory
92
+ const viableTiers = Object.values(MODEL_TIERS)
93
+ .filter(tier => tier.minMemory <= available)
94
+ .sort((a, b) => b.priority - a.priority);
95
+
96
+ if (viableTiers.length === 0) {
97
+ console.warn('[ModelLoader] Insufficient memory for any tier, using micro');
98
+ return MODEL_TIERS.micro;
99
+ }
100
+
101
+ // Respect preferred tier if viable
102
+ if (preferredTier && viableTiers.find(t => t.name === preferredTier)) {
103
+ return MODEL_TIERS[preferredTier];
104
+ }
105
+
106
+ // Otherwise use highest viable
107
+ return viableTiers[0];
108
+ }
109
+
110
+ // ============================================================================
111
+ // CACHE MANAGER
112
+ // ============================================================================
113
+
114
+ /**
115
+ * IndexedDB-based cache for models and chunks
116
+ */
117
+ export class ModelCache {
118
+ constructor(options = {}) {
119
+ this.dbName = options.dbName || 'ruvector-models';
120
+ this.version = options.version || 1;
121
+ this.db = null;
122
+ this.maxCacheSize = options.maxCacheSize || 2 * 1024 * 1024 * 1024; // 2GB
123
+ }
124
+
125
+ async open() {
126
+ if (this.db) return this.db;
127
+
128
+ return new Promise((resolve, reject) => {
129
+ const request = indexedDB.open(this.dbName, this.version);
130
+
131
+ request.onerror = () => reject(request.error);
132
+
133
+ request.onsuccess = () => {
134
+ this.db = request.result;
135
+ resolve(this.db);
136
+ };
137
+
138
+ request.onupgradeneeded = (event) => {
139
+ const db = event.target.result;
140
+
141
+ // Store for complete models
142
+ if (!db.objectStoreNames.contains('models')) {
143
+ const store = db.createObjectStore('models', { keyPath: 'id' });
144
+ store.createIndex('hash', 'hash', { unique: true });
145
+ store.createIndex('lastAccess', 'lastAccess');
146
+ }
147
+
148
+ // Store for individual chunks (for streaming)
149
+ if (!db.objectStoreNames.contains('chunks')) {
150
+ const store = db.createObjectStore('chunks', { keyPath: 'id' });
151
+ store.createIndex('modelId', 'modelId');
152
+ }
153
+
154
+ // Store for manifests
155
+ if (!db.objectStoreNames.contains('manifests')) {
156
+ db.createObjectStore('manifests', { keyPath: 'modelId' });
157
+ }
158
+ };
159
+ });
160
+ }
161
+
162
+ async get(modelId) {
163
+ await this.open();
164
+
165
+ return new Promise((resolve, reject) => {
166
+ const tx = this.db.transaction('models', 'readonly');
167
+ const store = tx.objectStore('models');
168
+ const request = store.get(modelId);
169
+
170
+ request.onsuccess = () => {
171
+ const result = request.result;
172
+ if (result) {
173
+ // Update last access
174
+ this.updateLastAccess(modelId);
175
+ }
176
+ resolve(result);
177
+ };
178
+ request.onerror = () => reject(request.error);
179
+ });
180
+ }
181
+
182
+ async put(modelId, data, manifest) {
183
+ await this.open();
184
+ await this.ensureSpace(data.byteLength || data.length);
185
+
186
+ return new Promise((resolve, reject) => {
187
+ const tx = this.db.transaction(['models', 'manifests'], 'readwrite');
188
+
189
+ const modelStore = tx.objectStore('models');
190
+ modelStore.put({
191
+ id: modelId,
192
+ data,
193
+ hash: manifest.integrity?.merkleRoot || 'unknown',
194
+ size: data.byteLength || data.length,
195
+ lastAccess: Date.now(),
196
+ cachedAt: Date.now(),
197
+ });
198
+
199
+ const manifestStore = tx.objectStore('manifests');
200
+ manifestStore.put({
201
+ modelId,
202
+ manifest,
203
+ cachedAt: Date.now(),
204
+ });
205
+
206
+ tx.oncomplete = () => resolve();
207
+ tx.onerror = () => reject(tx.error);
208
+ });
209
+ }
210
+
211
+ async getChunk(modelId, chunkIndex) {
212
+ await this.open();
213
+
214
+ return new Promise((resolve, reject) => {
215
+ const tx = this.db.transaction('chunks', 'readonly');
216
+ const store = tx.objectStore('chunks');
217
+ const request = store.get(`${modelId}:${chunkIndex}`);
218
+
219
+ request.onsuccess = () => resolve(request.result?.data);
220
+ request.onerror = () => reject(request.error);
221
+ });
222
+ }
223
+
224
+ async putChunk(modelId, chunkIndex, data, hash) {
225
+ await this.open();
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const tx = this.db.transaction('chunks', 'readwrite');
229
+ const store = tx.objectStore('chunks');
230
+ store.put({
231
+ id: `${modelId}:${chunkIndex}`,
232
+ modelId,
233
+ chunkIndex,
234
+ data,
235
+ hash,
236
+ cachedAt: Date.now(),
237
+ });
238
+
239
+ tx.oncomplete = () => resolve();
240
+ tx.onerror = () => reject(tx.error);
241
+ });
242
+ }
243
+
244
+ async updateLastAccess(modelId) {
245
+ await this.open();
246
+
247
+ return new Promise((resolve) => {
248
+ const tx = this.db.transaction('models', 'readwrite');
249
+ const store = tx.objectStore('models');
250
+ const request = store.get(modelId);
251
+
252
+ request.onsuccess = () => {
253
+ if (request.result) {
254
+ request.result.lastAccess = Date.now();
255
+ store.put(request.result);
256
+ }
257
+ resolve();
258
+ };
259
+ });
260
+ }
261
+
262
+ async ensureSpace(needed) {
263
+ await this.open();
264
+
265
+ // Get current usage
266
+ const estimate = await navigator.storage?.estimate?.();
267
+ const used = estimate?.usage || 0;
268
+
269
+ if (used + needed > this.maxCacheSize) {
270
+ await this.evictLRU(needed);
271
+ }
272
+ }
273
+
274
+ async evictLRU(needed) {
275
+ await this.open();
276
+
277
+ return new Promise((resolve, reject) => {
278
+ const tx = this.db.transaction('models', 'readwrite');
279
+ const store = tx.objectStore('models');
280
+ const index = store.index('lastAccess');
281
+ const request = index.openCursor();
282
+
283
+ let freed = 0;
284
+
285
+ request.onsuccess = (event) => {
286
+ const cursor = event.target.result;
287
+ if (cursor && freed < needed) {
288
+ freed += cursor.value.size || 0;
289
+ cursor.delete();
290
+ cursor.continue();
291
+ } else {
292
+ resolve(freed);
293
+ }
294
+ };
295
+ request.onerror = () => reject(request.error);
296
+ });
297
+ }
298
+
299
+ async getCacheStats() {
300
+ await this.open();
301
+
302
+ return new Promise((resolve, reject) => {
303
+ const tx = this.db.transaction('models', 'readonly');
304
+ const store = tx.objectStore('models');
305
+ const request = store.getAll();
306
+
307
+ request.onsuccess = () => {
308
+ const models = request.result;
309
+ const totalSize = models.reduce((sum, m) => sum + (m.size || 0), 0);
310
+ resolve({
311
+ modelCount: models.length,
312
+ totalSize,
313
+ models: models.map(m => ({
314
+ id: m.id,
315
+ size: m.size,
316
+ lastAccess: m.lastAccess,
317
+ })),
318
+ });
319
+ };
320
+ request.onerror = () => reject(request.error);
321
+ });
322
+ }
323
+
324
+ async clear() {
325
+ await this.open();
326
+
327
+ return new Promise((resolve, reject) => {
328
+ const tx = this.db.transaction(['models', 'chunks', 'manifests'], 'readwrite');
329
+ tx.objectStore('models').clear();
330
+ tx.objectStore('chunks').clear();
331
+ tx.objectStore('manifests').clear();
332
+ tx.oncomplete = () => resolve();
333
+ tx.onerror = () => reject(tx.error);
334
+ });
335
+ }
336
+ }
337
+
338
+ // ============================================================================
339
+ // MODEL LOADER
340
+ // ============================================================================
341
+
342
+ /**
343
+ * Model loader with tiered selection and chunk verification
344
+ */
345
+ export class ModelLoader {
346
+ constructor(options = {}) {
347
+ this.cache = new ModelCache(options.cache);
348
+ this.verifier = new ManifestVerifier(options.trustRoot);
349
+ this.registryUrl = options.registryUrl || 'https://models.ruvector.dev';
350
+
351
+ // Loading state
352
+ this.loadingModels = new Map();
353
+ this.loadedModels = new Map();
354
+
355
+ // Callbacks
356
+ this.onProgress = options.onProgress || (() => {});
357
+ this.onError = options.onError || console.error;
358
+
359
+ // Source preference order
360
+ this.sourceOrder = options.sourceOrder || ['cache', 'gcs', 'ipfs', 'p2p'];
361
+ }
362
+
363
+ /**
364
+ * Fetch manifest from registry (registry only returns manifest, not URLs)
365
+ */
366
+ async fetchManifest(modelId) {
367
+ // Check cache first
368
+ const cached = await this.cache.get(modelId);
369
+ if (cached?.manifest) {
370
+ return cached.manifest;
371
+ }
372
+
373
+ // Fetch from registry
374
+ const response = await fetch(`${this.registryUrl}/v2/manifests/${modelId}`);
375
+ if (!response.ok) {
376
+ throw new Error(`Failed to fetch manifest: ${response.status}`);
377
+ }
378
+
379
+ const manifest = await response.json();
380
+
381
+ // Verify manifest
382
+ const verification = this.verifier.verify(manifest);
383
+ if (!verification.valid) {
384
+ throw new Error(`Invalid manifest: ${verification.errors.join(', ')}`);
385
+ }
386
+
387
+ if (verification.warnings.length > 0) {
388
+ console.warn('[ModelLoader] Manifest warnings:', verification.warnings);
389
+ }
390
+
391
+ return manifest;
392
+ }
393
+
394
+ /**
395
+ * Select best model for required capabilities
396
+ */
397
+ async selectModel(requiredCapabilities, options = {}) {
398
+ const tier = selectTier(requiredCapabilities, options.preferredTier);
399
+
400
+ // Fetch model catalog for this tier
401
+ const response = await fetch(`${this.registryUrl}/v2/catalog?tier=${tier.name}`);
402
+ if (!response.ok) {
403
+ throw new Error(`Failed to fetch catalog: ${response.status}`);
404
+ }
405
+
406
+ const catalog = await response.json();
407
+
408
+ // Filter by capabilities
409
+ const candidates = catalog.models.filter(m => {
410
+ const hasCapabilities = requiredCapabilities.every(cap =>
411
+ m.capabilities?.includes(cap)
412
+ );
413
+ const fitsMemory = m.memoryRequirement <= detectAvailableMemory();
414
+ return hasCapabilities && fitsMemory;
415
+ });
416
+
417
+ if (candidates.length === 0) {
418
+ throw new Error(`No model found for capabilities: ${requiredCapabilities.join(', ')}`);
419
+ }
420
+
421
+ // Sort by capability priority (prefer embeddings over generation)
422
+ candidates.sort((a, b) => {
423
+ const aPriority = Math.min(...a.capabilities.map(c => CAPABILITY_PRIORITIES[c] || 10));
424
+ const bPriority = Math.min(...b.capabilities.map(c => CAPABILITY_PRIORITIES[c] || 10));
425
+ return aPriority - bPriority;
426
+ });
427
+
428
+ return candidates[0];
429
+ }
430
+
431
+ /**
432
+ * Load a model with chunk verification
433
+ */
434
+ async load(modelId, options = {}) {
435
+ // Return if already loaded
436
+ if (this.loadedModels.has(modelId)) {
437
+ return this.loadedModels.get(modelId);
438
+ }
439
+
440
+ // Return existing promise if loading
441
+ if (this.loadingModels.has(modelId)) {
442
+ return this.loadingModels.get(modelId);
443
+ }
444
+
445
+ const loadPromise = this._loadInternal(modelId, options);
446
+ this.loadingModels.set(modelId, loadPromise);
447
+
448
+ try {
449
+ const result = await loadPromise;
450
+ this.loadedModels.set(modelId, result);
451
+ return result;
452
+ } finally {
453
+ this.loadingModels.delete(modelId);
454
+ }
455
+ }
456
+
457
+ async _loadInternal(modelId, options) {
458
+ // 1. Get manifest
459
+ const manifest = await this.fetchManifest(modelId);
460
+
461
+ // 2. Memory check
462
+ const available = detectAvailableMemory();
463
+ if (manifest.model.memoryRequirement > available) {
464
+ throw new Error(
465
+ `Insufficient memory: need ${manifest.model.memoryRequirement}, have ${available}`
466
+ );
467
+ }
468
+
469
+ // 3. Check cache
470
+ const cached = await this.cache.get(modelId);
471
+ if (cached?.data) {
472
+ // Verify cached data against manifest
473
+ if (cached.hash === manifest.integrity?.merkleRoot) {
474
+ this.onProgress({ modelId, status: 'cached', progress: 1 });
475
+ return { manifest, data: cached.data, source: 'cache' };
476
+ }
477
+ // Cache invalid, continue to download
478
+ }
479
+
480
+ // 4. Download with chunk verification
481
+ const artifact = manifest.artifacts[0]; // Primary artifact
482
+ const data = await this._downloadWithVerification(modelId, manifest, artifact, options);
483
+
484
+ // 5. Cache the result
485
+ await this.cache.put(modelId, data, manifest);
486
+
487
+ return { manifest, data, source: options.source || 'remote' };
488
+ }
489
+
490
+ /**
491
+ * Download with streaming chunk verification
492
+ */
493
+ async _downloadWithVerification(modelId, manifest, artifact, options) {
494
+ const sources = this._getSourceUrls(manifest, artifact);
495
+
496
+ for (const source of sources) {
497
+ try {
498
+ const data = await this._downloadFromSource(
499
+ modelId,
500
+ source,
501
+ manifest,
502
+ artifact
503
+ );
504
+ return data;
505
+ } catch (error) {
506
+ console.warn(`[ModelLoader] Source failed: ${source.type}`, error.message);
507
+ continue;
508
+ }
509
+ }
510
+
511
+ throw new Error('All download sources failed');
512
+ }
513
+
514
+ /**
515
+ * Get ordered source URLs from manifest
516
+ */
517
+ _getSourceUrls(manifest, artifact) {
518
+ const sources = [];
519
+ const dist = manifest.distribution || {};
520
+
521
+ for (const sourceType of this.sourceOrder) {
522
+ if (sourceType === 'gcs' && dist.gcs) {
523
+ sources.push({ type: 'gcs', url: dist.gcs });
524
+ }
525
+ if (sourceType === 'ipfs' && dist.ipfs) {
526
+ sources.push({
527
+ type: 'ipfs',
528
+ url: `https://ipfs.io/ipfs/${dist.ipfs}`,
529
+ cid: dist.ipfs,
530
+ });
531
+ }
532
+ if (sourceType === 'p2p') {
533
+ // P2P would be handled separately
534
+ sources.push({ type: 'p2p', url: null });
535
+ }
536
+ }
537
+
538
+ // Add fallbacks
539
+ if (dist.fallbackUrls) {
540
+ for (const url of dist.fallbackUrls) {
541
+ sources.push({ type: 'fallback', url });
542
+ }
543
+ }
544
+
545
+ return sources;
546
+ }
547
+
548
+ /**
549
+ * Download from a specific source with chunk verification
550
+ */
551
+ async _downloadFromSource(modelId, source, manifest, artifact) {
552
+ if (source.type === 'p2p') {
553
+ return this._downloadFromP2P(modelId, manifest, artifact);
554
+ }
555
+
556
+ const response = await fetch(source.url);
557
+ if (!response.ok) {
558
+ throw new Error(`HTTP ${response.status}`);
559
+ }
560
+
561
+ const contentLength = parseInt(response.headers.get('content-length') || '0');
562
+ const chunking = manifest.integrity?.chunking;
563
+
564
+ if (chunking && response.body) {
565
+ // Streaming download with chunk verification
566
+ return this._streamWithVerification(
567
+ modelId,
568
+ response.body,
569
+ manifest,
570
+ contentLength
571
+ );
572
+ } else {
573
+ // Simple download
574
+ const buffer = await response.arrayBuffer();
575
+
576
+ // Verify full file hash
577
+ if (artifact.sha256) {
578
+ const hash = createHash('sha256')
579
+ .update(Buffer.from(buffer))
580
+ .digest('hex');
581
+ if (hash !== artifact.sha256) {
582
+ throw new Error('File hash mismatch');
583
+ }
584
+ }
585
+
586
+ return buffer;
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Stream download with chunk-by-chunk verification
592
+ */
593
+ async _streamWithVerification(modelId, body, manifest, totalSize) {
594
+ const chunking = manifest.integrity.chunking;
595
+ const chunkSize = chunking.chunkSize;
596
+ const expectedChunks = chunking.chunkCount;
597
+
598
+ const reader = body.getReader();
599
+ const chunks = [];
600
+ let buffer = new Uint8Array(0);
601
+ let chunkIndex = 0;
602
+ let bytesReceived = 0;
603
+
604
+ while (true) {
605
+ const { done, value } = await reader.read();
606
+
607
+ if (done) break;
608
+
609
+ // Append to buffer
610
+ const newBuffer = new Uint8Array(buffer.length + value.length);
611
+ newBuffer.set(buffer);
612
+ newBuffer.set(value, buffer.length);
613
+ buffer = newBuffer;
614
+ bytesReceived += value.length;
615
+
616
+ // Process complete chunks
617
+ while (buffer.length >= chunkSize || (bytesReceived === totalSize && buffer.length > 0)) {
618
+ const isLastChunk = bytesReceived === totalSize && buffer.length <= chunkSize;
619
+ const thisChunkSize = isLastChunk ? buffer.length : chunkSize;
620
+ const chunkData = buffer.slice(0, thisChunkSize);
621
+ buffer = buffer.slice(thisChunkSize);
622
+
623
+ // Verify chunk
624
+ const verification = this.verifier.verifyChunk(chunkData, chunkIndex, manifest);
625
+ if (!verification.valid) {
626
+ throw new Error(`Chunk verification failed: ${verification.error}`);
627
+ }
628
+
629
+ chunks.push(chunkData);
630
+ chunkIndex++;
631
+
632
+ // Cache chunk for resume capability
633
+ await this.cache.putChunk(
634
+ modelId,
635
+ chunkIndex - 1,
636
+ chunkData,
637
+ verification.hash
638
+ );
639
+
640
+ // Progress callback
641
+ this.onProgress({
642
+ modelId,
643
+ status: 'downloading',
644
+ progress: bytesReceived / totalSize,
645
+ chunksVerified: chunkIndex,
646
+ totalChunks: expectedChunks,
647
+ });
648
+
649
+ if (isLastChunk) break;
650
+ }
651
+ }
652
+
653
+ // Verify Merkle root
654
+ const chunkHashes = chunking.chunkHashes;
655
+ const computedRoot = computeMerkleRoot(chunkHashes);
656
+ if (computedRoot !== manifest.integrity.merkleRoot) {
657
+ throw new Error('Merkle root verification failed');
658
+ }
659
+
660
+ // Combine chunks
661
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
662
+ const result = new Uint8Array(totalLength);
663
+ let offset = 0;
664
+ for (const chunk of chunks) {
665
+ result.set(chunk, offset);
666
+ offset += chunk.length;
667
+ }
668
+
669
+ this.onProgress({
670
+ modelId,
671
+ status: 'complete',
672
+ progress: 1,
673
+ verified: true,
674
+ });
675
+
676
+ return result.buffer;
677
+ }
678
+
679
+ /**
680
+ * Download from P2P network (placeholder)
681
+ */
682
+ async _downloadFromP2P(modelId, manifest, artifact) {
683
+ // Would integrate with WebRTC P2P network
684
+ throw new Error('P2P download not implemented');
685
+ }
686
+
687
+ /**
688
+ * Preload a model in the background
689
+ */
690
+ async preload(modelId) {
691
+ try {
692
+ await this.load(modelId);
693
+ } catch (error) {
694
+ console.warn(`[ModelLoader] Preload failed for ${modelId}:`, error.message);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Unload a model from memory
700
+ */
701
+ unload(modelId) {
702
+ this.loadedModels.delete(modelId);
703
+ }
704
+
705
+ /**
706
+ * Get cache statistics
707
+ */
708
+ async getCacheStats() {
709
+ return this.cache.getCacheStats();
710
+ }
711
+
712
+ /**
713
+ * Clear all cached models
714
+ */
715
+ async clearCache() {
716
+ await this.cache.clear();
717
+ this.loadedModels.clear();
718
+ }
719
+ }
720
+
721
+ // ============================================================================
722
+ // EXPORTS
723
+ // ============================================================================
724
+
725
+ export default ModelLoader;