@swapai/core 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/effect.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1126 -193
- package/dist/index.js.map +1 -1
- package/dist/{types-DTbU1MEi.d.ts → types-DkvGbscw.d.ts} +1 -0
- package/package.json +1 -1
- package/python/swapai_worker.py +105 -43
package/dist/index.js
CHANGED
|
@@ -46,6 +46,9 @@ function validateResultConfig(result) {
|
|
|
46
46
|
if (result.max <= result.min) {
|
|
47
47
|
invalidConfiguration("number result max must be greater than min");
|
|
48
48
|
}
|
|
49
|
+
if (!Number.isFinite(result.max - result.min)) {
|
|
50
|
+
invalidConfiguration("number result range must be finite");
|
|
51
|
+
}
|
|
49
52
|
return;
|
|
50
53
|
}
|
|
51
54
|
if (result.type === "string") {
|
|
@@ -122,30 +125,82 @@ function averageError(config, comparisons) {
|
|
|
122
125
|
// src/runtime.ts
|
|
123
126
|
import { createHash, randomUUID } from "crypto";
|
|
124
127
|
import { spawn } from "child_process";
|
|
128
|
+
import { readFileSync, statSync } from "fs";
|
|
125
129
|
import {
|
|
126
130
|
chmod,
|
|
127
131
|
copyFile,
|
|
128
132
|
mkdir,
|
|
129
133
|
readFile,
|
|
130
134
|
readdir,
|
|
135
|
+
rm,
|
|
131
136
|
stat,
|
|
132
137
|
writeFile
|
|
133
138
|
} from "fs/promises";
|
|
134
139
|
import { createInterface } from "readline";
|
|
135
|
-
import { dirname, join } from "path";
|
|
140
|
+
import { dirname, join, resolve, sep } from "path";
|
|
136
141
|
import { fileURLToPath } from "url";
|
|
137
142
|
|
|
138
143
|
// src/needle.ts
|
|
139
|
-
|
|
144
|
+
var MAX_NEEDLE_NUMBER_LABELS = 64;
|
|
145
|
+
function createNeedleNumberLabels(values, config, acceptableError = 0) {
|
|
146
|
+
if (typeof acceptableError !== "number" || !Number.isFinite(acceptableError) || acceptableError < 0 || acceptableError > 1) {
|
|
147
|
+
throw new TypeError("acceptableError must be between 0 and 1.");
|
|
148
|
+
}
|
|
149
|
+
const valid = values.map(
|
|
150
|
+
(value) => validateNeedleResult(value, config)
|
|
151
|
+
);
|
|
152
|
+
const unique = [...new Set(valid)].sort((left, right) => left - right);
|
|
153
|
+
const desiredLabelCount = acceptableError === 0 ? MAX_NEEDLE_NUMBER_LABELS : Math.min(
|
|
154
|
+
MAX_NEEDLE_NUMBER_LABELS,
|
|
155
|
+
Math.max(1, Math.floor(1 / (2 * acceptableError)) + 1)
|
|
156
|
+
);
|
|
157
|
+
if (unique.length <= desiredLabelCount) return { values: unique };
|
|
158
|
+
const width = (config.max - config.min) / desiredLabelCount;
|
|
159
|
+
const occupied = /* @__PURE__ */ new Set();
|
|
160
|
+
for (const value of unique) {
|
|
161
|
+
occupied.add(Math.min(
|
|
162
|
+
desiredLabelCount - 1,
|
|
163
|
+
Math.floor((value - config.min) / width)
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
values: [...occupied].sort((left, right) => left - right).map((index) => config.min + (index + 0.5) * width)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function numberLabel(index, count) {
|
|
171
|
+
if (count === 1) return "only_score";
|
|
172
|
+
if (count === 2) return index === 0 ? "lower_score" : "higher_score";
|
|
173
|
+
return `score_bucket_${index}`;
|
|
174
|
+
}
|
|
175
|
+
function encodeNumberResult(value, labels) {
|
|
176
|
+
if (labels.values.length === 0) {
|
|
177
|
+
throw new TypeError("Numeric result has no Needle label.");
|
|
178
|
+
}
|
|
179
|
+
let closestIndex = 0;
|
|
180
|
+
let closestDistance = Math.abs(value - labels.values[0]);
|
|
181
|
+
for (let index = 1; index < labels.values.length; index += 1) {
|
|
182
|
+
const distance = Math.abs(value - labels.values[index]);
|
|
183
|
+
if (distance < closestDistance) {
|
|
184
|
+
closestIndex = index;
|
|
185
|
+
closestDistance = distance;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return numberLabel(closestIndex, labels.values.length);
|
|
189
|
+
}
|
|
190
|
+
function createNeedleTool(config, numberLabels) {
|
|
140
191
|
const description = "The classification result.";
|
|
141
192
|
let result;
|
|
142
193
|
switch (config.type) {
|
|
143
194
|
case "number":
|
|
195
|
+
if (!numberLabels || numberLabels.values.length === 0) {
|
|
196
|
+
throw new TypeError("Numeric Needle tools require at least one result label.");
|
|
197
|
+
}
|
|
144
198
|
result = {
|
|
145
|
-
type: "
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
199
|
+
type: "string",
|
|
200
|
+
enum: numberLabels.values.map(
|
|
201
|
+
(_, index) => numberLabel(index, numberLabels.values.length)
|
|
202
|
+
),
|
|
203
|
+
description: `The numeric classification bucket from ${config.min} to ${config.max}.`
|
|
149
204
|
};
|
|
150
205
|
break;
|
|
151
206
|
case "boolean":
|
|
@@ -166,12 +221,13 @@ function createNeedleTool(config) {
|
|
|
166
221
|
}
|
|
167
222
|
};
|
|
168
223
|
}
|
|
169
|
-
function createTrainingLine(input, value, config) {
|
|
224
|
+
function createTrainingLine(input, value, config, numberLabels) {
|
|
170
225
|
const result = validateNeedleResult(value, config);
|
|
226
|
+
const needleResult = config.type === "number" ? encodeNumberResult(result, numberLabels ?? { values: [] }) : result;
|
|
171
227
|
return JSON.stringify({
|
|
172
228
|
query: input,
|
|
173
|
-
tools: [createNeedleTool(config)],
|
|
174
|
-
answers: [{ name: "classify", arguments: { result } }]
|
|
229
|
+
tools: [createNeedleTool(config, numberLabels)],
|
|
230
|
+
answers: [{ name: "classify", arguments: { result: needleResult } }]
|
|
175
231
|
});
|
|
176
232
|
}
|
|
177
233
|
function validateNeedleResult(value, config) {
|
|
@@ -195,11 +251,62 @@ function validateNeedleResult(value, config) {
|
|
|
195
251
|
return value;
|
|
196
252
|
}
|
|
197
253
|
}
|
|
254
|
+
function decodeNeedleResult(result, config, numberLabels) {
|
|
255
|
+
if (config.type === "number") {
|
|
256
|
+
if (typeof result !== "string") {
|
|
257
|
+
throw new TypeError(
|
|
258
|
+
`Needle returned a number outside ${config.min} to ${config.max}.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const index = numberLabels?.values.findIndex(
|
|
262
|
+
(_, candidateIndex) => result === numberLabel(candidateIndex, numberLabels.values.length)
|
|
263
|
+
) ?? -1;
|
|
264
|
+
const decoded = numberLabels?.values[index];
|
|
265
|
+
if (index < 0 || decoded === void 0) {
|
|
266
|
+
throw new TypeError(
|
|
267
|
+
`Needle returned a number outside ${config.min} to ${config.max}.`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return validateNeedleResult(decoded, config);
|
|
271
|
+
}
|
|
272
|
+
return validateNeedleResult(result, config);
|
|
273
|
+
}
|
|
198
274
|
|
|
199
275
|
// src/runtime.ts
|
|
200
276
|
var NEEDLE_VERSION = "2.0.14";
|
|
277
|
+
var NEEDLE_NUMBER_MODEL_VERSION = `${NEEDLE_VERSION}/number-buckets-v1`;
|
|
201
278
|
var UV_VERSION = "0.11.4";
|
|
202
279
|
var PYTHON_VERSION = "3.12";
|
|
280
|
+
function needleModelVersion(resultConfig) {
|
|
281
|
+
return resultConfig.type === "number" ? NEEDLE_NUMBER_MODEL_VERSION : NEEDLE_VERSION;
|
|
282
|
+
}
|
|
283
|
+
var NeedleModelArtifactError = class extends SwapAIError {
|
|
284
|
+
constructor(message, cause) {
|
|
285
|
+
super(
|
|
286
|
+
"service_unavailable",
|
|
287
|
+
message,
|
|
288
|
+
cause === void 0 ? void 0 : { cause }
|
|
289
|
+
);
|
|
290
|
+
this.name = "NeedleModelArtifactError";
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
function hasNeedleModelArtifacts(options) {
|
|
294
|
+
try {
|
|
295
|
+
if (!statSync(options.modelPath).isFile()) return false;
|
|
296
|
+
if (options.resultConfig.type !== "number") return true;
|
|
297
|
+
const saved = JSON.parse(
|
|
298
|
+
readFileSync(`${options.modelPath}.numbers.json`, "utf8")
|
|
299
|
+
);
|
|
300
|
+
if (saved.format !== 1 || !Array.isArray(saved.values) || saved.values.length === 0) return false;
|
|
301
|
+
const savedValues = saved.values;
|
|
302
|
+
const labels = createNeedleNumberLabels(savedValues, options.resultConfig);
|
|
303
|
+
return labels.values.length === savedValues.length && labels.values.every(
|
|
304
|
+
(value, index) => value === savedValues[index]
|
|
305
|
+
);
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
203
310
|
var NeedleRuntimeError = class extends SwapAIError {
|
|
204
311
|
constructor(code, message, cause) {
|
|
205
312
|
super(code, message, cause === void 0 ? void 0 : { cause });
|
|
@@ -224,8 +331,8 @@ var ManagedNeedleRuntime = class {
|
|
|
224
331
|
#environment;
|
|
225
332
|
#closed = false;
|
|
226
333
|
constructor(options) {
|
|
227
|
-
this.#dataDirectory = options.dataDirectory;
|
|
228
|
-
this.#runtimeDirectory = join(
|
|
334
|
+
this.#dataDirectory = resolve(options.dataDirectory);
|
|
335
|
+
this.#runtimeDirectory = join(this.#dataDirectory, "runtime");
|
|
229
336
|
this.#runCommand = options.dependencies?.runCommand ?? runCommand;
|
|
230
337
|
this.#spawnProcess = options.dependencies?.spawnProcess ?? spawnProcess;
|
|
231
338
|
this.#fetch = options.dependencies?.fetch ?? globalThis.fetch;
|
|
@@ -252,6 +359,12 @@ var ManagedNeedleRuntime = class {
|
|
|
252
359
|
"Needle generation must be a non-negative integer."
|
|
253
360
|
);
|
|
254
361
|
}
|
|
362
|
+
if (!Number.isSafeInteger(options.expectedEpoch) || options.expectedEpoch < 0) {
|
|
363
|
+
throw new NeedleRuntimeError(
|
|
364
|
+
"service_unavailable",
|
|
365
|
+
"Needle data epoch must be a non-negative integer."
|
|
366
|
+
);
|
|
367
|
+
}
|
|
255
368
|
if (options.examples.length === 0) {
|
|
256
369
|
throw new NeedleRuntimeError(
|
|
257
370
|
"service_unavailable",
|
|
@@ -278,49 +391,110 @@ var ManagedNeedleRuntime = class {
|
|
|
278
391
|
const modelPath = join(candidateDirectory, "model.cact");
|
|
279
392
|
await mkdir(checkpointDirectory, { recursive: true, mode: 448 });
|
|
280
393
|
await mkdir(candidateDirectory, { recursive: true, mode: 448 });
|
|
394
|
+
const numberLabels = options.resultConfig.type === "number" ? createNeedleNumberLabels(
|
|
395
|
+
options.examples.map((example) => example.result),
|
|
396
|
+
options.resultConfig,
|
|
397
|
+
options.acceptableError
|
|
398
|
+
) : void 0;
|
|
281
399
|
const lines = options.examples.map(
|
|
282
|
-
(example) => createTrainingLine(
|
|
400
|
+
(example) => createTrainingLine(
|
|
401
|
+
example.input,
|
|
402
|
+
example.result,
|
|
403
|
+
options.resultConfig,
|
|
404
|
+
numberLabels
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
const lockPath = join(
|
|
408
|
+
this.#dataDirectory,
|
|
409
|
+
"locks",
|
|
410
|
+
`${createHash("sha256").update(options.classifierName).digest("hex")}.sqlite`
|
|
283
411
|
);
|
|
284
|
-
await writeFile(trainingPath, `${lines.join("\n")}
|
|
285
|
-
`, "utf8");
|
|
286
412
|
try {
|
|
413
|
+
const trainArguments = [
|
|
414
|
+
this.#workerPath,
|
|
415
|
+
"train",
|
|
416
|
+
"--training-data",
|
|
417
|
+
trainingPath,
|
|
418
|
+
"--output",
|
|
419
|
+
modelPath,
|
|
420
|
+
"--checkpoint-dir",
|
|
421
|
+
checkpointDirectory,
|
|
422
|
+
"--epochs",
|
|
423
|
+
String(options.epochs ?? 10),
|
|
424
|
+
"--artifact-lock-database",
|
|
425
|
+
lockPath,
|
|
426
|
+
"--main-database",
|
|
427
|
+
join(this.#dataDirectory, "swapai.sqlite"),
|
|
428
|
+
"--classifier-name",
|
|
429
|
+
options.classifierName,
|
|
430
|
+
"--expected-epoch",
|
|
431
|
+
String(options.expectedEpoch)
|
|
432
|
+
];
|
|
433
|
+
if (numberLabels) {
|
|
434
|
+
trainArguments.push("--numeric-labels-from-stdin");
|
|
435
|
+
}
|
|
287
436
|
await this.#runCommand(
|
|
288
437
|
this.#pythonPath,
|
|
289
|
-
|
|
290
|
-
this.#workerPath,
|
|
291
|
-
"train",
|
|
292
|
-
"--training-data",
|
|
293
|
-
trainingPath,
|
|
294
|
-
"--output",
|
|
295
|
-
modelPath,
|
|
296
|
-
"--checkpoint-dir",
|
|
297
|
-
checkpointDirectory,
|
|
298
|
-
"--epochs",
|
|
299
|
-
String(options.epochs ?? 10)
|
|
300
|
-
],
|
|
438
|
+
trainArguments,
|
|
301
439
|
{
|
|
302
440
|
cwd: candidateDirectory,
|
|
303
441
|
env: this.#environment,
|
|
304
|
-
timeoutMs: 6 * 60 * 60 * 1e3
|
|
442
|
+
timeoutMs: 6 * 60 * 60 * 1e3,
|
|
443
|
+
stdin: numberLabels ? `${JSON.stringify({ format: 1, values: numberLabels.values })}
|
|
444
|
+
${lines.join("\n")}
|
|
445
|
+
` : `${lines.join("\n")}
|
|
446
|
+
`
|
|
305
447
|
}
|
|
306
448
|
);
|
|
307
449
|
await stat(modelPath);
|
|
308
450
|
} catch (error) {
|
|
309
451
|
throw toRuntimeError("Needle training failed.", error);
|
|
310
452
|
}
|
|
311
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
modelPath,
|
|
455
|
+
needleVersion: needleModelVersion(options.resultConfig)
|
|
456
|
+
};
|
|
312
457
|
}
|
|
313
458
|
async loadModel(options) {
|
|
314
459
|
await this.ready();
|
|
315
460
|
try {
|
|
316
461
|
await stat(options.modelPath);
|
|
317
462
|
} catch (error) {
|
|
318
|
-
throw
|
|
463
|
+
throw new NeedleModelArtifactError(
|
|
464
|
+
`Needle model does not exist: ${options.modelPath}`,
|
|
465
|
+
error
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
let numberLabels;
|
|
469
|
+
if (options.resultConfig.type === "number") {
|
|
470
|
+
try {
|
|
471
|
+
const saved = JSON.parse(
|
|
472
|
+
await readFile(`${options.modelPath}.numbers.json`, "utf8")
|
|
473
|
+
);
|
|
474
|
+
if (saved.format !== 1 || !Array.isArray(saved.values) || saved.values.length === 0) {
|
|
475
|
+
throw new TypeError("Numeric label map has an invalid format.");
|
|
476
|
+
}
|
|
477
|
+
const savedValues = saved.values;
|
|
478
|
+
numberLabels = createNeedleNumberLabels(
|
|
479
|
+
savedValues,
|
|
480
|
+
options.resultConfig
|
|
481
|
+
);
|
|
482
|
+
if (numberLabels.values.length !== savedValues.length || numberLabels.values.some(
|
|
483
|
+
(value, index) => value !== savedValues[index]
|
|
484
|
+
)) {
|
|
485
|
+
throw new TypeError("Numeric label map is not sorted and unique.");
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw new NeedleModelArtifactError(
|
|
489
|
+
"Could not load Needle numeric labels.",
|
|
490
|
+
error
|
|
491
|
+
);
|
|
492
|
+
}
|
|
319
493
|
}
|
|
320
494
|
const schemaPath = `${options.modelPath}.schema.json`;
|
|
321
495
|
await writeFile(
|
|
322
496
|
schemaPath,
|
|
323
|
-
JSON.stringify([createNeedleTool(options.resultConfig)]),
|
|
497
|
+
JSON.stringify([createNeedleTool(options.resultConfig, numberLabels)]),
|
|
324
498
|
"utf8"
|
|
325
499
|
);
|
|
326
500
|
const process2 = this.#spawnProcess(
|
|
@@ -339,6 +513,7 @@ var ManagedNeedleRuntime = class {
|
|
|
339
513
|
const model = new NeedleModelProcess(
|
|
340
514
|
process2,
|
|
341
515
|
options.resultConfig,
|
|
516
|
+
numberLabels,
|
|
342
517
|
(error) => this.#onBackgroundError?.(error),
|
|
343
518
|
() => this.#models.delete(model)
|
|
344
519
|
);
|
|
@@ -352,6 +527,70 @@ var ManagedNeedleRuntime = class {
|
|
|
352
527
|
throw toRuntimeError("Needle model could not start.", error);
|
|
353
528
|
}
|
|
354
529
|
}
|
|
530
|
+
async clearClassifierArtifacts(classifierName) {
|
|
531
|
+
await rm(
|
|
532
|
+
join(this.#dataDirectory, "classifiers", classifierKey(classifierName)),
|
|
533
|
+
{ recursive: true, force: true }
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
async clearClassifierGenerationArtifacts(classifierName, generation) {
|
|
537
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
538
|
+
throw new NeedleRuntimeError(
|
|
539
|
+
"service_unavailable",
|
|
540
|
+
"Classifier generation must be a non-negative safe integer."
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const classifierDirectory = resolve(
|
|
544
|
+
this.#dataDirectory,
|
|
545
|
+
"classifiers",
|
|
546
|
+
classifierKey(classifierName)
|
|
547
|
+
);
|
|
548
|
+
const generationDirectory = resolve(
|
|
549
|
+
classifierDirectory,
|
|
550
|
+
`generation-${generation}`
|
|
551
|
+
);
|
|
552
|
+
if (!generationDirectory.startsWith(`${classifierDirectory}${sep}`)) {
|
|
553
|
+
throw new NeedleRuntimeError(
|
|
554
|
+
"service_unavailable",
|
|
555
|
+
"Classifier generation path is outside its classifier directory."
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
await rm(generationDirectory, { recursive: true, force: true });
|
|
559
|
+
}
|
|
560
|
+
async clearClassifierArtifactsThroughGeneration(classifierName, maximumGeneration) {
|
|
561
|
+
if (!Number.isSafeInteger(maximumGeneration) || maximumGeneration < 0) {
|
|
562
|
+
throw new NeedleRuntimeError(
|
|
563
|
+
"service_unavailable",
|
|
564
|
+
"Maximum classifier generation must be a non-negative safe integer."
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const classifierDirectory = join(
|
|
568
|
+
this.#dataDirectory,
|
|
569
|
+
"classifiers",
|
|
570
|
+
classifierKey(classifierName)
|
|
571
|
+
);
|
|
572
|
+
let entries;
|
|
573
|
+
try {
|
|
574
|
+
entries = await readdir(classifierDirectory, { withFileTypes: true });
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
await Promise.all(
|
|
582
|
+
entries.map(async (entry) => {
|
|
583
|
+
const match = /^generation-(\d+)$/.exec(entry.name);
|
|
584
|
+
if (!entry.isDirectory() || match === null) return;
|
|
585
|
+
const generation = Number(match[1]);
|
|
586
|
+
if (generation > maximumGeneration) return;
|
|
587
|
+
await rm(join(classifierDirectory, entry.name), {
|
|
588
|
+
recursive: true,
|
|
589
|
+
force: true
|
|
590
|
+
});
|
|
591
|
+
})
|
|
592
|
+
);
|
|
593
|
+
}
|
|
355
594
|
async close() {
|
|
356
595
|
if (this.#closed) return;
|
|
357
596
|
this.#closed = true;
|
|
@@ -515,6 +754,7 @@ var ManagedNeedleRuntime = class {
|
|
|
515
754
|
var NeedleModelProcess = class {
|
|
516
755
|
#process;
|
|
517
756
|
#resultConfig;
|
|
757
|
+
#numberLabels;
|
|
518
758
|
#onBackgroundError;
|
|
519
759
|
#onClose;
|
|
520
760
|
#pending = /* @__PURE__ */ new Map();
|
|
@@ -528,17 +768,18 @@ var NeedleModelProcess = class {
|
|
|
528
768
|
#exited = false;
|
|
529
769
|
#closing = false;
|
|
530
770
|
#stderr = "";
|
|
531
|
-
constructor(process2, resultConfig, onBackgroundError, onClose) {
|
|
771
|
+
constructor(process2, resultConfig, numberLabels, onBackgroundError, onClose) {
|
|
532
772
|
this.#process = process2;
|
|
533
773
|
this.#resultConfig = resultConfig;
|
|
774
|
+
this.#numberLabels = numberLabels;
|
|
534
775
|
this.#onBackgroundError = onBackgroundError;
|
|
535
776
|
this.#onClose = onClose;
|
|
536
|
-
this.#readyPromise = new Promise((
|
|
537
|
-
this.#resolveReady =
|
|
777
|
+
this.#readyPromise = new Promise((resolve2, reject) => {
|
|
778
|
+
this.#resolveReady = resolve2;
|
|
538
779
|
this.#rejectReady = reject;
|
|
539
780
|
});
|
|
540
|
-
this.#exitPromise = new Promise((
|
|
541
|
-
this.#resolveExit =
|
|
781
|
+
this.#exitPromise = new Promise((resolve2) => {
|
|
782
|
+
this.#resolveExit = resolve2;
|
|
542
783
|
});
|
|
543
784
|
this.#listen();
|
|
544
785
|
}
|
|
@@ -556,8 +797,8 @@ var NeedleModelProcess = class {
|
|
|
556
797
|
);
|
|
557
798
|
}
|
|
558
799
|
const id = ++this.#requestId;
|
|
559
|
-
const result = new Promise((
|
|
560
|
-
this.#pending.set(id, { resolve, reject });
|
|
800
|
+
const result = new Promise((resolve2, reject) => {
|
|
801
|
+
this.#pending.set(id, { resolve: resolve2, reject });
|
|
561
802
|
this.#process.stdin.write(
|
|
562
803
|
`${JSON.stringify({ id, type: "classify", input })}
|
|
563
804
|
`,
|
|
@@ -596,10 +837,7 @@ var NeedleModelProcess = class {
|
|
|
596
837
|
await withTimeout(this.#exitPromise, 2e3, "Needle ignored SIGTERM.");
|
|
597
838
|
} catch {
|
|
598
839
|
this.#process.kill("SIGKILL");
|
|
599
|
-
await
|
|
600
|
-
this.#exitPromise,
|
|
601
|
-
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
602
|
-
]);
|
|
840
|
+
await withTimeout(this.#exitPromise, 2e3, "Needle ignored SIGKILL.");
|
|
603
841
|
}
|
|
604
842
|
}
|
|
605
843
|
}
|
|
@@ -684,7 +922,13 @@ var NeedleModelProcess = class {
|
|
|
684
922
|
return;
|
|
685
923
|
}
|
|
686
924
|
try {
|
|
687
|
-
pending.resolve(
|
|
925
|
+
pending.resolve(
|
|
926
|
+
decodeNeedleResult(
|
|
927
|
+
message.result,
|
|
928
|
+
this.#resultConfig,
|
|
929
|
+
this.#numberLabels
|
|
930
|
+
)
|
|
931
|
+
);
|
|
688
932
|
} catch (error) {
|
|
689
933
|
pending.reject(
|
|
690
934
|
new NeedleRuntimeError(
|
|
@@ -710,12 +954,16 @@ var spawnProcess = (command, args, options) => spawn(command, [...args], {
|
|
|
710
954
|
env: options.env,
|
|
711
955
|
stdio: ["pipe", "pipe", "pipe"]
|
|
712
956
|
});
|
|
713
|
-
var runCommand = (command, args, options) => new Promise((
|
|
957
|
+
var runCommand = (command, args, options) => new Promise((resolve2, reject) => {
|
|
714
958
|
const child = spawn(command, [...args], {
|
|
715
959
|
cwd: options?.cwd,
|
|
716
960
|
env: options?.env,
|
|
717
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
961
|
+
stdio: [options?.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
|
|
718
962
|
});
|
|
963
|
+
if (options?.stdin !== void 0 && child.stdin !== null) {
|
|
964
|
+
child.stdin.on("error", () => void 0);
|
|
965
|
+
child.stdin.end(options.stdin, "utf8");
|
|
966
|
+
}
|
|
719
967
|
let stdout = "";
|
|
720
968
|
let stderr = "";
|
|
721
969
|
let timedOut = false;
|
|
@@ -764,7 +1012,7 @@ var runCommand = (command, args, options) => new Promise((resolve, reject) => {
|
|
|
764
1012
|
return;
|
|
765
1013
|
}
|
|
766
1014
|
if (code === 0) {
|
|
767
|
-
|
|
1015
|
+
resolve2({ stdout, stderr });
|
|
768
1016
|
return;
|
|
769
1017
|
}
|
|
770
1018
|
reject(
|
|
@@ -831,7 +1079,7 @@ async function findFile(directory, name) {
|
|
|
831
1079
|
return void 0;
|
|
832
1080
|
}
|
|
833
1081
|
function withTimeout(promise, milliseconds, message, onTimeout) {
|
|
834
|
-
return new Promise((
|
|
1082
|
+
return new Promise((resolve2, reject) => {
|
|
835
1083
|
const timer = setTimeout(() => {
|
|
836
1084
|
onTimeout?.();
|
|
837
1085
|
reject(new NeedleRuntimeError("service_unavailable", message));
|
|
@@ -840,7 +1088,7 @@ function withTimeout(promise, milliseconds, message, onTimeout) {
|
|
|
840
1088
|
promise.then(
|
|
841
1089
|
(value) => {
|
|
842
1090
|
clearTimeout(timer);
|
|
843
|
-
|
|
1091
|
+
resolve2(value);
|
|
844
1092
|
},
|
|
845
1093
|
(error) => {
|
|
846
1094
|
clearTimeout(timer);
|
|
@@ -879,7 +1127,12 @@ var SCHEMA = `
|
|
|
879
1127
|
training_lease_owner TEXT,
|
|
880
1128
|
training_lease_until INTEGER,
|
|
881
1129
|
created_at INTEGER NOT NULL,
|
|
882
|
-
updated_at INTEGER NOT NULL
|
|
1130
|
+
updated_at INTEGER NOT NULL,
|
|
1131
|
+
data_epoch INTEGER NOT NULL DEFAULT 0,
|
|
1132
|
+
clear_pending INTEGER NOT NULL DEFAULT 0,
|
|
1133
|
+
clear_erased INTEGER NOT NULL DEFAULT 0,
|
|
1134
|
+
clear_artifact_generation_max INTEGER,
|
|
1135
|
+
training_lease_epoch INTEGER
|
|
883
1136
|
);
|
|
884
1137
|
|
|
885
1138
|
CREATE TABLE IF NOT EXISTS generations (
|
|
@@ -911,6 +1164,43 @@ var SCHEMA = `
|
|
|
911
1164
|
ON examples(classifier_name, generation, id);
|
|
912
1165
|
CREATE INDEX IF NOT EXISTS examples_by_split
|
|
913
1166
|
ON examples(classifier_name, generation, split, id);
|
|
1167
|
+
|
|
1168
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_insert
|
|
1169
|
+
BEFORE INSERT ON classifiers
|
|
1170
|
+
WHEN swapai_writer_version() < 2
|
|
1171
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1172
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_update
|
|
1173
|
+
BEFORE UPDATE ON classifiers
|
|
1174
|
+
WHEN swapai_writer_version() < 2
|
|
1175
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1176
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_delete
|
|
1177
|
+
BEFORE DELETE ON classifiers
|
|
1178
|
+
WHEN swapai_writer_version() < 2
|
|
1179
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1180
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_insert
|
|
1181
|
+
BEFORE INSERT ON generations
|
|
1182
|
+
WHEN swapai_writer_version() < 2
|
|
1183
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1184
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_update
|
|
1185
|
+
BEFORE UPDATE ON generations
|
|
1186
|
+
WHEN swapai_writer_version() < 2
|
|
1187
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1188
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_delete
|
|
1189
|
+
BEFORE DELETE ON generations
|
|
1190
|
+
WHEN swapai_writer_version() < 2
|
|
1191
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1192
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_insert
|
|
1193
|
+
BEFORE INSERT ON examples
|
|
1194
|
+
WHEN swapai_writer_version() < 2
|
|
1195
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1196
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_update
|
|
1197
|
+
BEFORE UPDATE ON examples
|
|
1198
|
+
WHEN swapai_writer_version() < 2
|
|
1199
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
1200
|
+
CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_delete
|
|
1201
|
+
BEFORE DELETE ON examples
|
|
1202
|
+
WHEN swapai_writer_version() < 2
|
|
1203
|
+
BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
|
|
914
1204
|
`;
|
|
915
1205
|
function assignExampleSplit(name, input) {
|
|
916
1206
|
const hash = createHash2("sha256").update(name).update("\0").update(input).digest();
|
|
@@ -924,40 +1214,85 @@ function openStorage(options) {
|
|
|
924
1214
|
chmodSync(options.dataDirectory, 448);
|
|
925
1215
|
const databasePath = join2(options.dataDirectory, "swapai.sqlite");
|
|
926
1216
|
const database = new DatabaseSync(databasePath);
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
database.
|
|
939
|
-
|
|
940
|
-
|
|
1217
|
+
let artifactLockDatabase = null;
|
|
1218
|
+
try {
|
|
1219
|
+
database.function(
|
|
1220
|
+
"swapai_writer_version",
|
|
1221
|
+
{ deterministic: true },
|
|
1222
|
+
() => 2
|
|
1223
|
+
);
|
|
1224
|
+
chmodSync(databasePath, 384);
|
|
1225
|
+
database.exec("PRAGMA journal_mode = WAL");
|
|
1226
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
1227
|
+
database.exec("PRAGMA secure_delete = ON");
|
|
1228
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
1229
|
+
const secureDelete = requiredRow(
|
|
1230
|
+
database.prepare("PRAGMA secure_delete").get()
|
|
1231
|
+
);
|
|
1232
|
+
if (secureDelete.secure_delete !== 1) {
|
|
1233
|
+
throw new Error("SQLite secure deletion could not be enabled");
|
|
1234
|
+
}
|
|
1235
|
+
database.exec(SCHEMA);
|
|
1236
|
+
migrateClassifierColumns(database);
|
|
1237
|
+
const lockDirectory = join2(options.dataDirectory, "locks");
|
|
1238
|
+
mkdirSync(lockDirectory, { recursive: true, mode: 448 });
|
|
1239
|
+
chmodSync(lockDirectory, 448);
|
|
1240
|
+
const lockPath = join2(
|
|
1241
|
+
lockDirectory,
|
|
1242
|
+
`${createHash2("sha256").update(options.name).digest("hex")}.sqlite`
|
|
1243
|
+
);
|
|
1244
|
+
artifactLockDatabase = new DatabaseSync(lockPath);
|
|
1245
|
+
chmodSync(lockPath, 384);
|
|
1246
|
+
artifactLockDatabase.exec("PRAGMA busy_timeout = 0");
|
|
1247
|
+
artifactLockDatabase.exec(
|
|
1248
|
+
"CREATE TABLE IF NOT EXISTS artifact_lock (id INTEGER PRIMARY KEY)"
|
|
941
1249
|
);
|
|
1250
|
+
const now = Date.now();
|
|
1251
|
+
const configJson = stringifyJson(options.config, "classifier config");
|
|
1252
|
+
const existing = database.prepare(`
|
|
1253
|
+
SELECT config_json FROM classifiers WHERE name = ?
|
|
1254
|
+
`).get(options.name);
|
|
1255
|
+
if (existing !== void 0 && criticalConfigJson(existing.config_json) !== criticalConfigJson(configJson)) {
|
|
1256
|
+
throw new TypeError(
|
|
1257
|
+
`Classifier "${options.name}" already exists with different result or behavior settings`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
transaction(database, () => {
|
|
1261
|
+
database.prepare(`
|
|
1262
|
+
INSERT INTO classifiers (
|
|
1263
|
+
name, config_json, max_training_set, created_at, updated_at
|
|
1264
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1265
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
1266
|
+
config_json = excluded.config_json,
|
|
1267
|
+
max_training_set = excluded.max_training_set,
|
|
1268
|
+
updated_at = excluded.updated_at
|
|
1269
|
+
`).run(options.name, configJson, options.maxTrainingSet, now, now);
|
|
1270
|
+
database.prepare(`
|
|
1271
|
+
INSERT OR IGNORE INTO generations (
|
|
1272
|
+
classifier_name, generation, status, created_at
|
|
1273
|
+
) VALUES (?, 1, 'active', ?)
|
|
1274
|
+
`).run(options.name, now);
|
|
1275
|
+
trimExamples(
|
|
1276
|
+
database,
|
|
1277
|
+
options.name,
|
|
1278
|
+
activeGeneration(database, options.name),
|
|
1279
|
+
options.maxTrainingSet
|
|
1280
|
+
);
|
|
1281
|
+
});
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
try {
|
|
1284
|
+
artifactLockDatabase?.close();
|
|
1285
|
+
} catch {
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
database.close();
|
|
1289
|
+
} catch {
|
|
1290
|
+
}
|
|
1291
|
+
throw error;
|
|
942
1292
|
}
|
|
943
|
-
|
|
944
|
-
database.prepare(`
|
|
945
|
-
INSERT INTO classifiers (
|
|
946
|
-
name, config_json, max_training_set, created_at, updated_at
|
|
947
|
-
) VALUES (?, ?, ?, ?, ?)
|
|
948
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
949
|
-
config_json = excluded.config_json,
|
|
950
|
-
max_training_set = excluded.max_training_set,
|
|
951
|
-
updated_at = excluded.updated_at
|
|
952
|
-
`).run(options.name, configJson, options.maxTrainingSet, now, now);
|
|
953
|
-
database.prepare(`
|
|
954
|
-
INSERT OR IGNORE INTO generations (
|
|
955
|
-
classifier_name, generation, status, created_at
|
|
956
|
-
) VALUES (?, 1, 'active', ?)
|
|
957
|
-
`).run(options.name, now);
|
|
958
|
-
trimExamples(database, options.name, activeGeneration(database, options.name), options.maxTrainingSet);
|
|
959
|
-
});
|
|
1293
|
+
const initializedArtifactLockDatabase = artifactLockDatabase;
|
|
960
1294
|
let closed = false;
|
|
1295
|
+
let artifactLockHeld = false;
|
|
961
1296
|
const storage = {
|
|
962
1297
|
databasePath,
|
|
963
1298
|
snapshot() {
|
|
@@ -978,6 +1313,13 @@ function openStorage(options) {
|
|
|
978
1313
|
g.trained,
|
|
979
1314
|
g.model_path,
|
|
980
1315
|
g.needle_version,
|
|
1316
|
+
c.data_epoch,
|
|
1317
|
+
c.clear_pending,
|
|
1318
|
+
c.clear_erased,
|
|
1319
|
+
c.clear_artifact_generation_max,
|
|
1320
|
+
c.training_lease_owner,
|
|
1321
|
+
c.training_lease_epoch,
|
|
1322
|
+
c.training_lease_until,
|
|
981
1323
|
(
|
|
982
1324
|
SELECT COUNT(*)
|
|
983
1325
|
FROM examples e
|
|
@@ -1005,23 +1347,36 @@ function openStorage(options) {
|
|
|
1005
1347
|
consecutiveRetestFailures: row2.consecutive_retest_failures,
|
|
1006
1348
|
trained: row2.trained === 1,
|
|
1007
1349
|
modelPath: row2.model_path,
|
|
1008
|
-
needleVersion: row2.needle_version
|
|
1350
|
+
needleVersion: row2.needle_version,
|
|
1351
|
+
dataEpoch: row2.data_epoch,
|
|
1352
|
+
clearPending: row2.clear_pending === 1,
|
|
1353
|
+
clearErased: row2.clear_erased === 1,
|
|
1354
|
+
clearArtifactGenerationMax: row2.clear_artifact_generation_max,
|
|
1355
|
+
trainingLeaseOwner: row2.training_lease_owner,
|
|
1356
|
+
trainingLeaseEpoch: row2.training_lease_epoch,
|
|
1357
|
+
trainingLeaseUntil: row2.training_lease_until
|
|
1009
1358
|
};
|
|
1010
1359
|
},
|
|
1011
|
-
addExample(input, result) {
|
|
1360
|
+
addExample(input, result, expectedDataEpoch) {
|
|
1012
1361
|
assertOpen(closed);
|
|
1013
|
-
const generation = activeGeneration(database, options.name);
|
|
1014
1362
|
const split = assignExampleSplit(options.name, input);
|
|
1015
1363
|
const createdAt = Date.now();
|
|
1016
1364
|
const resultJson = stringifyJson(result, "classification result");
|
|
1017
1365
|
let id = 0;
|
|
1366
|
+
let generation = 0;
|
|
1367
|
+
let accepted = false;
|
|
1018
1368
|
transaction(database, () => {
|
|
1369
|
+
const state = classifierState(database, options.name);
|
|
1370
|
+
const epoch = expectedDataEpoch ?? state.data_epoch;
|
|
1371
|
+
if (state.data_epoch !== epoch || state.clear_pending === 1) return;
|
|
1372
|
+
generation = state.active_generation;
|
|
1019
1373
|
const insertion = database.prepare(`
|
|
1020
1374
|
INSERT INTO examples (
|
|
1021
1375
|
classifier_name, generation, input, result_json, split, created_at
|
|
1022
1376
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1023
1377
|
`).run(options.name, generation, input, resultJson, split, createdAt);
|
|
1024
1378
|
id = Number(insertion.lastInsertRowid);
|
|
1379
|
+
accepted = true;
|
|
1025
1380
|
database.prepare(`
|
|
1026
1381
|
UPDATE classifiers
|
|
1027
1382
|
SET total_examples_logged = total_examples_logged + 1,
|
|
@@ -1031,7 +1386,7 @@ function openStorage(options) {
|
|
|
1031
1386
|
`).run(createdAt, options.name);
|
|
1032
1387
|
trimExamples(database, options.name, generation, options.maxTrainingSet);
|
|
1033
1388
|
});
|
|
1034
|
-
return { id, generation, input, result, split, createdAt };
|
|
1389
|
+
return accepted ? { id, generation, input, result, split, createdAt } : null;
|
|
1035
1390
|
},
|
|
1036
1391
|
listExamples(split, generation) {
|
|
1037
1392
|
assertOpen(closed);
|
|
@@ -1049,44 +1404,76 @@ function openStorage(options) {
|
|
|
1049
1404
|
`).all(options.name, selectedGeneration, split);
|
|
1050
1405
|
return rows.map((value) => mapExample(row(value)));
|
|
1051
1406
|
},
|
|
1052
|
-
|
|
1407
|
+
listExamplesForTraining(split, generation, expectedDataEpoch) {
|
|
1408
|
+
assertOpen(closed);
|
|
1409
|
+
let examples = null;
|
|
1410
|
+
transaction(database, () => {
|
|
1411
|
+
const state = classifierState(database, options.name);
|
|
1412
|
+
if (state.data_epoch !== expectedDataEpoch || state.clear_pending === 1 || state.active_generation !== generation) {
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
examples = database.prepare(`
|
|
1416
|
+
SELECT id, generation, input, result_json, split, created_at
|
|
1417
|
+
FROM examples
|
|
1418
|
+
WHERE classifier_name = ? AND generation = ? AND split = ?
|
|
1419
|
+
ORDER BY id
|
|
1420
|
+
`).all(options.name, generation, split).map(
|
|
1421
|
+
(value) => mapExample(row(value))
|
|
1422
|
+
);
|
|
1423
|
+
});
|
|
1424
|
+
return examples;
|
|
1425
|
+
},
|
|
1426
|
+
markTrainingAttempted(exampleCount, expectedDataEpoch) {
|
|
1053
1427
|
assertOpen(closed);
|
|
1054
1428
|
if (!Number.isSafeInteger(exampleCount) || exampleCount <= 0) {
|
|
1055
1429
|
throw new TypeError("training example count must be a positive integer");
|
|
1056
1430
|
}
|
|
1057
|
-
|
|
1431
|
+
const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
|
|
1432
|
+
const result = database.prepare(`
|
|
1058
1433
|
UPDATE classifiers
|
|
1059
1434
|
SET new_examples_since_training = 0,
|
|
1060
1435
|
training_attempts = training_attempts + 1,
|
|
1061
1436
|
examples_used_for_training = ?,
|
|
1062
1437
|
updated_at = ?
|
|
1063
|
-
WHERE name = ?
|
|
1064
|
-
`).run(exampleCount, Date.now(), options.name);
|
|
1438
|
+
WHERE name = ? AND data_epoch = ? AND clear_pending = 0
|
|
1439
|
+
`).run(exampleCount, Date.now(), options.name, epoch);
|
|
1440
|
+
return result.changes === 1;
|
|
1065
1441
|
},
|
|
1066
|
-
promoteGeneration(model) {
|
|
1442
|
+
promoteGeneration(model, expectedDataEpoch) {
|
|
1067
1443
|
assertOpen(closed);
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1444
|
+
let generation = 0;
|
|
1445
|
+
let promoted = false;
|
|
1446
|
+
transaction(database, () => {
|
|
1447
|
+
const state = classifierState(database, options.name);
|
|
1448
|
+
const epoch = expectedDataEpoch ?? state.data_epoch;
|
|
1449
|
+
if (state.data_epoch !== epoch || state.clear_pending === 1) return;
|
|
1450
|
+
generation = state.active_generation;
|
|
1451
|
+
database.prepare(`
|
|
1452
|
+
UPDATE generations
|
|
1453
|
+
SET trained = 1, model_path = ?, needle_version = ?
|
|
1454
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1455
|
+
`).run(model.modelPath, model.needleVersion, options.name, generation);
|
|
1456
|
+
promoted = true;
|
|
1457
|
+
});
|
|
1458
|
+
return promoted ? generationByNumber(database, options.name, generation) : null;
|
|
1075
1459
|
},
|
|
1076
|
-
recordLocalClassification() {
|
|
1460
|
+
recordLocalClassification(expectedDataEpoch) {
|
|
1077
1461
|
assertOpen(closed);
|
|
1078
|
-
|
|
1462
|
+
const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
|
|
1463
|
+
const result = database.prepare(`
|
|
1079
1464
|
UPDATE classifiers
|
|
1080
1465
|
SET local_classifications_since_retest = local_classifications_since_retest + 1,
|
|
1081
1466
|
total_local_classifications = total_local_classifications + 1,
|
|
1082
1467
|
updated_at = ?
|
|
1083
|
-
WHERE name = ?
|
|
1084
|
-
`).run(Date.now(), options.name);
|
|
1468
|
+
WHERE name = ? AND data_epoch = ? AND clear_pending = 0
|
|
1469
|
+
`).run(Date.now(), options.name, epoch);
|
|
1470
|
+
if (result.changes !== 1) return null;
|
|
1085
1471
|
return storage.snapshot().localClassificationsSinceRetest;
|
|
1086
1472
|
},
|
|
1087
|
-
recordRetest(passed) {
|
|
1473
|
+
recordRetest(passed, expectedDataEpoch) {
|
|
1088
1474
|
assertOpen(closed);
|
|
1089
|
-
|
|
1475
|
+
const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
|
|
1476
|
+
const result = database.prepare(`
|
|
1090
1477
|
UPDATE classifiers
|
|
1091
1478
|
SET local_classifications_since_retest = 0,
|
|
1092
1479
|
total_retests = total_retests + 1,
|
|
@@ -1095,28 +1482,33 @@ function openStorage(options) {
|
|
|
1095
1482
|
ELSE consecutive_retest_failures + 1
|
|
1096
1483
|
END,
|
|
1097
1484
|
updated_at = ?
|
|
1098
|
-
WHERE name = ?
|
|
1099
|
-
`).run(passed ? 1 : 0, Date.now(), options.name);
|
|
1485
|
+
WHERE name = ? AND data_epoch = ? AND clear_pending = 0
|
|
1486
|
+
`).run(passed ? 1 : 0, Date.now(), options.name, epoch);
|
|
1487
|
+
if (result.changes !== 1) return null;
|
|
1100
1488
|
return storage.snapshot().consecutiveRetestFailures;
|
|
1101
1489
|
},
|
|
1102
|
-
claimTrainingLease(owner, durationMs) {
|
|
1490
|
+
claimTrainingLease(owner, durationMs, expectedDataEpoch) {
|
|
1103
1491
|
assertOpen(closed);
|
|
1104
1492
|
if (owner.trim() === "" || !Number.isSafeInteger(durationMs) || durationMs <= 0) {
|
|
1105
1493
|
throw new TypeError("training lease needs an owner and positive duration");
|
|
1106
1494
|
}
|
|
1107
|
-
const
|
|
1495
|
+
const now = Date.now();
|
|
1496
|
+
const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
|
|
1108
1497
|
const result = database.prepare(`
|
|
1109
1498
|
UPDATE classifiers
|
|
1110
1499
|
SET training_lease_owner = ?,
|
|
1111
1500
|
training_lease_until = ?,
|
|
1501
|
+
training_lease_epoch = ?,
|
|
1112
1502
|
updated_at = ?
|
|
1113
1503
|
WHERE name = ?
|
|
1504
|
+
AND data_epoch = ?
|
|
1505
|
+
AND clear_pending = 0
|
|
1114
1506
|
AND (
|
|
1115
1507
|
training_lease_owner IS NULL
|
|
1116
1508
|
OR training_lease_until <= ?
|
|
1117
1509
|
OR training_lease_owner = ?
|
|
1118
1510
|
)
|
|
1119
|
-
`).run(owner,
|
|
1511
|
+
`).run(owner, now + durationMs, epoch, now, options.name, epoch, now, owner);
|
|
1120
1512
|
return result.changes === 1;
|
|
1121
1513
|
},
|
|
1122
1514
|
releaseTrainingLease(owner) {
|
|
@@ -1125,16 +1517,21 @@ function openStorage(options) {
|
|
|
1125
1517
|
UPDATE classifiers
|
|
1126
1518
|
SET training_lease_owner = NULL,
|
|
1127
1519
|
training_lease_until = NULL,
|
|
1520
|
+
training_lease_epoch = NULL,
|
|
1128
1521
|
updated_at = ?
|
|
1129
1522
|
WHERE name = ? AND training_lease_owner = ?
|
|
1130
1523
|
`).run(Date.now(), options.name, owner);
|
|
1131
1524
|
},
|
|
1132
|
-
archiveAndReset() {
|
|
1525
|
+
archiveAndReset(expectedDataEpoch, resetOptions) {
|
|
1133
1526
|
assertOpen(closed);
|
|
1134
1527
|
let nextGeneration = 0;
|
|
1135
1528
|
const changedAt = Date.now();
|
|
1529
|
+
let reset = false;
|
|
1136
1530
|
transaction(database, () => {
|
|
1137
|
-
const
|
|
1531
|
+
const state = classifierState(database, options.name);
|
|
1532
|
+
const epoch = expectedDataEpoch ?? state.data_epoch;
|
|
1533
|
+
if (state.data_epoch !== epoch || state.clear_pending === 1) return;
|
|
1534
|
+
const currentGeneration = state.active_generation;
|
|
1138
1535
|
database.prepare(`
|
|
1139
1536
|
UPDATE generations
|
|
1140
1537
|
SET status = 'archived', archived_at = ?
|
|
@@ -1151,22 +1548,157 @@ function openStorage(options) {
|
|
|
1151
1548
|
classifier_name, generation, status, created_at
|
|
1152
1549
|
) VALUES (?, ?, 'active', ?)
|
|
1153
1550
|
`).run(options.name, nextGeneration, changedAt);
|
|
1551
|
+
let retainedExampleCount = 0;
|
|
1552
|
+
if (resetOptions?.retainExamples) {
|
|
1553
|
+
retainedExampleCount = Number(database.prepare(`
|
|
1554
|
+
UPDATE examples
|
|
1555
|
+
SET generation = ?
|
|
1556
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1557
|
+
`).run(nextGeneration, options.name, currentGeneration).changes);
|
|
1558
|
+
}
|
|
1154
1559
|
database.prepare(`
|
|
1155
1560
|
UPDATE classifiers
|
|
1156
1561
|
SET active_generation = ?,
|
|
1157
|
-
|
|
1562
|
+
data_epoch = data_epoch + 1,
|
|
1563
|
+
new_examples_since_training = ?,
|
|
1158
1564
|
training_attempts = 0,
|
|
1159
1565
|
examples_used_for_training = 0,
|
|
1160
1566
|
training_lease_owner = NULL,
|
|
1161
1567
|
training_lease_until = NULL,
|
|
1568
|
+
training_lease_epoch = NULL,
|
|
1162
1569
|
local_classifications_since_retest = 0,
|
|
1163
1570
|
consecutive_retest_failures = 0,
|
|
1164
1571
|
updated_at = ?
|
|
1165
1572
|
WHERE name = ?
|
|
1573
|
+
`).run(
|
|
1574
|
+
nextGeneration,
|
|
1575
|
+
retainedExampleCount,
|
|
1576
|
+
changedAt,
|
|
1577
|
+
options.name
|
|
1578
|
+
);
|
|
1579
|
+
reset = true;
|
|
1580
|
+
});
|
|
1581
|
+
return reset ? generationByNumber(database, options.name, nextGeneration) : null;
|
|
1582
|
+
},
|
|
1583
|
+
beginClearTrainingData() {
|
|
1584
|
+
assertOpen(closed);
|
|
1585
|
+
database.prepare(`
|
|
1586
|
+
UPDATE classifiers
|
|
1587
|
+
SET data_epoch = data_epoch + 1,
|
|
1588
|
+
clear_pending = 1,
|
|
1589
|
+
clear_erased = 0,
|
|
1590
|
+
clear_artifact_generation_max = (
|
|
1591
|
+
SELECT MAX(generation)
|
|
1592
|
+
FROM generations
|
|
1593
|
+
WHERE classifier_name = ?
|
|
1594
|
+
),
|
|
1595
|
+
updated_at = ?
|
|
1596
|
+
WHERE name = ?
|
|
1597
|
+
`).run(options.name, Date.now(), options.name);
|
|
1598
|
+
return classifierState(database, options.name).data_epoch;
|
|
1599
|
+
},
|
|
1600
|
+
eraseTrainingData(dataEpoch) {
|
|
1601
|
+
assertOpen(closed);
|
|
1602
|
+
let nextGeneration = 0;
|
|
1603
|
+
const changedAt = Date.now();
|
|
1604
|
+
let erased = false;
|
|
1605
|
+
transaction(database, () => {
|
|
1606
|
+
const state = classifierState(database, options.name);
|
|
1607
|
+
if (state.data_epoch !== dataEpoch || state.clear_pending !== 1) return;
|
|
1608
|
+
if (state.clear_erased === 1) return;
|
|
1609
|
+
const maximum = requiredRow(database.prepare(`
|
|
1610
|
+
SELECT MAX(generation) AS maximum
|
|
1611
|
+
FROM generations
|
|
1612
|
+
WHERE classifier_name = ?
|
|
1613
|
+
`).get(options.name));
|
|
1614
|
+
nextGeneration = maximum.maximum + 1;
|
|
1615
|
+
database.prepare(`
|
|
1616
|
+
DELETE FROM examples WHERE classifier_name = ?
|
|
1617
|
+
`).run(options.name);
|
|
1618
|
+
database.prepare(`
|
|
1619
|
+
DELETE FROM generations WHERE classifier_name = ?
|
|
1620
|
+
`).run(options.name);
|
|
1621
|
+
database.prepare(`
|
|
1622
|
+
INSERT INTO generations (
|
|
1623
|
+
classifier_name, generation, status, created_at
|
|
1624
|
+
) VALUES (?, ?, 'active', ?)
|
|
1625
|
+
`).run(options.name, nextGeneration, changedAt);
|
|
1626
|
+
database.prepare(`
|
|
1627
|
+
UPDATE classifiers
|
|
1628
|
+
SET active_generation = ?,
|
|
1629
|
+
total_examples_logged = 0,
|
|
1630
|
+
new_examples_since_training = 0,
|
|
1631
|
+
training_attempts = 0,
|
|
1632
|
+
examples_used_for_training = 0,
|
|
1633
|
+
local_classifications_since_retest = 0,
|
|
1634
|
+
total_local_classifications = 0,
|
|
1635
|
+
total_retests = 0,
|
|
1636
|
+
consecutive_retest_failures = 0,
|
|
1637
|
+
training_lease_owner = NULL,
|
|
1638
|
+
training_lease_until = NULL,
|
|
1639
|
+
training_lease_epoch = NULL,
|
|
1640
|
+
clear_erased = 1,
|
|
1641
|
+
updated_at = ?
|
|
1642
|
+
WHERE name = ?
|
|
1166
1643
|
`).run(nextGeneration, changedAt, options.name);
|
|
1644
|
+
erased = true;
|
|
1167
1645
|
});
|
|
1646
|
+
if (!erased) return null;
|
|
1647
|
+
truncateWriteAheadLog(database);
|
|
1168
1648
|
return generationByNumber(database, options.name, nextGeneration);
|
|
1169
1649
|
},
|
|
1650
|
+
finishClearTrainingData(dataEpoch) {
|
|
1651
|
+
assertOpen(closed);
|
|
1652
|
+
const state = classifierState(database, options.name);
|
|
1653
|
+
if (state.data_epoch !== dataEpoch || state.clear_pending !== 1 || state.clear_erased !== 1) {
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
truncateWriteAheadLog(database);
|
|
1657
|
+
const result = database.prepare(`
|
|
1658
|
+
UPDATE classifiers
|
|
1659
|
+
SET clear_pending = 0, updated_at = ?
|
|
1660
|
+
WHERE name = ?
|
|
1661
|
+
AND data_epoch = ?
|
|
1662
|
+
AND clear_pending = 1
|
|
1663
|
+
AND clear_erased = 1
|
|
1664
|
+
`).run(Date.now(), options.name, dataEpoch);
|
|
1665
|
+
return result.changes === 1;
|
|
1666
|
+
},
|
|
1667
|
+
clearTrainingData() {
|
|
1668
|
+
const dataEpoch = storage.beginClearTrainingData();
|
|
1669
|
+
if (!storage.claimArtifactWriteLock()) {
|
|
1670
|
+
throw new Error("Classifier artifacts are currently being written");
|
|
1671
|
+
}
|
|
1672
|
+
try {
|
|
1673
|
+
const generation = storage.eraseTrainingData(dataEpoch);
|
|
1674
|
+
if (generation === null || !storage.finishClearTrainingData(dataEpoch)) {
|
|
1675
|
+
throw new Error("Training data clear was superseded");
|
|
1676
|
+
}
|
|
1677
|
+
return generation;
|
|
1678
|
+
} finally {
|
|
1679
|
+
storage.releaseArtifactWriteLock();
|
|
1680
|
+
}
|
|
1681
|
+
},
|
|
1682
|
+
claimArtifactWriteLock() {
|
|
1683
|
+
assertOpen(closed);
|
|
1684
|
+
if (artifactLockHeld) return true;
|
|
1685
|
+
try {
|
|
1686
|
+
initializedArtifactLockDatabase.exec("BEGIN IMMEDIATE");
|
|
1687
|
+
artifactLockHeld = true;
|
|
1688
|
+
return true;
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ERR_SQLITE_ERROR" && "message" in error && typeof error.message === "string" && /locked|busy/i.test(error.message)) {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
throw error;
|
|
1694
|
+
}
|
|
1695
|
+
},
|
|
1696
|
+
releaseArtifactWriteLock() {
|
|
1697
|
+
assertOpen(closed);
|
|
1698
|
+
if (!artifactLockHeld) return;
|
|
1699
|
+
initializedArtifactLockDatabase.exec("COMMIT");
|
|
1700
|
+
artifactLockHeld = false;
|
|
1701
|
+
},
|
|
1170
1702
|
listGenerations() {
|
|
1171
1703
|
assertOpen(closed);
|
|
1172
1704
|
return database.prepare(`
|
|
@@ -1182,6 +1714,11 @@ function openStorage(options) {
|
|
|
1182
1714
|
if (closed) {
|
|
1183
1715
|
return;
|
|
1184
1716
|
}
|
|
1717
|
+
if (artifactLockHeld) {
|
|
1718
|
+
initializedArtifactLockDatabase.exec("ROLLBACK");
|
|
1719
|
+
artifactLockHeld = false;
|
|
1720
|
+
}
|
|
1721
|
+
initializedArtifactLockDatabase.close();
|
|
1185
1722
|
closed = true;
|
|
1186
1723
|
database.close();
|
|
1187
1724
|
}
|
|
@@ -1194,6 +1731,69 @@ function activeGeneration(database, name) {
|
|
|
1194
1731
|
`).get(name));
|
|
1195
1732
|
return active.active_generation;
|
|
1196
1733
|
}
|
|
1734
|
+
function truncateWriteAheadLog(database) {
|
|
1735
|
+
database.exec("PRAGMA busy_timeout = 0");
|
|
1736
|
+
let checkpoint;
|
|
1737
|
+
try {
|
|
1738
|
+
checkpoint = requiredRow(database.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get());
|
|
1739
|
+
} finally {
|
|
1740
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
1741
|
+
}
|
|
1742
|
+
if (checkpoint.busy !== 0) {
|
|
1743
|
+
throw new Error(
|
|
1744
|
+
"SQLite WAL could not be truncated after clearing training data"
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
function classifierState(database, name) {
|
|
1749
|
+
return requiredRow(database.prepare(`
|
|
1750
|
+
SELECT
|
|
1751
|
+
active_generation,
|
|
1752
|
+
data_epoch,
|
|
1753
|
+
clear_pending,
|
|
1754
|
+
clear_erased,
|
|
1755
|
+
clear_artifact_generation_max,
|
|
1756
|
+
training_lease_owner,
|
|
1757
|
+
training_lease_epoch,
|
|
1758
|
+
training_lease_until
|
|
1759
|
+
FROM classifiers
|
|
1760
|
+
WHERE name = ?
|
|
1761
|
+
`).get(name));
|
|
1762
|
+
}
|
|
1763
|
+
function migrateClassifierColumns(database) {
|
|
1764
|
+
transaction(database, () => {
|
|
1765
|
+
const columns = new Set(
|
|
1766
|
+
database.prepare("PRAGMA table_info(classifiers)").all().map(
|
|
1767
|
+
(value) => row(value).name
|
|
1768
|
+
)
|
|
1769
|
+
);
|
|
1770
|
+
if (!columns.has("data_epoch")) {
|
|
1771
|
+
database.exec(
|
|
1772
|
+
"ALTER TABLE classifiers ADD COLUMN data_epoch INTEGER NOT NULL DEFAULT 0"
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
if (!columns.has("clear_pending")) {
|
|
1776
|
+
database.exec(
|
|
1777
|
+
"ALTER TABLE classifiers ADD COLUMN clear_pending INTEGER NOT NULL DEFAULT 0"
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
if (!columns.has("clear_erased")) {
|
|
1781
|
+
database.exec(
|
|
1782
|
+
"ALTER TABLE classifiers ADD COLUMN clear_erased INTEGER NOT NULL DEFAULT 0"
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
if (!columns.has("clear_artifact_generation_max")) {
|
|
1786
|
+
database.exec(
|
|
1787
|
+
"ALTER TABLE classifiers ADD COLUMN clear_artifact_generation_max INTEGER"
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
if (!columns.has("training_lease_epoch")) {
|
|
1791
|
+
database.exec(
|
|
1792
|
+
"ALTER TABLE classifiers ADD COLUMN training_lease_epoch INTEGER"
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1197
1797
|
function generationByNumber(database, name, generation) {
|
|
1198
1798
|
const stored = requiredRow(database.prepare(`
|
|
1199
1799
|
SELECT
|
|
@@ -1325,6 +1925,7 @@ function requiredRow(value) {
|
|
|
1325
1925
|
// src/classifier.ts
|
|
1326
1926
|
var TRAINING_LEASE_DURATION_MS = 5 * 60 * 1e3;
|
|
1327
1927
|
var TRAINING_LEASE_RENEWAL_MS = 60 * 1e3;
|
|
1928
|
+
var ARTIFACT_LOCK_WAIT_MS = 3e4;
|
|
1328
1929
|
function init(inputConfig) {
|
|
1329
1930
|
const config = normalizeConfig(inputConfig);
|
|
1330
1931
|
const dataDirectory = config.dataDirectory ?? join3(process.cwd(), ".swapai");
|
|
@@ -1362,44 +1963,123 @@ function openClassifierStorage(config, dataDirectory) {
|
|
|
1362
1963
|
}
|
|
1363
1964
|
}
|
|
1364
1965
|
function createClassifier(config, storage, runtime) {
|
|
1966
|
+
const initialState = storage.snapshot();
|
|
1365
1967
|
let closed = false;
|
|
1366
|
-
let
|
|
1968
|
+
let closing = false;
|
|
1969
|
+
let closePromise = null;
|
|
1970
|
+
let trained = initialState.trained && !initialState.clearPending;
|
|
1367
1971
|
let loadedModel = null;
|
|
1972
|
+
let loadedModelEpoch = null;
|
|
1368
1973
|
let operationQueue = Promise.resolve();
|
|
1369
1974
|
let trainingQueue = Promise.resolve();
|
|
1370
1975
|
let trainingScheduled = false;
|
|
1371
1976
|
const trainingLeaseOwner = `${process.pid}:${randomUUID2()}`;
|
|
1372
1977
|
let classificationQueue = Promise.resolve();
|
|
1373
1978
|
let queuedFailure = null;
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1979
|
+
let clearFailure = null;
|
|
1980
|
+
let dataEpoch = initialState.dataEpoch;
|
|
1981
|
+
let clearedEpoch = initialState.clearPending ? initialState.dataEpoch - 1 : initialState.dataEpoch;
|
|
1982
|
+
const saved = initialState;
|
|
1983
|
+
if (!saved.clearPending && saved.trained && (saved.modelPath === null || saved.needleVersion !== needleModelVersion(config.result) || !hasNeedleModelArtifacts({
|
|
1984
|
+
modelPath: saved.modelPath,
|
|
1985
|
+
resultConfig: config.result
|
|
1986
|
+
}))) {
|
|
1987
|
+
if (storage.archiveAndReset(saved.dataEpoch, { retainExamples: true }) !== null) {
|
|
1988
|
+
const reset = storage.snapshot();
|
|
1989
|
+
dataEpoch = reset.dataEpoch;
|
|
1990
|
+
clearedEpoch = reset.dataEpoch;
|
|
1991
|
+
trained = false;
|
|
1992
|
+
}
|
|
1378
1993
|
}
|
|
1379
1994
|
const startup = startRuntime();
|
|
1380
1995
|
async function startRuntime() {
|
|
1996
|
+
const startupEpoch = dataEpoch;
|
|
1997
|
+
const beforeRuntime = storage.snapshot();
|
|
1998
|
+
if (beforeRuntime.clearPending) {
|
|
1999
|
+
trained = false;
|
|
2000
|
+
dataEpoch = beforeRuntime.dataEpoch;
|
|
2001
|
+
try {
|
|
2002
|
+
await performDurableClear(beforeRuntime.dataEpoch);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
rememberClearFailure(error);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1381
2007
|
try {
|
|
1382
2008
|
await runtime.ready();
|
|
1383
|
-
const snapshot = storage.snapshot();
|
|
1384
|
-
if (snapshot.trained && snapshot.modelPath !== null) {
|
|
1385
|
-
loadedModel = await runtime.loadModel({
|
|
1386
|
-
modelPath: snapshot.modelPath,
|
|
1387
|
-
resultConfig: config.result
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
2009
|
} catch (error) {
|
|
1391
|
-
|
|
2010
|
+
rememberBackgroundFailure(toSwapAIError(
|
|
1392
2011
|
error,
|
|
1393
2012
|
"service_unavailable",
|
|
1394
2013
|
"Needle could not start"
|
|
1395
|
-
);
|
|
1396
|
-
|
|
2014
|
+
));
|
|
2015
|
+
return;
|
|
1397
2016
|
}
|
|
2017
|
+
const snapshot = storage.snapshot();
|
|
2018
|
+
if (snapshot.clearPending) {
|
|
2019
|
+
trained = false;
|
|
2020
|
+
dataEpoch = snapshot.dataEpoch;
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (snapshot.trained && snapshot.modelPath !== null) {
|
|
2024
|
+
try {
|
|
2025
|
+
const restoredModel = await runtime.loadModel({
|
|
2026
|
+
modelPath: snapshot.modelPath,
|
|
2027
|
+
resultConfig: config.result
|
|
2028
|
+
});
|
|
2029
|
+
const current = storage.snapshot();
|
|
2030
|
+
if (startupEpoch === current.dataEpoch && !current.clearPending && current.trained) {
|
|
2031
|
+
loadedModel = restoredModel;
|
|
2032
|
+
loadedModelEpoch = startupEpoch;
|
|
2033
|
+
trained = true;
|
|
2034
|
+
} else {
|
|
2035
|
+
await restoredModel.close();
|
|
2036
|
+
}
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
if (error instanceof NeedleModelArtifactError && storage.archiveAndReset(snapshot.dataEpoch, {
|
|
2039
|
+
retainExamples: true
|
|
2040
|
+
}) !== null) {
|
|
2041
|
+
const reset = refreshStoredState();
|
|
2042
|
+
dataEpoch = reset.dataEpoch;
|
|
2043
|
+
clearedEpoch = reset.dataEpoch;
|
|
2044
|
+
trained = false;
|
|
2045
|
+
}
|
|
2046
|
+
const loadError = toSwapAIError(
|
|
2047
|
+
error,
|
|
2048
|
+
"service_unavailable",
|
|
2049
|
+
error instanceof NeedleModelArtifactError ? `Could not load classifier "${config.name}"; retraining from saved examples` : `Could not load classifier "${config.name}"`
|
|
2050
|
+
);
|
|
2051
|
+
if (error instanceof NeedleModelArtifactError) {
|
|
2052
|
+
reportBackgroundError(config, loadError);
|
|
2053
|
+
} else {
|
|
2054
|
+
rememberBackgroundFailure(loadError);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
scheduleTraining();
|
|
1398
2059
|
}
|
|
1399
2060
|
function rememberBackgroundFailure(error) {
|
|
1400
2061
|
queuedFailure = error;
|
|
1401
2062
|
reportBackgroundError(config, error);
|
|
1402
2063
|
}
|
|
2064
|
+
function rememberClearFailure(error) {
|
|
2065
|
+
clearFailure = toSwapAIError(
|
|
2066
|
+
error,
|
|
2067
|
+
"storage_failed",
|
|
2068
|
+
`Could not completely clear classifier "${config.name}"`
|
|
2069
|
+
);
|
|
2070
|
+
reportBackgroundError(config, clearFailure);
|
|
2071
|
+
}
|
|
2072
|
+
function refreshStoredState() {
|
|
2073
|
+
const snapshot = storage.snapshot();
|
|
2074
|
+
dataEpoch = snapshot.dataEpoch;
|
|
2075
|
+
if (snapshot.clearPending) {
|
|
2076
|
+
trained = false;
|
|
2077
|
+
} else {
|
|
2078
|
+
trained = snapshot.trained;
|
|
2079
|
+
clearedEpoch = snapshot.dataEpoch;
|
|
2080
|
+
}
|
|
2081
|
+
return snapshot;
|
|
2082
|
+
}
|
|
1403
2083
|
function enqueue(operation) {
|
|
1404
2084
|
const result = operationQueue.then(operation);
|
|
1405
2085
|
operationQueue = result.catch((error) => {
|
|
@@ -1409,14 +2089,15 @@ function createClassifier(config, storage, runtime) {
|
|
|
1409
2089
|
});
|
|
1410
2090
|
return result;
|
|
1411
2091
|
}
|
|
1412
|
-
function persistExample(input, result) {
|
|
2092
|
+
function persistExample(input, result, epoch = dataEpoch) {
|
|
2093
|
+
if (epoch !== dataEpoch) return Promise.resolve();
|
|
1413
2094
|
return enqueue(() => {
|
|
1414
|
-
|
|
1415
|
-
scheduleTraining();
|
|
2095
|
+
if (epoch !== dataEpoch) return;
|
|
2096
|
+
if (storage.addExample(input, result, epoch) !== null) scheduleTraining();
|
|
1416
2097
|
});
|
|
1417
2098
|
}
|
|
1418
2099
|
function shouldTrain(snapshot = storage.snapshot()) {
|
|
1419
|
-
return !snapshot.trained && snapshot.examplesUsedForTraining < config.maxTrainingSet && snapshot.newExamplesSinceTraining >= config.retrainOnCount;
|
|
2100
|
+
return clearedEpoch === dataEpoch && snapshot.dataEpoch === dataEpoch && !snapshot.clearPending && !snapshot.trained && snapshot.examplesUsedForTraining < config.maxTrainingSet && snapshot.newExamplesSinceTraining >= config.retrainOnCount;
|
|
1420
2101
|
}
|
|
1421
2102
|
function scheduleTraining() {
|
|
1422
2103
|
if (trainingScheduled || !shouldTrain()) return;
|
|
@@ -1435,8 +2116,13 @@ function createClassifier(config, storage, runtime) {
|
|
|
1435
2116
|
});
|
|
1436
2117
|
}
|
|
1437
2118
|
async function trainWhenDue() {
|
|
2119
|
+
const trainingEpoch = dataEpoch;
|
|
1438
2120
|
if (!shouldTrain()) return false;
|
|
1439
|
-
if (!storage.claimTrainingLease(
|
|
2121
|
+
if (!storage.claimTrainingLease(
|
|
2122
|
+
trainingLeaseOwner,
|
|
2123
|
+
TRAINING_LEASE_DURATION_MS,
|
|
2124
|
+
trainingEpoch
|
|
2125
|
+
)) {
|
|
1440
2126
|
return false;
|
|
1441
2127
|
}
|
|
1442
2128
|
let leaseHeld = true;
|
|
@@ -1445,7 +2131,8 @@ function createClassifier(config, storage, runtime) {
|
|
|
1445
2131
|
try {
|
|
1446
2132
|
leaseHeld = storage.claimTrainingLease(
|
|
1447
2133
|
trainingLeaseOwner,
|
|
1448
|
-
TRAINING_LEASE_DURATION_MS
|
|
2134
|
+
TRAINING_LEASE_DURATION_MS,
|
|
2135
|
+
trainingEpoch
|
|
1449
2136
|
);
|
|
1450
2137
|
} catch {
|
|
1451
2138
|
leaseHeld = false;
|
|
@@ -1460,17 +2147,33 @@ function createClassifier(config, storage, runtime) {
|
|
|
1460
2147
|
try {
|
|
1461
2148
|
const snapshot = storage.snapshot();
|
|
1462
2149
|
if (!shouldTrain(snapshot)) return false;
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
2150
|
+
if (!storage.markTrainingAttempted(
|
|
2151
|
+
snapshot.activeExampleCount,
|
|
2152
|
+
trainingEpoch
|
|
2153
|
+
)) {
|
|
2154
|
+
return false;
|
|
2155
|
+
}
|
|
2156
|
+
const trainingExamples = storage.listExamplesForTraining(
|
|
2157
|
+
"training",
|
|
2158
|
+
snapshot.activeGeneration,
|
|
2159
|
+
trainingEpoch
|
|
2160
|
+
);
|
|
2161
|
+
const heldOutExamples = storage.listExamplesForTraining(
|
|
2162
|
+
"held_out",
|
|
2163
|
+
snapshot.activeGeneration,
|
|
2164
|
+
trainingEpoch
|
|
2165
|
+
);
|
|
2166
|
+
if (trainingExamples === null || heldOutExamples === null) return false;
|
|
1466
2167
|
if (trainingExamples.length === 0 || heldOutExamples.length === 0) {
|
|
1467
2168
|
return true;
|
|
1468
2169
|
}
|
|
1469
2170
|
const candidate = await runtime.train({
|
|
1470
2171
|
classifierName: config.name,
|
|
1471
2172
|
generation: snapshot.activeGeneration,
|
|
2173
|
+
expectedEpoch: trainingEpoch,
|
|
1472
2174
|
examples: trainingExamples.map(({ input, result }) => ({ input, result })),
|
|
1473
|
-
resultConfig: config.result
|
|
2175
|
+
resultConfig: config.result,
|
|
2176
|
+
acceptableError: config.acceptableError
|
|
1474
2177
|
});
|
|
1475
2178
|
const candidateModel = await runtime.loadModel({
|
|
1476
2179
|
modelPath: candidate.modelPath,
|
|
@@ -1492,14 +2195,43 @@ function createClassifier(config, storage, runtime) {
|
|
|
1492
2195
|
return true;
|
|
1493
2196
|
}
|
|
1494
2197
|
if (!renewTrainingLease()) {
|
|
2198
|
+
const current = refreshStoredState();
|
|
2199
|
+
if (current.dataEpoch !== trainingEpoch || current.clearPending) {
|
|
2200
|
+
await runtime.clearClassifierGenerationArtifacts(
|
|
2201
|
+
config.name,
|
|
2202
|
+
snapshot.activeGeneration
|
|
2203
|
+
);
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
1495
2206
|
throw new SwapAIError(
|
|
1496
2207
|
"service_unavailable",
|
|
1497
2208
|
`Classifier "${config.name}" lost its training lease`
|
|
1498
2209
|
);
|
|
1499
2210
|
}
|
|
1500
|
-
|
|
2211
|
+
if (trainingEpoch !== refreshStoredState().dataEpoch) {
|
|
2212
|
+
await runtime.clearClassifierGenerationArtifacts(
|
|
2213
|
+
config.name,
|
|
2214
|
+
snapshot.activeGeneration
|
|
2215
|
+
);
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
if (storage.promoteGeneration(candidate, trainingEpoch) === null) {
|
|
2219
|
+
await runtime.clearClassifierGenerationArtifacts(
|
|
2220
|
+
config.name,
|
|
2221
|
+
snapshot.activeGeneration
|
|
2222
|
+
);
|
|
2223
|
+
return false;
|
|
2224
|
+
}
|
|
1501
2225
|
if (loadedModel !== null) await loadedModel.close();
|
|
2226
|
+
if (trainingEpoch !== refreshStoredState().dataEpoch) {
|
|
2227
|
+
await runtime.clearClassifierGenerationArtifacts(
|
|
2228
|
+
config.name,
|
|
2229
|
+
snapshot.activeGeneration
|
|
2230
|
+
);
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
1502
2233
|
loadedModel = candidateModel;
|
|
2234
|
+
loadedModelEpoch = trainingEpoch;
|
|
1503
2235
|
trained = true;
|
|
1504
2236
|
return true;
|
|
1505
2237
|
} finally {
|
|
@@ -1510,34 +2242,61 @@ function createClassifier(config, storage, runtime) {
|
|
|
1510
2242
|
storage.releaseTrainingLease(trainingLeaseOwner);
|
|
1511
2243
|
}
|
|
1512
2244
|
}
|
|
1513
|
-
async function model() {
|
|
2245
|
+
async function model(expectedEpoch) {
|
|
1514
2246
|
await startup;
|
|
1515
|
-
|
|
2247
|
+
const current = refreshStoredState();
|
|
2248
|
+
if (!trained || current.clearPending || current.dataEpoch !== expectedEpoch) {
|
|
1516
2249
|
throw new SwapAIError(
|
|
1517
2250
|
"not_trained",
|
|
1518
2251
|
`Classifier "${config.name}" has not passed its held-out test`
|
|
1519
2252
|
);
|
|
1520
2253
|
}
|
|
2254
|
+
if (loadedModel !== null && loadedModelEpoch !== expectedEpoch) {
|
|
2255
|
+
await loadedModel.close();
|
|
2256
|
+
loadedModel = null;
|
|
2257
|
+
loadedModelEpoch = null;
|
|
2258
|
+
}
|
|
1521
2259
|
if (loadedModel === null) {
|
|
1522
|
-
|
|
1523
|
-
if (snapshot.modelPath === null) {
|
|
2260
|
+
if (current.modelPath === null) {
|
|
1524
2261
|
throw new SwapAIError(
|
|
1525
2262
|
"service_unavailable",
|
|
1526
2263
|
`Classifier "${config.name}" is trained but has no saved model`
|
|
1527
2264
|
);
|
|
1528
2265
|
}
|
|
1529
2266
|
await runtime.ready();
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
2267
|
+
let restored;
|
|
2268
|
+
try {
|
|
2269
|
+
restored = await runtime.loadModel({
|
|
2270
|
+
modelPath: current.modelPath,
|
|
2271
|
+
resultConfig: config.result
|
|
2272
|
+
});
|
|
2273
|
+
} catch (error) {
|
|
2274
|
+
if (error instanceof NeedleModelArtifactError && storage.archiveAndReset(expectedEpoch, { retainExamples: true }) !== null) {
|
|
2275
|
+
const reset = refreshStoredState();
|
|
2276
|
+
dataEpoch = reset.dataEpoch;
|
|
2277
|
+
clearedEpoch = reset.dataEpoch;
|
|
2278
|
+
trained = false;
|
|
2279
|
+
scheduleTraining();
|
|
2280
|
+
}
|
|
2281
|
+
throw error;
|
|
2282
|
+
}
|
|
2283
|
+
const afterLoad = refreshStoredState();
|
|
2284
|
+
if (afterLoad.dataEpoch !== expectedEpoch || afterLoad.clearPending || !afterLoad.trained) {
|
|
2285
|
+
await restored.close();
|
|
2286
|
+
throw new SwapAIError(
|
|
2287
|
+
"not_trained",
|
|
2288
|
+
`Classifier "${config.name}" has been cleared`
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
loadedModel = restored;
|
|
2292
|
+
loadedModelEpoch = expectedEpoch;
|
|
1534
2293
|
}
|
|
1535
2294
|
return loadedModel;
|
|
1536
2295
|
}
|
|
1537
2296
|
async function callReference(input, reference) {
|
|
1538
2297
|
return validateResult(config.result, await reference(input));
|
|
1539
2298
|
}
|
|
1540
|
-
async function fallbackToReference(input, reference, candidateError, retestDue) {
|
|
2299
|
+
async function fallbackToReference(input, reference, candidateError, retestDue, epoch) {
|
|
1541
2300
|
if (reference === void 0) {
|
|
1542
2301
|
throw toSwapAIError(
|
|
1543
2302
|
candidateError,
|
|
@@ -1554,22 +2313,26 @@ function createClassifier(config, storage, runtime) {
|
|
|
1554
2313
|
)
|
|
1555
2314
|
);
|
|
1556
2315
|
const referenceResult = await callReference(input, reference);
|
|
1557
|
-
await persistExample(input, referenceResult);
|
|
1558
|
-
if (retestDue) {
|
|
1559
|
-
storage.recordLocalClassification();
|
|
1560
|
-
const failures = storage.recordRetest(false);
|
|
1561
|
-
if (failures >= config.retestRevertOn)
|
|
2316
|
+
await persistExample(input, referenceResult, epoch);
|
|
2317
|
+
if (retestDue && epoch === dataEpoch) {
|
|
2318
|
+
if (storage.recordLocalClassification(epoch) === null) return referenceResult;
|
|
2319
|
+
const failures = storage.recordRetest(false, epoch);
|
|
2320
|
+
if (failures !== null && failures >= config.retestRevertOn) {
|
|
2321
|
+
await disableModel(epoch);
|
|
2322
|
+
}
|
|
1562
2323
|
}
|
|
1563
2324
|
return referenceResult;
|
|
1564
2325
|
}
|
|
1565
|
-
async function disableModel() {
|
|
1566
|
-
storage.archiveAndReset();
|
|
2326
|
+
async function disableModel(epoch) {
|
|
2327
|
+
if (storage.archiveAndReset(epoch) === null) return;
|
|
2328
|
+
refreshStoredState();
|
|
1567
2329
|
trained = false;
|
|
1568
2330
|
const previousModel = loadedModel;
|
|
1569
2331
|
loadedModel = null;
|
|
2332
|
+
loadedModelEpoch = null;
|
|
1570
2333
|
if (previousModel !== null) await previousModel.close();
|
|
1571
2334
|
}
|
|
1572
|
-
async function classifyNow(input, reference) {
|
|
2335
|
+
async function classifyNow(input, reference, epoch) {
|
|
1573
2336
|
assertOpen2(closed);
|
|
1574
2337
|
if (!trained) {
|
|
1575
2338
|
if (reference === void 0) {
|
|
@@ -1579,7 +2342,7 @@ function createClassifier(config, storage, runtime) {
|
|
|
1579
2342
|
);
|
|
1580
2343
|
}
|
|
1581
2344
|
const referenceResult2 = await callReference(input, reference);
|
|
1582
|
-
await persistExample(input, referenceResult2);
|
|
2345
|
+
await persistExample(input, referenceResult2, epoch);
|
|
1583
2346
|
return referenceResult2;
|
|
1584
2347
|
}
|
|
1585
2348
|
const snapshot = storage.snapshot();
|
|
@@ -1588,37 +2351,190 @@ function createClassifier(config, storage, runtime) {
|
|
|
1588
2351
|
try {
|
|
1589
2352
|
candidateResult = validateResult(
|
|
1590
2353
|
config.result,
|
|
1591
|
-
await (await model()).classify(input)
|
|
2354
|
+
await (await model(epoch)).classify(input)
|
|
1592
2355
|
);
|
|
1593
2356
|
} catch (error) {
|
|
1594
|
-
return fallbackToReference(input, reference, error, retestDue);
|
|
2357
|
+
return fallbackToReference(input, reference, error, retestDue, epoch);
|
|
2358
|
+
}
|
|
2359
|
+
const afterClassification = refreshStoredState();
|
|
2360
|
+
if (afterClassification.dataEpoch !== epoch || afterClassification.clearPending) {
|
|
2361
|
+
return fallbackToReference(
|
|
2362
|
+
input,
|
|
2363
|
+
reference,
|
|
2364
|
+
new SwapAIError(
|
|
2365
|
+
"not_trained",
|
|
2366
|
+
`Classifier "${config.name}" was cleared during classification`
|
|
2367
|
+
),
|
|
2368
|
+
retestDue,
|
|
2369
|
+
epoch
|
|
2370
|
+
);
|
|
1595
2371
|
}
|
|
1596
2372
|
if (!retestDue || reference === void 0) {
|
|
1597
|
-
storage.recordLocalClassification()
|
|
2373
|
+
if (epoch !== dataEpoch || storage.recordLocalClassification(epoch) === null) {
|
|
2374
|
+
return fallbackToReference(
|
|
2375
|
+
input,
|
|
2376
|
+
reference,
|
|
2377
|
+
new SwapAIError(
|
|
2378
|
+
"not_trained",
|
|
2379
|
+
`Classifier "${config.name}" was cleared before returning its result`
|
|
2380
|
+
),
|
|
2381
|
+
retestDue,
|
|
2382
|
+
epoch
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
1598
2385
|
return candidateResult;
|
|
1599
2386
|
}
|
|
1600
2387
|
const referenceResult = await callReference(input, reference);
|
|
1601
|
-
await persistExample(input, referenceResult);
|
|
1602
|
-
|
|
2388
|
+
await persistExample(input, referenceResult, epoch);
|
|
2389
|
+
if (epoch !== dataEpoch) return referenceResult;
|
|
2390
|
+
if (storage.recordLocalClassification(epoch) === null) return referenceResult;
|
|
1603
2391
|
const passed = resultError(config.result, referenceResult, candidateResult) <= config.acceptableError;
|
|
1604
|
-
const failures = storage.recordRetest(passed);
|
|
1605
|
-
if (!passed && failures >= config.retestRevertOn) {
|
|
1606
|
-
await disableModel();
|
|
2392
|
+
const failures = storage.recordRetest(passed, epoch);
|
|
2393
|
+
if (!passed && failures !== null && failures >= config.retestRevertOn) {
|
|
2394
|
+
await disableModel(epoch);
|
|
1607
2395
|
}
|
|
1608
2396
|
return referenceResult;
|
|
1609
2397
|
}
|
|
2398
|
+
async function performDurableClear(clearEpoch) {
|
|
2399
|
+
trained = false;
|
|
2400
|
+
const deletionFailures = [];
|
|
2401
|
+
const throwDeletionFailures = () => {
|
|
2402
|
+
if (deletionFailures.length === 1) throw deletionFailures[0];
|
|
2403
|
+
if (deletionFailures.length > 1) {
|
|
2404
|
+
throw new AggregateError(
|
|
2405
|
+
deletionFailures,
|
|
2406
|
+
`Could not completely clear classifier "${config.name}"`
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
const previousModel = loadedModel;
|
|
2411
|
+
if (previousModel !== null) {
|
|
2412
|
+
try {
|
|
2413
|
+
await previousModel.close();
|
|
2414
|
+
if (loadedModel === previousModel) {
|
|
2415
|
+
loadedModel = null;
|
|
2416
|
+
loadedModelEpoch = null;
|
|
2417
|
+
}
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
deletionFailures.push(error);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
const lockWaitStarted = Date.now();
|
|
2423
|
+
let clearState = refreshStoredState();
|
|
2424
|
+
while (clearState.clearPending && clearState.dataEpoch === clearEpoch && (clearState.trainingLeaseOwner !== null && clearState.trainingLeaseEpoch === null && clearState.trainingLeaseUntil !== null && clearState.trainingLeaseUntil > Date.now() || !storage.claimArtifactWriteLock())) {
|
|
2425
|
+
if (Date.now() - lockWaitStarted >= ARTIFACT_LOCK_WAIT_MS) {
|
|
2426
|
+
throw new SwapAIError(
|
|
2427
|
+
"storage_failed",
|
|
2428
|
+
`Timed out waiting to clear classifier "${config.name}" while training was still running`
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
await new Promise((resolve2) => setTimeout(resolve2, 10));
|
|
2432
|
+
clearState = refreshStoredState();
|
|
2433
|
+
}
|
|
2434
|
+
if (!clearState.clearPending || clearState.dataEpoch !== clearEpoch) {
|
|
2435
|
+
throwDeletionFailures();
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
try {
|
|
2439
|
+
try {
|
|
2440
|
+
storage.eraseTrainingData(clearEpoch);
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
deletionFailures.push(error);
|
|
2443
|
+
}
|
|
2444
|
+
clearState = refreshStoredState();
|
|
2445
|
+
if (clearState.clearPending && clearState.dataEpoch === clearEpoch && clearState.clearErased && clearState.clearArtifactGenerationMax !== null) {
|
|
2446
|
+
try {
|
|
2447
|
+
await runtime.clearClassifierArtifactsThroughGeneration(
|
|
2448
|
+
config.name,
|
|
2449
|
+
clearState.clearArtifactGenerationMax
|
|
2450
|
+
);
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
deletionFailures.push(error);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
throwDeletionFailures();
|
|
2456
|
+
storage.finishClearTrainingData(clearEpoch);
|
|
2457
|
+
const current = refreshStoredState();
|
|
2458
|
+
if (!current.clearPending) {
|
|
2459
|
+
clearFailure = null;
|
|
2460
|
+
clearedEpoch = current.dataEpoch;
|
|
2461
|
+
}
|
|
2462
|
+
} finally {
|
|
2463
|
+
storage.releaseArtifactWriteLock();
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
async function flushNow() {
|
|
2467
|
+
const clearFailureBeforeFlush = clearFailure;
|
|
2468
|
+
await startup;
|
|
2469
|
+
while (true) {
|
|
2470
|
+
const operations = operationQueue;
|
|
2471
|
+
await operations;
|
|
2472
|
+
const training = trainingQueue;
|
|
2473
|
+
await training;
|
|
2474
|
+
if (operations === operationQueue && training === trainingQueue) break;
|
|
2475
|
+
}
|
|
2476
|
+
if (clearFailure !== null && clearFailure !== clearFailureBeforeFlush) {
|
|
2477
|
+
throw clearFailure;
|
|
2478
|
+
}
|
|
2479
|
+
const pending = refreshStoredState();
|
|
2480
|
+
if (pending.clearPending || clearFailureBeforeFlush !== null) {
|
|
2481
|
+
try {
|
|
2482
|
+
await performDurableClear(pending.dataEpoch);
|
|
2483
|
+
if (!refreshStoredState().clearPending) clearFailure = null;
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
rememberClearFailure(error);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
if (refreshStoredState().clearPending) {
|
|
2489
|
+
clearFailure ??= new SwapAIError(
|
|
2490
|
+
"storage_failed",
|
|
2491
|
+
`Could not completely clear classifier "${config.name}"`
|
|
2492
|
+
);
|
|
2493
|
+
throw clearFailure;
|
|
2494
|
+
}
|
|
2495
|
+
if (clearFailure !== null) throw clearFailure;
|
|
2496
|
+
if (queuedFailure !== null) {
|
|
2497
|
+
const failure = queuedFailure;
|
|
2498
|
+
queuedFailure = null;
|
|
2499
|
+
throw failure;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
1610
2502
|
const classifier = {
|
|
1611
2503
|
isTrained() {
|
|
2504
|
+
assertOpen2(closed || closing);
|
|
2505
|
+
refreshStoredState();
|
|
1612
2506
|
return trained;
|
|
1613
2507
|
},
|
|
1614
2508
|
logClassification(input, result) {
|
|
1615
|
-
assertOpen2(closed);
|
|
2509
|
+
assertOpen2(closed || closing);
|
|
1616
2510
|
const validResult = validateResult(config.result, result);
|
|
1617
|
-
|
|
2511
|
+
const epoch = refreshStoredState().dataEpoch;
|
|
2512
|
+
void persistExample(input, validResult, epoch);
|
|
2513
|
+
},
|
|
2514
|
+
clearTrainingData() {
|
|
2515
|
+
assertOpen2(closed || closing);
|
|
2516
|
+
trained = false;
|
|
2517
|
+
const clearEpoch = storage.beginClearTrainingData();
|
|
2518
|
+
dataEpoch = clearEpoch;
|
|
2519
|
+
const trainingBeforeClear = trainingQueue;
|
|
2520
|
+
const classificationsBeforeClear = classificationQueue;
|
|
2521
|
+
void enqueue(async () => {
|
|
2522
|
+
await startup;
|
|
2523
|
+
await trainingBeforeClear;
|
|
2524
|
+
await classificationsBeforeClear;
|
|
2525
|
+
try {
|
|
2526
|
+
await performDurableClear(clearEpoch);
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
rememberClearFailure(error);
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
1618
2531
|
},
|
|
1619
2532
|
classify(input, reference) {
|
|
1620
|
-
assertOpen2(closed);
|
|
1621
|
-
const
|
|
2533
|
+
assertOpen2(closed || closing);
|
|
2534
|
+
const epoch = refreshStoredState().dataEpoch;
|
|
2535
|
+
const task = classificationQueue.then(
|
|
2536
|
+
() => classifyNow(input, reference, epoch)
|
|
2537
|
+
);
|
|
1622
2538
|
classificationQueue = task.then(
|
|
1623
2539
|
() => void 0,
|
|
1624
2540
|
() => void 0
|
|
@@ -1626,36 +2542,53 @@ function createClassifier(config, storage, runtime) {
|
|
|
1626
2542
|
return task;
|
|
1627
2543
|
},
|
|
1628
2544
|
async flush() {
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const operations = operationQueue;
|
|
1632
|
-
await operations;
|
|
1633
|
-
const training = trainingQueue;
|
|
1634
|
-
await training;
|
|
1635
|
-
if (operations === operationQueue && training === trainingQueue) break;
|
|
1636
|
-
}
|
|
1637
|
-
if (queuedFailure !== null) {
|
|
1638
|
-
const failure = queuedFailure;
|
|
1639
|
-
queuedFailure = null;
|
|
1640
|
-
throw failure;
|
|
1641
|
-
}
|
|
2545
|
+
assertOpen2(closed || closing);
|
|
2546
|
+
await flushNow();
|
|
1642
2547
|
},
|
|
1643
|
-
|
|
1644
|
-
if (closed) return;
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2548
|
+
close() {
|
|
2549
|
+
if (closed) return Promise.resolve();
|
|
2550
|
+
if (closePromise !== null) return closePromise;
|
|
2551
|
+
closing = true;
|
|
2552
|
+
closePromise = (async () => {
|
|
2553
|
+
try {
|
|
2554
|
+
await classificationQueue;
|
|
2555
|
+
await flushNow();
|
|
2556
|
+
} catch (error) {
|
|
2557
|
+
closing = false;
|
|
2558
|
+
closePromise = null;
|
|
2559
|
+
throw error;
|
|
2560
|
+
}
|
|
2561
|
+
const failures = [];
|
|
2562
|
+
if (loadedModel !== null) {
|
|
2563
|
+
try {
|
|
2564
|
+
await loadedModel.close();
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
failures.push(error);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
1654
2569
|
loadedModel = null;
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
2570
|
+
loadedModelEpoch = null;
|
|
2571
|
+
try {
|
|
2572
|
+
await runtime.close();
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
failures.push(error);
|
|
2575
|
+
}
|
|
2576
|
+
try {
|
|
2577
|
+
storage.close();
|
|
2578
|
+
} catch (error) {
|
|
2579
|
+
failures.push(error);
|
|
2580
|
+
}
|
|
2581
|
+
closed = true;
|
|
2582
|
+
closing = false;
|
|
2583
|
+
if (failures.length === 1) throw failures[0];
|
|
2584
|
+
if (failures.length > 1) {
|
|
2585
|
+
throw new AggregateError(
|
|
2586
|
+
failures,
|
|
2587
|
+
`Could not completely close classifier "${config.name}"`
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
})();
|
|
2591
|
+
return closePromise;
|
|
1659
2592
|
}
|
|
1660
2593
|
};
|
|
1661
2594
|
return classifier;
|