@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,696 @@
1
+ /**
2
+ * @ruvector/edge-net Model Registry
3
+ *
4
+ * Manages model metadata, versions, dependencies, and discovery
5
+ * for the distributed model distribution infrastructure.
6
+ *
7
+ * @module @ruvector/edge-net/models/model-registry
8
+ */
9
+
10
+ import { EventEmitter } from 'events';
11
+ import { createHash, randomBytes } from 'crypto';
12
+ import { promises as fs } from 'fs';
13
+ import path from 'path';
14
+
15
+ // ============================================
16
+ // SEMVER UTILITIES
17
+ // ============================================
18
+
19
+ /**
20
+ * Parse a semver version string
21
+ * @param {string} version - Version string (e.g., "1.2.3", "1.0.0-beta.1")
22
+ * @returns {object} Parsed version object
23
+ */
24
+ export function parseSemver(version) {
25
+ const match = String(version).match(
26
+ /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$/
27
+ );
28
+
29
+ if (!match) {
30
+ throw new Error(`Invalid semver: ${version}`);
31
+ }
32
+
33
+ return {
34
+ major: parseInt(match[1], 10),
35
+ minor: parseInt(match[2], 10),
36
+ patch: parseInt(match[3], 10),
37
+ prerelease: match[4] || null,
38
+ build: match[5] || null,
39
+ raw: version,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Compare two semver versions
45
+ * @param {string} a - First version
46
+ * @param {string} b - Second version
47
+ * @returns {number} -1 if a < b, 0 if equal, 1 if a > b
48
+ */
49
+ export function compareSemver(a, b) {
50
+ const va = parseSemver(a);
51
+ const vb = parseSemver(b);
52
+
53
+ if (va.major !== vb.major) return va.major - vb.major;
54
+ if (va.minor !== vb.minor) return va.minor - vb.minor;
55
+ if (va.patch !== vb.patch) return va.patch - vb.patch;
56
+
57
+ // Prerelease versions have lower precedence
58
+ if (va.prerelease && !vb.prerelease) return -1;
59
+ if (!va.prerelease && vb.prerelease) return 1;
60
+ if (va.prerelease && vb.prerelease) {
61
+ return va.prerelease.localeCompare(vb.prerelease);
62
+ }
63
+
64
+ return 0;
65
+ }
66
+
67
+ /**
68
+ * Check if version satisfies a version range
69
+ * Supports: "1.0.0", "^1.0.0", "~1.0.0", ">=1.0.0", "1.x", "*"
70
+ * @param {string} version - Version to check
71
+ * @param {string} range - Version range
72
+ * @returns {boolean}
73
+ */
74
+ export function satisfiesSemver(version, range) {
75
+ const v = parseSemver(version);
76
+
77
+ // Exact match
78
+ if (range === version) return true;
79
+
80
+ // Wildcard
81
+ if (range === '*' || range === 'latest') return true;
82
+
83
+ // X-range: 1.x, 1.2.x
84
+ const xMatch = range.match(/^(\d+)(?:\.(\d+))?\.x$/);
85
+ if (xMatch) {
86
+ const major = parseInt(xMatch[1], 10);
87
+ const minor = xMatch[2] ? parseInt(xMatch[2], 10) : null;
88
+ if (v.major !== major) return false;
89
+ if (minor !== null && v.minor !== minor) return false;
90
+ return true;
91
+ }
92
+
93
+ // Caret range: ^1.0.0 (compatible with)
94
+ if (range.startsWith('^')) {
95
+ const r = parseSemver(range.slice(1));
96
+ if (v.major !== r.major) return false;
97
+ if (v.major === 0) {
98
+ if (v.minor !== r.minor) return false;
99
+ return v.patch >= r.patch;
100
+ }
101
+ return compareSemver(version, range.slice(1)) >= 0;
102
+ }
103
+
104
+ // Tilde range: ~1.0.0 (approximately equivalent)
105
+ if (range.startsWith('~')) {
106
+ const r = parseSemver(range.slice(1));
107
+ if (v.major !== r.major) return false;
108
+ if (v.minor !== r.minor) return false;
109
+ return v.patch >= r.patch;
110
+ }
111
+
112
+ // Comparison ranges: >=1.0.0, >1.0.0, <=1.0.0, <1.0.0
113
+ if (range.startsWith('>=')) {
114
+ return compareSemver(version, range.slice(2)) >= 0;
115
+ }
116
+ if (range.startsWith('>')) {
117
+ return compareSemver(version, range.slice(1)) > 0;
118
+ }
119
+ if (range.startsWith('<=')) {
120
+ return compareSemver(version, range.slice(2)) <= 0;
121
+ }
122
+ if (range.startsWith('<')) {
123
+ return compareSemver(version, range.slice(1)) < 0;
124
+ }
125
+
126
+ // Fallback to exact match
127
+ return compareSemver(version, range) === 0;
128
+ }
129
+
130
+ /**
131
+ * Get the latest version from a list
132
+ * @param {string[]} versions - List of version strings
133
+ * @returns {string} Latest version
134
+ */
135
+ export function getLatestVersion(versions) {
136
+ if (!versions || versions.length === 0) return null;
137
+ return versions.sort((a, b) => compareSemver(b, a))[0];
138
+ }
139
+
140
+ // ============================================
141
+ // MODEL METADATA
142
+ // ============================================
143
+
144
+ /**
145
+ * Model metadata structure
146
+ * @typedef {object} ModelMetadata
147
+ * @property {string} name - Model identifier (e.g., "phi-1.5-int4")
148
+ * @property {string} version - Semantic version
149
+ * @property {number} size - Model size in bytes
150
+ * @property {string} hash - SHA256 hash for integrity
151
+ * @property {string} format - Model format (onnx, safetensors, gguf)
152
+ * @property {string[]} capabilities - Model capabilities
153
+ * @property {object} sources - Download sources (gcs, ipfs, cdn)
154
+ * @property {object} dependencies - Base models and adapters
155
+ * @property {object} quantization - Quantization details
156
+ * @property {object} metadata - Additional metadata
157
+ */
158
+
159
+ /**
160
+ * Create a model metadata object
161
+ * @param {object} options - Model options
162
+ * @returns {ModelMetadata}
163
+ */
164
+ export function createModelMetadata(options) {
165
+ const {
166
+ name,
167
+ version = '1.0.0',
168
+ size = 0,
169
+ hash = '',
170
+ format = 'onnx',
171
+ capabilities = [],
172
+ sources = {},
173
+ dependencies = {},
174
+ quantization = null,
175
+ metadata = {},
176
+ } = options;
177
+
178
+ if (!name) {
179
+ throw new Error('Model name is required');
180
+ }
181
+
182
+ // Validate version
183
+ parseSemver(version);
184
+
185
+ return {
186
+ name,
187
+ version,
188
+ size,
189
+ hash,
190
+ format,
191
+ capabilities: Array.isArray(capabilities) ? capabilities : [capabilities],
192
+ sources: {
193
+ gcs: sources.gcs || null,
194
+ ipfs: sources.ipfs || null,
195
+ cdn: sources.cdn || null,
196
+ ...sources,
197
+ },
198
+ dependencies: {
199
+ base: dependencies.base || null,
200
+ adapters: dependencies.adapters || [],
201
+ ...dependencies,
202
+ },
203
+ quantization: quantization ? {
204
+ type: quantization.type || 'int4',
205
+ bits: quantization.bits || 4,
206
+ blockSize: quantization.blockSize || 32,
207
+ symmetric: quantization.symmetric ?? true,
208
+ } : null,
209
+ metadata: {
210
+ createdAt: metadata.createdAt || new Date().toISOString(),
211
+ updatedAt: metadata.updatedAt || new Date().toISOString(),
212
+ author: metadata.author || 'RuVector',
213
+ license: metadata.license || 'Apache-2.0',
214
+ description: metadata.description || '',
215
+ tags: metadata.tags || [],
216
+ ...metadata,
217
+ },
218
+ };
219
+ }
220
+
221
+ // ============================================
222
+ // MODEL REGISTRY
223
+ // ============================================
224
+
225
+ /**
226
+ * ModelRegistry - Manages model metadata, versions, and dependencies
227
+ */
228
+ export class ModelRegistry extends EventEmitter {
229
+ /**
230
+ * Create a new ModelRegistry
231
+ * @param {object} options - Registry options
232
+ */
233
+ constructor(options = {}) {
234
+ super();
235
+
236
+ this.id = `registry-${randomBytes(6).toString('hex')}`;
237
+ this.registryPath = options.registryPath || null;
238
+
239
+ // Model storage: { modelName: { version: ModelMetadata } }
240
+ this.models = new Map();
241
+
242
+ // Dependency graph
243
+ this.dependencies = new Map();
244
+
245
+ // Search index
246
+ this.searchIndex = {
247
+ byCapability: new Map(),
248
+ byFormat: new Map(),
249
+ byTag: new Map(),
250
+ };
251
+
252
+ // Stats
253
+ this.stats = {
254
+ totalModels: 0,
255
+ totalVersions: 0,
256
+ totalSize: 0,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Register a new model or version
262
+ * @param {object} modelData - Model metadata
263
+ * @returns {ModelMetadata}
264
+ */
265
+ register(modelData) {
266
+ const metadata = createModelMetadata(modelData);
267
+ const { name, version } = metadata;
268
+
269
+ // Get or create model entry
270
+ if (!this.models.has(name)) {
271
+ this.models.set(name, new Map());
272
+ this.stats.totalModels++;
273
+ }
274
+
275
+ const versions = this.models.get(name);
276
+
277
+ // Check if version exists
278
+ if (versions.has(version)) {
279
+ this.emit('warning', {
280
+ type: 'version_exists',
281
+ model: name,
282
+ version,
283
+ });
284
+ }
285
+
286
+ // Store metadata
287
+ versions.set(version, metadata);
288
+ this.stats.totalVersions++;
289
+ this.stats.totalSize += metadata.size;
290
+
291
+ // Update search index
292
+ this._indexModel(metadata);
293
+
294
+ // Update dependency graph
295
+ this._updateDependencies(metadata);
296
+
297
+ this.emit('registered', { name, version, metadata });
298
+
299
+ return metadata;
300
+ }
301
+
302
+ /**
303
+ * Get model metadata
304
+ * @param {string} name - Model name
305
+ * @param {string} version - Version (default: latest)
306
+ * @returns {ModelMetadata|null}
307
+ */
308
+ get(name, version = 'latest') {
309
+ const versions = this.models.get(name);
310
+ if (!versions) return null;
311
+
312
+ if (version === 'latest') {
313
+ const latest = getLatestVersion([...versions.keys()]);
314
+ return latest ? versions.get(latest) : null;
315
+ }
316
+
317
+ // Check for exact match first
318
+ if (versions.has(version)) {
319
+ return versions.get(version);
320
+ }
321
+
322
+ // Try to find matching version in range
323
+ for (const [v, metadata] of versions) {
324
+ if (satisfiesSemver(v, version)) {
325
+ return metadata;
326
+ }
327
+ }
328
+
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * List all versions of a model
334
+ * @param {string} name - Model name
335
+ * @returns {string[]}
336
+ */
337
+ listVersions(name) {
338
+ const versions = this.models.get(name);
339
+ if (!versions) return [];
340
+
341
+ return [...versions.keys()].sort((a, b) => compareSemver(b, a));
342
+ }
343
+
344
+ /**
345
+ * List all registered models
346
+ * @returns {string[]}
347
+ */
348
+ listModels() {
349
+ return [...this.models.keys()];
350
+ }
351
+
352
+ /**
353
+ * Search for models
354
+ * @param {object} criteria - Search criteria
355
+ * @returns {ModelMetadata[]}
356
+ */
357
+ search(criteria = {}) {
358
+ const {
359
+ name = null,
360
+ capability = null,
361
+ format = null,
362
+ tag = null,
363
+ minVersion = null,
364
+ maxVersion = null,
365
+ maxSize = null,
366
+ query = null,
367
+ } = criteria;
368
+
369
+ let results = [];
370
+
371
+ // Start with all models or filtered by name
372
+ if (name) {
373
+ const versions = this.models.get(name);
374
+ if (versions) {
375
+ results = [...versions.values()];
376
+ }
377
+ } else {
378
+ // Collect all model versions
379
+ for (const versions of this.models.values()) {
380
+ results.push(...versions.values());
381
+ }
382
+ }
383
+
384
+ // Filter by capability
385
+ if (capability) {
386
+ results = results.filter(m =>
387
+ m.capabilities.includes(capability)
388
+ );
389
+ }
390
+
391
+ // Filter by format
392
+ if (format) {
393
+ results = results.filter(m => m.format === format);
394
+ }
395
+
396
+ // Filter by tag
397
+ if (tag) {
398
+ results = results.filter(m =>
399
+ m.metadata.tags && m.metadata.tags.includes(tag)
400
+ );
401
+ }
402
+
403
+ // Filter by version range
404
+ if (minVersion) {
405
+ results = results.filter(m =>
406
+ compareSemver(m.version, minVersion) >= 0
407
+ );
408
+ }
409
+
410
+ if (maxVersion) {
411
+ results = results.filter(m =>
412
+ compareSemver(m.version, maxVersion) <= 0
413
+ );
414
+ }
415
+
416
+ // Filter by size
417
+ if (maxSize) {
418
+ results = results.filter(m => m.size <= maxSize);
419
+ }
420
+
421
+ // Text search
422
+ if (query) {
423
+ const q = query.toLowerCase();
424
+ results = results.filter(m =>
425
+ m.name.toLowerCase().includes(q) ||
426
+ m.metadata.description?.toLowerCase().includes(q) ||
427
+ m.metadata.tags?.some(t => t.toLowerCase().includes(q))
428
+ );
429
+ }
430
+
431
+ return results;
432
+ }
433
+
434
+ /**
435
+ * Get models by capability
436
+ * @param {string} capability - Capability to search for
437
+ * @returns {ModelMetadata[]}
438
+ */
439
+ getByCapability(capability) {
440
+ const models = this.searchIndex.byCapability.get(capability);
441
+ if (!models) return [];
442
+
443
+ return models.map(key => {
444
+ const [name, version] = key.split('@');
445
+ return this.get(name, version);
446
+ }).filter(Boolean);
447
+ }
448
+
449
+ /**
450
+ * Get all dependencies for a model
451
+ * @param {string} name - Model name
452
+ * @param {string} version - Version
453
+ * @param {boolean} recursive - Include transitive dependencies
454
+ * @returns {ModelMetadata[]}
455
+ */
456
+ getDependencies(name, version = 'latest', recursive = true) {
457
+ const model = this.get(name, version);
458
+ if (!model) return [];
459
+
460
+ const deps = [];
461
+ const visited = new Set();
462
+ const queue = [model];
463
+
464
+ while (queue.length > 0) {
465
+ const current = queue.shift();
466
+ const key = `${current.name}@${current.version}`;
467
+
468
+ if (visited.has(key)) continue;
469
+ visited.add(key);
470
+
471
+ // Add base model
472
+ if (current.dependencies.base) {
473
+ const [baseName, baseVersion] = current.dependencies.base.split('@');
474
+ const baseDep = this.get(baseName, baseVersion || 'latest');
475
+ if (baseDep) {
476
+ deps.push(baseDep);
477
+ if (recursive) queue.push(baseDep);
478
+ }
479
+ }
480
+
481
+ // Add adapters
482
+ if (current.dependencies.adapters) {
483
+ for (const adapter of current.dependencies.adapters) {
484
+ const [adapterName, adapterVersion] = adapter.split('@');
485
+ const adapterDep = this.get(adapterName, adapterVersion || 'latest');
486
+ if (adapterDep) {
487
+ deps.push(adapterDep);
488
+ if (recursive) queue.push(adapterDep);
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ return deps;
495
+ }
496
+
497
+ /**
498
+ * Get dependents (models that depend on this one)
499
+ * @param {string} name - Model name
500
+ * @param {string} version - Version
501
+ * @returns {ModelMetadata[]}
502
+ */
503
+ getDependents(name, version = 'latest') {
504
+ const key = version === 'latest'
505
+ ? name
506
+ : `${name}@${version}`;
507
+
508
+ const dependents = [];
509
+
510
+ for (const [depKey, dependencies] of this.dependencies) {
511
+ if (dependencies.includes(key) || dependencies.includes(name)) {
512
+ const [modelName, modelVersion] = depKey.split('@');
513
+ const model = this.get(modelName, modelVersion);
514
+ if (model) dependents.push(model);
515
+ }
516
+ }
517
+
518
+ return dependents;
519
+ }
520
+
521
+ /**
522
+ * Compute hash for a model file
523
+ * @param {Buffer|Uint8Array} data - Model data
524
+ * @returns {string} SHA256 hash
525
+ */
526
+ static computeHash(data) {
527
+ return `sha256:${createHash('sha256').update(data).digest('hex')}`;
528
+ }
529
+
530
+ /**
531
+ * Verify model integrity
532
+ * @param {string} name - Model name
533
+ * @param {string} version - Version
534
+ * @param {Buffer|Uint8Array} data - Model data
535
+ * @returns {boolean}
536
+ */
537
+ verify(name, version, data) {
538
+ const model = this.get(name, version);
539
+ if (!model) return false;
540
+
541
+ const computedHash = ModelRegistry.computeHash(data);
542
+ return model.hash === computedHash;
543
+ }
544
+
545
+ /**
546
+ * Update search index for a model
547
+ * @private
548
+ */
549
+ _indexModel(metadata) {
550
+ const key = `${metadata.name}@${metadata.version}`;
551
+
552
+ // Index by capability
553
+ for (const cap of metadata.capabilities) {
554
+ if (!this.searchIndex.byCapability.has(cap)) {
555
+ this.searchIndex.byCapability.set(cap, []);
556
+ }
557
+ this.searchIndex.byCapability.get(cap).push(key);
558
+ }
559
+
560
+ // Index by format
561
+ if (!this.searchIndex.byFormat.has(metadata.format)) {
562
+ this.searchIndex.byFormat.set(metadata.format, []);
563
+ }
564
+ this.searchIndex.byFormat.get(metadata.format).push(key);
565
+
566
+ // Index by tags
567
+ if (metadata.metadata.tags) {
568
+ for (const tag of metadata.metadata.tags) {
569
+ if (!this.searchIndex.byTag.has(tag)) {
570
+ this.searchIndex.byTag.set(tag, []);
571
+ }
572
+ this.searchIndex.byTag.get(tag).push(key);
573
+ }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Update dependency graph
579
+ * @private
580
+ */
581
+ _updateDependencies(metadata) {
582
+ const key = `${metadata.name}@${metadata.version}`;
583
+ const deps = [];
584
+
585
+ if (metadata.dependencies.base) {
586
+ deps.push(metadata.dependencies.base);
587
+ }
588
+
589
+ if (metadata.dependencies.adapters) {
590
+ deps.push(...metadata.dependencies.adapters);
591
+ }
592
+
593
+ if (deps.length > 0) {
594
+ this.dependencies.set(key, deps);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Export registry to JSON
600
+ * @returns {object}
601
+ */
602
+ export() {
603
+ const models = {};
604
+
605
+ for (const [name, versions] of this.models) {
606
+ models[name] = {};
607
+ for (const [version, metadata] of versions) {
608
+ models[name][version] = metadata;
609
+ }
610
+ }
611
+
612
+ return {
613
+ version: '1.0.0',
614
+ generatedAt: new Date().toISOString(),
615
+ stats: this.stats,
616
+ models,
617
+ };
618
+ }
619
+
620
+ /**
621
+ * Import registry from JSON
622
+ * @param {object} data - Registry data
623
+ */
624
+ import(data) {
625
+ if (!data.models) return;
626
+
627
+ for (const [name, versions] of Object.entries(data.models)) {
628
+ for (const [version, metadata] of Object.entries(versions)) {
629
+ this.register({
630
+ ...metadata,
631
+ name,
632
+ version,
633
+ });
634
+ }
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Save registry to file
640
+ * @param {string} filePath - File path
641
+ */
642
+ async save(filePath = null) {
643
+ const targetPath = filePath || this.registryPath;
644
+ if (!targetPath) {
645
+ throw new Error('No registry path specified');
646
+ }
647
+
648
+ const data = JSON.stringify(this.export(), null, 2);
649
+ await fs.writeFile(targetPath, data, 'utf-8');
650
+
651
+ this.emit('saved', { path: targetPath });
652
+ }
653
+
654
+ /**
655
+ * Load registry from file
656
+ * @param {string} filePath - File path
657
+ */
658
+ async load(filePath = null) {
659
+ const targetPath = filePath || this.registryPath;
660
+ if (!targetPath) {
661
+ throw new Error('No registry path specified');
662
+ }
663
+
664
+ try {
665
+ const data = await fs.readFile(targetPath, 'utf-8');
666
+ this.import(JSON.parse(data));
667
+ this.emit('loaded', { path: targetPath });
668
+ } catch (error) {
669
+ if (error.code === 'ENOENT') {
670
+ this.emit('warning', { message: 'Registry file not found, starting fresh' });
671
+ } else {
672
+ throw error;
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Get registry statistics
679
+ * @returns {object}
680
+ */
681
+ getStats() {
682
+ return {
683
+ ...this.stats,
684
+ capabilities: this.searchIndex.byCapability.size,
685
+ formats: this.searchIndex.byFormat.size,
686
+ tags: this.searchIndex.byTag.size,
687
+ dependencyEdges: this.dependencies.size,
688
+ };
689
+ }
690
+ }
691
+
692
+ // ============================================
693
+ // DEFAULT EXPORT
694
+ // ============================================
695
+
696
+ export default ModelRegistry;