@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/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
- function createNeedleTool(config) {
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: "number",
146
- minimum: config.min,
147
- maximum: config.max,
148
- description
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(options.dataDirectory, "runtime");
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(example.input, example.result, options.resultConfig)
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 { modelPath, needleVersion: NEEDLE_VERSION };
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 toRuntimeError(`Needle model does not exist: ${options.modelPath}`, error);
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((resolve, reject) => {
537
- this.#resolveReady = resolve;
777
+ this.#readyPromise = new Promise((resolve2, reject) => {
778
+ this.#resolveReady = resolve2;
538
779
  this.#rejectReady = reject;
539
780
  });
540
- this.#exitPromise = new Promise((resolve) => {
541
- this.#resolveExit = resolve;
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((resolve, reject) => {
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 Promise.race([
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(validateNeedleResult(message.result, this.#resultConfig));
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((resolve, reject) => {
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
- resolve({ stdout, stderr });
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((resolve, reject) => {
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
- resolve(value);
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
- chmodSync(databasePath, 384);
928
- database.exec("PRAGMA journal_mode = WAL");
929
- database.exec("PRAGMA foreign_keys = ON");
930
- database.exec("PRAGMA busy_timeout = 5000");
931
- database.exec(SCHEMA);
932
- const now = Date.now();
933
- const configJson = stringifyJson(options.config, "classifier config");
934
- const existing = database.prepare(`
935
- SELECT config_json FROM classifiers WHERE name = ?
936
- `).get(options.name);
937
- if (existing !== void 0 && criticalConfigJson(existing.config_json) !== criticalConfigJson(configJson)) {
938
- database.close();
939
- throw new TypeError(
940
- `Classifier "${options.name}" already exists with different result or behavior settings`
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
- transaction(database, () => {
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
- markTrainingAttempted(exampleCount) {
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
- database.prepare(`
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
- const generation = activeGeneration(database, options.name);
1069
- database.prepare(`
1070
- UPDATE generations
1071
- SET trained = 1, model_path = ?, needle_version = ?
1072
- WHERE classifier_name = ? AND generation = ?
1073
- `).run(model.modelPath, model.needleVersion, options.name, generation);
1074
- return generationByNumber(database, options.name, generation);
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
- database.prepare(`
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
- database.prepare(`
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 now2 = Date.now();
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, now2 + durationMs, now2, options.name, now2, 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 currentGeneration = activeGeneration(database, options.name);
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
- new_examples_since_training = 0,
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 trained = storage.snapshot().trained;
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
- const saved = storage.snapshot();
1375
- if (saved.trained && (saved.modelPath === null || saved.needleVersion !== NEEDLE_VERSION)) {
1376
- storage.archiveAndReset();
1377
- trained = false;
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
- const swapAIError = toSwapAIError(
2010
+ rememberBackgroundFailure(toSwapAIError(
1392
2011
  error,
1393
2012
  "service_unavailable",
1394
2013
  "Needle could not start"
1395
- );
1396
- rememberBackgroundFailure(swapAIError);
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
- storage.addExample(input, result);
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(trainingLeaseOwner, TRAINING_LEASE_DURATION_MS)) {
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
- const trainingExamples = storage.listExamples("training");
1464
- const heldOutExamples = storage.listExamples("held_out");
1465
- storage.markTrainingAttempted(snapshot.activeExampleCount);
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
- storage.promoteGeneration(candidate);
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
- if (!trained) {
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
- const snapshot = storage.snapshot();
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
- loadedModel = await runtime.loadModel({
1531
- modelPath: snapshot.modelPath,
1532
- resultConfig: config.result
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) await disableModel();
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
- storage.recordLocalClassification();
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
- void persistExample(input, validResult);
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 task = classificationQueue.then(() => classifyNow(input, reference));
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
- await startup;
1630
- while (true) {
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
- async close() {
1644
- if (closed) return;
1645
- closed = true;
1646
- let failure;
1647
- try {
1648
- await classificationQueue;
1649
- await classifier.flush();
1650
- } catch (error) {
1651
- failure = error;
1652
- } finally {
1653
- if (loadedModel !== null) await loadedModel.close();
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
- await runtime.close();
1656
- storage.close();
1657
- }
1658
- if (failure !== void 0) throw failure;
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;