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.
- package/dist/s3db.cjs.js +8066 -3562
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +8066 -3563
- package/dist/s3db.es.js.map +1 -1
- package/package.json +5 -1
- package/src/clients/memory-client.class.js +41 -24
- package/src/database.class.js +52 -17
- package/src/plugins/api/index.js +13 -16
- package/src/plugins/api/routes/resource-routes.js +81 -3
- package/src/plugins/api/server.js +29 -9
- package/src/plugins/audit.plugin.js +2 -4
- package/src/plugins/backup.plugin.js +2 -4
- package/src/plugins/cache.plugin.js +1 -3
- package/src/plugins/costs.plugin.js +0 -2
- package/src/plugins/eventual-consistency/index.js +1 -3
- package/src/plugins/fulltext.plugin.js +2 -4
- package/src/plugins/geo.plugin.js +1 -3
- package/src/plugins/importer/index.js +0 -2
- package/src/plugins/index.js +1 -1
- package/src/plugins/metrics.plugin.js +2 -4
- package/src/plugins/ml/base-model.class.js +459 -0
- package/src/plugins/ml/classification-model.class.js +338 -0
- package/src/plugins/ml/neural-network-model.class.js +312 -0
- package/src/plugins/ml/regression-model.class.js +159 -0
- package/src/plugins/ml/timeseries-model.class.js +346 -0
- package/src/plugins/ml.errors.js +130 -0
- package/src/plugins/ml.plugin.js +1417 -0
- package/src/plugins/plugin.class.js +1 -3
- package/src/plugins/queue-consumer.plugin.js +1 -3
- package/src/plugins/relation.plugin.js +1 -3
- package/src/plugins/replicator.plugin.js +2 -4
- package/src/plugins/s3-queue.plugin.js +1 -3
- package/src/plugins/scheduler.plugin.js +2 -4
- package/src/plugins/state-machine.plugin.js +2 -4
- package/src/plugins/tfstate/index.js +0 -2
- package/src/plugins/ttl.plugin.js +36 -21
- package/src/plugins/vector.plugin.js +0 -2
- 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
|
+
}
|