@swapai/core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,14 @@ init({
95
95
 
96
96
  The string result is inferred as `"rabbit" | "fish" | "pig"`.
97
97
 
98
+ Needle is a classifier, not a regression engine. For bounded numbers, SwapAI
99
+ converts reference scores into a small, closed set of internal labels, then
100
+ converts the selected label back to a number. When the training set contains
101
+ only a few scores, those scores stay exact. Larger score sets are quantized to
102
+ a bounded set based on `acceptableError`. The held-out test still compares the
103
+ decoded number with the original reference score, so quantization consumes the
104
+ same error budget and cannot bypass the accuracy gate.
105
+
98
106
  ## Effect
99
107
 
100
108
  Effect is optional and lives in a separate import:
package/dist/index.js CHANGED
@@ -46,6 +46,9 @@ function validateResultConfig(result) {
46
46
  if (result.max <= result.min) {
47
47
  invalidConfiguration("number result max must be greater than min");
48
48
  }
49
+ if (!Number.isFinite(result.max - result.min)) {
50
+ invalidConfiguration("number result range must be finite");
51
+ }
49
52
  return;
50
53
  }
51
54
  if (result.type === "string") {
@@ -122,6 +125,7 @@ function averageError(config, comparisons) {
122
125
  // src/runtime.ts
123
126
  import { createHash, randomUUID } from "crypto";
124
127
  import { spawn } from "child_process";
128
+ import { readFileSync, statSync } from "fs";
125
129
  import {
126
130
  chmod,
127
131
  copyFile,
@@ -137,16 +141,66 @@ import { dirname, join, resolve, sep } from "path";
137
141
  import { fileURLToPath } from "url";
138
142
 
139
143
  // src/needle.ts
140
- function createNeedleTool(config) {
144
+ var MAX_NEEDLE_NUMBER_LABELS = 64;
145
+ function createNeedleNumberLabels(values, config, acceptableError = 0) {
146
+ if (typeof acceptableError !== "number" || !Number.isFinite(acceptableError) || acceptableError < 0 || acceptableError > 1) {
147
+ throw new TypeError("acceptableError must be between 0 and 1.");
148
+ }
149
+ const valid = values.map(
150
+ (value) => validateNeedleResult(value, config)
151
+ );
152
+ const unique = [...new Set(valid)].sort((left, right) => left - right);
153
+ const desiredLabelCount = acceptableError === 0 ? MAX_NEEDLE_NUMBER_LABELS : Math.min(
154
+ MAX_NEEDLE_NUMBER_LABELS,
155
+ Math.max(2, Math.floor(1 / (2 * acceptableError)) + 1)
156
+ );
157
+ if (unique.length <= desiredLabelCount) return { values: unique };
158
+ const width = (config.max - config.min) / desiredLabelCount;
159
+ const occupied = /* @__PURE__ */ new Set();
160
+ for (const value of unique) {
161
+ occupied.add(Math.min(
162
+ desiredLabelCount - 1,
163
+ Math.floor((value - config.min) / width)
164
+ ));
165
+ }
166
+ return {
167
+ values: [...occupied].sort((left, right) => left - right).map((index) => config.min + (index + 0.5) * width)
168
+ };
169
+ }
170
+ function numberLabel(index, count) {
171
+ if (count === 1) return "only_score";
172
+ if (count === 2) return index === 0 ? "lower_score" : "higher_score";
173
+ return `score_bucket_${index}`;
174
+ }
175
+ function encodeNumberResult(value, labels) {
176
+ if (labels.values.length === 0) {
177
+ throw new TypeError("Numeric result has no Needle label.");
178
+ }
179
+ let closestIndex = 0;
180
+ let closestDistance = Math.abs(value - labels.values[0]);
181
+ for (let index = 1; index < labels.values.length; index += 1) {
182
+ const distance = Math.abs(value - labels.values[index]);
183
+ if (distance < closestDistance) {
184
+ closestIndex = index;
185
+ closestDistance = distance;
186
+ }
187
+ }
188
+ return numberLabel(closestIndex, labels.values.length);
189
+ }
190
+ function createNeedleTool(config, numberLabels) {
141
191
  const description = "The classification result.";
142
192
  let result;
143
193
  switch (config.type) {
144
194
  case "number":
195
+ if (!numberLabels || numberLabels.values.length === 0) {
196
+ throw new TypeError("Numeric Needle tools require at least one result label.");
197
+ }
145
198
  result = {
146
- type: "number",
147
- minimum: config.min,
148
- maximum: config.max,
149
- description
199
+ type: "string",
200
+ enum: numberLabels.values.map(
201
+ (_, index) => numberLabel(index, numberLabels.values.length)
202
+ ),
203
+ description: `The numeric classification bucket from ${config.min} to ${config.max}.`
150
204
  };
151
205
  break;
152
206
  case "boolean":
@@ -167,12 +221,13 @@ function createNeedleTool(config) {
167
221
  }
168
222
  };
169
223
  }
170
- function createTrainingLine(input, value, config) {
224
+ function createTrainingLine(input, value, config, numberLabels) {
171
225
  const result = validateNeedleResult(value, config);
226
+ const needleResult = config.type === "number" ? encodeNumberResult(result, numberLabels ?? { values: [] }) : result;
172
227
  return JSON.stringify({
173
228
  query: input,
174
- tools: [createNeedleTool(config)],
175
- answers: [{ name: "classify", arguments: { result } }]
229
+ tools: [createNeedleTool(config, numberLabels)],
230
+ answers: [{ name: "classify", arguments: { result: needleResult } }]
176
231
  });
177
232
  }
178
233
  function validateNeedleResult(value, config) {
@@ -196,11 +251,62 @@ function validateNeedleResult(value, config) {
196
251
  return value;
197
252
  }
198
253
  }
254
+ function decodeNeedleResult(result, config, numberLabels) {
255
+ if (config.type === "number") {
256
+ if (typeof result !== "string") {
257
+ throw new TypeError(
258
+ `Needle returned a number outside ${config.min} to ${config.max}.`
259
+ );
260
+ }
261
+ const index = numberLabels?.values.findIndex(
262
+ (_, candidateIndex) => result === numberLabel(candidateIndex, numberLabels.values.length)
263
+ ) ?? -1;
264
+ const decoded = numberLabels?.values[index];
265
+ if (index < 0 || decoded === void 0) {
266
+ throw new TypeError(
267
+ `Needle returned a number outside ${config.min} to ${config.max}.`
268
+ );
269
+ }
270
+ return validateNeedleResult(decoded, config);
271
+ }
272
+ return validateNeedleResult(result, config);
273
+ }
199
274
 
200
275
  // src/runtime.ts
201
276
  var NEEDLE_VERSION = "2.0.14";
277
+ var NEEDLE_NUMBER_MODEL_VERSION = `${NEEDLE_VERSION}/number-buckets-v1`;
202
278
  var UV_VERSION = "0.11.4";
203
279
  var PYTHON_VERSION = "3.12";
280
+ function needleModelVersion(resultConfig) {
281
+ return resultConfig.type === "number" ? NEEDLE_NUMBER_MODEL_VERSION : NEEDLE_VERSION;
282
+ }
283
+ var NeedleModelArtifactError = class extends SwapAIError {
284
+ constructor(message, cause) {
285
+ super(
286
+ "service_unavailable",
287
+ message,
288
+ cause === void 0 ? void 0 : { cause }
289
+ );
290
+ this.name = "NeedleModelArtifactError";
291
+ }
292
+ };
293
+ function hasNeedleModelArtifacts(options) {
294
+ try {
295
+ if (!statSync(options.modelPath).isFile()) return false;
296
+ if (options.resultConfig.type !== "number") return true;
297
+ const saved = JSON.parse(
298
+ readFileSync(`${options.modelPath}.numbers.json`, "utf8")
299
+ );
300
+ if (saved.format !== 1 || !Array.isArray(saved.values) || saved.values.length === 0) return false;
301
+ const savedValues = saved.values;
302
+ const labels = createNeedleNumberLabels(savedValues, options.resultConfig);
303
+ return labels.values.length === savedValues.length && labels.values.every(
304
+ (value, index) => value === savedValues[index]
305
+ );
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
204
310
  var NeedleRuntimeError = class extends SwapAIError {
205
311
  constructor(code, message, cause) {
206
312
  super(code, message, cause === void 0 ? void 0 : { cause });
@@ -285,8 +391,18 @@ var ManagedNeedleRuntime = class {
285
391
  const modelPath = join(candidateDirectory, "model.cact");
286
392
  await mkdir(checkpointDirectory, { recursive: true, mode: 448 });
287
393
  await mkdir(candidateDirectory, { recursive: true, mode: 448 });
394
+ const numberLabels = options.resultConfig.type === "number" ? createNeedleNumberLabels(
395
+ options.examples.map((example) => example.result),
396
+ options.resultConfig,
397
+ options.acceptableError
398
+ ) : void 0;
288
399
  const lines = options.examples.map(
289
- (example) => createTrainingLine(example.input, example.result, options.resultConfig)
400
+ (example) => createTrainingLine(
401
+ example.input,
402
+ example.result,
403
+ options.resultConfig,
404
+ numberLabels
405
+ )
290
406
  );
291
407
  const lockPath = join(
292
408
  this.#dataDirectory,
@@ -294,33 +410,39 @@ var ManagedNeedleRuntime = class {
294
410
  `${createHash("sha256").update(options.classifierName).digest("hex")}.sqlite`
295
411
  );
296
412
  try {
413
+ const trainArguments = [
414
+ this.#workerPath,
415
+ "train",
416
+ "--training-data",
417
+ trainingPath,
418
+ "--output",
419
+ modelPath,
420
+ "--checkpoint-dir",
421
+ checkpointDirectory,
422
+ "--epochs",
423
+ String(options.epochs ?? 10),
424
+ "--artifact-lock-database",
425
+ lockPath,
426
+ "--main-database",
427
+ join(this.#dataDirectory, "swapai.sqlite"),
428
+ "--classifier-name",
429
+ options.classifierName,
430
+ "--expected-epoch",
431
+ String(options.expectedEpoch)
432
+ ];
433
+ if (numberLabels) {
434
+ trainArguments.push("--numeric-labels-from-stdin");
435
+ }
297
436
  await this.#runCommand(
298
437
  this.#pythonPath,
299
- [
300
- this.#workerPath,
301
- "train",
302
- "--training-data",
303
- trainingPath,
304
- "--output",
305
- modelPath,
306
- "--checkpoint-dir",
307
- checkpointDirectory,
308
- "--epochs",
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)
318
- ],
438
+ trainArguments,
319
439
  {
320
440
  cwd: candidateDirectory,
321
441
  env: this.#environment,
322
442
  timeoutMs: 6 * 60 * 60 * 1e3,
323
- stdin: `${lines.join("\n")}
443
+ stdin: numberLabels ? `${JSON.stringify({ format: 1, values: numberLabels.values })}
444
+ ${lines.join("\n")}
445
+ ` : `${lines.join("\n")}
324
446
  `
325
447
  }
326
448
  );
@@ -328,19 +450,51 @@ var ManagedNeedleRuntime = class {
328
450
  } catch (error) {
329
451
  throw toRuntimeError("Needle training failed.", error);
330
452
  }
331
- return { modelPath, needleVersion: NEEDLE_VERSION };
453
+ return {
454
+ modelPath,
455
+ needleVersion: needleModelVersion(options.resultConfig)
456
+ };
332
457
  }
333
458
  async loadModel(options) {
334
459
  await this.ready();
335
460
  try {
336
461
  await stat(options.modelPath);
337
462
  } catch (error) {
338
- throw toRuntimeError(`Needle model does not exist: ${options.modelPath}`, error);
463
+ throw new NeedleModelArtifactError(
464
+ `Needle model does not exist: ${options.modelPath}`,
465
+ error
466
+ );
467
+ }
468
+ let numberLabels;
469
+ if (options.resultConfig.type === "number") {
470
+ try {
471
+ const saved = JSON.parse(
472
+ await readFile(`${options.modelPath}.numbers.json`, "utf8")
473
+ );
474
+ if (saved.format !== 1 || !Array.isArray(saved.values) || saved.values.length === 0) {
475
+ throw new TypeError("Numeric label map has an invalid format.");
476
+ }
477
+ const savedValues = saved.values;
478
+ numberLabels = createNeedleNumberLabels(
479
+ savedValues,
480
+ options.resultConfig
481
+ );
482
+ if (numberLabels.values.length !== savedValues.length || numberLabels.values.some(
483
+ (value, index) => value !== savedValues[index]
484
+ )) {
485
+ throw new TypeError("Numeric label map is not sorted and unique.");
486
+ }
487
+ } catch (error) {
488
+ throw new NeedleModelArtifactError(
489
+ "Could not load Needle numeric labels.",
490
+ error
491
+ );
492
+ }
339
493
  }
340
494
  const schemaPath = `${options.modelPath}.schema.json`;
341
495
  await writeFile(
342
496
  schemaPath,
343
- JSON.stringify([createNeedleTool(options.resultConfig)]),
497
+ JSON.stringify([createNeedleTool(options.resultConfig, numberLabels)]),
344
498
  "utf8"
345
499
  );
346
500
  const process2 = this.#spawnProcess(
@@ -359,6 +513,7 @@ var ManagedNeedleRuntime = class {
359
513
  const model = new NeedleModelProcess(
360
514
  process2,
361
515
  options.resultConfig,
516
+ numberLabels,
362
517
  (error) => this.#onBackgroundError?.(error),
363
518
  () => this.#models.delete(model)
364
519
  );
@@ -599,6 +754,7 @@ var ManagedNeedleRuntime = class {
599
754
  var NeedleModelProcess = class {
600
755
  #process;
601
756
  #resultConfig;
757
+ #numberLabels;
602
758
  #onBackgroundError;
603
759
  #onClose;
604
760
  #pending = /* @__PURE__ */ new Map();
@@ -612,9 +768,10 @@ var NeedleModelProcess = class {
612
768
  #exited = false;
613
769
  #closing = false;
614
770
  #stderr = "";
615
- constructor(process2, resultConfig, onBackgroundError, onClose) {
771
+ constructor(process2, resultConfig, numberLabels, onBackgroundError, onClose) {
616
772
  this.#process = process2;
617
773
  this.#resultConfig = resultConfig;
774
+ this.#numberLabels = numberLabels;
618
775
  this.#onBackgroundError = onBackgroundError;
619
776
  this.#onClose = onClose;
620
777
  this.#readyPromise = new Promise((resolve2, reject) => {
@@ -765,7 +922,13 @@ var NeedleModelProcess = class {
765
922
  return;
766
923
  }
767
924
  try {
768
- pending.resolve(validateNeedleResult(message.result, this.#resultConfig));
925
+ pending.resolve(
926
+ decodeNeedleResult(
927
+ message.result,
928
+ this.#resultConfig,
929
+ this.#numberLabels
930
+ )
931
+ );
769
932
  } catch (error) {
770
933
  pending.reject(
771
934
  new NeedleRuntimeError(
@@ -1359,7 +1522,7 @@ function openStorage(options) {
1359
1522
  WHERE name = ? AND training_lease_owner = ?
1360
1523
  `).run(Date.now(), options.name, owner);
1361
1524
  },
1362
- archiveAndReset(expectedDataEpoch) {
1525
+ archiveAndReset(expectedDataEpoch, resetOptions) {
1363
1526
  assertOpen(closed);
1364
1527
  let nextGeneration = 0;
1365
1528
  const changedAt = Date.now();
@@ -1385,11 +1548,19 @@ function openStorage(options) {
1385
1548
  classifier_name, generation, status, created_at
1386
1549
  ) VALUES (?, ?, 'active', ?)
1387
1550
  `).run(options.name, nextGeneration, changedAt);
1551
+ let retainedExampleCount = 0;
1552
+ if (resetOptions?.retainExamples) {
1553
+ retainedExampleCount = Number(database.prepare(`
1554
+ UPDATE examples
1555
+ SET generation = ?
1556
+ WHERE classifier_name = ? AND generation = ?
1557
+ `).run(nextGeneration, options.name, currentGeneration).changes);
1558
+ }
1388
1559
  database.prepare(`
1389
1560
  UPDATE classifiers
1390
1561
  SET active_generation = ?,
1391
1562
  data_epoch = data_epoch + 1,
1392
- new_examples_since_training = 0,
1563
+ new_examples_since_training = ?,
1393
1564
  training_attempts = 0,
1394
1565
  examples_used_for_training = 0,
1395
1566
  training_lease_owner = NULL,
@@ -1399,7 +1570,12 @@ function openStorage(options) {
1399
1570
  consecutive_retest_failures = 0,
1400
1571
  updated_at = ?
1401
1572
  WHERE name = ?
1402
- `).run(nextGeneration, changedAt, options.name);
1573
+ `).run(
1574
+ nextGeneration,
1575
+ retainedExampleCount,
1576
+ changedAt,
1577
+ options.name
1578
+ );
1403
1579
  reset = true;
1404
1580
  });
1405
1581
  return reset ? generationByNumber(database, options.name, nextGeneration) : null;
@@ -1804,8 +1980,11 @@ function createClassifier(config, storage, runtime) {
1804
1980
  let dataEpoch = initialState.dataEpoch;
1805
1981
  let clearedEpoch = initialState.clearPending ? initialState.dataEpoch - 1 : initialState.dataEpoch;
1806
1982
  const saved = initialState;
1807
- if (!saved.clearPending && saved.trained && (saved.modelPath === null || saved.needleVersion !== NEEDLE_VERSION)) {
1808
- if (storage.archiveAndReset(saved.dataEpoch) !== null) {
1983
+ if (!saved.clearPending && saved.trained && (saved.modelPath === null || saved.needleVersion !== needleModelVersion(config.result) || !hasNeedleModelArtifacts({
1984
+ modelPath: saved.modelPath,
1985
+ resultConfig: config.result
1986
+ }))) {
1987
+ if (storage.archiveAndReset(saved.dataEpoch, { retainExamples: true }) !== null) {
1809
1988
  const reset = storage.snapshot();
1810
1989
  dataEpoch = reset.dataEpoch;
1811
1990
  clearedEpoch = reset.dataEpoch;
@@ -1827,13 +2006,22 @@ function createClassifier(config, storage, runtime) {
1827
2006
  }
1828
2007
  try {
1829
2008
  await runtime.ready();
1830
- const snapshot = storage.snapshot();
1831
- if (snapshot.clearPending) {
1832
- trained = false;
1833
- dataEpoch = snapshot.dataEpoch;
1834
- return;
1835
- }
1836
- if (snapshot.trained && snapshot.modelPath !== null) {
2009
+ } catch (error) {
2010
+ rememberBackgroundFailure(toSwapAIError(
2011
+ error,
2012
+ "service_unavailable",
2013
+ "Needle could not start"
2014
+ ));
2015
+ return;
2016
+ }
2017
+ const snapshot = storage.snapshot();
2018
+ if (snapshot.clearPending) {
2019
+ trained = false;
2020
+ dataEpoch = snapshot.dataEpoch;
2021
+ return;
2022
+ }
2023
+ if (snapshot.trained && snapshot.modelPath !== null) {
2024
+ try {
1837
2025
  const restoredModel = await runtime.loadModel({
1838
2026
  modelPath: snapshot.modelPath,
1839
2027
  resultConfig: config.result
@@ -1846,15 +2034,28 @@ function createClassifier(config, storage, runtime) {
1846
2034
  } else {
1847
2035
  await restoredModel.close();
1848
2036
  }
2037
+ } catch (error) {
2038
+ if (error instanceof NeedleModelArtifactError && storage.archiveAndReset(snapshot.dataEpoch, {
2039
+ retainExamples: true
2040
+ }) !== null) {
2041
+ const reset = refreshStoredState();
2042
+ dataEpoch = reset.dataEpoch;
2043
+ clearedEpoch = reset.dataEpoch;
2044
+ trained = false;
2045
+ }
2046
+ const loadError = toSwapAIError(
2047
+ error,
2048
+ "service_unavailable",
2049
+ error instanceof NeedleModelArtifactError ? `Could not load classifier "${config.name}"; retraining from saved examples` : `Could not load classifier "${config.name}"`
2050
+ );
2051
+ if (error instanceof NeedleModelArtifactError) {
2052
+ reportBackgroundError(config, loadError);
2053
+ } else {
2054
+ rememberBackgroundFailure(loadError);
2055
+ }
1849
2056
  }
1850
- } catch (error) {
1851
- const swapAIError = toSwapAIError(
1852
- error,
1853
- "service_unavailable",
1854
- "Needle could not start"
1855
- );
1856
- rememberBackgroundFailure(swapAIError);
1857
2057
  }
2058
+ scheduleTraining();
1858
2059
  }
1859
2060
  function rememberBackgroundFailure(error) {
1860
2061
  queuedFailure = error;
@@ -1971,7 +2172,8 @@ function createClassifier(config, storage, runtime) {
1971
2172
  generation: snapshot.activeGeneration,
1972
2173
  expectedEpoch: trainingEpoch,
1973
2174
  examples: trainingExamples.map(({ input, result }) => ({ input, result })),
1974
- resultConfig: config.result
2175
+ resultConfig: config.result,
2176
+ acceptableError: config.acceptableError
1975
2177
  });
1976
2178
  const candidateModel = await runtime.loadModel({
1977
2179
  modelPath: candidate.modelPath,
@@ -1980,10 +2182,18 @@ function createClassifier(config, storage, runtime) {
1980
2182
  try {
1981
2183
  const comparisons = [];
1982
2184
  for (const example of heldOutExamples) {
1983
- const candidateResult = validateResult(
1984
- config.result,
1985
- await candidateModel.classify(example.input)
1986
- );
2185
+ let candidateResult;
2186
+ try {
2187
+ candidateResult = validateResult(
2188
+ config.result,
2189
+ await candidateModel.classify(example.input)
2190
+ );
2191
+ } catch (error) {
2192
+ if (error instanceof SwapAIError && error.code === "classification_failed") {
2193
+ return true;
2194
+ }
2195
+ throw error;
2196
+ }
1987
2197
  comparisons.push({
1988
2198
  reference: validateResult(config.result, example.result),
1989
2199
  candidate: candidateResult
@@ -2062,10 +2272,22 @@ function createClassifier(config, storage, runtime) {
2062
2272
  );
2063
2273
  }
2064
2274
  await runtime.ready();
2065
- const restored = await runtime.loadModel({
2066
- modelPath: current.modelPath,
2067
- resultConfig: config.result
2068
- });
2275
+ let restored;
2276
+ try {
2277
+ restored = await runtime.loadModel({
2278
+ modelPath: current.modelPath,
2279
+ resultConfig: config.result
2280
+ });
2281
+ } catch (error) {
2282
+ if (error instanceof NeedleModelArtifactError && storage.archiveAndReset(expectedEpoch, { retainExamples: true }) !== null) {
2283
+ const reset = refreshStoredState();
2284
+ dataEpoch = reset.dataEpoch;
2285
+ clearedEpoch = reset.dataEpoch;
2286
+ trained = false;
2287
+ scheduleTraining();
2288
+ }
2289
+ throw error;
2290
+ }
2069
2291
  const afterLoad = refreshStoredState();
2070
2292
  if (afterLoad.dataEpoch !== expectedEpoch || afterLoad.clearPending || !afterLoad.trained) {
2071
2293
  await restored.close();