@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.
- package/README.md +281 -10
- package/core-invariants.js +942 -0
- package/models/adapter-hub.js +1008 -0
- package/models/adapter-security.js +792 -0
- package/models/benchmark.js +688 -0
- package/models/distribution.js +791 -0
- package/models/index.js +109 -0
- package/models/integrity.js +753 -0
- package/models/loader.js +725 -0
- package/models/microlora.js +1298 -0
- package/models/model-loader.js +922 -0
- package/models/model-optimizer.js +1245 -0
- package/models/model-registry.js +696 -0
- package/models/model-utils.js +548 -0
- package/models/models-cli.js +914 -0
- package/models/registry.json +214 -0
- package/models/training-utils.js +1418 -0
- package/models/wasm-core.js +1025 -0
- package/network-genesis.js +2847 -0
- package/onnx-worker.js +462 -8
- package/package.json +33 -3
- package/plugins/SECURITY-AUDIT.md +654 -0
- package/plugins/cli.js +43 -3
- package/plugins/implementations/e2e-encryption.js +57 -12
- package/plugins/plugin-loader.js +610 -21
- package/tests/model-optimizer.test.js +644 -0
- package/tests/network-genesis.test.js +562 -0
- package/tests/plugin-benchmark.js +1239 -0
- package/tests/plugin-system-test.js +163 -0
- package/tests/wasm-core.test.js +368 -0
package/models/loader.js
ADDED
|
@@ -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;
|