/** * @ruvector/edge-net Model Registry * * Manages model metadata, versions, dependencies, and discovery * for the distributed model distribution infrastructure. * * @module @ruvector/edge-net/models/model-registry */ import { EventEmitter } from 'events'; import { createHash, randomBytes } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; // ============================================ // SEMVER UTILITIES // ============================================ /** * Parse a semver version string * @param {string} version - Version string (e.g., "1.2.3", "1.0.0-beta.1") * @returns {object} Parsed version object */ export function parseSemver(version) { const match = String(version).match( /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$/ ); if (!match) { throw new Error(`Invalid semver: ${version}`); } return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10), patch: parseInt(match[3], 10), prerelease: match[4] || null, build: match[5] || null, raw: version, }; } /** * Compare two semver versions * @param {string} a - First version * @param {string} b - Second version * @returns {number} -1 if a < b, 0 if equal, 1 if a > b */ export function compareSemver(a, b) { const va = parseSemver(a); const vb = parseSemver(b); if (va.major !== vb.major) return va.major - vb.major; if (va.minor !== vb.minor) return va.minor - vb.minor; if (va.patch !== vb.patch) return va.patch - vb.patch; // Prerelease versions have lower precedence if (va.prerelease && !vb.prerelease) return -1; if (!va.prerelease && vb.prerelease) return 1; if (va.prerelease && vb.prerelease) { return va.prerelease.localeCompare(vb.prerelease); } return 0; } /** * Check if version satisfies a version range * Supports: "1.0.0", "^1.0.0", "~1.0.0", ">=1.0.0", "1.x", "*" * @param {string} version - Version to check * @param {string} range - Version range * @returns {boolean} */ export function satisfiesSemver(version, range) { const v = parseSemver(version); // Exact match if (range === version) return true; // Wildcard if (range === '*' || range === 'latest') return true; // X-range: 1.x, 1.2.x const xMatch = range.match(/^(\d+)(?:\.(\d+))?\.x$/); if (xMatch) { const major = parseInt(xMatch[1], 10); const minor = xMatch[2] ? parseInt(xMatch[2], 10) : null; if (v.major !== major) return false; if (minor !== null && v.minor !== minor) return false; return true; } // Caret range: ^1.0.0 (compatible with) if (range.startsWith('^')) { const r = parseSemver(range.slice(1)); if (v.major !== r.major) return false; if (v.major === 0) { if (v.minor !== r.minor) return false; return v.patch >= r.patch; } return compareSemver(version, range.slice(1)) >= 0; } // Tilde range: ~1.0.0 (approximately equivalent) if (range.startsWith('~')) { const r = parseSemver(range.slice(1)); if (v.major !== r.major) return false; if (v.minor !== r.minor) return false; return v.patch >= r.patch; } // Comparison ranges: >=1.0.0, >1.0.0, <=1.0.0, <1.0.0 if (range.startsWith('>=')) { return compareSemver(version, range.slice(2)) >= 0; } if (range.startsWith('>')) { return compareSemver(version, range.slice(1)) > 0; } if (range.startsWith('<=')) { return compareSemver(version, range.slice(2)) <= 0; } if (range.startsWith('<')) { return compareSemver(version, range.slice(1)) < 0; } // Fallback to exact match return compareSemver(version, range) === 0; } /** * Get the latest version from a list * @param {string[]} versions - List of version strings * @returns {string} Latest version */ export function getLatestVersion(versions) { if (!versions || versions.length === 0) return null; return versions.sort((a, b) => compareSemver(b, a))[0]; } // ============================================ // MODEL METADATA // ============================================ /** * Model metadata structure * @typedef {object} ModelMetadata * @property {string} name - Model identifier (e.g., "phi-1.5-int4") * @property {string} version - Semantic version * @property {number} size - Model size in bytes * @property {string} hash - SHA256 hash for integrity * @property {string} format - Model format (onnx, safetensors, gguf) * @property {string[]} capabilities - Model capabilities * @property {object} sources - Download sources (gcs, ipfs, cdn) * @property {object} dependencies - Base models and adapters * @property {object} quantization - Quantization details * @property {object} metadata - Additional metadata */ /** * Create a model metadata object * @param {object} options - Model options * @returns {ModelMetadata} */ export function createModelMetadata(options) { const { name, version = '1.0.0', size = 0, hash = '', format = 'onnx', capabilities = [], sources = {}, dependencies = {}, quantization = null, metadata = {}, } = options; if (!name) { throw new Error('Model name is required'); } // Validate version parseSemver(version); return { name, version, size, hash, format, capabilities: Array.isArray(capabilities) ? capabilities : [capabilities], sources: { gcs: sources.gcs || null, ipfs: sources.ipfs || null, cdn: sources.cdn || null, ...sources, }, dependencies: { base: dependencies.base || null, adapters: dependencies.adapters || [], ...dependencies, }, quantization: quantization ? { type: quantization.type || 'int4', bits: quantization.bits || 4, blockSize: quantization.blockSize || 32, symmetric: quantization.symmetric ?? true, } : null, metadata: { createdAt: metadata.createdAt || new Date().toISOString(), updatedAt: metadata.updatedAt || new Date().toISOString(), author: metadata.author || 'RuVector', license: metadata.license || 'Apache-2.0', description: metadata.description || '', tags: metadata.tags || [], ...metadata, }, }; } // ============================================ // MODEL REGISTRY // ============================================ /** * ModelRegistry - Manages model metadata, versions, and dependencies */ export class ModelRegistry extends EventEmitter { /** * Create a new ModelRegistry * @param {object} options - Registry options */ constructor(options = {}) { super(); this.id = `registry-${randomBytes(6).toString('hex')}`; this.registryPath = options.registryPath || null; // Model storage: { modelName: { version: ModelMetadata } } this.models = new Map(); // Dependency graph this.dependencies = new Map(); // Search index this.searchIndex = { byCapability: new Map(), byFormat: new Map(), byTag: new Map(), }; // Stats this.stats = { totalModels: 0, totalVersions: 0, totalSize: 0, }; } /** * Register a new model or version * @param {object} modelData - Model metadata * @returns {ModelMetadata} */ register(modelData) { const metadata = createModelMetadata(modelData); const { name, version } = metadata; // Get or create model entry if (!this.models.has(name)) { this.models.set(name, new Map()); this.stats.totalModels++; } const versions = this.models.get(name); // Check if version exists if (versions.has(version)) { this.emit('warning', { type: 'version_exists', model: name, version, }); } // Store metadata versions.set(version, metadata); this.stats.totalVersions++; this.stats.totalSize += metadata.size; // Update search index this._indexModel(metadata); // Update dependency graph this._updateDependencies(metadata); this.emit('registered', { name, version, metadata }); return metadata; } /** * Get model metadata * @param {string} name - Model name * @param {string} version - Version (default: latest) * @returns {ModelMetadata|null} */ get(name, version = 'latest') { const versions = this.models.get(name); if (!versions) return null; if (version === 'latest') { const latest = getLatestVersion([...versions.keys()]); return latest ? versions.get(latest) : null; } // Check for exact match first if (versions.has(version)) { return versions.get(version); } // Try to find matching version in range for (const [v, metadata] of versions) { if (satisfiesSemver(v, version)) { return metadata; } } return null; } /** * List all versions of a model * @param {string} name - Model name * @returns {string[]} */ listVersions(name) { const versions = this.models.get(name); if (!versions) return []; return [...versions.keys()].sort((a, b) => compareSemver(b, a)); } /** * List all registered models * @returns {string[]} */ listModels() { return [...this.models.keys()]; } /** * Search for models * @param {object} criteria - Search criteria * @returns {ModelMetadata[]} */ search(criteria = {}) { const { name = null, capability = null, format = null, tag = null, minVersion = null, maxVersion = null, maxSize = null, query = null, } = criteria; let results = []; // Start with all models or filtered by name if (name) { const versions = this.models.get(name); if (versions) { results = [...versions.values()]; } } else { // Collect all model versions for (const versions of this.models.values()) { results.push(...versions.values()); } } // Filter by capability if (capability) { results = results.filter(m => m.capabilities.includes(capability) ); } // Filter by format if (format) { results = results.filter(m => m.format === format); } // Filter by tag if (tag) { results = results.filter(m => m.metadata.tags && m.metadata.tags.includes(tag) ); } // Filter by version range if (minVersion) { results = results.filter(m => compareSemver(m.version, minVersion) >= 0 ); } if (maxVersion) { results = results.filter(m => compareSemver(m.version, maxVersion) <= 0 ); } // Filter by size if (maxSize) { results = results.filter(m => m.size <= maxSize); } // Text search if (query) { const q = query.toLowerCase(); results = results.filter(m => m.name.toLowerCase().includes(q) || m.metadata.description?.toLowerCase().includes(q) || m.metadata.tags?.some(t => t.toLowerCase().includes(q)) ); } return results; } /** * Get models by capability * @param {string} capability - Capability to search for * @returns {ModelMetadata[]} */ getByCapability(capability) { const models = this.searchIndex.byCapability.get(capability); if (!models) return []; return models.map(key => { const [name, version] = key.split('@'); return this.get(name, version); }).filter(Boolean); } /** * Get all dependencies for a model * @param {string} name - Model name * @param {string} version - Version * @param {boolean} recursive - Include transitive dependencies * @returns {ModelMetadata[]} */ getDependencies(name, version = 'latest', recursive = true) { const model = this.get(name, version); if (!model) return []; const deps = []; const visited = new Set(); const queue = [model]; while (queue.length > 0) { const current = queue.shift(); const key = `${current.name}@${current.version}`; if (visited.has(key)) continue; visited.add(key); // Add base model if (current.dependencies.base) { const [baseName, baseVersion] = current.dependencies.base.split('@'); const baseDep = this.get(baseName, baseVersion || 'latest'); if (baseDep) { deps.push(baseDep); if (recursive) queue.push(baseDep); } } // Add adapters if (current.dependencies.adapters) { for (const adapter of current.dependencies.adapters) { const [adapterName, adapterVersion] = adapter.split('@'); const adapterDep = this.get(adapterName, adapterVersion || 'latest'); if (adapterDep) { deps.push(adapterDep); if (recursive) queue.push(adapterDep); } } } } return deps; } /** * Get dependents (models that depend on this one) * @param {string} name - Model name * @param {string} version - Version * @returns {ModelMetadata[]} */ getDependents(name, version = 'latest') { const key = version === 'latest' ? name : `${name}@${version}`; const dependents = []; for (const [depKey, dependencies] of this.dependencies) { if (dependencies.includes(key) || dependencies.includes(name)) { const [modelName, modelVersion] = depKey.split('@'); const model = this.get(modelName, modelVersion); if (model) dependents.push(model); } } return dependents; } /** * Compute hash for a model file * @param {Buffer|Uint8Array} data - Model data * @returns {string} SHA256 hash */ static computeHash(data) { return `sha256:${createHash('sha256').update(data).digest('hex')}`; } /** * Verify model integrity * @param {string} name - Model name * @param {string} version - Version * @param {Buffer|Uint8Array} data - Model data * @returns {boolean} */ verify(name, version, data) { const model = this.get(name, version); if (!model) return false; const computedHash = ModelRegistry.computeHash(data); return model.hash === computedHash; } /** * Update search index for a model * @private */ _indexModel(metadata) { const key = `${metadata.name}@${metadata.version}`; // Index by capability for (const cap of metadata.capabilities) { if (!this.searchIndex.byCapability.has(cap)) { this.searchIndex.byCapability.set(cap, []); } this.searchIndex.byCapability.get(cap).push(key); } // Index by format if (!this.searchIndex.byFormat.has(metadata.format)) { this.searchIndex.byFormat.set(metadata.format, []); } this.searchIndex.byFormat.get(metadata.format).push(key); // Index by tags if (metadata.metadata.tags) { for (const tag of metadata.metadata.tags) { if (!this.searchIndex.byTag.has(tag)) { this.searchIndex.byTag.set(tag, []); } this.searchIndex.byTag.get(tag).push(key); } } } /** * Update dependency graph * @private */ _updateDependencies(metadata) { const key = `${metadata.name}@${metadata.version}`; const deps = []; if (metadata.dependencies.base) { deps.push(metadata.dependencies.base); } if (metadata.dependencies.adapters) { deps.push(...metadata.dependencies.adapters); } if (deps.length > 0) { this.dependencies.set(key, deps); } } /** * Export registry to JSON * @returns {object} */ export() { const models = {}; for (const [name, versions] of this.models) { models[name] = {}; for (const [version, metadata] of versions) { models[name][version] = metadata; } } return { version: '1.0.0', generatedAt: new Date().toISOString(), stats: this.stats, models, }; } /** * Import registry from JSON * @param {object} data - Registry data */ import(data) { if (!data.models) return; for (const [name, versions] of Object.entries(data.models)) { for (const [version, metadata] of Object.entries(versions)) { this.register({ ...metadata, name, version, }); } } } /** * Save registry to file * @param {string} filePath - File path */ async save(filePath = null) { const targetPath = filePath || this.registryPath; if (!targetPath) { throw new Error('No registry path specified'); } const data = JSON.stringify(this.export(), null, 2); await fs.writeFile(targetPath, data, 'utf-8'); this.emit('saved', { path: targetPath }); } /** * Load registry from file * @param {string} filePath - File path */ async load(filePath = null) { const targetPath = filePath || this.registryPath; if (!targetPath) { throw new Error('No registry path specified'); } try { const data = await fs.readFile(targetPath, 'utf-8'); this.import(JSON.parse(data)); this.emit('loaded', { path: targetPath }); } catch (error) { if (error.code === 'ENOENT') { this.emit('warning', { message: 'Registry file not found, starting fresh' }); } else { throw error; } } } /** * Get registry statistics * @returns {object} */ getStats() { return { ...this.stats, capabilities: this.searchIndex.byCapability.size, formats: this.searchIndex.byFormat.size, tags: this.searchIndex.byTag.size, dependencyEdges: this.dependencies.size, }; } } // ============================================ // DEFAULT EXPORT // ============================================ export default ModelRegistry;