s3db.js 12.4.0 → 13.1.0

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.
Files changed (38) hide show
  1. package/dist/s3db.cjs.js +8066 -3562
  2. package/dist/s3db.cjs.js.map +1 -1
  3. package/dist/s3db.es.js +8066 -3563
  4. package/dist/s3db.es.js.map +1 -1
  5. package/package.json +5 -1
  6. package/src/clients/memory-client.class.js +41 -24
  7. package/src/database.class.js +52 -17
  8. package/src/plugins/api/index.js +13 -16
  9. package/src/plugins/api/routes/resource-routes.js +81 -3
  10. package/src/plugins/api/server.js +29 -9
  11. package/src/plugins/audit.plugin.js +2 -4
  12. package/src/plugins/backup.plugin.js +2 -4
  13. package/src/plugins/cache.plugin.js +1 -3
  14. package/src/plugins/costs.plugin.js +0 -2
  15. package/src/plugins/eventual-consistency/index.js +1 -3
  16. package/src/plugins/fulltext.plugin.js +2 -4
  17. package/src/plugins/geo.plugin.js +1 -3
  18. package/src/plugins/importer/index.js +0 -2
  19. package/src/plugins/index.js +1 -1
  20. package/src/plugins/metrics.plugin.js +2 -4
  21. package/src/plugins/ml/base-model.class.js +459 -0
  22. package/src/plugins/ml/classification-model.class.js +338 -0
  23. package/src/plugins/ml/neural-network-model.class.js +312 -0
  24. package/src/plugins/ml/regression-model.class.js +159 -0
  25. package/src/plugins/ml/timeseries-model.class.js +346 -0
  26. package/src/plugins/ml.errors.js +130 -0
  27. package/src/plugins/ml.plugin.js +1417 -0
  28. package/src/plugins/plugin.class.js +1 -3
  29. package/src/plugins/queue-consumer.plugin.js +1 -3
  30. package/src/plugins/relation.plugin.js +1 -3
  31. package/src/plugins/replicator.plugin.js +2 -4
  32. package/src/plugins/s3-queue.plugin.js +1 -3
  33. package/src/plugins/scheduler.plugin.js +2 -4
  34. package/src/plugins/state-machine.plugin.js +2 -4
  35. package/src/plugins/tfstate/index.js +0 -2
  36. package/src/plugins/ttl.plugin.js +36 -21
  37. package/src/plugins/vector.plugin.js +0 -2
  38. package/src/resource.class.js +106 -34
@@ -0,0 +1,1417 @@
1
+ /**
2
+ * Machine Learning Plugin
3
+ *
4
+ * Train and use ML models directly on s3db.js resources
5
+ * Supports regression, classification, time series, and custom neural networks
6
+ */
7
+
8
+ import { Plugin } from './plugin.class.js';
9
+ import { requirePluginDependency } from './concerns/plugin-dependencies.js';
10
+ import tryFn from '../concerns/try-fn.js';
11
+
12
+ import { RegressionModel } from './ml/regression-model.class.js';
13
+ import { ClassificationModel } from './ml/classification-model.class.js';
14
+ import { TimeSeriesModel } from './ml/timeseries-model.class.js';
15
+ import { NeuralNetworkModel } from './ml/neural-network-model.class.js';
16
+
17
+ import {
18
+ MLError,
19
+ ModelConfigError,
20
+ ModelNotFoundError,
21
+ TrainingError,
22
+ TensorFlowDependencyError
23
+ } from './ml.errors.js';
24
+
25
+ /**
26
+ * ML Plugin Configuration
27
+ *
28
+ * @typedef {Object} MLPluginOptions
29
+ * @property {Object} models - Model configurations
30
+ * @property {boolean} [verbose=false] - Enable verbose logging
31
+ * @property {number} [minTrainingSamples=10] - Minimum samples required for training
32
+ * @property {boolean} [saveModel=true] - Save trained models to S3
33
+ * @property {boolean} [saveTrainingData=false] - Save intermediate training data to S3
34
+ *
35
+ * @example
36
+ * new MLPlugin({
37
+ * models: {
38
+ * productPrices: {
39
+ * type: 'regression',
40
+ * resource: 'products',
41
+ * features: ['cost', 'margin', 'demand'],
42
+ * target: 'price',
43
+ * partition: { name: 'byCategory', values: { category: 'electronics' } }, // Optional
44
+ * autoTrain: true,
45
+ * trainInterval: 3600000, // 1 hour
46
+ * trainAfterInserts: 100,
47
+ * saveModel: true, // Save to S3 after training
48
+ * saveTrainingData: true, // Save prepared dataset
49
+ * modelConfig: {
50
+ * epochs: 50,
51
+ * batchSize: 32,
52
+ * learningRate: 0.01
53
+ * }
54
+ * }
55
+ * },
56
+ * verbose: true,
57
+ * saveModel: true,
58
+ * saveTrainingData: false
59
+ * })
60
+ */
61
+ export class MLPlugin extends Plugin {
62
+ constructor(options = {}) {
63
+ super(options);
64
+
65
+ this.config = {
66
+ models: options.models || {},
67
+ verbose: options.verbose || false,
68
+ minTrainingSamples: options.minTrainingSamples || 10,
69
+ saveModel: options.saveModel !== false, // Default true
70
+ saveTrainingData: options.saveTrainingData || false,
71
+ enableVersioning: options.enableVersioning !== false // Default true
72
+ };
73
+
74
+ // Validate TensorFlow.js dependency
75
+ requirePluginDependency('@tensorflow/tfjs-node', 'MLPlugin', {
76
+ installCommand: 'pnpm add @tensorflow/tfjs-node',
77
+ reason: 'Required for machine learning model training and inference'
78
+ });
79
+
80
+ // Model instances
81
+ this.models = {};
82
+
83
+ // Model versioning
84
+ this.modelVersions = new Map(); // Track versions per model: { currentVersion, latestVersion }
85
+
86
+ // Model cache for resource.predict()
87
+ this.modelCache = new Map(); // Cache: resourceName_attribute -> modelName
88
+
89
+ // Training state
90
+ this.training = new Map(); // Track ongoing training
91
+ this.insertCounters = new Map(); // Track inserts per resource
92
+
93
+ // Interval handles for auto-training
94
+ this.intervals = [];
95
+
96
+ // Stats
97
+ this.stats = {
98
+ totalTrainings: 0,
99
+ totalPredictions: 0,
100
+ totalErrors: 0,
101
+ startedAt: null
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Install the plugin
107
+ */
108
+ async onInstall() {
109
+ if (this.config.verbose) {
110
+ console.log('[MLPlugin] Installing ML Plugin...');
111
+ }
112
+
113
+ // Validate model configurations
114
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
115
+ this._validateModelConfig(modelName, modelConfig);
116
+ }
117
+
118
+ // Initialize models
119
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
120
+ await this._initializeModel(modelName, modelConfig);
121
+ }
122
+
123
+ // Build model cache (resource -> attribute -> modelName mapping)
124
+ this._buildModelCache();
125
+
126
+ // Inject ML methods into Resource prototype
127
+ this._injectResourceMethods();
128
+
129
+ // Setup auto-training hooks
130
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
131
+ if (modelConfig.autoTrain) {
132
+ this._setupAutoTraining(modelName, modelConfig);
133
+ }
134
+ }
135
+
136
+ this.stats.startedAt = new Date().toISOString();
137
+
138
+ if (this.config.verbose) {
139
+ console.log(`[MLPlugin] Installed with ${Object.keys(this.models).length} models`);
140
+ }
141
+
142
+ this.emit('installed', {
143
+ plugin: 'MLPlugin',
144
+ models: Object.keys(this.models)
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Start the plugin
150
+ */
151
+ async onStart() {
152
+ // Initialize versioning for each model
153
+ if (this.config.enableVersioning) {
154
+ for (const modelName of Object.keys(this.models)) {
155
+ await this._initializeVersioning(modelName);
156
+ }
157
+ }
158
+
159
+ // Try to load previously trained models
160
+ for (const modelName of Object.keys(this.models)) {
161
+ await this._loadModel(modelName);
162
+ }
163
+
164
+ if (this.config.verbose) {
165
+ console.log('[MLPlugin] Started');
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Stop the plugin
171
+ */
172
+ async onStop() {
173
+ // Stop all intervals
174
+ for (const handle of this.intervals) {
175
+ clearInterval(handle);
176
+ }
177
+ this.intervals = [];
178
+
179
+ // Dispose all models
180
+ for (const [modelName, model] of Object.entries(this.models)) {
181
+ if (model && model.dispose) {
182
+ model.dispose();
183
+ }
184
+ }
185
+
186
+ if (this.config.verbose) {
187
+ console.log('[MLPlugin] Stopped');
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Uninstall the plugin
193
+ */
194
+ async onUninstall(options = {}) {
195
+ await this.onStop();
196
+
197
+ if (options.purgeData) {
198
+ // Delete all saved models and training data from plugin storage
199
+ for (const modelName of Object.keys(this.models)) {
200
+ await this._deleteModel(modelName);
201
+ await this._deleteTrainingData(modelName);
202
+ }
203
+
204
+ if (this.config.verbose) {
205
+ console.log('[MLPlugin] Purged all model data and training data');
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Build model cache for fast lookup
212
+ * @private
213
+ */
214
+ _buildModelCache() {
215
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
216
+ const cacheKey = `${modelConfig.resource}_${modelConfig.target}`;
217
+ this.modelCache.set(cacheKey, modelName);
218
+
219
+ if (this.config.verbose) {
220
+ console.log(`[MLPlugin] Cached model "${modelName}" for ${modelConfig.resource}.predict(..., '${modelConfig.target}')`);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Inject ML methods into Resource instances
227
+ * @private
228
+ */
229
+ _injectResourceMethods() {
230
+ const plugin = this;
231
+
232
+ // Store reference to plugin in database for resource access
233
+ if (!this.database._mlPlugin) {
234
+ this.database._mlPlugin = this;
235
+ }
236
+
237
+ // Add predict() method to Resource prototype
238
+ if (!this.database.Resource.prototype.predict) {
239
+ this.database.Resource.prototype.predict = async function(input, targetAttribute) {
240
+ const mlPlugin = this.database._mlPlugin;
241
+ if (!mlPlugin) {
242
+ throw new Error('MLPlugin not installed');
243
+ }
244
+
245
+ return await mlPlugin._resourcePredict(this.name, input, targetAttribute);
246
+ };
247
+ }
248
+
249
+ // Add trainModel() method to Resource prototype
250
+ if (!this.database.Resource.prototype.trainModel) {
251
+ this.database.Resource.prototype.trainModel = async function(targetAttribute, options = {}) {
252
+ const mlPlugin = this.database._mlPlugin;
253
+ if (!mlPlugin) {
254
+ throw new Error('MLPlugin not installed');
255
+ }
256
+
257
+ return await mlPlugin._resourceTrainModel(this.name, targetAttribute, options);
258
+ };
259
+ }
260
+
261
+ // Add listModels() method to Resource prototype
262
+ if (!this.database.Resource.prototype.listModels) {
263
+ this.database.Resource.prototype.listModels = function() {
264
+ const mlPlugin = this.database._mlPlugin;
265
+ if (!mlPlugin) {
266
+ throw new Error('MLPlugin not installed');
267
+ }
268
+
269
+ return mlPlugin._resourceListModels(this.name);
270
+ };
271
+ }
272
+
273
+ if (this.config.verbose) {
274
+ console.log('[MLPlugin] Injected ML methods into Resource prototype');
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Find model for a resource and target attribute
280
+ * @private
281
+ */
282
+ _findModelForResource(resourceName, targetAttribute) {
283
+ const cacheKey = `${resourceName}_${targetAttribute}`;
284
+
285
+ // Try cache first
286
+ if (this.modelCache.has(cacheKey)) {
287
+ return this.modelCache.get(cacheKey);
288
+ }
289
+
290
+ // Search through all models
291
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
292
+ if (modelConfig.resource === resourceName && modelConfig.target === targetAttribute) {
293
+ // Cache for next time
294
+ this.modelCache.set(cacheKey, modelName);
295
+ return modelName;
296
+ }
297
+ }
298
+
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * Resource predict implementation
304
+ * @private
305
+ */
306
+ async _resourcePredict(resourceName, input, targetAttribute) {
307
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
308
+
309
+ if (!modelName) {
310
+ throw new ModelNotFoundError(
311
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
312
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
313
+ );
314
+ }
315
+
316
+ if (this.config.verbose) {
317
+ console.log(`[MLPlugin] Resource prediction: ${resourceName}.predict(..., '${targetAttribute}') -> model "${modelName}"`);
318
+ }
319
+
320
+ return await this.predict(modelName, input);
321
+ }
322
+
323
+ /**
324
+ * Resource trainModel implementation
325
+ * @private
326
+ */
327
+ async _resourceTrainModel(resourceName, targetAttribute, options = {}) {
328
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
329
+
330
+ if (!modelName) {
331
+ throw new ModelNotFoundError(
332
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
333
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
334
+ );
335
+ }
336
+
337
+ if (this.config.verbose) {
338
+ console.log(`[MLPlugin] Resource training: ${resourceName}.trainModel('${targetAttribute}') -> model "${modelName}"`);
339
+ }
340
+
341
+ return await this.train(modelName, options);
342
+ }
343
+
344
+ /**
345
+ * List models for a resource
346
+ * @private
347
+ */
348
+ _resourceListModels(resourceName) {
349
+ const models = [];
350
+
351
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
352
+ if (modelConfig.resource === resourceName) {
353
+ models.push({
354
+ name: modelName,
355
+ type: modelConfig.type,
356
+ target: modelConfig.target,
357
+ features: modelConfig.features,
358
+ isTrained: this.models[modelName]?.isTrained || false
359
+ });
360
+ }
361
+ }
362
+
363
+ return models;
364
+ }
365
+
366
+ /**
367
+ * Validate model configuration
368
+ * @private
369
+ */
370
+ _validateModelConfig(modelName, config) {
371
+ const validTypes = ['regression', 'classification', 'timeseries', 'neural-network'];
372
+
373
+ if (!config.type || !validTypes.includes(config.type)) {
374
+ throw new ModelConfigError(
375
+ `Model "${modelName}" must have a valid type: ${validTypes.join(', ')}`,
376
+ { modelName, type: config.type, validTypes }
377
+ );
378
+ }
379
+
380
+ if (!config.resource) {
381
+ throw new ModelConfigError(
382
+ `Model "${modelName}" must specify a resource`,
383
+ { modelName }
384
+ );
385
+ }
386
+
387
+ if (!config.features || !Array.isArray(config.features) || config.features.length === 0) {
388
+ throw new ModelConfigError(
389
+ `Model "${modelName}" must specify at least one feature`,
390
+ { modelName, features: config.features }
391
+ );
392
+ }
393
+
394
+ if (!config.target) {
395
+ throw new ModelConfigError(
396
+ `Model "${modelName}" must specify a target field`,
397
+ { modelName }
398
+ );
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Initialize a model instance
404
+ * @private
405
+ */
406
+ async _initializeModel(modelName, config) {
407
+ const modelOptions = {
408
+ name: modelName,
409
+ resource: config.resource,
410
+ features: config.features,
411
+ target: config.target,
412
+ modelConfig: config.modelConfig || {},
413
+ verbose: this.config.verbose
414
+ };
415
+
416
+ try {
417
+ switch (config.type) {
418
+ case 'regression':
419
+ this.models[modelName] = new RegressionModel(modelOptions);
420
+ break;
421
+
422
+ case 'classification':
423
+ this.models[modelName] = new ClassificationModel(modelOptions);
424
+ break;
425
+
426
+ case 'timeseries':
427
+ this.models[modelName] = new TimeSeriesModel(modelOptions);
428
+ break;
429
+
430
+ case 'neural-network':
431
+ this.models[modelName] = new NeuralNetworkModel(modelOptions);
432
+ break;
433
+
434
+ default:
435
+ throw new ModelConfigError(
436
+ `Unknown model type: ${config.type}`,
437
+ { modelName, type: config.type }
438
+ );
439
+ }
440
+
441
+ if (this.config.verbose) {
442
+ console.log(`[MLPlugin] Initialized model "${modelName}" (${config.type})`);
443
+ }
444
+ } catch (error) {
445
+ console.error(`[MLPlugin] Failed to initialize model "${modelName}":`, error.message);
446
+ throw error;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Setup auto-training for a model
452
+ * @private
453
+ */
454
+ _setupAutoTraining(modelName, config) {
455
+ const resource = this.database.resources[config.resource];
456
+
457
+ if (!resource) {
458
+ console.warn(`[MLPlugin] Resource "${config.resource}" not found for model "${modelName}"`);
459
+ return;
460
+ }
461
+
462
+ // Initialize insert counter
463
+ this.insertCounters.set(modelName, 0);
464
+
465
+ // Hook: Track inserts
466
+ if (config.trainAfterInserts && config.trainAfterInserts > 0) {
467
+ this.addMiddleware(resource, 'insert', async (next, data, options) => {
468
+ const result = await next(data, options);
469
+
470
+ // Increment counter
471
+ const currentCount = this.insertCounters.get(modelName) || 0;
472
+ this.insertCounters.set(modelName, currentCount + 1);
473
+
474
+ // Check if we should train
475
+ if (this.insertCounters.get(modelName) >= config.trainAfterInserts) {
476
+ if (this.config.verbose) {
477
+ console.log(`[MLPlugin] Auto-training "${modelName}" after ${config.trainAfterInserts} inserts`);
478
+ }
479
+
480
+ // Reset counter
481
+ this.insertCounters.set(modelName, 0);
482
+
483
+ // Train asynchronously (don't block insert)
484
+ this.train(modelName).catch(err => {
485
+ console.error(`[MLPlugin] Auto-training failed for "${modelName}":`, err.message);
486
+ });
487
+ }
488
+
489
+ return result;
490
+ });
491
+ }
492
+
493
+ // Interval-based training
494
+ if (config.trainInterval && config.trainInterval > 0) {
495
+ const handle = setInterval(async () => {
496
+ if (this.config.verbose) {
497
+ console.log(`[MLPlugin] Auto-training "${modelName}" (interval: ${config.trainInterval}ms)`);
498
+ }
499
+
500
+ try {
501
+ await this.train(modelName);
502
+ } catch (error) {
503
+ console.error(`[MLPlugin] Auto-training failed for "${modelName}":`, error.message);
504
+ }
505
+ }, config.trainInterval);
506
+
507
+ this.intervals.push(handle);
508
+
509
+ if (this.config.verbose) {
510
+ console.log(`[MLPlugin] Setup interval training for "${modelName}" (every ${config.trainInterval}ms)`);
511
+ }
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Train a model
517
+ * @param {string} modelName - Model name
518
+ * @param {Object} options - Training options
519
+ * @returns {Object} Training results
520
+ */
521
+ async train(modelName, options = {}) {
522
+ const model = this.models[modelName];
523
+ if (!model) {
524
+ throw new ModelNotFoundError(
525
+ `Model "${modelName}" not found`,
526
+ { modelName, availableModels: Object.keys(this.models) }
527
+ );
528
+ }
529
+
530
+ // Check if already training
531
+ if (this.training.get(modelName)) {
532
+ if (this.config.verbose) {
533
+ console.log(`[MLPlugin] Model "${modelName}" is already training, skipping...`);
534
+ }
535
+ return { skipped: true, reason: 'already_training' };
536
+ }
537
+
538
+ // Mark as training
539
+ this.training.set(modelName, true);
540
+
541
+ try {
542
+ // Get model config
543
+ const modelConfig = this.config.models[modelName];
544
+
545
+ // Get resource
546
+ const resource = this.database.resources[modelConfig.resource];
547
+ if (!resource) {
548
+ throw new ModelNotFoundError(
549
+ `Resource "${modelConfig.resource}" not found`,
550
+ { modelName, resource: modelConfig.resource }
551
+ );
552
+ }
553
+
554
+ // Fetch training data (with optional partition filtering)
555
+ if (this.config.verbose) {
556
+ console.log(`[MLPlugin] Fetching training data for "${modelName}"...`);
557
+ }
558
+
559
+ let data;
560
+ const partition = modelConfig.partition;
561
+
562
+ if (partition && partition.name) {
563
+ // Use partition filtering
564
+ if (this.config.verbose) {
565
+ console.log(`[MLPlugin] Using partition "${partition.name}" with values:`, partition.values);
566
+ }
567
+
568
+ const [ok, err, partitionData] = await tryFn(() =>
569
+ resource.listPartition(partition.name, partition.values)
570
+ );
571
+
572
+ if (!ok) {
573
+ throw new TrainingError(
574
+ `Failed to fetch training data from partition: ${err.message}`,
575
+ { modelName, resource: modelConfig.resource, partition: partition.name, originalError: err.message }
576
+ );
577
+ }
578
+
579
+ data = partitionData;
580
+ } else {
581
+ // Fetch all data
582
+ const [ok, err, allData] = await tryFn(() => resource.list());
583
+
584
+ if (!ok) {
585
+ throw new TrainingError(
586
+ `Failed to fetch training data: ${err.message}`,
587
+ { modelName, resource: modelConfig.resource, originalError: err.message }
588
+ );
589
+ }
590
+
591
+ data = allData;
592
+ }
593
+
594
+ if (!data || data.length < this.config.minTrainingSamples) {
595
+ throw new TrainingError(
596
+ `Insufficient training data: ${data?.length || 0} samples (minimum: ${this.config.minTrainingSamples})`,
597
+ { modelName, samples: data?.length || 0, minimum: this.config.minTrainingSamples }
598
+ );
599
+ }
600
+
601
+ if (this.config.verbose) {
602
+ console.log(`[MLPlugin] Training "${modelName}" with ${data.length} samples...`);
603
+ }
604
+
605
+ // Save intermediate training data if enabled
606
+ const shouldSaveTrainingData = modelConfig.saveTrainingData !== undefined
607
+ ? modelConfig.saveTrainingData
608
+ : this.config.saveTrainingData;
609
+
610
+ if (shouldSaveTrainingData) {
611
+ await this._saveTrainingData(modelName, data);
612
+ }
613
+
614
+ // Train model
615
+ const result = await model.train(data);
616
+
617
+ // Save model to plugin storage if enabled
618
+ const shouldSaveModel = modelConfig.saveModel !== undefined
619
+ ? modelConfig.saveModel
620
+ : this.config.saveModel;
621
+
622
+ if (shouldSaveModel) {
623
+ await this._saveModel(modelName);
624
+ }
625
+
626
+ this.stats.totalTrainings++;
627
+
628
+ if (this.config.verbose) {
629
+ console.log(`[MLPlugin] Training completed for "${modelName}":`, result);
630
+ }
631
+
632
+ this.emit('modelTrained', {
633
+ modelName,
634
+ type: modelConfig.type,
635
+ result
636
+ });
637
+
638
+ return result;
639
+ } catch (error) {
640
+ this.stats.totalErrors++;
641
+
642
+ if (error instanceof MLError) {
643
+ throw error;
644
+ }
645
+
646
+ throw new TrainingError(
647
+ `Training failed for "${modelName}": ${error.message}`,
648
+ { modelName, originalError: error.message }
649
+ );
650
+ } finally {
651
+ this.training.set(modelName, false);
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Make a prediction
657
+ * @param {string} modelName - Model name
658
+ * @param {Object|Array} input - Input data (object for single prediction, array for time series)
659
+ * @returns {Object} Prediction result
660
+ */
661
+ async predict(modelName, input) {
662
+ const model = this.models[modelName];
663
+ if (!model) {
664
+ throw new ModelNotFoundError(
665
+ `Model "${modelName}" not found`,
666
+ { modelName, availableModels: Object.keys(this.models) }
667
+ );
668
+ }
669
+
670
+ try {
671
+ const result = await model.predict(input);
672
+ this.stats.totalPredictions++;
673
+
674
+ this.emit('prediction', {
675
+ modelName,
676
+ input,
677
+ result
678
+ });
679
+
680
+ return result;
681
+ } catch (error) {
682
+ this.stats.totalErrors++;
683
+ throw error;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Make predictions for multiple inputs
689
+ * @param {string} modelName - Model name
690
+ * @param {Array} inputs - Array of input objects
691
+ * @returns {Array} Array of prediction results
692
+ */
693
+ async predictBatch(modelName, inputs) {
694
+ const model = this.models[modelName];
695
+ if (!model) {
696
+ throw new ModelNotFoundError(
697
+ `Model "${modelName}" not found`,
698
+ { modelName, availableModels: Object.keys(this.models) }
699
+ );
700
+ }
701
+
702
+ return await model.predictBatch(inputs);
703
+ }
704
+
705
+ /**
706
+ * Retrain a model (reset and train from scratch)
707
+ * @param {string} modelName - Model name
708
+ * @param {Object} options - Options
709
+ * @returns {Object} Training results
710
+ */
711
+ async retrain(modelName, options = {}) {
712
+ const model = this.models[modelName];
713
+ if (!model) {
714
+ throw new ModelNotFoundError(
715
+ `Model "${modelName}" not found`,
716
+ { modelName, availableModels: Object.keys(this.models) }
717
+ );
718
+ }
719
+
720
+ // Dispose current model
721
+ if (model.dispose) {
722
+ model.dispose();
723
+ }
724
+
725
+ // Re-initialize
726
+ const modelConfig = this.config.models[modelName];
727
+ await this._initializeModel(modelName, modelConfig);
728
+
729
+ // Train
730
+ return await this.train(modelName, options);
731
+ }
732
+
733
+ /**
734
+ * Get model statistics
735
+ * @param {string} modelName - Model name
736
+ * @returns {Object} Model stats
737
+ */
738
+ getModelStats(modelName) {
739
+ const model = this.models[modelName];
740
+ if (!model) {
741
+ throw new ModelNotFoundError(
742
+ `Model "${modelName}" not found`,
743
+ { modelName, availableModels: Object.keys(this.models) }
744
+ );
745
+ }
746
+
747
+ return model.getStats();
748
+ }
749
+
750
+ /**
751
+ * Get plugin statistics
752
+ * @returns {Object} Plugin stats
753
+ */
754
+ getStats() {
755
+ return {
756
+ ...this.stats,
757
+ models: Object.keys(this.models).length,
758
+ trainedModels: Object.values(this.models).filter(m => m.isTrained).length
759
+ };
760
+ }
761
+
762
+ /**
763
+ * Export a model
764
+ * @param {string} modelName - Model name
765
+ * @returns {Object} Serialized model
766
+ */
767
+ async exportModel(modelName) {
768
+ const model = this.models[modelName];
769
+ if (!model) {
770
+ throw new ModelNotFoundError(
771
+ `Model "${modelName}" not found`,
772
+ { modelName, availableModels: Object.keys(this.models) }
773
+ );
774
+ }
775
+
776
+ return await model.export();
777
+ }
778
+
779
+ /**
780
+ * Import a model
781
+ * @param {string} modelName - Model name
782
+ * @param {Object} data - Serialized model data
783
+ */
784
+ async importModel(modelName, data) {
785
+ const model = this.models[modelName];
786
+ if (!model) {
787
+ throw new ModelNotFoundError(
788
+ `Model "${modelName}" not found`,
789
+ { modelName, availableModels: Object.keys(this.models) }
790
+ );
791
+ }
792
+
793
+ await model.import(data);
794
+
795
+ // Save to plugin storage
796
+ await this._saveModel(modelName);
797
+
798
+ if (this.config.verbose) {
799
+ console.log(`[MLPlugin] Imported model "${modelName}"`);
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Initialize versioning for a model
805
+ * @private
806
+ */
807
+ async _initializeVersioning(modelName) {
808
+ try {
809
+ const storage = this.getStorage();
810
+ const [ok, err, versionInfo] = await tryFn(() => storage.get(`version_${modelName}`));
811
+
812
+ if (ok && versionInfo) {
813
+ // Load existing version info
814
+ this.modelVersions.set(modelName, {
815
+ currentVersion: versionInfo.currentVersion || 1,
816
+ latestVersion: versionInfo.latestVersion || 1
817
+ });
818
+
819
+ if (this.config.verbose) {
820
+ console.log(`[MLPlugin] Loaded version info for "${modelName}": v${versionInfo.currentVersion}`);
821
+ }
822
+ } else {
823
+ // Initialize new versioning
824
+ this.modelVersions.set(modelName, {
825
+ currentVersion: 1,
826
+ latestVersion: 0 // No versions yet
827
+ });
828
+
829
+ if (this.config.verbose) {
830
+ console.log(`[MLPlugin] Initialized versioning for "${modelName}"`);
831
+ }
832
+ }
833
+ } catch (error) {
834
+ console.error(`[MLPlugin] Failed to initialize versioning for "${modelName}":`, error.message);
835
+ // Fallback to v1
836
+ this.modelVersions.set(modelName, { currentVersion: 1, latestVersion: 0 });
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Get next version number for a model
842
+ * @private
843
+ */
844
+ _getNextVersion(modelName) {
845
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
846
+ return versionInfo.latestVersion + 1;
847
+ }
848
+
849
+ /**
850
+ * Update version info in storage
851
+ * @private
852
+ */
853
+ async _updateVersionInfo(modelName, version) {
854
+ try {
855
+ const storage = this.getStorage();
856
+ const versionInfo = this.modelVersions.get(modelName) || { currentVersion: 1, latestVersion: 0 };
857
+
858
+ versionInfo.latestVersion = Math.max(versionInfo.latestVersion, version);
859
+ versionInfo.currentVersion = version; // Set new version as current
860
+
861
+ this.modelVersions.set(modelName, versionInfo);
862
+
863
+ await storage.patch(`version_${modelName}`, {
864
+ modelName,
865
+ currentVersion: versionInfo.currentVersion,
866
+ latestVersion: versionInfo.latestVersion,
867
+ updatedAt: new Date().toISOString()
868
+ });
869
+
870
+ if (this.config.verbose) {
871
+ console.log(`[MLPlugin] Updated version info for "${modelName}": current=v${versionInfo.currentVersion}, latest=v${versionInfo.latestVersion}`);
872
+ }
873
+ } catch (error) {
874
+ console.error(`[MLPlugin] Failed to update version info for "${modelName}":`, error.message);
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Save model to plugin storage
880
+ * @private
881
+ */
882
+ async _saveModel(modelName) {
883
+ try {
884
+ const storage = this.getStorage();
885
+ const exportedModel = await this.models[modelName].export();
886
+
887
+ if (!exportedModel) {
888
+ if (this.config.verbose) {
889
+ console.log(`[MLPlugin] Model "${modelName}" not trained, skipping save`);
890
+ }
891
+ return;
892
+ }
893
+
894
+ const enableVersioning = this.config.enableVersioning;
895
+
896
+ if (enableVersioning) {
897
+ // Save with version
898
+ const version = this._getNextVersion(modelName);
899
+ const modelStats = this.models[modelName].getStats();
900
+
901
+ // Save versioned model
902
+ await storage.patch(`model_${modelName}_v${version}`, {
903
+ modelName,
904
+ version,
905
+ type: 'model',
906
+ data: JSON.stringify(exportedModel),
907
+ metrics: JSON.stringify({
908
+ loss: modelStats.loss,
909
+ accuracy: modelStats.accuracy,
910
+ samples: modelStats.samples
911
+ }),
912
+ savedAt: new Date().toISOString()
913
+ });
914
+
915
+ // Update version info
916
+ await this._updateVersionInfo(modelName, version);
917
+
918
+ // Save active reference (points to current version)
919
+ await storage.patch(`model_${modelName}_active`, {
920
+ modelName,
921
+ version,
922
+ type: 'reference',
923
+ updatedAt: new Date().toISOString()
924
+ });
925
+
926
+ if (this.config.verbose) {
927
+ console.log(`[MLPlugin] Saved model "${modelName}" v${version} to plugin storage (S3)`);
928
+ }
929
+ } else {
930
+ // Save without versioning (legacy behavior)
931
+ await storage.patch(`model_${modelName}`, {
932
+ modelName,
933
+ type: 'model',
934
+ data: JSON.stringify(exportedModel),
935
+ savedAt: new Date().toISOString()
936
+ });
937
+
938
+ if (this.config.verbose) {
939
+ console.log(`[MLPlugin] Saved model "${modelName}" to plugin storage (S3)`);
940
+ }
941
+ }
942
+ } catch (error) {
943
+ console.error(`[MLPlugin] Failed to save model "${modelName}":`, error.message);
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Save intermediate training data to plugin storage (incremental)
949
+ * @private
950
+ */
951
+ async _saveTrainingData(modelName, rawData) {
952
+ try {
953
+ const storage = this.getStorage();
954
+ const model = this.models[modelName];
955
+ const modelConfig = this.config.models[modelName];
956
+ const modelStats = model.getStats();
957
+ const enableVersioning = this.config.enableVersioning;
958
+
959
+ // Extract features and target from raw data
960
+ const trainingEntry = {
961
+ version: enableVersioning ? this.modelVersions.get(modelName)?.latestVersion || 1 : undefined,
962
+ samples: rawData.length,
963
+ features: modelConfig.features,
964
+ target: modelConfig.target,
965
+ data: rawData.map(item => {
966
+ const features = {};
967
+ modelConfig.features.forEach(feature => {
968
+ features[feature] = item[feature];
969
+ });
970
+ return {
971
+ features,
972
+ target: item[modelConfig.target]
973
+ };
974
+ }),
975
+ metrics: {
976
+ loss: modelStats.loss,
977
+ accuracy: modelStats.accuracy,
978
+ r2: modelStats.r2
979
+ },
980
+ trainedAt: new Date().toISOString()
981
+ };
982
+
983
+ if (enableVersioning) {
984
+ // Incremental: Load existing history and append
985
+ const [ok, err, existing] = await tryFn(() => storage.get(`training_history_${modelName}`));
986
+
987
+ let history = [];
988
+ if (ok && existing && existing.history) {
989
+ try {
990
+ history = JSON.parse(existing.history);
991
+ } catch (e) {
992
+ history = [];
993
+ }
994
+ }
995
+
996
+ // Append new entry
997
+ history.push(trainingEntry);
998
+
999
+ // Save updated history
1000
+ await storage.patch(`training_history_${modelName}`, {
1001
+ modelName,
1002
+ type: 'training_history',
1003
+ totalTrainings: history.length,
1004
+ latestVersion: trainingEntry.version,
1005
+ history: JSON.stringify(history),
1006
+ updatedAt: new Date().toISOString()
1007
+ });
1008
+
1009
+ if (this.config.verbose) {
1010
+ console.log(`[MLPlugin] Appended training data for "${modelName}" v${trainingEntry.version} (${trainingEntry.samples} samples, total: ${history.length} trainings)`);
1011
+ }
1012
+ } else {
1013
+ // Legacy: Replace training data (non-incremental)
1014
+ await storage.patch(`training_data_${modelName}`, {
1015
+ modelName,
1016
+ type: 'training_data',
1017
+ samples: trainingEntry.samples,
1018
+ features: JSON.stringify(trainingEntry.features),
1019
+ target: trainingEntry.target,
1020
+ data: JSON.stringify(trainingEntry.data),
1021
+ savedAt: trainingEntry.trainedAt
1022
+ });
1023
+
1024
+ if (this.config.verbose) {
1025
+ console.log(`[MLPlugin] Saved training data for "${modelName}" (${trainingEntry.samples} samples) to plugin storage (S3)`);
1026
+ }
1027
+ }
1028
+ } catch (error) {
1029
+ console.error(`[MLPlugin] Failed to save training data for "${modelName}":`, error.message);
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Load model from plugin storage
1035
+ * @private
1036
+ */
1037
+ async _loadModel(modelName) {
1038
+ try {
1039
+ const storage = this.getStorage();
1040
+ const enableVersioning = this.config.enableVersioning;
1041
+
1042
+ if (enableVersioning) {
1043
+ // Load active version
1044
+ const [okRef, errRef, activeRef] = await tryFn(() => storage.get(`model_${modelName}_active`));
1045
+
1046
+ if (okRef && activeRef && activeRef.version) {
1047
+ // Load the active version
1048
+ const version = activeRef.version;
1049
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
1050
+
1051
+ if (ok && versionData) {
1052
+ const modelData = JSON.parse(versionData.data);
1053
+ await this.models[modelName].import(modelData);
1054
+
1055
+ if (this.config.verbose) {
1056
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (active) from plugin storage (S3)`);
1057
+ }
1058
+ return;
1059
+ }
1060
+ }
1061
+
1062
+ // No active reference, try to load latest version
1063
+ const versionInfo = this.modelVersions.get(modelName);
1064
+ if (versionInfo && versionInfo.latestVersion > 0) {
1065
+ const version = versionInfo.latestVersion;
1066
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
1067
+
1068
+ if (ok && versionData) {
1069
+ const modelData = JSON.parse(versionData.data);
1070
+ await this.models[modelName].import(modelData);
1071
+
1072
+ if (this.config.verbose) {
1073
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (latest) from plugin storage (S3)`);
1074
+ }
1075
+ return;
1076
+ }
1077
+ }
1078
+
1079
+ if (this.config.verbose) {
1080
+ console.log(`[MLPlugin] No saved model versions found for "${modelName}"`);
1081
+ }
1082
+ } else {
1083
+ // Legacy: Load non-versioned model
1084
+ const [ok, err, record] = await tryFn(() => storage.get(`model_${modelName}`));
1085
+
1086
+ if (!ok || !record) {
1087
+ if (this.config.verbose) {
1088
+ console.log(`[MLPlugin] No saved model found for "${modelName}"`);
1089
+ }
1090
+ return;
1091
+ }
1092
+
1093
+ const modelData = JSON.parse(record.data);
1094
+ await this.models[modelName].import(modelData);
1095
+
1096
+ if (this.config.verbose) {
1097
+ console.log(`[MLPlugin] Loaded model "${modelName}" from plugin storage (S3)`);
1098
+ }
1099
+ }
1100
+ } catch (error) {
1101
+ console.error(`[MLPlugin] Failed to load model "${modelName}":`, error.message);
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * Load training data from plugin storage
1107
+ * @param {string} modelName - Model name
1108
+ * @returns {Object|null} Training data or null if not found
1109
+ */
1110
+ async getTrainingData(modelName) {
1111
+ try {
1112
+ const storage = this.getStorage();
1113
+ const [ok, err, record] = await tryFn(() => storage.get(`training_data_${modelName}`));
1114
+
1115
+ if (!ok || !record) {
1116
+ if (this.config.verbose) {
1117
+ console.log(`[MLPlugin] No saved training data found for "${modelName}"`);
1118
+ }
1119
+ return null;
1120
+ }
1121
+
1122
+ return {
1123
+ modelName: record.modelName,
1124
+ samples: record.samples,
1125
+ features: JSON.parse(record.features),
1126
+ target: record.target,
1127
+ data: JSON.parse(record.data),
1128
+ savedAt: record.savedAt
1129
+ };
1130
+ } catch (error) {
1131
+ console.error(`[MLPlugin] Failed to load training data for "${modelName}":`, error.message);
1132
+ return null;
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Delete model from plugin storage
1138
+ * @private
1139
+ */
1140
+ async _deleteModel(modelName) {
1141
+ try {
1142
+ const storage = this.getStorage();
1143
+ await storage.delete(`model_${modelName}`);
1144
+
1145
+ if (this.config.verbose) {
1146
+ console.log(`[MLPlugin] Deleted model "${modelName}" from plugin storage`);
1147
+ }
1148
+ } catch (error) {
1149
+ // Ignore errors (model might not exist)
1150
+ if (this.config.verbose) {
1151
+ console.log(`[MLPlugin] Could not delete model "${modelName}": ${error.message}`);
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Delete training data from plugin storage
1158
+ * @private
1159
+ */
1160
+ async _deleteTrainingData(modelName) {
1161
+ try {
1162
+ const storage = this.getStorage();
1163
+ await storage.delete(`training_data_${modelName}`);
1164
+
1165
+ if (this.config.verbose) {
1166
+ console.log(`[MLPlugin] Deleted training data for "${modelName}" from plugin storage`);
1167
+ }
1168
+ } catch (error) {
1169
+ // Ignore errors (training data might not exist)
1170
+ if (this.config.verbose) {
1171
+ console.log(`[MLPlugin] Could not delete training data "${modelName}": ${error.message}`);
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * List all versions of a model
1178
+ * @param {string} modelName - Model name
1179
+ * @returns {Array} List of version info
1180
+ */
1181
+ async listModelVersions(modelName) {
1182
+ if (!this.config.enableVersioning) {
1183
+ throw new MLError('Versioning is not enabled', { modelName });
1184
+ }
1185
+
1186
+ try {
1187
+ const storage = this.getStorage();
1188
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
1189
+ const versions = [];
1190
+
1191
+ // Load each version
1192
+ for (let v = 1; v <= versionInfo.latestVersion; v++) {
1193
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${v}`));
1194
+
1195
+ if (ok && versionData) {
1196
+ const metrics = versionData.metrics ? JSON.parse(versionData.metrics) : {};
1197
+ versions.push({
1198
+ version: v,
1199
+ savedAt: versionData.savedAt,
1200
+ isCurrent: v === versionInfo.currentVersion,
1201
+ metrics
1202
+ });
1203
+ }
1204
+ }
1205
+
1206
+ return versions;
1207
+ } catch (error) {
1208
+ console.error(`[MLPlugin] Failed to list versions for "${modelName}":`, error.message);
1209
+ return [];
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Load a specific version of a model
1215
+ * @param {string} modelName - Model name
1216
+ * @param {number} version - Version number
1217
+ */
1218
+ async loadModelVersion(modelName, version) {
1219
+ if (!this.config.enableVersioning) {
1220
+ throw new MLError('Versioning is not enabled', { modelName });
1221
+ }
1222
+
1223
+ if (!this.models[modelName]) {
1224
+ throw new ModelNotFoundError(`Model "${modelName}" not found`, { modelName });
1225
+ }
1226
+
1227
+ try {
1228
+ const storage = this.getStorage();
1229
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
1230
+
1231
+ if (!ok || !versionData) {
1232
+ throw new MLError(`Version ${version} not found for model "${modelName}"`, { modelName, version });
1233
+ }
1234
+
1235
+ const modelData = JSON.parse(versionData.data);
1236
+ await this.models[modelName].import(modelData);
1237
+
1238
+ // Update current version in memory (don't save to storage yet)
1239
+ const versionInfo = this.modelVersions.get(modelName);
1240
+ if (versionInfo) {
1241
+ versionInfo.currentVersion = version;
1242
+ this.modelVersions.set(modelName, versionInfo);
1243
+ }
1244
+
1245
+ if (this.config.verbose) {
1246
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version}`);
1247
+ }
1248
+
1249
+ return {
1250
+ version,
1251
+ metrics: versionData.metrics ? JSON.parse(versionData.metrics) : {},
1252
+ savedAt: versionData.savedAt
1253
+ };
1254
+ } catch (error) {
1255
+ console.error(`[MLPlugin] Failed to load version ${version} for "${modelName}":`, error.message);
1256
+ throw error;
1257
+ }
1258
+ }
1259
+
1260
+ /**
1261
+ * Set active version for a model (used for predictions)
1262
+ * @param {string} modelName - Model name
1263
+ * @param {number} version - Version number
1264
+ */
1265
+ async setActiveVersion(modelName, version) {
1266
+ if (!this.config.enableVersioning) {
1267
+ throw new MLError('Versioning is not enabled', { modelName });
1268
+ }
1269
+
1270
+ // Load the version into the model
1271
+ await this.loadModelVersion(modelName, version);
1272
+
1273
+ // Update version info in storage
1274
+ await this._updateVersionInfo(modelName, version);
1275
+
1276
+ // Update active reference
1277
+ const storage = this.getStorage();
1278
+ await storage.patch(`model_${modelName}_active`, {
1279
+ modelName,
1280
+ version,
1281
+ type: 'reference',
1282
+ updatedAt: new Date().toISOString()
1283
+ });
1284
+
1285
+ if (this.config.verbose) {
1286
+ console.log(`[MLPlugin] Set model "${modelName}" active version to v${version}`);
1287
+ }
1288
+
1289
+ return { modelName, version };
1290
+ }
1291
+
1292
+ /**
1293
+ * Get training history for a model
1294
+ * @param {string} modelName - Model name
1295
+ * @returns {Array} Training history
1296
+ */
1297
+ async getTrainingHistory(modelName) {
1298
+ if (!this.config.enableVersioning) {
1299
+ // Fallback to legacy getTrainingData
1300
+ return await this.getTrainingData(modelName);
1301
+ }
1302
+
1303
+ try {
1304
+ const storage = this.getStorage();
1305
+ const [ok, err, historyData] = await tryFn(() => storage.get(`training_history_${modelName}`));
1306
+
1307
+ if (!ok || !historyData) {
1308
+ return null;
1309
+ }
1310
+
1311
+ return {
1312
+ modelName: historyData.modelName,
1313
+ totalTrainings: historyData.totalTrainings,
1314
+ latestVersion: historyData.latestVersion,
1315
+ history: JSON.parse(historyData.history),
1316
+ updatedAt: historyData.updatedAt
1317
+ };
1318
+ } catch (error) {
1319
+ console.error(`[MLPlugin] Failed to load training history for "${modelName}":`, error.message);
1320
+ return null;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Compare metrics between two versions
1326
+ * @param {string} modelName - Model name
1327
+ * @param {number} version1 - First version
1328
+ * @param {number} version2 - Second version
1329
+ * @returns {Object} Comparison results
1330
+ */
1331
+ async compareVersions(modelName, version1, version2) {
1332
+ if (!this.config.enableVersioning) {
1333
+ throw new MLError('Versioning is not enabled', { modelName });
1334
+ }
1335
+
1336
+ try {
1337
+ const storage = this.getStorage();
1338
+
1339
+ const [ok1, err1, v1Data] = await tryFn(() => storage.get(`model_${modelName}_v${version1}`));
1340
+ const [ok2, err2, v2Data] = await tryFn(() => storage.get(`model_${modelName}_v${version2}`));
1341
+
1342
+ if (!ok1 || !v1Data) {
1343
+ throw new MLError(`Version ${version1} not found`, { modelName, version: version1 });
1344
+ }
1345
+
1346
+ if (!ok2 || !v2Data) {
1347
+ throw new MLError(`Version ${version2} not found`, { modelName, version: version2 });
1348
+ }
1349
+
1350
+ const metrics1 = v1Data.metrics ? JSON.parse(v1Data.metrics) : {};
1351
+ const metrics2 = v2Data.metrics ? JSON.parse(v2Data.metrics) : {};
1352
+
1353
+ return {
1354
+ modelName,
1355
+ version1: {
1356
+ version: version1,
1357
+ savedAt: v1Data.savedAt,
1358
+ metrics: metrics1
1359
+ },
1360
+ version2: {
1361
+ version: version2,
1362
+ savedAt: v2Data.savedAt,
1363
+ metrics: metrics2
1364
+ },
1365
+ improvement: {
1366
+ loss: metrics1.loss && metrics2.loss ? ((metrics1.loss - metrics2.loss) / metrics1.loss * 100).toFixed(2) + '%' : 'N/A',
1367
+ accuracy: metrics1.accuracy && metrics2.accuracy ? ((metrics2.accuracy - metrics1.accuracy) / metrics1.accuracy * 100).toFixed(2) + '%' : 'N/A'
1368
+ }
1369
+ };
1370
+ } catch (error) {
1371
+ console.error(`[MLPlugin] Failed to compare versions for "${modelName}":`, error.message);
1372
+ throw error;
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * Rollback to a previous version
1378
+ * @param {string} modelName - Model name
1379
+ * @param {number} version - Version to rollback to (defaults to previous version)
1380
+ * @returns {Object} Rollback info
1381
+ */
1382
+ async rollbackVersion(modelName, version = null) {
1383
+ if (!this.config.enableVersioning) {
1384
+ throw new MLError('Versioning is not enabled', { modelName });
1385
+ }
1386
+
1387
+ const versionInfo = this.modelVersions.get(modelName);
1388
+ if (!versionInfo) {
1389
+ throw new MLError(`No version info found for model "${modelName}"`, { modelName });
1390
+ }
1391
+
1392
+ // If no version specified, rollback to previous
1393
+ const targetVersion = version !== null ? version : Math.max(1, versionInfo.currentVersion - 1);
1394
+
1395
+ if (targetVersion === versionInfo.currentVersion) {
1396
+ throw new MLError('Cannot rollback to the same version', { modelName, version: targetVersion });
1397
+ }
1398
+
1399
+ if (targetVersion < 1 || targetVersion > versionInfo.latestVersion) {
1400
+ throw new MLError(`Invalid version ${targetVersion}`, { modelName, version: targetVersion, latestVersion: versionInfo.latestVersion });
1401
+ }
1402
+
1403
+ // Load and set as active
1404
+ const result = await this.setActiveVersion(modelName, targetVersion);
1405
+
1406
+ if (this.config.verbose) {
1407
+ console.log(`[MLPlugin] Rolled back model "${modelName}" from v${versionInfo.currentVersion} to v${targetVersion}`);
1408
+ }
1409
+
1410
+ return {
1411
+ modelName,
1412
+ previousVersion: versionInfo.currentVersion,
1413
+ currentVersion: targetVersion,
1414
+ ...result
1415
+ };
1416
+ }
1417
+ }