@swapai/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/dist/chunk-SXECHZ3L.js +15 -0
- package/dist/chunk-SXECHZ3L.js.map +1 -0
- package/dist/effect.d.ts +7 -0
- package/dist/effect.js +55 -0
- package/dist/effect.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1682 -0
- package/dist/index.js.map +1 -0
- package/dist/types-DTbU1MEi.d.ts +51 -0
- package/package.json +65 -0
- package/python/requirements.in +1 -0
- package/python/requirements.lock +823 -0
- package/python/swapai_worker.py +173 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1682 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SwapAIError
|
|
3
|
+
} from "./chunk-SXECHZ3L.js";
|
|
4
|
+
|
|
5
|
+
// src/classifier.ts
|
|
6
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
7
|
+
import { join as join3 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
function invalidConfiguration(message) {
|
|
11
|
+
throw new SwapAIError("invalid_configuration", message);
|
|
12
|
+
}
|
|
13
|
+
function requirePositiveInteger(value, field) {
|
|
14
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
15
|
+
invalidConfiguration(`${field} must be a positive integer`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function requireNonNegativeInteger(value, field) {
|
|
19
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
20
|
+
invalidConfiguration(`${field} must be a non-negative integer`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function normalizeAcceptableError(value) {
|
|
24
|
+
let normalized;
|
|
25
|
+
if (typeof value === "number") {
|
|
26
|
+
normalized = value;
|
|
27
|
+
} else {
|
|
28
|
+
const match = /^\s*(\d+(?:\.\d+)?)%\s*$/.exec(value);
|
|
29
|
+
if (match === null) {
|
|
30
|
+
invalidConfiguration(
|
|
31
|
+
'acceptableError must be a number or a percentage such as "10%"'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
normalized = Number(match[1]) / 100;
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1) {
|
|
37
|
+
invalidConfiguration("acceptableError must be between 0 and 1");
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
function validateResultConfig(result) {
|
|
42
|
+
if (result.type === "number") {
|
|
43
|
+
if (!Number.isFinite(result.min) || !Number.isFinite(result.max)) {
|
|
44
|
+
invalidConfiguration("number result bounds must be finite");
|
|
45
|
+
}
|
|
46
|
+
if (result.max <= result.min) {
|
|
47
|
+
invalidConfiguration("number result max must be greater than min");
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (result.type === "string") {
|
|
52
|
+
if (result.values.length === 0) {
|
|
53
|
+
invalidConfiguration("string result values must not be empty");
|
|
54
|
+
}
|
|
55
|
+
if (result.values.some((value) => typeof value !== "string") || new Set(result.values).size !== result.values.length) {
|
|
56
|
+
invalidConfiguration("string result values must be unique strings");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalizeConfig(config) {
|
|
61
|
+
if (typeof config.name !== "string" || config.name.trim() === "") {
|
|
62
|
+
invalidConfiguration("name must not be empty");
|
|
63
|
+
}
|
|
64
|
+
validateResultConfig(config.result);
|
|
65
|
+
requirePositiveInteger(config.retrainOnCount, "retrainOnCount");
|
|
66
|
+
requireNonNegativeInteger(config.retestInterval, "retestInterval");
|
|
67
|
+
requirePositiveInteger(config.retestRevertOn, "retestRevertOn");
|
|
68
|
+
requirePositiveInteger(config.maxTrainingSet, "maxTrainingSet");
|
|
69
|
+
if (config.maxTrainingSet < config.retrainOnCount) {
|
|
70
|
+
invalidConfiguration(
|
|
71
|
+
"maxTrainingSet must be greater than or equal to retrainOnCount"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (config.model !== "needle2") {
|
|
75
|
+
invalidConfiguration('model must be "needle2"');
|
|
76
|
+
}
|
|
77
|
+
if (config.dataDirectory !== void 0 && (typeof config.dataDirectory !== "string" || config.dataDirectory.trim() === "")) {
|
|
78
|
+
invalidConfiguration("dataDirectory must not be empty");
|
|
79
|
+
}
|
|
80
|
+
if (config.onBackgroundError !== void 0 && typeof config.onBackgroundError !== "function") {
|
|
81
|
+
invalidConfiguration("onBackgroundError must be a function");
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
...config,
|
|
85
|
+
acceptableError: normalizeAcceptableError(config.acceptableError)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function validateResult(config, value) {
|
|
89
|
+
const valid = config.type === "number" ? typeof value === "number" && Number.isFinite(value) && value >= config.min && value <= config.max : config.type === "boolean" ? typeof value === "boolean" : typeof value === "string" && config.values.includes(value);
|
|
90
|
+
if (!valid) {
|
|
91
|
+
throw new SwapAIError(
|
|
92
|
+
"invalid_result",
|
|
93
|
+
"classification result does not match the declared allowed results"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/evaluation.ts
|
|
100
|
+
function resultError(config, reference, candidate) {
|
|
101
|
+
const validReference = validateResult(config, reference);
|
|
102
|
+
const validCandidate = validateResult(config, candidate);
|
|
103
|
+
if (config.type === "number") {
|
|
104
|
+
return Math.abs(validReference - validCandidate) / (config.max - config.min);
|
|
105
|
+
}
|
|
106
|
+
return validReference === validCandidate ? 0 : 1;
|
|
107
|
+
}
|
|
108
|
+
function averageError(config, comparisons) {
|
|
109
|
+
if (comparisons.length === 0) {
|
|
110
|
+
throw new SwapAIError(
|
|
111
|
+
"invalid_result",
|
|
112
|
+
"at least one held-out example is required to calculate average error"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const total = comparisons.reduce(
|
|
116
|
+
(sum, comparison) => sum + resultError(config, comparison.reference, comparison.candidate),
|
|
117
|
+
0
|
|
118
|
+
);
|
|
119
|
+
return total / comparisons.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/runtime.ts
|
|
123
|
+
import { createHash, randomUUID } from "crypto";
|
|
124
|
+
import { spawn } from "child_process";
|
|
125
|
+
import {
|
|
126
|
+
chmod,
|
|
127
|
+
copyFile,
|
|
128
|
+
mkdir,
|
|
129
|
+
readFile,
|
|
130
|
+
readdir,
|
|
131
|
+
stat,
|
|
132
|
+
writeFile
|
|
133
|
+
} from "fs/promises";
|
|
134
|
+
import { createInterface } from "readline";
|
|
135
|
+
import { dirname, join } from "path";
|
|
136
|
+
import { fileURLToPath } from "url";
|
|
137
|
+
|
|
138
|
+
// src/needle.ts
|
|
139
|
+
function createNeedleTool(config) {
|
|
140
|
+
const description = "The classification result.";
|
|
141
|
+
let result;
|
|
142
|
+
switch (config.type) {
|
|
143
|
+
case "number":
|
|
144
|
+
result = {
|
|
145
|
+
type: "number",
|
|
146
|
+
minimum: config.min,
|
|
147
|
+
maximum: config.max,
|
|
148
|
+
description
|
|
149
|
+
};
|
|
150
|
+
break;
|
|
151
|
+
case "boolean":
|
|
152
|
+
result = { type: "boolean", description };
|
|
153
|
+
break;
|
|
154
|
+
case "string":
|
|
155
|
+
result = { type: "string", enum: [...config.values], description };
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
name: "classify",
|
|
160
|
+
description: "Classify every supplied input and always return the learned result.",
|
|
161
|
+
parameters: {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: { result },
|
|
164
|
+
required: ["result"],
|
|
165
|
+
additionalProperties: false
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function createTrainingLine(input, value, config) {
|
|
170
|
+
const result = validateNeedleResult(value, config);
|
|
171
|
+
return JSON.stringify({
|
|
172
|
+
query: input,
|
|
173
|
+
tools: [createNeedleTool(config)],
|
|
174
|
+
answers: [{ name: "classify", arguments: { result } }]
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function validateNeedleResult(value, config) {
|
|
178
|
+
switch (config.type) {
|
|
179
|
+
case "number":
|
|
180
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < config.min || value > config.max) {
|
|
181
|
+
throw new TypeError(
|
|
182
|
+
`Needle returned a number outside ${config.min} to ${config.max}.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
case "boolean":
|
|
187
|
+
if (typeof value !== "boolean") {
|
|
188
|
+
throw new TypeError("Needle did not return a boolean result.");
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
case "string":
|
|
192
|
+
if (typeof value !== "string" || !config.values.includes(value)) {
|
|
193
|
+
throw new TypeError("Needle returned a value that is not an allowed string result.");
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/runtime.ts
|
|
200
|
+
var NEEDLE_VERSION = "2.0.14";
|
|
201
|
+
var UV_VERSION = "0.11.4";
|
|
202
|
+
var PYTHON_VERSION = "3.12";
|
|
203
|
+
var NeedleRuntimeError = class extends SwapAIError {
|
|
204
|
+
constructor(code, message, cause) {
|
|
205
|
+
super(code, message, cause === void 0 ? void 0 : { cause });
|
|
206
|
+
this.name = "NeedleRuntimeError";
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
function createNeedleRuntime(options) {
|
|
210
|
+
return new ManagedNeedleRuntime(options);
|
|
211
|
+
}
|
|
212
|
+
var ManagedNeedleRuntime = class {
|
|
213
|
+
#dataDirectory;
|
|
214
|
+
#runtimeDirectory;
|
|
215
|
+
#runCommand;
|
|
216
|
+
#spawnProcess;
|
|
217
|
+
#fetch;
|
|
218
|
+
#workerPath;
|
|
219
|
+
#requirementsPath;
|
|
220
|
+
#onBackgroundError;
|
|
221
|
+
#models = /* @__PURE__ */ new Set();
|
|
222
|
+
#readyPromise;
|
|
223
|
+
#pythonPath;
|
|
224
|
+
#environment;
|
|
225
|
+
#closed = false;
|
|
226
|
+
constructor(options) {
|
|
227
|
+
this.#dataDirectory = options.dataDirectory;
|
|
228
|
+
this.#runtimeDirectory = join(options.dataDirectory, "runtime");
|
|
229
|
+
this.#runCommand = options.dependencies?.runCommand ?? runCommand;
|
|
230
|
+
this.#spawnProcess = options.dependencies?.spawnProcess ?? spawnProcess;
|
|
231
|
+
this.#fetch = options.dependencies?.fetch ?? globalThis.fetch;
|
|
232
|
+
this.#workerPath = options.dependencies?.workerPath ?? fileURLToPath(new URL("../python/swapai_worker.py", import.meta.url));
|
|
233
|
+
this.#requirementsPath = options.dependencies?.requirementsPath ?? fileURLToPath(new URL("../python/requirements.lock", import.meta.url));
|
|
234
|
+
this.#onBackgroundError = options.onBackgroundError;
|
|
235
|
+
}
|
|
236
|
+
ready() {
|
|
237
|
+
if (this.#closed) {
|
|
238
|
+
return Promise.reject(
|
|
239
|
+
new NeedleRuntimeError("service_unavailable", "Needle runtime is closed.")
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
this.#readyPromise ??= this.#prepare().catch((error) => {
|
|
243
|
+
this.#readyPromise = void 0;
|
|
244
|
+
throw toRuntimeError("Could not prepare Needle.", error);
|
|
245
|
+
});
|
|
246
|
+
return this.#readyPromise;
|
|
247
|
+
}
|
|
248
|
+
async train(options) {
|
|
249
|
+
if (!Number.isSafeInteger(options.generation) || options.generation < 0) {
|
|
250
|
+
throw new NeedleRuntimeError(
|
|
251
|
+
"service_unavailable",
|
|
252
|
+
"Needle generation must be a non-negative integer."
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (options.examples.length === 0) {
|
|
256
|
+
throw new NeedleRuntimeError(
|
|
257
|
+
"service_unavailable",
|
|
258
|
+
"Needle needs at least one training example."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
await this.ready();
|
|
262
|
+
const classifierDirectory = join(
|
|
263
|
+
this.#dataDirectory,
|
|
264
|
+
"classifiers",
|
|
265
|
+
classifierKey(options.classifierName)
|
|
266
|
+
);
|
|
267
|
+
const generationDirectory = join(
|
|
268
|
+
classifierDirectory,
|
|
269
|
+
`generation-${options.generation}`
|
|
270
|
+
);
|
|
271
|
+
const checkpointDirectory = join(generationDirectory, "checkpoints");
|
|
272
|
+
const candidateDirectory = join(
|
|
273
|
+
generationDirectory,
|
|
274
|
+
"candidates",
|
|
275
|
+
randomUUID()
|
|
276
|
+
);
|
|
277
|
+
const trainingPath = join(candidateDirectory, "training.jsonl");
|
|
278
|
+
const modelPath = join(candidateDirectory, "model.cact");
|
|
279
|
+
await mkdir(checkpointDirectory, { recursive: true, mode: 448 });
|
|
280
|
+
await mkdir(candidateDirectory, { recursive: true, mode: 448 });
|
|
281
|
+
const lines = options.examples.map(
|
|
282
|
+
(example) => createTrainingLine(example.input, example.result, options.resultConfig)
|
|
283
|
+
);
|
|
284
|
+
await writeFile(trainingPath, `${lines.join("\n")}
|
|
285
|
+
`, "utf8");
|
|
286
|
+
try {
|
|
287
|
+
await this.#runCommand(
|
|
288
|
+
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
|
+
],
|
|
301
|
+
{
|
|
302
|
+
cwd: candidateDirectory,
|
|
303
|
+
env: this.#environment,
|
|
304
|
+
timeoutMs: 6 * 60 * 60 * 1e3
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
await stat(modelPath);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw toRuntimeError("Needle training failed.", error);
|
|
310
|
+
}
|
|
311
|
+
return { modelPath, needleVersion: NEEDLE_VERSION };
|
|
312
|
+
}
|
|
313
|
+
async loadModel(options) {
|
|
314
|
+
await this.ready();
|
|
315
|
+
try {
|
|
316
|
+
await stat(options.modelPath);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
throw toRuntimeError(`Needle model does not exist: ${options.modelPath}`, error);
|
|
319
|
+
}
|
|
320
|
+
const schemaPath = `${options.modelPath}.schema.json`;
|
|
321
|
+
await writeFile(
|
|
322
|
+
schemaPath,
|
|
323
|
+
JSON.stringify([createNeedleTool(options.resultConfig)]),
|
|
324
|
+
"utf8"
|
|
325
|
+
);
|
|
326
|
+
const process2 = this.#spawnProcess(
|
|
327
|
+
this.#pythonPath,
|
|
328
|
+
[
|
|
329
|
+
"-u",
|
|
330
|
+
this.#workerPath,
|
|
331
|
+
"serve",
|
|
332
|
+
"--model",
|
|
333
|
+
options.modelPath,
|
|
334
|
+
"--schema",
|
|
335
|
+
schemaPath
|
|
336
|
+
],
|
|
337
|
+
{ cwd: dirname(options.modelPath), env: this.#environment }
|
|
338
|
+
);
|
|
339
|
+
const model = new NeedleModelProcess(
|
|
340
|
+
process2,
|
|
341
|
+
options.resultConfig,
|
|
342
|
+
(error) => this.#onBackgroundError?.(error),
|
|
343
|
+
() => this.#models.delete(model)
|
|
344
|
+
);
|
|
345
|
+
this.#models.add(model);
|
|
346
|
+
try {
|
|
347
|
+
await model.ready();
|
|
348
|
+
return model;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.#models.delete(model);
|
|
351
|
+
process2.kill("SIGTERM");
|
|
352
|
+
throw toRuntimeError("Needle model could not start.", error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async close() {
|
|
356
|
+
if (this.#closed) return;
|
|
357
|
+
this.#closed = true;
|
|
358
|
+
await Promise.allSettled([...this.#models].map((model) => model.close()));
|
|
359
|
+
this.#models.clear();
|
|
360
|
+
}
|
|
361
|
+
async #prepare() {
|
|
362
|
+
const installDirectory = join(
|
|
363
|
+
this.#runtimeDirectory,
|
|
364
|
+
`needle-${NEEDLE_VERSION}`
|
|
365
|
+
);
|
|
366
|
+
const environmentDirectory = join(installDirectory, ".venv");
|
|
367
|
+
const markerPath = join(installDirectory, "installed.json");
|
|
368
|
+
await mkdir(installDirectory, { recursive: true });
|
|
369
|
+
const baseEnvironment = this.#makeEnvironment();
|
|
370
|
+
const uvPath = await this.#findOrInstallUv(baseEnvironment);
|
|
371
|
+
const pythonPath = pythonIn(environmentDirectory);
|
|
372
|
+
const environment = this.#makeEnvironment(uvPath, environmentDirectory);
|
|
373
|
+
if (!await exists(pythonPath)) {
|
|
374
|
+
await this.#runCommand(
|
|
375
|
+
uvPath,
|
|
376
|
+
["venv", "--python", PYTHON_VERSION, environmentDirectory],
|
|
377
|
+
{ cwd: installDirectory, env: environment, timeoutMs: 15 * 60 * 1e3 }
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const requirementsDigest = createHash("sha256").update(await readFile(this.#requirementsPath)).digest("hex");
|
|
381
|
+
const marker = await readJson(markerPath);
|
|
382
|
+
let installRequired = marker?.needleVersion !== NEEDLE_VERSION || marker.requirementsDigest !== requirementsDigest;
|
|
383
|
+
let forceReinstall = false;
|
|
384
|
+
const healthCheck = [
|
|
385
|
+
"-c",
|
|
386
|
+
`import needle, jax, flax, optax; assert needle.__version__ == "${NEEDLE_VERSION}"`
|
|
387
|
+
];
|
|
388
|
+
if (!installRequired) {
|
|
389
|
+
try {
|
|
390
|
+
await this.#runCommand(pythonPath, healthCheck, {
|
|
391
|
+
cwd: installDirectory,
|
|
392
|
+
env: environment,
|
|
393
|
+
timeoutMs: 6e4
|
|
394
|
+
});
|
|
395
|
+
} catch {
|
|
396
|
+
installRequired = true;
|
|
397
|
+
forceReinstall = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (installRequired) {
|
|
401
|
+
await this.#runCommand(
|
|
402
|
+
uvPath,
|
|
403
|
+
[
|
|
404
|
+
"pip",
|
|
405
|
+
"install",
|
|
406
|
+
"--python",
|
|
407
|
+
pythonPath,
|
|
408
|
+
...forceReinstall ? ["--reinstall"] : [],
|
|
409
|
+
"--require-hashes",
|
|
410
|
+
"--requirement",
|
|
411
|
+
this.#requirementsPath
|
|
412
|
+
],
|
|
413
|
+
{ cwd: installDirectory, env: environment, timeoutMs: 30 * 60 * 1e3 }
|
|
414
|
+
);
|
|
415
|
+
await this.#runCommand(pythonPath, healthCheck, {
|
|
416
|
+
cwd: installDirectory,
|
|
417
|
+
env: environment,
|
|
418
|
+
timeoutMs: 6e4
|
|
419
|
+
});
|
|
420
|
+
await writeFile(
|
|
421
|
+
markerPath,
|
|
422
|
+
JSON.stringify({ needleVersion: NEEDLE_VERSION, requirementsDigest }),
|
|
423
|
+
"utf8"
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
this.#pythonPath = pythonPath;
|
|
427
|
+
this.#environment = environment;
|
|
428
|
+
}
|
|
429
|
+
#makeEnvironment(uvPath, environmentDirectory) {
|
|
430
|
+
const pathParts = [
|
|
431
|
+
environmentDirectory ? dirname(pythonIn(environmentDirectory)) : void 0,
|
|
432
|
+
uvPath ? dirname(uvPath) : void 0,
|
|
433
|
+
process.env.PATH
|
|
434
|
+
].filter((part) => Boolean(part));
|
|
435
|
+
return {
|
|
436
|
+
...process.env,
|
|
437
|
+
PATH: pathParts.join(process.platform === "win32" ? ";" : ":"),
|
|
438
|
+
PYTHONNOUSERSITE: "1",
|
|
439
|
+
PYTHONUNBUFFERED: "1",
|
|
440
|
+
NEEDLE_TELEMETRY: "0",
|
|
441
|
+
DO_NOT_TRACK: "1",
|
|
442
|
+
HF_HOME: join(this.#runtimeDirectory, "huggingface"),
|
|
443
|
+
UV_CACHE_DIR: join(this.#runtimeDirectory, "uv-cache"),
|
|
444
|
+
XDG_CACHE_HOME: join(this.#runtimeDirectory, "cache")
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async #findOrInstallUv(environment) {
|
|
448
|
+
const candidates = [process.env.SWAPAI_UV_PATH, "uv"].filter(
|
|
449
|
+
(candidate) => Boolean(candidate)
|
|
450
|
+
);
|
|
451
|
+
for (const candidate of candidates) {
|
|
452
|
+
try {
|
|
453
|
+
await this.#runCommand(candidate, ["--version"], {
|
|
454
|
+
env: environment,
|
|
455
|
+
timeoutMs: 3e4
|
|
456
|
+
});
|
|
457
|
+
return candidate;
|
|
458
|
+
} catch {
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const managed = join(
|
|
462
|
+
this.#runtimeDirectory,
|
|
463
|
+
"tools",
|
|
464
|
+
process.platform === "win32" ? "uv.exe" : "uv"
|
|
465
|
+
);
|
|
466
|
+
if (!await exists(managed)) {
|
|
467
|
+
await this.#downloadUv(managed, environment);
|
|
468
|
+
}
|
|
469
|
+
await this.#runCommand(managed, ["--version"], {
|
|
470
|
+
env: environment,
|
|
471
|
+
timeoutMs: 3e4
|
|
472
|
+
});
|
|
473
|
+
return managed;
|
|
474
|
+
}
|
|
475
|
+
async #downloadUv(destination, environment) {
|
|
476
|
+
const asset = uvAsset();
|
|
477
|
+
const toolsDirectory = dirname(destination);
|
|
478
|
+
const archivePath = join(toolsDirectory, asset);
|
|
479
|
+
const extractDirectory = join(toolsDirectory, `uv-${UV_VERSION}`);
|
|
480
|
+
await mkdir(extractDirectory, { recursive: true });
|
|
481
|
+
const release = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}`;
|
|
482
|
+
const [archiveResponse, sumsResponse] = await Promise.all([
|
|
483
|
+
this.#fetch(`${release}/${asset}`, {
|
|
484
|
+
signal: AbortSignal.timeout(5 * 60 * 1e3)
|
|
485
|
+
}),
|
|
486
|
+
this.#fetch(`${release}/sha256.sum`, {
|
|
487
|
+
signal: AbortSignal.timeout(5 * 60 * 1e3)
|
|
488
|
+
})
|
|
489
|
+
]);
|
|
490
|
+
if (!archiveResponse.ok || !sumsResponse.ok) {
|
|
491
|
+
throw new Error(`Could not download the managed uv ${UV_VERSION} binary.`);
|
|
492
|
+
}
|
|
493
|
+
const archive = Buffer.from(await archiveResponse.arrayBuffer());
|
|
494
|
+
const sums = await sumsResponse.text();
|
|
495
|
+
const expected = checksumFor(asset, sums);
|
|
496
|
+
const actual = createHash("sha256").update(archive).digest("hex");
|
|
497
|
+
if (actual !== expected) {
|
|
498
|
+
throw new Error("The managed uv download failed its SHA-256 check.");
|
|
499
|
+
}
|
|
500
|
+
await writeFile(archivePath, archive);
|
|
501
|
+
await this.#runCommand(
|
|
502
|
+
"tar",
|
|
503
|
+
[asset.endsWith(".tar.gz") ? "-xzf" : "-xf", archivePath, "-C", extractDirectory],
|
|
504
|
+
{ env: environment, timeoutMs: 5 * 60 * 1e3 }
|
|
505
|
+
);
|
|
506
|
+
const extracted = await findFile(
|
|
507
|
+
extractDirectory,
|
|
508
|
+
process.platform === "win32" ? "uv.exe" : "uv"
|
|
509
|
+
);
|
|
510
|
+
if (!extracted) throw new Error("The managed uv archive did not contain uv.");
|
|
511
|
+
await copyFile(extracted, destination);
|
|
512
|
+
if (process.platform !== "win32") await chmod(destination, 493);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
var NeedleModelProcess = class {
|
|
516
|
+
#process;
|
|
517
|
+
#resultConfig;
|
|
518
|
+
#onBackgroundError;
|
|
519
|
+
#onClose;
|
|
520
|
+
#pending = /* @__PURE__ */ new Map();
|
|
521
|
+
#readyPromise;
|
|
522
|
+
#exitPromise;
|
|
523
|
+
#resolveReady;
|
|
524
|
+
#rejectReady;
|
|
525
|
+
#resolveExit;
|
|
526
|
+
#requestId = 0;
|
|
527
|
+
#ready = false;
|
|
528
|
+
#exited = false;
|
|
529
|
+
#closing = false;
|
|
530
|
+
#stderr = "";
|
|
531
|
+
constructor(process2, resultConfig, onBackgroundError, onClose) {
|
|
532
|
+
this.#process = process2;
|
|
533
|
+
this.#resultConfig = resultConfig;
|
|
534
|
+
this.#onBackgroundError = onBackgroundError;
|
|
535
|
+
this.#onClose = onClose;
|
|
536
|
+
this.#readyPromise = new Promise((resolve, reject) => {
|
|
537
|
+
this.#resolveReady = resolve;
|
|
538
|
+
this.#rejectReady = reject;
|
|
539
|
+
});
|
|
540
|
+
this.#exitPromise = new Promise((resolve) => {
|
|
541
|
+
this.#resolveExit = resolve;
|
|
542
|
+
});
|
|
543
|
+
this.#listen();
|
|
544
|
+
}
|
|
545
|
+
ready() {
|
|
546
|
+
return withTimeout(
|
|
547
|
+
this.#readyPromise,
|
|
548
|
+
3e5,
|
|
549
|
+
"Needle did not become ready within five minutes."
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
classify(input) {
|
|
553
|
+
if (!this.#ready || this.#closing || this.#exited) {
|
|
554
|
+
return Promise.reject(
|
|
555
|
+
new NeedleRuntimeError("service_unavailable", "Needle model is not running.")
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const id = ++this.#requestId;
|
|
559
|
+
const result = new Promise((resolve, reject) => {
|
|
560
|
+
this.#pending.set(id, { resolve, reject });
|
|
561
|
+
this.#process.stdin.write(
|
|
562
|
+
`${JSON.stringify({ id, type: "classify", input })}
|
|
563
|
+
`,
|
|
564
|
+
(error) => {
|
|
565
|
+
if (!error) return;
|
|
566
|
+
this.#pending.delete(id);
|
|
567
|
+
reject(
|
|
568
|
+
new NeedleRuntimeError(
|
|
569
|
+
"service_unavailable",
|
|
570
|
+
"Could not send input to Needle.",
|
|
571
|
+
error
|
|
572
|
+
)
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
return withTimeout(
|
|
578
|
+
result,
|
|
579
|
+
6e4,
|
|
580
|
+
"Needle did not classify the input within one minute.",
|
|
581
|
+
() => this.#pending.delete(id)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
async close() {
|
|
585
|
+
if (this.#exited) return;
|
|
586
|
+
if (!this.#closing) {
|
|
587
|
+
this.#closing = true;
|
|
588
|
+
this.#process.stdin.write(`${JSON.stringify({ type: "close" })}
|
|
589
|
+
`);
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
await withTimeout(this.#exitPromise, 5e3, "Needle did not stop.");
|
|
593
|
+
} catch {
|
|
594
|
+
this.#process.kill("SIGTERM");
|
|
595
|
+
try {
|
|
596
|
+
await withTimeout(this.#exitPromise, 2e3, "Needle ignored SIGTERM.");
|
|
597
|
+
} catch {
|
|
598
|
+
this.#process.kill("SIGKILL");
|
|
599
|
+
await Promise.race([
|
|
600
|
+
this.#exitPromise,
|
|
601
|
+
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
602
|
+
]);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
#listen() {
|
|
607
|
+
const lines = createInterface({ input: this.#process.stdout });
|
|
608
|
+
lines.on("line", (line) => this.#handleLine(line));
|
|
609
|
+
this.#process.stderr.on("data", (chunk) => {
|
|
610
|
+
this.#stderr = `${this.#stderr}${chunk.toString()}`.slice(-16384);
|
|
611
|
+
});
|
|
612
|
+
this.#process.once("error", (error) => {
|
|
613
|
+
this.#fail(
|
|
614
|
+
new NeedleRuntimeError("service_unavailable", "Needle process failed.", error)
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
this.#process.once(
|
|
618
|
+
"exit",
|
|
619
|
+
(code, signal) => {
|
|
620
|
+
this.#exited = true;
|
|
621
|
+
this.#resolveExit();
|
|
622
|
+
this.#onClose();
|
|
623
|
+
if (this.#closing && code === 0) {
|
|
624
|
+
this.#ready = false;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const detail = this.#stderr.trim();
|
|
628
|
+
this.#fail(
|
|
629
|
+
new NeedleRuntimeError(
|
|
630
|
+
"service_unavailable",
|
|
631
|
+
`Needle stopped${code === null ? ` with ${signal ?? "a signal"}` : ` with code ${code}`}${detail ? `: ${detail}` : "."}`
|
|
632
|
+
)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
#handleLine(line) {
|
|
638
|
+
let message;
|
|
639
|
+
try {
|
|
640
|
+
message = JSON.parse(line);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
this.#fail(
|
|
643
|
+
new NeedleRuntimeError(
|
|
644
|
+
"service_unavailable",
|
|
645
|
+
"Needle returned invalid process data.",
|
|
646
|
+
error
|
|
647
|
+
)
|
|
648
|
+
);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (!isRecord(message)) return;
|
|
652
|
+
if (message.type === "ready") {
|
|
653
|
+
if (message.needleVersion !== NEEDLE_VERSION) {
|
|
654
|
+
this.#fail(
|
|
655
|
+
new NeedleRuntimeError(
|
|
656
|
+
"service_unavailable",
|
|
657
|
+
`Needle ${NEEDLE_VERSION} is required; worker reported ${String(message.needleVersion)}.`
|
|
658
|
+
)
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.#ready = true;
|
|
663
|
+
this.#resolveReady();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (message.type === "error" && !this.#ready) {
|
|
667
|
+
this.#fail(
|
|
668
|
+
new NeedleRuntimeError(
|
|
669
|
+
"service_unavailable",
|
|
670
|
+
`Needle could not start: ${String(message.message ?? "unknown error")}`
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (typeof message.id !== "number") return;
|
|
676
|
+
const pending = this.#pending.get(message.id);
|
|
677
|
+
if (!pending) return;
|
|
678
|
+
this.#pending.delete(message.id);
|
|
679
|
+
if (message.ok !== true) {
|
|
680
|
+
const workerError = isRecord(message.error) ? String(message.error.message ?? "Needle classification failed.") : "Needle classification failed.";
|
|
681
|
+
pending.reject(
|
|
682
|
+
new NeedleRuntimeError("classification_failed", workerError)
|
|
683
|
+
);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
pending.resolve(validateNeedleResult(message.result, this.#resultConfig));
|
|
688
|
+
} catch (error) {
|
|
689
|
+
pending.reject(
|
|
690
|
+
new NeedleRuntimeError(
|
|
691
|
+
"classification_failed",
|
|
692
|
+
"Needle returned an invalid classification.",
|
|
693
|
+
error
|
|
694
|
+
)
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
#fail(error) {
|
|
699
|
+
const wasReady = this.#ready;
|
|
700
|
+
this.#ready = false;
|
|
701
|
+
if (!wasReady) this.#rejectReady(error);
|
|
702
|
+
for (const pending of this.#pending.values()) pending.reject(error);
|
|
703
|
+
this.#pending.clear();
|
|
704
|
+
if (wasReady && !this.#closing) this.#onBackgroundError(error);
|
|
705
|
+
if (!this.#exited && !this.#closing) this.#process.kill("SIGTERM");
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
var spawnProcess = (command, args, options) => spawn(command, [...args], {
|
|
709
|
+
cwd: options.cwd,
|
|
710
|
+
env: options.env,
|
|
711
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
712
|
+
});
|
|
713
|
+
var runCommand = (command, args, options) => new Promise((resolve, reject) => {
|
|
714
|
+
const child = spawn(command, [...args], {
|
|
715
|
+
cwd: options?.cwd,
|
|
716
|
+
env: options?.env,
|
|
717
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
718
|
+
});
|
|
719
|
+
let stdout = "";
|
|
720
|
+
let stderr = "";
|
|
721
|
+
let timedOut = false;
|
|
722
|
+
let settled = false;
|
|
723
|
+
let forceKill;
|
|
724
|
+
let rejectAfterKill;
|
|
725
|
+
const timeout = options?.timeoutMs === void 0 ? void 0 : setTimeout(() => {
|
|
726
|
+
timedOut = true;
|
|
727
|
+
child.kill("SIGTERM");
|
|
728
|
+
forceKill = setTimeout(() => {
|
|
729
|
+
child.kill("SIGKILL");
|
|
730
|
+
rejectAfterKill = setTimeout(() => {
|
|
731
|
+
if (settled) return;
|
|
732
|
+
settled = true;
|
|
733
|
+
clearTimers();
|
|
734
|
+
reject(new Error(`${command} exceeded its time limit.`));
|
|
735
|
+
}, 2e3);
|
|
736
|
+
rejectAfterKill.unref();
|
|
737
|
+
}, 2e3);
|
|
738
|
+
forceKill.unref();
|
|
739
|
+
}, options.timeoutMs);
|
|
740
|
+
timeout?.unref();
|
|
741
|
+
const clearTimers = () => {
|
|
742
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
743
|
+
if (forceKill !== void 0) clearTimeout(forceKill);
|
|
744
|
+
if (rejectAfterKill !== void 0) clearTimeout(rejectAfterKill);
|
|
745
|
+
};
|
|
746
|
+
child.stdout.on("data", (chunk) => {
|
|
747
|
+
stdout = `${stdout}${chunk.toString()}`.slice(-262144);
|
|
748
|
+
});
|
|
749
|
+
child.stderr.on("data", (chunk) => {
|
|
750
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-262144);
|
|
751
|
+
});
|
|
752
|
+
child.once("error", (error) => {
|
|
753
|
+
if (settled) return;
|
|
754
|
+
settled = true;
|
|
755
|
+
clearTimers();
|
|
756
|
+
reject(error);
|
|
757
|
+
});
|
|
758
|
+
child.once("exit", (code, signal) => {
|
|
759
|
+
if (settled) return;
|
|
760
|
+
settled = true;
|
|
761
|
+
clearTimers();
|
|
762
|
+
if (timedOut) {
|
|
763
|
+
reject(new Error(`${command} exceeded its time limit.`));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (code === 0) {
|
|
767
|
+
resolve({ stdout, stderr });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
reject(
|
|
771
|
+
new Error(
|
|
772
|
+
`${command} failed${code === null ? ` with ${signal ?? "a signal"}` : ` with code ${code}`}${stderr.trim() ? `: ${stderr.trim()}` : "."}`
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
function classifierKey(name) {
|
|
778
|
+
const readable = name.normalize("NFKD").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "classifier";
|
|
779
|
+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 12);
|
|
780
|
+
return `${readable}-${hash}`;
|
|
781
|
+
}
|
|
782
|
+
function pythonIn(environmentDirectory) {
|
|
783
|
+
return process.platform === "win32" ? join(environmentDirectory, "Scripts", "python.exe") : join(environmentDirectory, "bin", "python");
|
|
784
|
+
}
|
|
785
|
+
async function exists(path) {
|
|
786
|
+
try {
|
|
787
|
+
await stat(path);
|
|
788
|
+
return true;
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async function readJson(path) {
|
|
794
|
+
try {
|
|
795
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
796
|
+
return isRecord(value) ? value : void 0;
|
|
797
|
+
} catch {
|
|
798
|
+
return void 0;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function uvAsset() {
|
|
802
|
+
const key = `${process.platform}-${process.arch}`;
|
|
803
|
+
const assets = {
|
|
804
|
+
"darwin-arm64": "uv-aarch64-apple-darwin.tar.gz",
|
|
805
|
+
"darwin-x64": "uv-x86_64-apple-darwin.tar.gz",
|
|
806
|
+
"linux-arm64": "uv-aarch64-unknown-linux-gnu.tar.gz",
|
|
807
|
+
"linux-x64": "uv-x86_64-unknown-linux-gnu.tar.gz",
|
|
808
|
+
"win32-arm64": "uv-aarch64-pc-windows-msvc.zip",
|
|
809
|
+
"win32-x64": "uv-x86_64-pc-windows-msvc.zip"
|
|
810
|
+
};
|
|
811
|
+
const asset = assets[key];
|
|
812
|
+
if (!asset) throw new Error(`SwapAI does not support uv on ${key}.`);
|
|
813
|
+
return asset;
|
|
814
|
+
}
|
|
815
|
+
function checksumFor(asset, sums) {
|
|
816
|
+
for (const line of sums.split("\n")) {
|
|
817
|
+
const [hash, file] = line.trim().split(/\s+\*?/);
|
|
818
|
+
if (file === asset && /^[a-f0-9]{64}$/i.test(hash ?? "")) return hash;
|
|
819
|
+
}
|
|
820
|
+
throw new Error(`The uv checksum list did not contain ${asset}.`);
|
|
821
|
+
}
|
|
822
|
+
async function findFile(directory, name) {
|
|
823
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
824
|
+
const path = join(directory, entry.name);
|
|
825
|
+
if (entry.isFile() && entry.name === name) return path;
|
|
826
|
+
if (entry.isDirectory()) {
|
|
827
|
+
const nested = await findFile(path, name);
|
|
828
|
+
if (nested) return nested;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return void 0;
|
|
832
|
+
}
|
|
833
|
+
function withTimeout(promise, milliseconds, message, onTimeout) {
|
|
834
|
+
return new Promise((resolve, reject) => {
|
|
835
|
+
const timer = setTimeout(() => {
|
|
836
|
+
onTimeout?.();
|
|
837
|
+
reject(new NeedleRuntimeError("service_unavailable", message));
|
|
838
|
+
}, milliseconds);
|
|
839
|
+
timer.unref();
|
|
840
|
+
promise.then(
|
|
841
|
+
(value) => {
|
|
842
|
+
clearTimeout(timer);
|
|
843
|
+
resolve(value);
|
|
844
|
+
},
|
|
845
|
+
(error) => {
|
|
846
|
+
clearTimeout(timer);
|
|
847
|
+
reject(error);
|
|
848
|
+
}
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function toRuntimeError(message, cause) {
|
|
853
|
+
return cause instanceof NeedleRuntimeError ? cause : new NeedleRuntimeError("service_unavailable", message, cause);
|
|
854
|
+
}
|
|
855
|
+
function isRecord(value) {
|
|
856
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/storage.ts
|
|
860
|
+
import { createHash as createHash2 } from "crypto";
|
|
861
|
+
import { chmodSync, mkdirSync } from "fs";
|
|
862
|
+
import { createRequire } from "module";
|
|
863
|
+
import { join as join2 } from "path";
|
|
864
|
+
var { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
865
|
+
var SCHEMA = `
|
|
866
|
+
CREATE TABLE IF NOT EXISTS classifiers (
|
|
867
|
+
name TEXT PRIMARY KEY,
|
|
868
|
+
config_json TEXT NOT NULL,
|
|
869
|
+
max_training_set INTEGER NOT NULL,
|
|
870
|
+
active_generation INTEGER NOT NULL DEFAULT 1,
|
|
871
|
+
total_examples_logged INTEGER NOT NULL DEFAULT 0,
|
|
872
|
+
new_examples_since_training INTEGER NOT NULL DEFAULT 0,
|
|
873
|
+
training_attempts INTEGER NOT NULL DEFAULT 0,
|
|
874
|
+
examples_used_for_training INTEGER NOT NULL DEFAULT 0,
|
|
875
|
+
local_classifications_since_retest INTEGER NOT NULL DEFAULT 0,
|
|
876
|
+
total_local_classifications INTEGER NOT NULL DEFAULT 0,
|
|
877
|
+
total_retests INTEGER NOT NULL DEFAULT 0,
|
|
878
|
+
consecutive_retest_failures INTEGER NOT NULL DEFAULT 0,
|
|
879
|
+
training_lease_owner TEXT,
|
|
880
|
+
training_lease_until INTEGER,
|
|
881
|
+
created_at INTEGER NOT NULL,
|
|
882
|
+
updated_at INTEGER NOT NULL
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
CREATE TABLE IF NOT EXISTS generations (
|
|
886
|
+
classifier_name TEXT NOT NULL,
|
|
887
|
+
generation INTEGER NOT NULL,
|
|
888
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
|
|
889
|
+
trained INTEGER NOT NULL DEFAULT 0,
|
|
890
|
+
model_path TEXT,
|
|
891
|
+
needle_version TEXT,
|
|
892
|
+
created_at INTEGER NOT NULL,
|
|
893
|
+
archived_at INTEGER,
|
|
894
|
+
PRIMARY KEY (classifier_name, generation),
|
|
895
|
+
FOREIGN KEY (classifier_name) REFERENCES classifiers(name) ON DELETE CASCADE
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
CREATE TABLE IF NOT EXISTS examples (
|
|
899
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
900
|
+
classifier_name TEXT NOT NULL,
|
|
901
|
+
generation INTEGER NOT NULL,
|
|
902
|
+
input TEXT NOT NULL,
|
|
903
|
+
result_json TEXT NOT NULL,
|
|
904
|
+
split TEXT NOT NULL CHECK (split IN ('training', 'held_out')),
|
|
905
|
+
created_at INTEGER NOT NULL,
|
|
906
|
+
FOREIGN KEY (classifier_name, generation)
|
|
907
|
+
REFERENCES generations(classifier_name, generation) ON DELETE CASCADE
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
CREATE INDEX IF NOT EXISTS examples_by_generation
|
|
911
|
+
ON examples(classifier_name, generation, id);
|
|
912
|
+
CREATE INDEX IF NOT EXISTS examples_by_split
|
|
913
|
+
ON examples(classifier_name, generation, split, id);
|
|
914
|
+
`;
|
|
915
|
+
function assignExampleSplit(name, input) {
|
|
916
|
+
const hash = createHash2("sha256").update(name).update("\0").update(input).digest();
|
|
917
|
+
return hash.readUInt32BE(0) % 10 < 8 ? "training" : "held_out";
|
|
918
|
+
}
|
|
919
|
+
function openStorage(options) {
|
|
920
|
+
if (!Number.isSafeInteger(options.maxTrainingSet) || options.maxTrainingSet <= 0) {
|
|
921
|
+
throw new TypeError("maxTrainingSet must be a positive integer");
|
|
922
|
+
}
|
|
923
|
+
mkdirSync(options.dataDirectory, { recursive: true, mode: 448 });
|
|
924
|
+
chmodSync(options.dataDirectory, 448);
|
|
925
|
+
const databasePath = join2(options.dataDirectory, "swapai.sqlite");
|
|
926
|
+
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`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
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
|
+
});
|
|
960
|
+
let closed = false;
|
|
961
|
+
const storage = {
|
|
962
|
+
databasePath,
|
|
963
|
+
snapshot() {
|
|
964
|
+
assertOpen(closed);
|
|
965
|
+
const row2 = requiredRow(database.prepare(`
|
|
966
|
+
SELECT
|
|
967
|
+
c.name,
|
|
968
|
+
c.config_json,
|
|
969
|
+
c.active_generation,
|
|
970
|
+
c.total_examples_logged,
|
|
971
|
+
c.new_examples_since_training,
|
|
972
|
+
c.training_attempts,
|
|
973
|
+
c.examples_used_for_training,
|
|
974
|
+
c.local_classifications_since_retest,
|
|
975
|
+
c.total_local_classifications,
|
|
976
|
+
c.total_retests,
|
|
977
|
+
c.consecutive_retest_failures,
|
|
978
|
+
g.trained,
|
|
979
|
+
g.model_path,
|
|
980
|
+
g.needle_version,
|
|
981
|
+
(
|
|
982
|
+
SELECT COUNT(*)
|
|
983
|
+
FROM examples e
|
|
984
|
+
WHERE e.classifier_name = c.name
|
|
985
|
+
AND e.generation = c.active_generation
|
|
986
|
+
) AS active_example_count
|
|
987
|
+
FROM classifiers c
|
|
988
|
+
JOIN generations g
|
|
989
|
+
ON g.classifier_name = c.name
|
|
990
|
+
AND g.generation = c.active_generation
|
|
991
|
+
WHERE c.name = ?
|
|
992
|
+
`).get(options.name));
|
|
993
|
+
return {
|
|
994
|
+
name: row2.name,
|
|
995
|
+
config: JSON.parse(row2.config_json),
|
|
996
|
+
activeGeneration: row2.active_generation,
|
|
997
|
+
activeExampleCount: row2.active_example_count,
|
|
998
|
+
totalExamplesLogged: row2.total_examples_logged,
|
|
999
|
+
newExamplesSinceTraining: row2.new_examples_since_training,
|
|
1000
|
+
trainingAttempts: row2.training_attempts,
|
|
1001
|
+
examplesUsedForTraining: row2.examples_used_for_training,
|
|
1002
|
+
localClassificationsSinceRetest: row2.local_classifications_since_retest,
|
|
1003
|
+
totalLocalClassifications: row2.total_local_classifications,
|
|
1004
|
+
totalRetests: row2.total_retests,
|
|
1005
|
+
consecutiveRetestFailures: row2.consecutive_retest_failures,
|
|
1006
|
+
trained: row2.trained === 1,
|
|
1007
|
+
modelPath: row2.model_path,
|
|
1008
|
+
needleVersion: row2.needle_version
|
|
1009
|
+
};
|
|
1010
|
+
},
|
|
1011
|
+
addExample(input, result) {
|
|
1012
|
+
assertOpen(closed);
|
|
1013
|
+
const generation = activeGeneration(database, options.name);
|
|
1014
|
+
const split = assignExampleSplit(options.name, input);
|
|
1015
|
+
const createdAt = Date.now();
|
|
1016
|
+
const resultJson = stringifyJson(result, "classification result");
|
|
1017
|
+
let id = 0;
|
|
1018
|
+
transaction(database, () => {
|
|
1019
|
+
const insertion = database.prepare(`
|
|
1020
|
+
INSERT INTO examples (
|
|
1021
|
+
classifier_name, generation, input, result_json, split, created_at
|
|
1022
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1023
|
+
`).run(options.name, generation, input, resultJson, split, createdAt);
|
|
1024
|
+
id = Number(insertion.lastInsertRowid);
|
|
1025
|
+
database.prepare(`
|
|
1026
|
+
UPDATE classifiers
|
|
1027
|
+
SET total_examples_logged = total_examples_logged + 1,
|
|
1028
|
+
new_examples_since_training = new_examples_since_training + 1,
|
|
1029
|
+
updated_at = ?
|
|
1030
|
+
WHERE name = ?
|
|
1031
|
+
`).run(createdAt, options.name);
|
|
1032
|
+
trimExamples(database, options.name, generation, options.maxTrainingSet);
|
|
1033
|
+
});
|
|
1034
|
+
return { id, generation, input, result, split, createdAt };
|
|
1035
|
+
},
|
|
1036
|
+
listExamples(split, generation) {
|
|
1037
|
+
assertOpen(closed);
|
|
1038
|
+
const selectedGeneration = generation ?? activeGeneration(database, options.name);
|
|
1039
|
+
const rows = split === void 0 ? database.prepare(`
|
|
1040
|
+
SELECT id, generation, input, result_json, split, created_at
|
|
1041
|
+
FROM examples
|
|
1042
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1043
|
+
ORDER BY id
|
|
1044
|
+
`).all(options.name, selectedGeneration) : database.prepare(`
|
|
1045
|
+
SELECT id, generation, input, result_json, split, created_at
|
|
1046
|
+
FROM examples
|
|
1047
|
+
WHERE classifier_name = ? AND generation = ? AND split = ?
|
|
1048
|
+
ORDER BY id
|
|
1049
|
+
`).all(options.name, selectedGeneration, split);
|
|
1050
|
+
return rows.map((value) => mapExample(row(value)));
|
|
1051
|
+
},
|
|
1052
|
+
markTrainingAttempted(exampleCount) {
|
|
1053
|
+
assertOpen(closed);
|
|
1054
|
+
if (!Number.isSafeInteger(exampleCount) || exampleCount <= 0) {
|
|
1055
|
+
throw new TypeError("training example count must be a positive integer");
|
|
1056
|
+
}
|
|
1057
|
+
database.prepare(`
|
|
1058
|
+
UPDATE classifiers
|
|
1059
|
+
SET new_examples_since_training = 0,
|
|
1060
|
+
training_attempts = training_attempts + 1,
|
|
1061
|
+
examples_used_for_training = ?,
|
|
1062
|
+
updated_at = ?
|
|
1063
|
+
WHERE name = ?
|
|
1064
|
+
`).run(exampleCount, Date.now(), options.name);
|
|
1065
|
+
},
|
|
1066
|
+
promoteGeneration(model) {
|
|
1067
|
+
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);
|
|
1075
|
+
},
|
|
1076
|
+
recordLocalClassification() {
|
|
1077
|
+
assertOpen(closed);
|
|
1078
|
+
database.prepare(`
|
|
1079
|
+
UPDATE classifiers
|
|
1080
|
+
SET local_classifications_since_retest = local_classifications_since_retest + 1,
|
|
1081
|
+
total_local_classifications = total_local_classifications + 1,
|
|
1082
|
+
updated_at = ?
|
|
1083
|
+
WHERE name = ?
|
|
1084
|
+
`).run(Date.now(), options.name);
|
|
1085
|
+
return storage.snapshot().localClassificationsSinceRetest;
|
|
1086
|
+
},
|
|
1087
|
+
recordRetest(passed) {
|
|
1088
|
+
assertOpen(closed);
|
|
1089
|
+
database.prepare(`
|
|
1090
|
+
UPDATE classifiers
|
|
1091
|
+
SET local_classifications_since_retest = 0,
|
|
1092
|
+
total_retests = total_retests + 1,
|
|
1093
|
+
consecutive_retest_failures = CASE
|
|
1094
|
+
WHEN ? = 1 THEN 0
|
|
1095
|
+
ELSE consecutive_retest_failures + 1
|
|
1096
|
+
END,
|
|
1097
|
+
updated_at = ?
|
|
1098
|
+
WHERE name = ?
|
|
1099
|
+
`).run(passed ? 1 : 0, Date.now(), options.name);
|
|
1100
|
+
return storage.snapshot().consecutiveRetestFailures;
|
|
1101
|
+
},
|
|
1102
|
+
claimTrainingLease(owner, durationMs) {
|
|
1103
|
+
assertOpen(closed);
|
|
1104
|
+
if (owner.trim() === "" || !Number.isSafeInteger(durationMs) || durationMs <= 0) {
|
|
1105
|
+
throw new TypeError("training lease needs an owner and positive duration");
|
|
1106
|
+
}
|
|
1107
|
+
const now2 = Date.now();
|
|
1108
|
+
const result = database.prepare(`
|
|
1109
|
+
UPDATE classifiers
|
|
1110
|
+
SET training_lease_owner = ?,
|
|
1111
|
+
training_lease_until = ?,
|
|
1112
|
+
updated_at = ?
|
|
1113
|
+
WHERE name = ?
|
|
1114
|
+
AND (
|
|
1115
|
+
training_lease_owner IS NULL
|
|
1116
|
+
OR training_lease_until <= ?
|
|
1117
|
+
OR training_lease_owner = ?
|
|
1118
|
+
)
|
|
1119
|
+
`).run(owner, now2 + durationMs, now2, options.name, now2, owner);
|
|
1120
|
+
return result.changes === 1;
|
|
1121
|
+
},
|
|
1122
|
+
releaseTrainingLease(owner) {
|
|
1123
|
+
assertOpen(closed);
|
|
1124
|
+
database.prepare(`
|
|
1125
|
+
UPDATE classifiers
|
|
1126
|
+
SET training_lease_owner = NULL,
|
|
1127
|
+
training_lease_until = NULL,
|
|
1128
|
+
updated_at = ?
|
|
1129
|
+
WHERE name = ? AND training_lease_owner = ?
|
|
1130
|
+
`).run(Date.now(), options.name, owner);
|
|
1131
|
+
},
|
|
1132
|
+
archiveAndReset() {
|
|
1133
|
+
assertOpen(closed);
|
|
1134
|
+
let nextGeneration = 0;
|
|
1135
|
+
const changedAt = Date.now();
|
|
1136
|
+
transaction(database, () => {
|
|
1137
|
+
const currentGeneration = activeGeneration(database, options.name);
|
|
1138
|
+
database.prepare(`
|
|
1139
|
+
UPDATE generations
|
|
1140
|
+
SET status = 'archived', archived_at = ?
|
|
1141
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1142
|
+
`).run(changedAt, options.name, currentGeneration);
|
|
1143
|
+
const maximum = requiredRow(database.prepare(`
|
|
1144
|
+
SELECT MAX(generation) AS maximum
|
|
1145
|
+
FROM generations
|
|
1146
|
+
WHERE classifier_name = ?
|
|
1147
|
+
`).get(options.name));
|
|
1148
|
+
nextGeneration = maximum.maximum + 1;
|
|
1149
|
+
database.prepare(`
|
|
1150
|
+
INSERT INTO generations (
|
|
1151
|
+
classifier_name, generation, status, created_at
|
|
1152
|
+
) VALUES (?, ?, 'active', ?)
|
|
1153
|
+
`).run(options.name, nextGeneration, changedAt);
|
|
1154
|
+
database.prepare(`
|
|
1155
|
+
UPDATE classifiers
|
|
1156
|
+
SET active_generation = ?,
|
|
1157
|
+
new_examples_since_training = 0,
|
|
1158
|
+
training_attempts = 0,
|
|
1159
|
+
examples_used_for_training = 0,
|
|
1160
|
+
training_lease_owner = NULL,
|
|
1161
|
+
training_lease_until = NULL,
|
|
1162
|
+
local_classifications_since_retest = 0,
|
|
1163
|
+
consecutive_retest_failures = 0,
|
|
1164
|
+
updated_at = ?
|
|
1165
|
+
WHERE name = ?
|
|
1166
|
+
`).run(nextGeneration, changedAt, options.name);
|
|
1167
|
+
});
|
|
1168
|
+
return generationByNumber(database, options.name, nextGeneration);
|
|
1169
|
+
},
|
|
1170
|
+
listGenerations() {
|
|
1171
|
+
assertOpen(closed);
|
|
1172
|
+
return database.prepare(`
|
|
1173
|
+
SELECT
|
|
1174
|
+
generation, status, trained, model_path, needle_version,
|
|
1175
|
+
created_at, archived_at
|
|
1176
|
+
FROM generations
|
|
1177
|
+
WHERE classifier_name = ?
|
|
1178
|
+
ORDER BY generation
|
|
1179
|
+
`).all(options.name).map((value) => mapGeneration(row(value)));
|
|
1180
|
+
},
|
|
1181
|
+
close() {
|
|
1182
|
+
if (closed) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
closed = true;
|
|
1186
|
+
database.close();
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
return storage;
|
|
1190
|
+
}
|
|
1191
|
+
function activeGeneration(database, name) {
|
|
1192
|
+
const active = requiredRow(database.prepare(`
|
|
1193
|
+
SELECT active_generation FROM classifiers WHERE name = ?
|
|
1194
|
+
`).get(name));
|
|
1195
|
+
return active.active_generation;
|
|
1196
|
+
}
|
|
1197
|
+
function generationByNumber(database, name, generation) {
|
|
1198
|
+
const stored = requiredRow(database.prepare(`
|
|
1199
|
+
SELECT
|
|
1200
|
+
generation, status, trained, model_path, needle_version,
|
|
1201
|
+
created_at, archived_at
|
|
1202
|
+
FROM generations
|
|
1203
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1204
|
+
`).get(name, generation));
|
|
1205
|
+
return mapGeneration(stored);
|
|
1206
|
+
}
|
|
1207
|
+
function trimExamples(database, name, generation, maxTrainingSet) {
|
|
1208
|
+
database.prepare(`
|
|
1209
|
+
DELETE FROM examples
|
|
1210
|
+
WHERE classifier_name = ?
|
|
1211
|
+
AND generation = ?
|
|
1212
|
+
AND id NOT IN (
|
|
1213
|
+
SELECT id
|
|
1214
|
+
FROM examples
|
|
1215
|
+
WHERE classifier_name = ? AND generation = ?
|
|
1216
|
+
ORDER BY id DESC
|
|
1217
|
+
LIMIT ?
|
|
1218
|
+
)
|
|
1219
|
+
`).run(name, generation, name, generation, maxTrainingSet);
|
|
1220
|
+
}
|
|
1221
|
+
function mapExample(stored) {
|
|
1222
|
+
return {
|
|
1223
|
+
id: stored.id,
|
|
1224
|
+
generation: stored.generation,
|
|
1225
|
+
input: stored.input,
|
|
1226
|
+
result: JSON.parse(stored.result_json),
|
|
1227
|
+
split: stored.split,
|
|
1228
|
+
createdAt: stored.created_at
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
function mapGeneration(stored) {
|
|
1232
|
+
return {
|
|
1233
|
+
generation: stored.generation,
|
|
1234
|
+
status: stored.status,
|
|
1235
|
+
trained: stored.trained === 1,
|
|
1236
|
+
modelPath: stored.model_path,
|
|
1237
|
+
needleVersion: stored.needle_version,
|
|
1238
|
+
createdAt: stored.created_at,
|
|
1239
|
+
archivedAt: stored.archived_at
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
function transaction(database, action) {
|
|
1243
|
+
database.exec("BEGIN IMMEDIATE");
|
|
1244
|
+
try {
|
|
1245
|
+
action();
|
|
1246
|
+
database.exec("COMMIT");
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
database.exec("ROLLBACK");
|
|
1249
|
+
throw error;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function stringifyJson(value, description) {
|
|
1253
|
+
const serialized = JSON.stringify(value);
|
|
1254
|
+
if (serialized === void 0) {
|
|
1255
|
+
throw new TypeError(`${description} must be JSON serializable`);
|
|
1256
|
+
}
|
|
1257
|
+
return serialized;
|
|
1258
|
+
}
|
|
1259
|
+
function criticalConfigJson(configJson) {
|
|
1260
|
+
const config = JSON.parse(configJson);
|
|
1261
|
+
if (!isRecord2(config)) {
|
|
1262
|
+
throw new TypeError("classifier config must be an object");
|
|
1263
|
+
}
|
|
1264
|
+
const critical = {};
|
|
1265
|
+
for (const key of [
|
|
1266
|
+
"result",
|
|
1267
|
+
"retrainOnCount",
|
|
1268
|
+
"acceptableError",
|
|
1269
|
+
"retestInterval",
|
|
1270
|
+
"retestRevertOn",
|
|
1271
|
+
"model"
|
|
1272
|
+
]) {
|
|
1273
|
+
if (Object.hasOwn(config, key)) {
|
|
1274
|
+
critical[key] = config[key];
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (typeof critical.acceptableError === "string") {
|
|
1278
|
+
const percentage = /^(\d+(?:\.\d+)?)%$/.exec(critical.acceptableError);
|
|
1279
|
+
if (percentage !== null) {
|
|
1280
|
+
critical.acceptableError = Number(percentage[1]) / 100;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (isRecord2(critical.result) && Array.isArray(critical.result.values)) {
|
|
1284
|
+
critical.result = {
|
|
1285
|
+
...critical.result,
|
|
1286
|
+
values: [...critical.result.values].sort(compareJsonValues)
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
return JSON.stringify(sortObjectKeys(critical));
|
|
1290
|
+
}
|
|
1291
|
+
function sortObjectKeys(value) {
|
|
1292
|
+
if (Array.isArray(value)) {
|
|
1293
|
+
return value.map(sortObjectKeys);
|
|
1294
|
+
}
|
|
1295
|
+
if (!isRecord2(value)) {
|
|
1296
|
+
return value;
|
|
1297
|
+
}
|
|
1298
|
+
return Object.fromEntries(
|
|
1299
|
+
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, sortObjectKeys(child)])
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
function compareJsonValues(left, right) {
|
|
1303
|
+
return JSON.stringify(sortObjectKeys(left)).localeCompare(
|
|
1304
|
+
JSON.stringify(sortObjectKeys(right))
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
function isRecord2(value) {
|
|
1308
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1309
|
+
}
|
|
1310
|
+
function assertOpen(closed) {
|
|
1311
|
+
if (closed) {
|
|
1312
|
+
throw new Error("Storage is closed");
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
function row(value) {
|
|
1316
|
+
return value;
|
|
1317
|
+
}
|
|
1318
|
+
function requiredRow(value) {
|
|
1319
|
+
if (value === void 0) {
|
|
1320
|
+
throw new Error("Expected stored classifier data");
|
|
1321
|
+
}
|
|
1322
|
+
return row(value);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// src/classifier.ts
|
|
1326
|
+
var TRAINING_LEASE_DURATION_MS = 5 * 60 * 1e3;
|
|
1327
|
+
var TRAINING_LEASE_RENEWAL_MS = 60 * 1e3;
|
|
1328
|
+
function init(inputConfig) {
|
|
1329
|
+
const config = normalizeConfig(inputConfig);
|
|
1330
|
+
const dataDirectory = config.dataDirectory ?? join3(process.cwd(), ".swapai");
|
|
1331
|
+
const storage = openClassifierStorage(config, dataDirectory);
|
|
1332
|
+
const runtime = createNeedleRuntime({
|
|
1333
|
+
dataDirectory,
|
|
1334
|
+
onBackgroundError: (error) => reportBackgroundError(
|
|
1335
|
+
config,
|
|
1336
|
+
new SwapAIError("service_unavailable", error.message, { cause: error })
|
|
1337
|
+
)
|
|
1338
|
+
});
|
|
1339
|
+
return createClassifier(config, storage, runtime);
|
|
1340
|
+
}
|
|
1341
|
+
function openClassifierStorage(config, dataDirectory) {
|
|
1342
|
+
try {
|
|
1343
|
+
return openStorage({
|
|
1344
|
+
dataDirectory,
|
|
1345
|
+
name: config.name,
|
|
1346
|
+
maxTrainingSet: config.maxTrainingSet,
|
|
1347
|
+
config: {
|
|
1348
|
+
result: config.result,
|
|
1349
|
+
retrainOnCount: config.retrainOnCount,
|
|
1350
|
+
acceptableError: config.acceptableError,
|
|
1351
|
+
retestInterval: config.retestInterval,
|
|
1352
|
+
retestRevertOn: config.retestRevertOn,
|
|
1353
|
+
model: config.model
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
throw new SwapAIError(
|
|
1358
|
+
error instanceof TypeError ? "invalid_configuration" : "storage_failed",
|
|
1359
|
+
error instanceof Error ? error.message : "Could not open SwapAI storage",
|
|
1360
|
+
{ cause: error }
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function createClassifier(config, storage, runtime) {
|
|
1365
|
+
let closed = false;
|
|
1366
|
+
let trained = storage.snapshot().trained;
|
|
1367
|
+
let loadedModel = null;
|
|
1368
|
+
let operationQueue = Promise.resolve();
|
|
1369
|
+
let trainingQueue = Promise.resolve();
|
|
1370
|
+
let trainingScheduled = false;
|
|
1371
|
+
const trainingLeaseOwner = `${process.pid}:${randomUUID2()}`;
|
|
1372
|
+
let classificationQueue = Promise.resolve();
|
|
1373
|
+
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;
|
|
1378
|
+
}
|
|
1379
|
+
const startup = startRuntime();
|
|
1380
|
+
async function startRuntime() {
|
|
1381
|
+
try {
|
|
1382
|
+
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
|
+
} catch (error) {
|
|
1391
|
+
const swapAIError = toSwapAIError(
|
|
1392
|
+
error,
|
|
1393
|
+
"service_unavailable",
|
|
1394
|
+
"Needle could not start"
|
|
1395
|
+
);
|
|
1396
|
+
rememberBackgroundFailure(swapAIError);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
function rememberBackgroundFailure(error) {
|
|
1400
|
+
queuedFailure = error;
|
|
1401
|
+
reportBackgroundError(config, error);
|
|
1402
|
+
}
|
|
1403
|
+
function enqueue(operation) {
|
|
1404
|
+
const result = operationQueue.then(operation);
|
|
1405
|
+
operationQueue = result.catch((error) => {
|
|
1406
|
+
rememberBackgroundFailure(
|
|
1407
|
+
toSwapAIError(error, "storage_failed", "Background work failed")
|
|
1408
|
+
);
|
|
1409
|
+
});
|
|
1410
|
+
return result;
|
|
1411
|
+
}
|
|
1412
|
+
function persistExample(input, result) {
|
|
1413
|
+
return enqueue(() => {
|
|
1414
|
+
storage.addExample(input, result);
|
|
1415
|
+
scheduleTraining();
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
function shouldTrain(snapshot = storage.snapshot()) {
|
|
1419
|
+
return !snapshot.trained && snapshot.examplesUsedForTraining < config.maxTrainingSet && snapshot.newExamplesSinceTraining >= config.retrainOnCount;
|
|
1420
|
+
}
|
|
1421
|
+
function scheduleTraining() {
|
|
1422
|
+
if (trainingScheduled || !shouldTrain()) return;
|
|
1423
|
+
trainingScheduled = true;
|
|
1424
|
+
let completedAttempt = false;
|
|
1425
|
+
const attempt = operationQueue.then(async () => {
|
|
1426
|
+
completedAttempt = await trainWhenDue();
|
|
1427
|
+
});
|
|
1428
|
+
trainingQueue = attempt.catch((error) => {
|
|
1429
|
+
rememberBackgroundFailure(
|
|
1430
|
+
toSwapAIError(error, "service_unavailable", "Needle training failed")
|
|
1431
|
+
);
|
|
1432
|
+
}).finally(() => {
|
|
1433
|
+
trainingScheduled = false;
|
|
1434
|
+
if (completedAttempt) scheduleTraining();
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
async function trainWhenDue() {
|
|
1438
|
+
if (!shouldTrain()) return false;
|
|
1439
|
+
if (!storage.claimTrainingLease(trainingLeaseOwner, TRAINING_LEASE_DURATION_MS)) {
|
|
1440
|
+
return false;
|
|
1441
|
+
}
|
|
1442
|
+
let leaseHeld = true;
|
|
1443
|
+
const renewTrainingLease = () => {
|
|
1444
|
+
if (!leaseHeld) return false;
|
|
1445
|
+
try {
|
|
1446
|
+
leaseHeld = storage.claimTrainingLease(
|
|
1447
|
+
trainingLeaseOwner,
|
|
1448
|
+
TRAINING_LEASE_DURATION_MS
|
|
1449
|
+
);
|
|
1450
|
+
} catch {
|
|
1451
|
+
leaseHeld = false;
|
|
1452
|
+
}
|
|
1453
|
+
return leaseHeld;
|
|
1454
|
+
};
|
|
1455
|
+
const leaseRenewal = setInterval(
|
|
1456
|
+
renewTrainingLease,
|
|
1457
|
+
TRAINING_LEASE_RENEWAL_MS
|
|
1458
|
+
);
|
|
1459
|
+
leaseRenewal.unref();
|
|
1460
|
+
try {
|
|
1461
|
+
const snapshot = storage.snapshot();
|
|
1462
|
+
if (!shouldTrain(snapshot)) return false;
|
|
1463
|
+
const trainingExamples = storage.listExamples("training");
|
|
1464
|
+
const heldOutExamples = storage.listExamples("held_out");
|
|
1465
|
+
storage.markTrainingAttempted(snapshot.activeExampleCount);
|
|
1466
|
+
if (trainingExamples.length === 0 || heldOutExamples.length === 0) {
|
|
1467
|
+
return true;
|
|
1468
|
+
}
|
|
1469
|
+
const candidate = await runtime.train({
|
|
1470
|
+
classifierName: config.name,
|
|
1471
|
+
generation: snapshot.activeGeneration,
|
|
1472
|
+
examples: trainingExamples.map(({ input, result }) => ({ input, result })),
|
|
1473
|
+
resultConfig: config.result
|
|
1474
|
+
});
|
|
1475
|
+
const candidateModel = await runtime.loadModel({
|
|
1476
|
+
modelPath: candidate.modelPath,
|
|
1477
|
+
resultConfig: config.result
|
|
1478
|
+
});
|
|
1479
|
+
try {
|
|
1480
|
+
const comparisons = [];
|
|
1481
|
+
for (const example of heldOutExamples) {
|
|
1482
|
+
const candidateResult = validateResult(
|
|
1483
|
+
config.result,
|
|
1484
|
+
await candidateModel.classify(example.input)
|
|
1485
|
+
);
|
|
1486
|
+
comparisons.push({
|
|
1487
|
+
reference: validateResult(config.result, example.result),
|
|
1488
|
+
candidate: candidateResult
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
if (averageError(config.result, comparisons) > config.acceptableError) {
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
if (!renewTrainingLease()) {
|
|
1495
|
+
throw new SwapAIError(
|
|
1496
|
+
"service_unavailable",
|
|
1497
|
+
`Classifier "${config.name}" lost its training lease`
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
storage.promoteGeneration(candidate);
|
|
1501
|
+
if (loadedModel !== null) await loadedModel.close();
|
|
1502
|
+
loadedModel = candidateModel;
|
|
1503
|
+
trained = true;
|
|
1504
|
+
return true;
|
|
1505
|
+
} finally {
|
|
1506
|
+
if (loadedModel !== candidateModel) await candidateModel.close();
|
|
1507
|
+
}
|
|
1508
|
+
} finally {
|
|
1509
|
+
clearInterval(leaseRenewal);
|
|
1510
|
+
storage.releaseTrainingLease(trainingLeaseOwner);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
async function model() {
|
|
1514
|
+
await startup;
|
|
1515
|
+
if (!trained) {
|
|
1516
|
+
throw new SwapAIError(
|
|
1517
|
+
"not_trained",
|
|
1518
|
+
`Classifier "${config.name}" has not passed its held-out test`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
if (loadedModel === null) {
|
|
1522
|
+
const snapshot = storage.snapshot();
|
|
1523
|
+
if (snapshot.modelPath === null) {
|
|
1524
|
+
throw new SwapAIError(
|
|
1525
|
+
"service_unavailable",
|
|
1526
|
+
`Classifier "${config.name}" is trained but has no saved model`
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
await runtime.ready();
|
|
1530
|
+
loadedModel = await runtime.loadModel({
|
|
1531
|
+
modelPath: snapshot.modelPath,
|
|
1532
|
+
resultConfig: config.result
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
return loadedModel;
|
|
1536
|
+
}
|
|
1537
|
+
async function callReference(input, reference) {
|
|
1538
|
+
return validateResult(config.result, await reference(input));
|
|
1539
|
+
}
|
|
1540
|
+
async function fallbackToReference(input, reference, candidateError, retestDue) {
|
|
1541
|
+
if (reference === void 0) {
|
|
1542
|
+
throw toSwapAIError(
|
|
1543
|
+
candidateError,
|
|
1544
|
+
"classification_failed",
|
|
1545
|
+
"Needle classification failed"
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
reportBackgroundError(
|
|
1549
|
+
config,
|
|
1550
|
+
toSwapAIError(
|
|
1551
|
+
candidateError,
|
|
1552
|
+
"classification_failed",
|
|
1553
|
+
"Needle classification failed; the reference classifier was used"
|
|
1554
|
+
)
|
|
1555
|
+
);
|
|
1556
|
+
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();
|
|
1562
|
+
}
|
|
1563
|
+
return referenceResult;
|
|
1564
|
+
}
|
|
1565
|
+
async function disableModel() {
|
|
1566
|
+
storage.archiveAndReset();
|
|
1567
|
+
trained = false;
|
|
1568
|
+
const previousModel = loadedModel;
|
|
1569
|
+
loadedModel = null;
|
|
1570
|
+
if (previousModel !== null) await previousModel.close();
|
|
1571
|
+
}
|
|
1572
|
+
async function classifyNow(input, reference) {
|
|
1573
|
+
assertOpen2(closed);
|
|
1574
|
+
if (!trained) {
|
|
1575
|
+
if (reference === void 0) {
|
|
1576
|
+
throw new SwapAIError(
|
|
1577
|
+
"not_trained",
|
|
1578
|
+
`Classifier "${config.name}" has not passed its held-out test`
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
const referenceResult2 = await callReference(input, reference);
|
|
1582
|
+
await persistExample(input, referenceResult2);
|
|
1583
|
+
return referenceResult2;
|
|
1584
|
+
}
|
|
1585
|
+
const snapshot = storage.snapshot();
|
|
1586
|
+
const retestDue = config.retestInterval > 0 && snapshot.localClassificationsSinceRetest + 1 >= config.retestInterval;
|
|
1587
|
+
let candidateResult;
|
|
1588
|
+
try {
|
|
1589
|
+
candidateResult = validateResult(
|
|
1590
|
+
config.result,
|
|
1591
|
+
await (await model()).classify(input)
|
|
1592
|
+
);
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
return fallbackToReference(input, reference, error, retestDue);
|
|
1595
|
+
}
|
|
1596
|
+
if (!retestDue || reference === void 0) {
|
|
1597
|
+
storage.recordLocalClassification();
|
|
1598
|
+
return candidateResult;
|
|
1599
|
+
}
|
|
1600
|
+
const referenceResult = await callReference(input, reference);
|
|
1601
|
+
await persistExample(input, referenceResult);
|
|
1602
|
+
storage.recordLocalClassification();
|
|
1603
|
+
const passed = resultError(config.result, referenceResult, candidateResult) <= config.acceptableError;
|
|
1604
|
+
const failures = storage.recordRetest(passed);
|
|
1605
|
+
if (!passed && failures >= config.retestRevertOn) {
|
|
1606
|
+
await disableModel();
|
|
1607
|
+
}
|
|
1608
|
+
return referenceResult;
|
|
1609
|
+
}
|
|
1610
|
+
const classifier = {
|
|
1611
|
+
isTrained() {
|
|
1612
|
+
return trained;
|
|
1613
|
+
},
|
|
1614
|
+
logClassification(input, result) {
|
|
1615
|
+
assertOpen2(closed);
|
|
1616
|
+
const validResult = validateResult(config.result, result);
|
|
1617
|
+
void persistExample(input, validResult);
|
|
1618
|
+
},
|
|
1619
|
+
classify(input, reference) {
|
|
1620
|
+
assertOpen2(closed);
|
|
1621
|
+
const task = classificationQueue.then(() => classifyNow(input, reference));
|
|
1622
|
+
classificationQueue = task.then(
|
|
1623
|
+
() => void 0,
|
|
1624
|
+
() => void 0
|
|
1625
|
+
);
|
|
1626
|
+
return task;
|
|
1627
|
+
},
|
|
1628
|
+
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
|
+
}
|
|
1642
|
+
},
|
|
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();
|
|
1654
|
+
loadedModel = null;
|
|
1655
|
+
await runtime.close();
|
|
1656
|
+
storage.close();
|
|
1657
|
+
}
|
|
1658
|
+
if (failure !== void 0) throw failure;
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
return classifier;
|
|
1662
|
+
}
|
|
1663
|
+
function assertOpen2(closed) {
|
|
1664
|
+
if (closed) {
|
|
1665
|
+
throw new SwapAIError("service_unavailable", "Classifier is closed");
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function toSwapAIError(error, code, message) {
|
|
1669
|
+
if (error instanceof SwapAIError) return error;
|
|
1670
|
+
return new SwapAIError(code, message, { cause: error });
|
|
1671
|
+
}
|
|
1672
|
+
function reportBackgroundError(config, error) {
|
|
1673
|
+
try {
|
|
1674
|
+
config.onBackgroundError?.(error);
|
|
1675
|
+
} catch {
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
export {
|
|
1679
|
+
SwapAIError,
|
|
1680
|
+
init
|
|
1681
|
+
};
|
|
1682
|
+
//# sourceMappingURL=index.js.map
|