@swapai/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -128,11 +128,12 @@ import {
128
128
  mkdir,
129
129
  readFile,
130
130
  readdir,
131
+ rm,
131
132
  stat,
132
133
  writeFile
133
134
  } from "fs/promises";
134
135
  import { createInterface } from "readline";
135
- import { dirname, join } from "path";
136
+ import { dirname, join, resolve, sep } from "path";
136
137
  import { fileURLToPath } from "url";
137
138
 
138
139
  // src/needle.ts
@@ -224,8 +225,8 @@ var ManagedNeedleRuntime = class {
224
225
  #environment;
225
226
  #closed = false;
226
227
  constructor(options) {
227
- this.#dataDirectory = options.dataDirectory;
228
- this.#runtimeDirectory = join(options.dataDirectory, "runtime");
228
+ this.#dataDirectory = resolve(options.dataDirectory);
229
+ this.#runtimeDirectory = join(this.#dataDirectory, "runtime");
229
230
  this.#runCommand = options.dependencies?.runCommand ?? runCommand;
230
231
  this.#spawnProcess = options.dependencies?.spawnProcess ?? spawnProcess;
231
232
  this.#fetch = options.dependencies?.fetch ?? globalThis.fetch;
@@ -252,6 +253,12 @@ var ManagedNeedleRuntime = class {
252
253
  "Needle generation must be a non-negative integer."
253
254
  );
254
255
  }
256
+ if (!Number.isSafeInteger(options.expectedEpoch) || options.expectedEpoch < 0) {
257
+ throw new NeedleRuntimeError(
258
+ "service_unavailable",
259
+ "Needle data epoch must be a non-negative integer."
260
+ );
261
+ }
255
262
  if (options.examples.length === 0) {
256
263
  throw new NeedleRuntimeError(
257
264
  "service_unavailable",
@@ -281,8 +288,11 @@ var ManagedNeedleRuntime = class {
281
288
  const lines = options.examples.map(
282
289
  (example) => createTrainingLine(example.input, example.result, options.resultConfig)
283
290
  );
284
- await writeFile(trainingPath, `${lines.join("\n")}
285
- `, "utf8");
291
+ const lockPath = join(
292
+ this.#dataDirectory,
293
+ "locks",
294
+ `${createHash("sha256").update(options.classifierName).digest("hex")}.sqlite`
295
+ );
286
296
  try {
287
297
  await this.#runCommand(
288
298
  this.#pythonPath,
@@ -296,12 +306,22 @@ var ManagedNeedleRuntime = class {
296
306
  "--checkpoint-dir",
297
307
  checkpointDirectory,
298
308
  "--epochs",
299
- String(options.epochs ?? 10)
309
+ String(options.epochs ?? 10),
310
+ "--artifact-lock-database",
311
+ lockPath,
312
+ "--main-database",
313
+ join(this.#dataDirectory, "swapai.sqlite"),
314
+ "--classifier-name",
315
+ options.classifierName,
316
+ "--expected-epoch",
317
+ String(options.expectedEpoch)
300
318
  ],
301
319
  {
302
320
  cwd: candidateDirectory,
303
321
  env: this.#environment,
304
- timeoutMs: 6 * 60 * 60 * 1e3
322
+ timeoutMs: 6 * 60 * 60 * 1e3,
323
+ stdin: `${lines.join("\n")}
324
+ `
305
325
  }
306
326
  );
307
327
  await stat(modelPath);
@@ -352,6 +372,70 @@ var ManagedNeedleRuntime = class {
352
372
  throw toRuntimeError("Needle model could not start.", error);
353
373
  }
354
374
  }
375
+ async clearClassifierArtifacts(classifierName) {
376
+ await rm(
377
+ join(this.#dataDirectory, "classifiers", classifierKey(classifierName)),
378
+ { recursive: true, force: true }
379
+ );
380
+ }
381
+ async clearClassifierGenerationArtifacts(classifierName, generation) {
382
+ if (!Number.isSafeInteger(generation) || generation < 0) {
383
+ throw new NeedleRuntimeError(
384
+ "service_unavailable",
385
+ "Classifier generation must be a non-negative safe integer."
386
+ );
387
+ }
388
+ const classifierDirectory = resolve(
389
+ this.#dataDirectory,
390
+ "classifiers",
391
+ classifierKey(classifierName)
392
+ );
393
+ const generationDirectory = resolve(
394
+ classifierDirectory,
395
+ `generation-${generation}`
396
+ );
397
+ if (!generationDirectory.startsWith(`${classifierDirectory}${sep}`)) {
398
+ throw new NeedleRuntimeError(
399
+ "service_unavailable",
400
+ "Classifier generation path is outside its classifier directory."
401
+ );
402
+ }
403
+ await rm(generationDirectory, { recursive: true, force: true });
404
+ }
405
+ async clearClassifierArtifactsThroughGeneration(classifierName, maximumGeneration) {
406
+ if (!Number.isSafeInteger(maximumGeneration) || maximumGeneration < 0) {
407
+ throw new NeedleRuntimeError(
408
+ "service_unavailable",
409
+ "Maximum classifier generation must be a non-negative safe integer."
410
+ );
411
+ }
412
+ const classifierDirectory = join(
413
+ this.#dataDirectory,
414
+ "classifiers",
415
+ classifierKey(classifierName)
416
+ );
417
+ let entries;
418
+ try {
419
+ entries = await readdir(classifierDirectory, { withFileTypes: true });
420
+ } catch (error) {
421
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
422
+ return;
423
+ }
424
+ throw error;
425
+ }
426
+ await Promise.all(
427
+ entries.map(async (entry) => {
428
+ const match = /^generation-(\d+)$/.exec(entry.name);
429
+ if (!entry.isDirectory() || match === null) return;
430
+ const generation = Number(match[1]);
431
+ if (generation > maximumGeneration) return;
432
+ await rm(join(classifierDirectory, entry.name), {
433
+ recursive: true,
434
+ force: true
435
+ });
436
+ })
437
+ );
438
+ }
355
439
  async close() {
356
440
  if (this.#closed) return;
357
441
  this.#closed = true;
@@ -533,12 +617,12 @@ var NeedleModelProcess = class {
533
617
  this.#resultConfig = resultConfig;
534
618
  this.#onBackgroundError = onBackgroundError;
535
619
  this.#onClose = onClose;
536
- this.#readyPromise = new Promise((resolve, reject) => {
537
- this.#resolveReady = resolve;
620
+ this.#readyPromise = new Promise((resolve2, reject) => {
621
+ this.#resolveReady = resolve2;
538
622
  this.#rejectReady = reject;
539
623
  });
540
- this.#exitPromise = new Promise((resolve) => {
541
- this.#resolveExit = resolve;
624
+ this.#exitPromise = new Promise((resolve2) => {
625
+ this.#resolveExit = resolve2;
542
626
  });
543
627
  this.#listen();
544
628
  }
@@ -556,8 +640,8 @@ var NeedleModelProcess = class {
556
640
  );
557
641
  }
558
642
  const id = ++this.#requestId;
559
- const result = new Promise((resolve, reject) => {
560
- this.#pending.set(id, { resolve, reject });
643
+ const result = new Promise((resolve2, reject) => {
644
+ this.#pending.set(id, { resolve: resolve2, reject });
561
645
  this.#process.stdin.write(
562
646
  `${JSON.stringify({ id, type: "classify", input })}
563
647
  `,
@@ -596,10 +680,7 @@ var NeedleModelProcess = class {
596
680
  await withTimeout(this.#exitPromise, 2e3, "Needle ignored SIGTERM.");
597
681
  } catch {
598
682
  this.#process.kill("SIGKILL");
599
- await Promise.race([
600
- this.#exitPromise,
601
- new Promise((resolve) => setTimeout(resolve, 2e3))
602
- ]);
683
+ await withTimeout(this.#exitPromise, 2e3, "Needle ignored SIGKILL.");
603
684
  }
604
685
  }
605
686
  }
@@ -710,12 +791,16 @@ var spawnProcess = (command, args, options) => spawn(command, [...args], {
710
791
  env: options.env,
711
792
  stdio: ["pipe", "pipe", "pipe"]
712
793
  });
713
- var runCommand = (command, args, options) => new Promise((resolve, reject) => {
794
+ var runCommand = (command, args, options) => new Promise((resolve2, reject) => {
714
795
  const child = spawn(command, [...args], {
715
796
  cwd: options?.cwd,
716
797
  env: options?.env,
717
- stdio: ["ignore", "pipe", "pipe"]
798
+ stdio: [options?.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
718
799
  });
800
+ if (options?.stdin !== void 0 && child.stdin !== null) {
801
+ child.stdin.on("error", () => void 0);
802
+ child.stdin.end(options.stdin, "utf8");
803
+ }
719
804
  let stdout = "";
720
805
  let stderr = "";
721
806
  let timedOut = false;
@@ -764,7 +849,7 @@ var runCommand = (command, args, options) => new Promise((resolve, reject) => {
764
849
  return;
765
850
  }
766
851
  if (code === 0) {
767
- resolve({ stdout, stderr });
852
+ resolve2({ stdout, stderr });
768
853
  return;
769
854
  }
770
855
  reject(
@@ -831,7 +916,7 @@ async function findFile(directory, name) {
831
916
  return void 0;
832
917
  }
833
918
  function withTimeout(promise, milliseconds, message, onTimeout) {
834
- return new Promise((resolve, reject) => {
919
+ return new Promise((resolve2, reject) => {
835
920
  const timer = setTimeout(() => {
836
921
  onTimeout?.();
837
922
  reject(new NeedleRuntimeError("service_unavailable", message));
@@ -840,7 +925,7 @@ function withTimeout(promise, milliseconds, message, onTimeout) {
840
925
  promise.then(
841
926
  (value) => {
842
927
  clearTimeout(timer);
843
- resolve(value);
928
+ resolve2(value);
844
929
  },
845
930
  (error) => {
846
931
  clearTimeout(timer);
@@ -879,7 +964,12 @@ var SCHEMA = `
879
964
  training_lease_owner TEXT,
880
965
  training_lease_until INTEGER,
881
966
  created_at INTEGER NOT NULL,
882
- updated_at INTEGER NOT NULL
967
+ updated_at INTEGER NOT NULL,
968
+ data_epoch INTEGER NOT NULL DEFAULT 0,
969
+ clear_pending INTEGER NOT NULL DEFAULT 0,
970
+ clear_erased INTEGER NOT NULL DEFAULT 0,
971
+ clear_artifact_generation_max INTEGER,
972
+ training_lease_epoch INTEGER
883
973
  );
884
974
 
885
975
  CREATE TABLE IF NOT EXISTS generations (
@@ -911,6 +1001,43 @@ var SCHEMA = `
911
1001
  ON examples(classifier_name, generation, id);
912
1002
  CREATE INDEX IF NOT EXISTS examples_by_split
913
1003
  ON examples(classifier_name, generation, split, id);
1004
+
1005
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_insert
1006
+ BEFORE INSERT ON classifiers
1007
+ WHEN swapai_writer_version() < 2
1008
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1009
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_update
1010
+ BEFORE UPDATE ON classifiers
1011
+ WHEN swapai_writer_version() < 2
1012
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1013
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_classifiers_delete
1014
+ BEFORE DELETE ON classifiers
1015
+ WHEN swapai_writer_version() < 2
1016
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1017
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_insert
1018
+ BEFORE INSERT ON generations
1019
+ WHEN swapai_writer_version() < 2
1020
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1021
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_update
1022
+ BEFORE UPDATE ON generations
1023
+ WHEN swapai_writer_version() < 2
1024
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1025
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_generations_delete
1026
+ BEFORE DELETE ON generations
1027
+ WHEN swapai_writer_version() < 2
1028
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1029
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_insert
1030
+ BEFORE INSERT ON examples
1031
+ WHEN swapai_writer_version() < 2
1032
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1033
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_update
1034
+ BEFORE UPDATE ON examples
1035
+ WHEN swapai_writer_version() < 2
1036
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
1037
+ CREATE TRIGGER IF NOT EXISTS swapai_v2_examples_delete
1038
+ BEFORE DELETE ON examples
1039
+ WHEN swapai_writer_version() < 2
1040
+ BEGIN SELECT RAISE(ABORT, 'SwapAI writer is too old'); END;
914
1041
  `;
915
1042
  function assignExampleSplit(name, input) {
916
1043
  const hash = createHash2("sha256").update(name).update("\0").update(input).digest();
@@ -924,40 +1051,85 @@ function openStorage(options) {
924
1051
  chmodSync(options.dataDirectory, 448);
925
1052
  const databasePath = join2(options.dataDirectory, "swapai.sqlite");
926
1053
  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`
1054
+ let artifactLockDatabase = null;
1055
+ try {
1056
+ database.function(
1057
+ "swapai_writer_version",
1058
+ { deterministic: true },
1059
+ () => 2
941
1060
  );
1061
+ chmodSync(databasePath, 384);
1062
+ database.exec("PRAGMA journal_mode = WAL");
1063
+ database.exec("PRAGMA foreign_keys = ON");
1064
+ database.exec("PRAGMA secure_delete = ON");
1065
+ database.exec("PRAGMA busy_timeout = 5000");
1066
+ const secureDelete = requiredRow(
1067
+ database.prepare("PRAGMA secure_delete").get()
1068
+ );
1069
+ if (secureDelete.secure_delete !== 1) {
1070
+ throw new Error("SQLite secure deletion could not be enabled");
1071
+ }
1072
+ database.exec(SCHEMA);
1073
+ migrateClassifierColumns(database);
1074
+ const lockDirectory = join2(options.dataDirectory, "locks");
1075
+ mkdirSync(lockDirectory, { recursive: true, mode: 448 });
1076
+ chmodSync(lockDirectory, 448);
1077
+ const lockPath = join2(
1078
+ lockDirectory,
1079
+ `${createHash2("sha256").update(options.name).digest("hex")}.sqlite`
1080
+ );
1081
+ artifactLockDatabase = new DatabaseSync(lockPath);
1082
+ chmodSync(lockPath, 384);
1083
+ artifactLockDatabase.exec("PRAGMA busy_timeout = 0");
1084
+ artifactLockDatabase.exec(
1085
+ "CREATE TABLE IF NOT EXISTS artifact_lock (id INTEGER PRIMARY KEY)"
1086
+ );
1087
+ const now = Date.now();
1088
+ const configJson = stringifyJson(options.config, "classifier config");
1089
+ const existing = database.prepare(`
1090
+ SELECT config_json FROM classifiers WHERE name = ?
1091
+ `).get(options.name);
1092
+ if (existing !== void 0 && criticalConfigJson(existing.config_json) !== criticalConfigJson(configJson)) {
1093
+ throw new TypeError(
1094
+ `Classifier "${options.name}" already exists with different result or behavior settings`
1095
+ );
1096
+ }
1097
+ transaction(database, () => {
1098
+ database.prepare(`
1099
+ INSERT INTO classifiers (
1100
+ name, config_json, max_training_set, created_at, updated_at
1101
+ ) VALUES (?, ?, ?, ?, ?)
1102
+ ON CONFLICT(name) DO UPDATE SET
1103
+ config_json = excluded.config_json,
1104
+ max_training_set = excluded.max_training_set,
1105
+ updated_at = excluded.updated_at
1106
+ `).run(options.name, configJson, options.maxTrainingSet, now, now);
1107
+ database.prepare(`
1108
+ INSERT OR IGNORE INTO generations (
1109
+ classifier_name, generation, status, created_at
1110
+ ) VALUES (?, 1, 'active', ?)
1111
+ `).run(options.name, now);
1112
+ trimExamples(
1113
+ database,
1114
+ options.name,
1115
+ activeGeneration(database, options.name),
1116
+ options.maxTrainingSet
1117
+ );
1118
+ });
1119
+ } catch (error) {
1120
+ try {
1121
+ artifactLockDatabase?.close();
1122
+ } catch {
1123
+ }
1124
+ try {
1125
+ database.close();
1126
+ } catch {
1127
+ }
1128
+ throw error;
942
1129
  }
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
- });
1130
+ const initializedArtifactLockDatabase = artifactLockDatabase;
960
1131
  let closed = false;
1132
+ let artifactLockHeld = false;
961
1133
  const storage = {
962
1134
  databasePath,
963
1135
  snapshot() {
@@ -978,6 +1150,13 @@ function openStorage(options) {
978
1150
  g.trained,
979
1151
  g.model_path,
980
1152
  g.needle_version,
1153
+ c.data_epoch,
1154
+ c.clear_pending,
1155
+ c.clear_erased,
1156
+ c.clear_artifact_generation_max,
1157
+ c.training_lease_owner,
1158
+ c.training_lease_epoch,
1159
+ c.training_lease_until,
981
1160
  (
982
1161
  SELECT COUNT(*)
983
1162
  FROM examples e
@@ -1005,23 +1184,36 @@ function openStorage(options) {
1005
1184
  consecutiveRetestFailures: row2.consecutive_retest_failures,
1006
1185
  trained: row2.trained === 1,
1007
1186
  modelPath: row2.model_path,
1008
- needleVersion: row2.needle_version
1187
+ needleVersion: row2.needle_version,
1188
+ dataEpoch: row2.data_epoch,
1189
+ clearPending: row2.clear_pending === 1,
1190
+ clearErased: row2.clear_erased === 1,
1191
+ clearArtifactGenerationMax: row2.clear_artifact_generation_max,
1192
+ trainingLeaseOwner: row2.training_lease_owner,
1193
+ trainingLeaseEpoch: row2.training_lease_epoch,
1194
+ trainingLeaseUntil: row2.training_lease_until
1009
1195
  };
1010
1196
  },
1011
- addExample(input, result) {
1197
+ addExample(input, result, expectedDataEpoch) {
1012
1198
  assertOpen(closed);
1013
- const generation = activeGeneration(database, options.name);
1014
1199
  const split = assignExampleSplit(options.name, input);
1015
1200
  const createdAt = Date.now();
1016
1201
  const resultJson = stringifyJson(result, "classification result");
1017
1202
  let id = 0;
1203
+ let generation = 0;
1204
+ let accepted = false;
1018
1205
  transaction(database, () => {
1206
+ const state = classifierState(database, options.name);
1207
+ const epoch = expectedDataEpoch ?? state.data_epoch;
1208
+ if (state.data_epoch !== epoch || state.clear_pending === 1) return;
1209
+ generation = state.active_generation;
1019
1210
  const insertion = database.prepare(`
1020
1211
  INSERT INTO examples (
1021
1212
  classifier_name, generation, input, result_json, split, created_at
1022
1213
  ) VALUES (?, ?, ?, ?, ?, ?)
1023
1214
  `).run(options.name, generation, input, resultJson, split, createdAt);
1024
1215
  id = Number(insertion.lastInsertRowid);
1216
+ accepted = true;
1025
1217
  database.prepare(`
1026
1218
  UPDATE classifiers
1027
1219
  SET total_examples_logged = total_examples_logged + 1,
@@ -1031,7 +1223,7 @@ function openStorage(options) {
1031
1223
  `).run(createdAt, options.name);
1032
1224
  trimExamples(database, options.name, generation, options.maxTrainingSet);
1033
1225
  });
1034
- return { id, generation, input, result, split, createdAt };
1226
+ return accepted ? { id, generation, input, result, split, createdAt } : null;
1035
1227
  },
1036
1228
  listExamples(split, generation) {
1037
1229
  assertOpen(closed);
@@ -1049,44 +1241,76 @@ function openStorage(options) {
1049
1241
  `).all(options.name, selectedGeneration, split);
1050
1242
  return rows.map((value) => mapExample(row(value)));
1051
1243
  },
1052
- markTrainingAttempted(exampleCount) {
1244
+ listExamplesForTraining(split, generation, expectedDataEpoch) {
1245
+ assertOpen(closed);
1246
+ let examples = null;
1247
+ transaction(database, () => {
1248
+ const state = classifierState(database, options.name);
1249
+ if (state.data_epoch !== expectedDataEpoch || state.clear_pending === 1 || state.active_generation !== generation) {
1250
+ return;
1251
+ }
1252
+ examples = database.prepare(`
1253
+ SELECT id, generation, input, result_json, split, created_at
1254
+ FROM examples
1255
+ WHERE classifier_name = ? AND generation = ? AND split = ?
1256
+ ORDER BY id
1257
+ `).all(options.name, generation, split).map(
1258
+ (value) => mapExample(row(value))
1259
+ );
1260
+ });
1261
+ return examples;
1262
+ },
1263
+ markTrainingAttempted(exampleCount, expectedDataEpoch) {
1053
1264
  assertOpen(closed);
1054
1265
  if (!Number.isSafeInteger(exampleCount) || exampleCount <= 0) {
1055
1266
  throw new TypeError("training example count must be a positive integer");
1056
1267
  }
1057
- database.prepare(`
1268
+ const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
1269
+ const result = database.prepare(`
1058
1270
  UPDATE classifiers
1059
1271
  SET new_examples_since_training = 0,
1060
1272
  training_attempts = training_attempts + 1,
1061
1273
  examples_used_for_training = ?,
1062
1274
  updated_at = ?
1063
- WHERE name = ?
1064
- `).run(exampleCount, Date.now(), options.name);
1275
+ WHERE name = ? AND data_epoch = ? AND clear_pending = 0
1276
+ `).run(exampleCount, Date.now(), options.name, epoch);
1277
+ return result.changes === 1;
1065
1278
  },
1066
- promoteGeneration(model) {
1279
+ promoteGeneration(model, expectedDataEpoch) {
1067
1280
  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);
1281
+ let generation = 0;
1282
+ let promoted = false;
1283
+ transaction(database, () => {
1284
+ const state = classifierState(database, options.name);
1285
+ const epoch = expectedDataEpoch ?? state.data_epoch;
1286
+ if (state.data_epoch !== epoch || state.clear_pending === 1) return;
1287
+ generation = state.active_generation;
1288
+ database.prepare(`
1289
+ UPDATE generations
1290
+ SET trained = 1, model_path = ?, needle_version = ?
1291
+ WHERE classifier_name = ? AND generation = ?
1292
+ `).run(model.modelPath, model.needleVersion, options.name, generation);
1293
+ promoted = true;
1294
+ });
1295
+ return promoted ? generationByNumber(database, options.name, generation) : null;
1075
1296
  },
1076
- recordLocalClassification() {
1297
+ recordLocalClassification(expectedDataEpoch) {
1077
1298
  assertOpen(closed);
1078
- database.prepare(`
1299
+ const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
1300
+ const result = database.prepare(`
1079
1301
  UPDATE classifiers
1080
1302
  SET local_classifications_since_retest = local_classifications_since_retest + 1,
1081
1303
  total_local_classifications = total_local_classifications + 1,
1082
1304
  updated_at = ?
1083
- WHERE name = ?
1084
- `).run(Date.now(), options.name);
1305
+ WHERE name = ? AND data_epoch = ? AND clear_pending = 0
1306
+ `).run(Date.now(), options.name, epoch);
1307
+ if (result.changes !== 1) return null;
1085
1308
  return storage.snapshot().localClassificationsSinceRetest;
1086
1309
  },
1087
- recordRetest(passed) {
1310
+ recordRetest(passed, expectedDataEpoch) {
1088
1311
  assertOpen(closed);
1089
- database.prepare(`
1312
+ const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
1313
+ const result = database.prepare(`
1090
1314
  UPDATE classifiers
1091
1315
  SET local_classifications_since_retest = 0,
1092
1316
  total_retests = total_retests + 1,
@@ -1095,28 +1319,33 @@ function openStorage(options) {
1095
1319
  ELSE consecutive_retest_failures + 1
1096
1320
  END,
1097
1321
  updated_at = ?
1098
- WHERE name = ?
1099
- `).run(passed ? 1 : 0, Date.now(), options.name);
1322
+ WHERE name = ? AND data_epoch = ? AND clear_pending = 0
1323
+ `).run(passed ? 1 : 0, Date.now(), options.name, epoch);
1324
+ if (result.changes !== 1) return null;
1100
1325
  return storage.snapshot().consecutiveRetestFailures;
1101
1326
  },
1102
- claimTrainingLease(owner, durationMs) {
1327
+ claimTrainingLease(owner, durationMs, expectedDataEpoch) {
1103
1328
  assertOpen(closed);
1104
1329
  if (owner.trim() === "" || !Number.isSafeInteger(durationMs) || durationMs <= 0) {
1105
1330
  throw new TypeError("training lease needs an owner and positive duration");
1106
1331
  }
1107
- const now2 = Date.now();
1332
+ const now = Date.now();
1333
+ const epoch = expectedDataEpoch ?? storage.snapshot().dataEpoch;
1108
1334
  const result = database.prepare(`
1109
1335
  UPDATE classifiers
1110
1336
  SET training_lease_owner = ?,
1111
1337
  training_lease_until = ?,
1338
+ training_lease_epoch = ?,
1112
1339
  updated_at = ?
1113
1340
  WHERE name = ?
1341
+ AND data_epoch = ?
1342
+ AND clear_pending = 0
1114
1343
  AND (
1115
1344
  training_lease_owner IS NULL
1116
1345
  OR training_lease_until <= ?
1117
1346
  OR training_lease_owner = ?
1118
1347
  )
1119
- `).run(owner, now2 + durationMs, now2, options.name, now2, owner);
1348
+ `).run(owner, now + durationMs, epoch, now, options.name, epoch, now, owner);
1120
1349
  return result.changes === 1;
1121
1350
  },
1122
1351
  releaseTrainingLease(owner) {
@@ -1125,16 +1354,21 @@ function openStorage(options) {
1125
1354
  UPDATE classifiers
1126
1355
  SET training_lease_owner = NULL,
1127
1356
  training_lease_until = NULL,
1357
+ training_lease_epoch = NULL,
1128
1358
  updated_at = ?
1129
1359
  WHERE name = ? AND training_lease_owner = ?
1130
1360
  `).run(Date.now(), options.name, owner);
1131
1361
  },
1132
- archiveAndReset() {
1362
+ archiveAndReset(expectedDataEpoch) {
1133
1363
  assertOpen(closed);
1134
1364
  let nextGeneration = 0;
1135
1365
  const changedAt = Date.now();
1366
+ let reset = false;
1136
1367
  transaction(database, () => {
1137
- const currentGeneration = activeGeneration(database, options.name);
1368
+ const state = classifierState(database, options.name);
1369
+ const epoch = expectedDataEpoch ?? state.data_epoch;
1370
+ if (state.data_epoch !== epoch || state.clear_pending === 1) return;
1371
+ const currentGeneration = state.active_generation;
1138
1372
  database.prepare(`
1139
1373
  UPDATE generations
1140
1374
  SET status = 'archived', archived_at = ?
@@ -1154,19 +1388,141 @@ function openStorage(options) {
1154
1388
  database.prepare(`
1155
1389
  UPDATE classifiers
1156
1390
  SET active_generation = ?,
1391
+ data_epoch = data_epoch + 1,
1157
1392
  new_examples_since_training = 0,
1158
1393
  training_attempts = 0,
1159
1394
  examples_used_for_training = 0,
1160
1395
  training_lease_owner = NULL,
1161
1396
  training_lease_until = NULL,
1397
+ training_lease_epoch = NULL,
1398
+ local_classifications_since_retest = 0,
1399
+ consecutive_retest_failures = 0,
1400
+ updated_at = ?
1401
+ WHERE name = ?
1402
+ `).run(nextGeneration, changedAt, options.name);
1403
+ reset = true;
1404
+ });
1405
+ return reset ? generationByNumber(database, options.name, nextGeneration) : null;
1406
+ },
1407
+ beginClearTrainingData() {
1408
+ assertOpen(closed);
1409
+ database.prepare(`
1410
+ UPDATE classifiers
1411
+ SET data_epoch = data_epoch + 1,
1412
+ clear_pending = 1,
1413
+ clear_erased = 0,
1414
+ clear_artifact_generation_max = (
1415
+ SELECT MAX(generation)
1416
+ FROM generations
1417
+ WHERE classifier_name = ?
1418
+ ),
1419
+ updated_at = ?
1420
+ WHERE name = ?
1421
+ `).run(options.name, Date.now(), options.name);
1422
+ return classifierState(database, options.name).data_epoch;
1423
+ },
1424
+ eraseTrainingData(dataEpoch) {
1425
+ assertOpen(closed);
1426
+ let nextGeneration = 0;
1427
+ const changedAt = Date.now();
1428
+ let erased = false;
1429
+ transaction(database, () => {
1430
+ const state = classifierState(database, options.name);
1431
+ if (state.data_epoch !== dataEpoch || state.clear_pending !== 1) return;
1432
+ if (state.clear_erased === 1) return;
1433
+ const maximum = requiredRow(database.prepare(`
1434
+ SELECT MAX(generation) AS maximum
1435
+ FROM generations
1436
+ WHERE classifier_name = ?
1437
+ `).get(options.name));
1438
+ nextGeneration = maximum.maximum + 1;
1439
+ database.prepare(`
1440
+ DELETE FROM examples WHERE classifier_name = ?
1441
+ `).run(options.name);
1442
+ database.prepare(`
1443
+ DELETE FROM generations WHERE classifier_name = ?
1444
+ `).run(options.name);
1445
+ database.prepare(`
1446
+ INSERT INTO generations (
1447
+ classifier_name, generation, status, created_at
1448
+ ) VALUES (?, ?, 'active', ?)
1449
+ `).run(options.name, nextGeneration, changedAt);
1450
+ database.prepare(`
1451
+ UPDATE classifiers
1452
+ SET active_generation = ?,
1453
+ total_examples_logged = 0,
1454
+ new_examples_since_training = 0,
1455
+ training_attempts = 0,
1456
+ examples_used_for_training = 0,
1162
1457
  local_classifications_since_retest = 0,
1458
+ total_local_classifications = 0,
1459
+ total_retests = 0,
1163
1460
  consecutive_retest_failures = 0,
1461
+ training_lease_owner = NULL,
1462
+ training_lease_until = NULL,
1463
+ training_lease_epoch = NULL,
1464
+ clear_erased = 1,
1164
1465
  updated_at = ?
1165
1466
  WHERE name = ?
1166
1467
  `).run(nextGeneration, changedAt, options.name);
1468
+ erased = true;
1167
1469
  });
1470
+ if (!erased) return null;
1471
+ truncateWriteAheadLog(database);
1168
1472
  return generationByNumber(database, options.name, nextGeneration);
1169
1473
  },
1474
+ finishClearTrainingData(dataEpoch) {
1475
+ assertOpen(closed);
1476
+ const state = classifierState(database, options.name);
1477
+ if (state.data_epoch !== dataEpoch || state.clear_pending !== 1 || state.clear_erased !== 1) {
1478
+ return false;
1479
+ }
1480
+ truncateWriteAheadLog(database);
1481
+ const result = database.prepare(`
1482
+ UPDATE classifiers
1483
+ SET clear_pending = 0, updated_at = ?
1484
+ WHERE name = ?
1485
+ AND data_epoch = ?
1486
+ AND clear_pending = 1
1487
+ AND clear_erased = 1
1488
+ `).run(Date.now(), options.name, dataEpoch);
1489
+ return result.changes === 1;
1490
+ },
1491
+ clearTrainingData() {
1492
+ const dataEpoch = storage.beginClearTrainingData();
1493
+ if (!storage.claimArtifactWriteLock()) {
1494
+ throw new Error("Classifier artifacts are currently being written");
1495
+ }
1496
+ try {
1497
+ const generation = storage.eraseTrainingData(dataEpoch);
1498
+ if (generation === null || !storage.finishClearTrainingData(dataEpoch)) {
1499
+ throw new Error("Training data clear was superseded");
1500
+ }
1501
+ return generation;
1502
+ } finally {
1503
+ storage.releaseArtifactWriteLock();
1504
+ }
1505
+ },
1506
+ claimArtifactWriteLock() {
1507
+ assertOpen(closed);
1508
+ if (artifactLockHeld) return true;
1509
+ try {
1510
+ initializedArtifactLockDatabase.exec("BEGIN IMMEDIATE");
1511
+ artifactLockHeld = true;
1512
+ return true;
1513
+ } catch (error) {
1514
+ 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)) {
1515
+ return false;
1516
+ }
1517
+ throw error;
1518
+ }
1519
+ },
1520
+ releaseArtifactWriteLock() {
1521
+ assertOpen(closed);
1522
+ if (!artifactLockHeld) return;
1523
+ initializedArtifactLockDatabase.exec("COMMIT");
1524
+ artifactLockHeld = false;
1525
+ },
1170
1526
  listGenerations() {
1171
1527
  assertOpen(closed);
1172
1528
  return database.prepare(`
@@ -1182,6 +1538,11 @@ function openStorage(options) {
1182
1538
  if (closed) {
1183
1539
  return;
1184
1540
  }
1541
+ if (artifactLockHeld) {
1542
+ initializedArtifactLockDatabase.exec("ROLLBACK");
1543
+ artifactLockHeld = false;
1544
+ }
1545
+ initializedArtifactLockDatabase.close();
1185
1546
  closed = true;
1186
1547
  database.close();
1187
1548
  }
@@ -1194,6 +1555,69 @@ function activeGeneration(database, name) {
1194
1555
  `).get(name));
1195
1556
  return active.active_generation;
1196
1557
  }
1558
+ function truncateWriteAheadLog(database) {
1559
+ database.exec("PRAGMA busy_timeout = 0");
1560
+ let checkpoint;
1561
+ try {
1562
+ checkpoint = requiredRow(database.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get());
1563
+ } finally {
1564
+ database.exec("PRAGMA busy_timeout = 5000");
1565
+ }
1566
+ if (checkpoint.busy !== 0) {
1567
+ throw new Error(
1568
+ "SQLite WAL could not be truncated after clearing training data"
1569
+ );
1570
+ }
1571
+ }
1572
+ function classifierState(database, name) {
1573
+ return requiredRow(database.prepare(`
1574
+ SELECT
1575
+ active_generation,
1576
+ data_epoch,
1577
+ clear_pending,
1578
+ clear_erased,
1579
+ clear_artifact_generation_max,
1580
+ training_lease_owner,
1581
+ training_lease_epoch,
1582
+ training_lease_until
1583
+ FROM classifiers
1584
+ WHERE name = ?
1585
+ `).get(name));
1586
+ }
1587
+ function migrateClassifierColumns(database) {
1588
+ transaction(database, () => {
1589
+ const columns = new Set(
1590
+ database.prepare("PRAGMA table_info(classifiers)").all().map(
1591
+ (value) => row(value).name
1592
+ )
1593
+ );
1594
+ if (!columns.has("data_epoch")) {
1595
+ database.exec(
1596
+ "ALTER TABLE classifiers ADD COLUMN data_epoch INTEGER NOT NULL DEFAULT 0"
1597
+ );
1598
+ }
1599
+ if (!columns.has("clear_pending")) {
1600
+ database.exec(
1601
+ "ALTER TABLE classifiers ADD COLUMN clear_pending INTEGER NOT NULL DEFAULT 0"
1602
+ );
1603
+ }
1604
+ if (!columns.has("clear_erased")) {
1605
+ database.exec(
1606
+ "ALTER TABLE classifiers ADD COLUMN clear_erased INTEGER NOT NULL DEFAULT 0"
1607
+ );
1608
+ }
1609
+ if (!columns.has("clear_artifact_generation_max")) {
1610
+ database.exec(
1611
+ "ALTER TABLE classifiers ADD COLUMN clear_artifact_generation_max INTEGER"
1612
+ );
1613
+ }
1614
+ if (!columns.has("training_lease_epoch")) {
1615
+ database.exec(
1616
+ "ALTER TABLE classifiers ADD COLUMN training_lease_epoch INTEGER"
1617
+ );
1618
+ }
1619
+ });
1620
+ }
1197
1621
  function generationByNumber(database, name, generation) {
1198
1622
  const stored = requiredRow(database.prepare(`
1199
1623
  SELECT
@@ -1325,6 +1749,7 @@ function requiredRow(value) {
1325
1749
  // src/classifier.ts
1326
1750
  var TRAINING_LEASE_DURATION_MS = 5 * 60 * 1e3;
1327
1751
  var TRAINING_LEASE_RENEWAL_MS = 60 * 1e3;
1752
+ var ARTIFACT_LOCK_WAIT_MS = 3e4;
1328
1753
  function init(inputConfig) {
1329
1754
  const config = normalizeConfig(inputConfig);
1330
1755
  const dataDirectory = config.dataDirectory ?? join3(process.cwd(), ".swapai");
@@ -1362,30 +1787,65 @@ function openClassifierStorage(config, dataDirectory) {
1362
1787
  }
1363
1788
  }
1364
1789
  function createClassifier(config, storage, runtime) {
1790
+ const initialState = storage.snapshot();
1365
1791
  let closed = false;
1366
- let trained = storage.snapshot().trained;
1792
+ let closing = false;
1793
+ let closePromise = null;
1794
+ let trained = initialState.trained && !initialState.clearPending;
1367
1795
  let loadedModel = null;
1796
+ let loadedModelEpoch = null;
1368
1797
  let operationQueue = Promise.resolve();
1369
1798
  let trainingQueue = Promise.resolve();
1370
1799
  let trainingScheduled = false;
1371
1800
  const trainingLeaseOwner = `${process.pid}:${randomUUID2()}`;
1372
1801
  let classificationQueue = Promise.resolve();
1373
1802
  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;
1803
+ let clearFailure = null;
1804
+ let dataEpoch = initialState.dataEpoch;
1805
+ let clearedEpoch = initialState.clearPending ? initialState.dataEpoch - 1 : initialState.dataEpoch;
1806
+ const saved = initialState;
1807
+ if (!saved.clearPending && saved.trained && (saved.modelPath === null || saved.needleVersion !== NEEDLE_VERSION)) {
1808
+ if (storage.archiveAndReset(saved.dataEpoch) !== null) {
1809
+ const reset = storage.snapshot();
1810
+ dataEpoch = reset.dataEpoch;
1811
+ clearedEpoch = reset.dataEpoch;
1812
+ trained = false;
1813
+ }
1378
1814
  }
1379
1815
  const startup = startRuntime();
1380
1816
  async function startRuntime() {
1817
+ const startupEpoch = dataEpoch;
1818
+ const beforeRuntime = storage.snapshot();
1819
+ if (beforeRuntime.clearPending) {
1820
+ trained = false;
1821
+ dataEpoch = beforeRuntime.dataEpoch;
1822
+ try {
1823
+ await performDurableClear(beforeRuntime.dataEpoch);
1824
+ } catch (error) {
1825
+ rememberClearFailure(error);
1826
+ }
1827
+ }
1381
1828
  try {
1382
1829
  await runtime.ready();
1383
1830
  const snapshot = storage.snapshot();
1831
+ if (snapshot.clearPending) {
1832
+ trained = false;
1833
+ dataEpoch = snapshot.dataEpoch;
1834
+ return;
1835
+ }
1384
1836
  if (snapshot.trained && snapshot.modelPath !== null) {
1385
- loadedModel = await runtime.loadModel({
1837
+ const restoredModel = await runtime.loadModel({
1386
1838
  modelPath: snapshot.modelPath,
1387
1839
  resultConfig: config.result
1388
1840
  });
1841
+ const current = storage.snapshot();
1842
+ if (startupEpoch === current.dataEpoch && !current.clearPending && current.trained) {
1843
+ loadedModel = restoredModel;
1844
+ loadedModelEpoch = startupEpoch;
1845
+ trained = true;
1846
+ } else {
1847
+ await restoredModel.close();
1848
+ }
1389
1849
  }
1390
1850
  } catch (error) {
1391
1851
  const swapAIError = toSwapAIError(
@@ -1400,6 +1860,25 @@ function createClassifier(config, storage, runtime) {
1400
1860
  queuedFailure = error;
1401
1861
  reportBackgroundError(config, error);
1402
1862
  }
1863
+ function rememberClearFailure(error) {
1864
+ clearFailure = toSwapAIError(
1865
+ error,
1866
+ "storage_failed",
1867
+ `Could not completely clear classifier "${config.name}"`
1868
+ );
1869
+ reportBackgroundError(config, clearFailure);
1870
+ }
1871
+ function refreshStoredState() {
1872
+ const snapshot = storage.snapshot();
1873
+ dataEpoch = snapshot.dataEpoch;
1874
+ if (snapshot.clearPending) {
1875
+ trained = false;
1876
+ } else {
1877
+ trained = snapshot.trained;
1878
+ clearedEpoch = snapshot.dataEpoch;
1879
+ }
1880
+ return snapshot;
1881
+ }
1403
1882
  function enqueue(operation) {
1404
1883
  const result = operationQueue.then(operation);
1405
1884
  operationQueue = result.catch((error) => {
@@ -1409,14 +1888,15 @@ function createClassifier(config, storage, runtime) {
1409
1888
  });
1410
1889
  return result;
1411
1890
  }
1412
- function persistExample(input, result) {
1891
+ function persistExample(input, result, epoch = dataEpoch) {
1892
+ if (epoch !== dataEpoch) return Promise.resolve();
1413
1893
  return enqueue(() => {
1414
- storage.addExample(input, result);
1415
- scheduleTraining();
1894
+ if (epoch !== dataEpoch) return;
1895
+ if (storage.addExample(input, result, epoch) !== null) scheduleTraining();
1416
1896
  });
1417
1897
  }
1418
1898
  function shouldTrain(snapshot = storage.snapshot()) {
1419
- return !snapshot.trained && snapshot.examplesUsedForTraining < config.maxTrainingSet && snapshot.newExamplesSinceTraining >= config.retrainOnCount;
1899
+ return clearedEpoch === dataEpoch && snapshot.dataEpoch === dataEpoch && !snapshot.clearPending && !snapshot.trained && snapshot.examplesUsedForTraining < config.maxTrainingSet && snapshot.newExamplesSinceTraining >= config.retrainOnCount;
1420
1900
  }
1421
1901
  function scheduleTraining() {
1422
1902
  if (trainingScheduled || !shouldTrain()) return;
@@ -1435,8 +1915,13 @@ function createClassifier(config, storage, runtime) {
1435
1915
  });
1436
1916
  }
1437
1917
  async function trainWhenDue() {
1918
+ const trainingEpoch = dataEpoch;
1438
1919
  if (!shouldTrain()) return false;
1439
- if (!storage.claimTrainingLease(trainingLeaseOwner, TRAINING_LEASE_DURATION_MS)) {
1920
+ if (!storage.claimTrainingLease(
1921
+ trainingLeaseOwner,
1922
+ TRAINING_LEASE_DURATION_MS,
1923
+ trainingEpoch
1924
+ )) {
1440
1925
  return false;
1441
1926
  }
1442
1927
  let leaseHeld = true;
@@ -1445,7 +1930,8 @@ function createClassifier(config, storage, runtime) {
1445
1930
  try {
1446
1931
  leaseHeld = storage.claimTrainingLease(
1447
1932
  trainingLeaseOwner,
1448
- TRAINING_LEASE_DURATION_MS
1933
+ TRAINING_LEASE_DURATION_MS,
1934
+ trainingEpoch
1449
1935
  );
1450
1936
  } catch {
1451
1937
  leaseHeld = false;
@@ -1460,15 +1946,30 @@ function createClassifier(config, storage, runtime) {
1460
1946
  try {
1461
1947
  const snapshot = storage.snapshot();
1462
1948
  if (!shouldTrain(snapshot)) return false;
1463
- const trainingExamples = storage.listExamples("training");
1464
- const heldOutExamples = storage.listExamples("held_out");
1465
- storage.markTrainingAttempted(snapshot.activeExampleCount);
1949
+ if (!storage.markTrainingAttempted(
1950
+ snapshot.activeExampleCount,
1951
+ trainingEpoch
1952
+ )) {
1953
+ return false;
1954
+ }
1955
+ const trainingExamples = storage.listExamplesForTraining(
1956
+ "training",
1957
+ snapshot.activeGeneration,
1958
+ trainingEpoch
1959
+ );
1960
+ const heldOutExamples = storage.listExamplesForTraining(
1961
+ "held_out",
1962
+ snapshot.activeGeneration,
1963
+ trainingEpoch
1964
+ );
1965
+ if (trainingExamples === null || heldOutExamples === null) return false;
1466
1966
  if (trainingExamples.length === 0 || heldOutExamples.length === 0) {
1467
1967
  return true;
1468
1968
  }
1469
1969
  const candidate = await runtime.train({
1470
1970
  classifierName: config.name,
1471
1971
  generation: snapshot.activeGeneration,
1972
+ expectedEpoch: trainingEpoch,
1472
1973
  examples: trainingExamples.map(({ input, result }) => ({ input, result })),
1473
1974
  resultConfig: config.result
1474
1975
  });
@@ -1492,14 +1993,43 @@ function createClassifier(config, storage, runtime) {
1492
1993
  return true;
1493
1994
  }
1494
1995
  if (!renewTrainingLease()) {
1996
+ const current = refreshStoredState();
1997
+ if (current.dataEpoch !== trainingEpoch || current.clearPending) {
1998
+ await runtime.clearClassifierGenerationArtifacts(
1999
+ config.name,
2000
+ snapshot.activeGeneration
2001
+ );
2002
+ return false;
2003
+ }
1495
2004
  throw new SwapAIError(
1496
2005
  "service_unavailable",
1497
2006
  `Classifier "${config.name}" lost its training lease`
1498
2007
  );
1499
2008
  }
1500
- storage.promoteGeneration(candidate);
2009
+ if (trainingEpoch !== refreshStoredState().dataEpoch) {
2010
+ await runtime.clearClassifierGenerationArtifacts(
2011
+ config.name,
2012
+ snapshot.activeGeneration
2013
+ );
2014
+ return false;
2015
+ }
2016
+ if (storage.promoteGeneration(candidate, trainingEpoch) === null) {
2017
+ await runtime.clearClassifierGenerationArtifacts(
2018
+ config.name,
2019
+ snapshot.activeGeneration
2020
+ );
2021
+ return false;
2022
+ }
1501
2023
  if (loadedModel !== null) await loadedModel.close();
2024
+ if (trainingEpoch !== refreshStoredState().dataEpoch) {
2025
+ await runtime.clearClassifierGenerationArtifacts(
2026
+ config.name,
2027
+ snapshot.activeGeneration
2028
+ );
2029
+ return false;
2030
+ }
1502
2031
  loadedModel = candidateModel;
2032
+ loadedModelEpoch = trainingEpoch;
1503
2033
  trained = true;
1504
2034
  return true;
1505
2035
  } finally {
@@ -1510,34 +2040,49 @@ function createClassifier(config, storage, runtime) {
1510
2040
  storage.releaseTrainingLease(trainingLeaseOwner);
1511
2041
  }
1512
2042
  }
1513
- async function model() {
2043
+ async function model(expectedEpoch) {
1514
2044
  await startup;
1515
- if (!trained) {
2045
+ const current = refreshStoredState();
2046
+ if (!trained || current.clearPending || current.dataEpoch !== expectedEpoch) {
1516
2047
  throw new SwapAIError(
1517
2048
  "not_trained",
1518
2049
  `Classifier "${config.name}" has not passed its held-out test`
1519
2050
  );
1520
2051
  }
2052
+ if (loadedModel !== null && loadedModelEpoch !== expectedEpoch) {
2053
+ await loadedModel.close();
2054
+ loadedModel = null;
2055
+ loadedModelEpoch = null;
2056
+ }
1521
2057
  if (loadedModel === null) {
1522
- const snapshot = storage.snapshot();
1523
- if (snapshot.modelPath === null) {
2058
+ if (current.modelPath === null) {
1524
2059
  throw new SwapAIError(
1525
2060
  "service_unavailable",
1526
2061
  `Classifier "${config.name}" is trained but has no saved model`
1527
2062
  );
1528
2063
  }
1529
2064
  await runtime.ready();
1530
- loadedModel = await runtime.loadModel({
1531
- modelPath: snapshot.modelPath,
2065
+ const restored = await runtime.loadModel({
2066
+ modelPath: current.modelPath,
1532
2067
  resultConfig: config.result
1533
2068
  });
2069
+ const afterLoad = refreshStoredState();
2070
+ if (afterLoad.dataEpoch !== expectedEpoch || afterLoad.clearPending || !afterLoad.trained) {
2071
+ await restored.close();
2072
+ throw new SwapAIError(
2073
+ "not_trained",
2074
+ `Classifier "${config.name}" has been cleared`
2075
+ );
2076
+ }
2077
+ loadedModel = restored;
2078
+ loadedModelEpoch = expectedEpoch;
1534
2079
  }
1535
2080
  return loadedModel;
1536
2081
  }
1537
2082
  async function callReference(input, reference) {
1538
2083
  return validateResult(config.result, await reference(input));
1539
2084
  }
1540
- async function fallbackToReference(input, reference, candidateError, retestDue) {
2085
+ async function fallbackToReference(input, reference, candidateError, retestDue, epoch) {
1541
2086
  if (reference === void 0) {
1542
2087
  throw toSwapAIError(
1543
2088
  candidateError,
@@ -1554,22 +2099,26 @@ function createClassifier(config, storage, runtime) {
1554
2099
  )
1555
2100
  );
1556
2101
  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();
2102
+ await persistExample(input, referenceResult, epoch);
2103
+ if (retestDue && epoch === dataEpoch) {
2104
+ if (storage.recordLocalClassification(epoch) === null) return referenceResult;
2105
+ const failures = storage.recordRetest(false, epoch);
2106
+ if (failures !== null && failures >= config.retestRevertOn) {
2107
+ await disableModel(epoch);
2108
+ }
1562
2109
  }
1563
2110
  return referenceResult;
1564
2111
  }
1565
- async function disableModel() {
1566
- storage.archiveAndReset();
2112
+ async function disableModel(epoch) {
2113
+ if (storage.archiveAndReset(epoch) === null) return;
2114
+ refreshStoredState();
1567
2115
  trained = false;
1568
2116
  const previousModel = loadedModel;
1569
2117
  loadedModel = null;
2118
+ loadedModelEpoch = null;
1570
2119
  if (previousModel !== null) await previousModel.close();
1571
2120
  }
1572
- async function classifyNow(input, reference) {
2121
+ async function classifyNow(input, reference, epoch) {
1573
2122
  assertOpen2(closed);
1574
2123
  if (!trained) {
1575
2124
  if (reference === void 0) {
@@ -1579,7 +2128,7 @@ function createClassifier(config, storage, runtime) {
1579
2128
  );
1580
2129
  }
1581
2130
  const referenceResult2 = await callReference(input, reference);
1582
- await persistExample(input, referenceResult2);
2131
+ await persistExample(input, referenceResult2, epoch);
1583
2132
  return referenceResult2;
1584
2133
  }
1585
2134
  const snapshot = storage.snapshot();
@@ -1588,37 +2137,190 @@ function createClassifier(config, storage, runtime) {
1588
2137
  try {
1589
2138
  candidateResult = validateResult(
1590
2139
  config.result,
1591
- await (await model()).classify(input)
2140
+ await (await model(epoch)).classify(input)
1592
2141
  );
1593
2142
  } catch (error) {
1594
- return fallbackToReference(input, reference, error, retestDue);
2143
+ return fallbackToReference(input, reference, error, retestDue, epoch);
2144
+ }
2145
+ const afterClassification = refreshStoredState();
2146
+ if (afterClassification.dataEpoch !== epoch || afterClassification.clearPending) {
2147
+ return fallbackToReference(
2148
+ input,
2149
+ reference,
2150
+ new SwapAIError(
2151
+ "not_trained",
2152
+ `Classifier "${config.name}" was cleared during classification`
2153
+ ),
2154
+ retestDue,
2155
+ epoch
2156
+ );
1595
2157
  }
1596
2158
  if (!retestDue || reference === void 0) {
1597
- storage.recordLocalClassification();
2159
+ if (epoch !== dataEpoch || storage.recordLocalClassification(epoch) === null) {
2160
+ return fallbackToReference(
2161
+ input,
2162
+ reference,
2163
+ new SwapAIError(
2164
+ "not_trained",
2165
+ `Classifier "${config.name}" was cleared before returning its result`
2166
+ ),
2167
+ retestDue,
2168
+ epoch
2169
+ );
2170
+ }
1598
2171
  return candidateResult;
1599
2172
  }
1600
2173
  const referenceResult = await callReference(input, reference);
1601
- await persistExample(input, referenceResult);
1602
- storage.recordLocalClassification();
2174
+ await persistExample(input, referenceResult, epoch);
2175
+ if (epoch !== dataEpoch) return referenceResult;
2176
+ if (storage.recordLocalClassification(epoch) === null) return referenceResult;
1603
2177
  const passed = resultError(config.result, referenceResult, candidateResult) <= config.acceptableError;
1604
- const failures = storage.recordRetest(passed);
1605
- if (!passed && failures >= config.retestRevertOn) {
1606
- await disableModel();
2178
+ const failures = storage.recordRetest(passed, epoch);
2179
+ if (!passed && failures !== null && failures >= config.retestRevertOn) {
2180
+ await disableModel(epoch);
1607
2181
  }
1608
2182
  return referenceResult;
1609
2183
  }
2184
+ async function performDurableClear(clearEpoch) {
2185
+ trained = false;
2186
+ const deletionFailures = [];
2187
+ const throwDeletionFailures = () => {
2188
+ if (deletionFailures.length === 1) throw deletionFailures[0];
2189
+ if (deletionFailures.length > 1) {
2190
+ throw new AggregateError(
2191
+ deletionFailures,
2192
+ `Could not completely clear classifier "${config.name}"`
2193
+ );
2194
+ }
2195
+ };
2196
+ const previousModel = loadedModel;
2197
+ if (previousModel !== null) {
2198
+ try {
2199
+ await previousModel.close();
2200
+ if (loadedModel === previousModel) {
2201
+ loadedModel = null;
2202
+ loadedModelEpoch = null;
2203
+ }
2204
+ } catch (error) {
2205
+ deletionFailures.push(error);
2206
+ }
2207
+ }
2208
+ const lockWaitStarted = Date.now();
2209
+ let clearState = refreshStoredState();
2210
+ while (clearState.clearPending && clearState.dataEpoch === clearEpoch && (clearState.trainingLeaseOwner !== null && clearState.trainingLeaseEpoch === null && clearState.trainingLeaseUntil !== null && clearState.trainingLeaseUntil > Date.now() || !storage.claimArtifactWriteLock())) {
2211
+ if (Date.now() - lockWaitStarted >= ARTIFACT_LOCK_WAIT_MS) {
2212
+ throw new SwapAIError(
2213
+ "storage_failed",
2214
+ `Timed out waiting to clear classifier "${config.name}" while training was still running`
2215
+ );
2216
+ }
2217
+ await new Promise((resolve2) => setTimeout(resolve2, 10));
2218
+ clearState = refreshStoredState();
2219
+ }
2220
+ if (!clearState.clearPending || clearState.dataEpoch !== clearEpoch) {
2221
+ throwDeletionFailures();
2222
+ return;
2223
+ }
2224
+ try {
2225
+ try {
2226
+ storage.eraseTrainingData(clearEpoch);
2227
+ } catch (error) {
2228
+ deletionFailures.push(error);
2229
+ }
2230
+ clearState = refreshStoredState();
2231
+ if (clearState.clearPending && clearState.dataEpoch === clearEpoch && clearState.clearErased && clearState.clearArtifactGenerationMax !== null) {
2232
+ try {
2233
+ await runtime.clearClassifierArtifactsThroughGeneration(
2234
+ config.name,
2235
+ clearState.clearArtifactGenerationMax
2236
+ );
2237
+ } catch (error) {
2238
+ deletionFailures.push(error);
2239
+ }
2240
+ }
2241
+ throwDeletionFailures();
2242
+ storage.finishClearTrainingData(clearEpoch);
2243
+ const current = refreshStoredState();
2244
+ if (!current.clearPending) {
2245
+ clearFailure = null;
2246
+ clearedEpoch = current.dataEpoch;
2247
+ }
2248
+ } finally {
2249
+ storage.releaseArtifactWriteLock();
2250
+ }
2251
+ }
2252
+ async function flushNow() {
2253
+ const clearFailureBeforeFlush = clearFailure;
2254
+ await startup;
2255
+ while (true) {
2256
+ const operations = operationQueue;
2257
+ await operations;
2258
+ const training = trainingQueue;
2259
+ await training;
2260
+ if (operations === operationQueue && training === trainingQueue) break;
2261
+ }
2262
+ if (clearFailure !== null && clearFailure !== clearFailureBeforeFlush) {
2263
+ throw clearFailure;
2264
+ }
2265
+ const pending = refreshStoredState();
2266
+ if (pending.clearPending || clearFailureBeforeFlush !== null) {
2267
+ try {
2268
+ await performDurableClear(pending.dataEpoch);
2269
+ if (!refreshStoredState().clearPending) clearFailure = null;
2270
+ } catch (error) {
2271
+ rememberClearFailure(error);
2272
+ }
2273
+ }
2274
+ if (refreshStoredState().clearPending) {
2275
+ clearFailure ??= new SwapAIError(
2276
+ "storage_failed",
2277
+ `Could not completely clear classifier "${config.name}"`
2278
+ );
2279
+ throw clearFailure;
2280
+ }
2281
+ if (clearFailure !== null) throw clearFailure;
2282
+ if (queuedFailure !== null) {
2283
+ const failure = queuedFailure;
2284
+ queuedFailure = null;
2285
+ throw failure;
2286
+ }
2287
+ }
1610
2288
  const classifier = {
1611
2289
  isTrained() {
2290
+ assertOpen2(closed || closing);
2291
+ refreshStoredState();
1612
2292
  return trained;
1613
2293
  },
1614
2294
  logClassification(input, result) {
1615
- assertOpen2(closed);
2295
+ assertOpen2(closed || closing);
1616
2296
  const validResult = validateResult(config.result, result);
1617
- void persistExample(input, validResult);
2297
+ const epoch = refreshStoredState().dataEpoch;
2298
+ void persistExample(input, validResult, epoch);
2299
+ },
2300
+ clearTrainingData() {
2301
+ assertOpen2(closed || closing);
2302
+ trained = false;
2303
+ const clearEpoch = storage.beginClearTrainingData();
2304
+ dataEpoch = clearEpoch;
2305
+ const trainingBeforeClear = trainingQueue;
2306
+ const classificationsBeforeClear = classificationQueue;
2307
+ void enqueue(async () => {
2308
+ await startup;
2309
+ await trainingBeforeClear;
2310
+ await classificationsBeforeClear;
2311
+ try {
2312
+ await performDurableClear(clearEpoch);
2313
+ } catch (error) {
2314
+ rememberClearFailure(error);
2315
+ }
2316
+ });
1618
2317
  },
1619
2318
  classify(input, reference) {
1620
- assertOpen2(closed);
1621
- const task = classificationQueue.then(() => classifyNow(input, reference));
2319
+ assertOpen2(closed || closing);
2320
+ const epoch = refreshStoredState().dataEpoch;
2321
+ const task = classificationQueue.then(
2322
+ () => classifyNow(input, reference, epoch)
2323
+ );
1622
2324
  classificationQueue = task.then(
1623
2325
  () => void 0,
1624
2326
  () => void 0
@@ -1626,36 +2328,53 @@ function createClassifier(config, storage, runtime) {
1626
2328
  return task;
1627
2329
  },
1628
2330
  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
- }
2331
+ assertOpen2(closed || closing);
2332
+ await flushNow();
1642
2333
  },
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();
2334
+ close() {
2335
+ if (closed) return Promise.resolve();
2336
+ if (closePromise !== null) return closePromise;
2337
+ closing = true;
2338
+ closePromise = (async () => {
2339
+ try {
2340
+ await classificationQueue;
2341
+ await flushNow();
2342
+ } catch (error) {
2343
+ closing = false;
2344
+ closePromise = null;
2345
+ throw error;
2346
+ }
2347
+ const failures = [];
2348
+ if (loadedModel !== null) {
2349
+ try {
2350
+ await loadedModel.close();
2351
+ } catch (error) {
2352
+ failures.push(error);
2353
+ }
2354
+ }
1654
2355
  loadedModel = null;
1655
- await runtime.close();
1656
- storage.close();
1657
- }
1658
- if (failure !== void 0) throw failure;
2356
+ loadedModelEpoch = null;
2357
+ try {
2358
+ await runtime.close();
2359
+ } catch (error) {
2360
+ failures.push(error);
2361
+ }
2362
+ try {
2363
+ storage.close();
2364
+ } catch (error) {
2365
+ failures.push(error);
2366
+ }
2367
+ closed = true;
2368
+ closing = false;
2369
+ if (failures.length === 1) throw failures[0];
2370
+ if (failures.length > 1) {
2371
+ throw new AggregateError(
2372
+ failures,
2373
+ `Could not completely close classifier "${config.name}"`
2374
+ );
2375
+ }
2376
+ })();
2377
+ return closePromise;
1659
2378
  }
1660
2379
  };
1661
2380
  return classifier;