@hviana/sema 0.1.4 → 0.1.5

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.
Files changed (122) hide show
  1. package/dist/example/demo.js +25 -25
  2. package/dist/example/train_base.d.ts +21 -19
  3. package/dist/example/train_base.js +1952 -1684
  4. package/dist/src/alphabet.d.ts +3 -3
  5. package/dist/src/alphabet.js +27 -24
  6. package/dist/src/alu/src/alu.d.ts +194 -169
  7. package/dist/src/alu/src/alu.js +414 -374
  8. package/dist/src/alu/src/expr.d.ts +43 -36
  9. package/dist/src/alu/src/expr.js +278 -252
  10. package/dist/src/alu/src/index.d.ts +98 -9
  11. package/dist/src/alu/src/index.js +63 -9
  12. package/dist/src/alu/src/kernel-arith.d.ts +5 -1
  13. package/dist/src/alu/src/kernel-arith.js +285 -195
  14. package/dist/src/alu/src/kernel-bits.d.ts +5 -1
  15. package/dist/src/alu/src/kernel-bits.js +115 -81
  16. package/dist/src/alu/src/kernel-logic.js +63 -33
  17. package/dist/src/alu/src/kernel-nd.js +195 -155
  18. package/dist/src/alu/src/kernel-numeric.d.ts +80 -16
  19. package/dist/src/alu/src/kernel-numeric.js +331 -259
  20. package/dist/src/alu/src/operation.d.ts +132 -112
  21. package/dist/src/alu/src/operation.js +163 -149
  22. package/dist/src/alu/src/parser.d.ts +175 -168
  23. package/dist/src/alu/src/parser.js +421 -382
  24. package/dist/src/alu/src/resonance.d.ts +25 -16
  25. package/dist/src/alu/src/resonance.js +56 -49
  26. package/dist/src/alu/src/text.d.ts +7 -3
  27. package/dist/src/alu/src/text.js +37 -31
  28. package/dist/src/alu/src/value.d.ts +14 -14
  29. package/dist/src/alu/src/value.js +160 -150
  30. package/dist/src/alu/test/alu.test.js +656 -543
  31. package/dist/src/bytes.d.ts +5 -1
  32. package/dist/src/bytes.js +40 -33
  33. package/dist/src/config.d.ts +102 -102
  34. package/dist/src/config.js +83 -83
  35. package/dist/src/derive/src/deduction.d.ts +62 -60
  36. package/dist/src/derive/src/deduction.js +109 -103
  37. package/dist/src/derive/src/index.d.ts +7 -1
  38. package/dist/src/derive/src/priority-queue.d.ts +8 -8
  39. package/dist/src/derive/src/priority-queue.js +61 -57
  40. package/dist/src/derive/src/rewrite.d.ts +18 -15
  41. package/dist/src/derive/src/rewrite.js +67 -59
  42. package/dist/src/derive/src/trie.d.ts +56 -56
  43. package/dist/src/derive/src/trie.js +179 -175
  44. package/dist/src/derive/test/derive.test.js +100 -96
  45. package/dist/src/extension.d.ts +16 -13
  46. package/dist/src/geometry.d.ts +42 -17
  47. package/dist/src/geometry.js +266 -235
  48. package/dist/src/index.d.ts +17 -2
  49. package/dist/src/index.js +7 -1
  50. package/dist/src/ingest-cache.d.ts +30 -25
  51. package/dist/src/ingest-cache.js +127 -108
  52. package/dist/src/mind/articulation.d.ts +5 -1
  53. package/dist/src/mind/articulation.js +112 -77
  54. package/dist/src/mind/attention.d.ts +108 -40
  55. package/dist/src/mind/attention.js +871 -749
  56. package/dist/src/mind/canonical.d.ts +19 -4
  57. package/dist/src/mind/canonical.js +31 -27
  58. package/dist/src/mind/graph-search.d.ts +225 -201
  59. package/dist/src/mind/graph-search.js +821 -742
  60. package/dist/src/mind/index.d.ts +10 -2
  61. package/dist/src/mind/junction.d.ts +58 -31
  62. package/dist/src/mind/junction.js +237 -172
  63. package/dist/src/mind/learning.d.ts +52 -16
  64. package/dist/src/mind/learning.js +165 -143
  65. package/dist/src/mind/match.d.ts +69 -20
  66. package/dist/src/mind/match.js +318 -259
  67. package/dist/src/mind/mechanisms/alu.js +16 -16
  68. package/dist/src/mind/mechanisms/cast.d.ts +13 -9
  69. package/dist/src/mind/mechanisms/cast.js +456 -363
  70. package/dist/src/mind/mechanisms/confluence.d.ts +12 -8
  71. package/dist/src/mind/mechanisms/confluence.js +183 -152
  72. package/dist/src/mind/mechanisms/cover.d.ts +8 -2
  73. package/dist/src/mind/mechanisms/cover.js +210 -148
  74. package/dist/src/mind/mechanisms/extraction.d.ts +34 -8
  75. package/dist/src/mind/mechanisms/extraction.js +288 -234
  76. package/dist/src/mind/mechanisms/recall.d.ts +10 -6
  77. package/dist/src/mind/mechanisms/recall.js +185 -126
  78. package/dist/src/mind/mind.d.ts +154 -129
  79. package/dist/src/mind/mind.js +288 -258
  80. package/dist/src/mind/pipeline-mechanism.d.ts +124 -112
  81. package/dist/src/mind/pipeline-mechanism.js +172 -161
  82. package/dist/src/mind/pipeline.d.ts +14 -4
  83. package/dist/src/mind/pipeline.js +189 -125
  84. package/dist/src/mind/primitives.d.ts +32 -8
  85. package/dist/src/mind/primitives.js +117 -99
  86. package/dist/src/mind/rationale.d.ts +98 -86
  87. package/dist/src/mind/rationale.js +121 -113
  88. package/dist/src/mind/reasoning.d.ts +13 -2
  89. package/dist/src/mind/reasoning.js +166 -129
  90. package/dist/src/mind/recognition.d.ts +4 -1
  91. package/dist/src/mind/recognition.js +208 -183
  92. package/dist/src/mind/resonance.d.ts +22 -5
  93. package/dist/src/mind/resonance.js +0 -0
  94. package/dist/src/mind/trace.d.ts +25 -6
  95. package/dist/src/mind/trace.js +58 -50
  96. package/dist/src/mind/traverse.d.ts +58 -16
  97. package/dist/src/mind/traverse.js +357 -304
  98. package/dist/src/mind/types.d.ts +127 -120
  99. package/dist/src/mind/types.js +69 -60
  100. package/dist/src/rabitq-hnsw/src/database.d.ts +179 -177
  101. package/dist/src/rabitq-hnsw/src/database.js +359 -342
  102. package/dist/src/rabitq-hnsw/src/heap.d.ts +12 -12
  103. package/dist/src/rabitq-hnsw/src/heap.js +80 -75
  104. package/dist/src/rabitq-hnsw/src/hnsw.d.ts +103 -103
  105. package/dist/src/rabitq-hnsw/src/hnsw.js +454 -428
  106. package/dist/src/rabitq-hnsw/src/index.d.ts +6 -1
  107. package/dist/src/rabitq-hnsw/src/prng.d.ts +10 -10
  108. package/dist/src/rabitq-hnsw/src/prng.js +27 -25
  109. package/dist/src/rabitq-hnsw/src/rabitq.d.ts +69 -69
  110. package/dist/src/rabitq-hnsw/src/rabitq.js +262 -246
  111. package/dist/src/rabitq-hnsw/src/store.d.ts +132 -132
  112. package/dist/src/rabitq-hnsw/src/store.js +843 -752
  113. package/dist/src/rabitq-hnsw/test/hnsw.test.js +1054 -805
  114. package/dist/src/sema.d.ts +13 -9
  115. package/dist/src/sema.js +40 -26
  116. package/dist/src/store-sqlite.d.ts +166 -144
  117. package/dist/src/store-sqlite.js +660 -578
  118. package/dist/src/store.d.ts +630 -593
  119. package/dist/src/store.js +1581 -1440
  120. package/dist/src/vec.d.ts +9 -5
  121. package/dist/src/vec.js +73 -61
  122. package/package.json +2 -2
@@ -101,7 +101,19 @@ import { CachedIngest, Mind, SQliteStore } from "../src/index.js";
101
101
  // writing a file is the single capability the web platform does not expose, so
102
102
  // the download sink uses the synchronous fs descriptor calls below. The durable
103
103
  // disk cache is therefore the sole, irreducible Node dependency.
104
- import { closeSync, existsSync, fsyncSync, mkdirSync, openAsBlob, openSync, readdirSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
104
+ import {
105
+ closeSync,
106
+ existsSync,
107
+ fsyncSync,
108
+ mkdirSync,
109
+ openAsBlob,
110
+ openSync,
111
+ readdirSync,
112
+ renameSync,
113
+ statSync,
114
+ unlinkSync,
115
+ writeSync,
116
+ } from "node:fs";
105
117
  import { basename, join } from "node:path";
106
118
  // The ONLY third-party dependencies, and only for the one source that ships
107
119
  // exclusively as Snappy-compressed Parquet (Aya): hyparquet is a pure-JS,
@@ -131,13 +143,16 @@ const env = (k, d) => process.env[k] ?? d;
131
143
  const SMOLSENT = env("SMOLSENT", "1") !== "0";
132
144
  const SMOLSENT_DATASET = env("SMOLSENT_DATASET", "google/smol");
133
145
  const SMOLSENT_PAIRS = (process.env.SMOLSENT_PAIRS ?? "")
134
- .split(",").map((s) => s.trim()).filter(Boolean);
146
+ .split(",").map((s) => s.trim()).filter(Boolean);
135
147
  // The resume id PREFIX for the SmolSent stage; one completed-files entry per
136
148
  // file (e.g. "smolsent::ha_en.jsonl").
137
149
  const SMOLSENT_ID = "smolsent";
138
150
  // A SmolSent side longer than this is skipped (a sentence pair is short; a huge
139
151
  // value is corruption, not a sentence).
140
- const MAX_SMOLSENT_CHARS = Math.max(2_000, Math.floor(Number(env("MAX_SMOLSENT_KB", "16")) * 1000) || 16_000);
152
+ const MAX_SMOLSENT_CHARS = Math.max(
153
+ 2_000,
154
+ Math.floor(Number(env("MAX_SMOLSENT_KB", "16")) * 1000) || 16_000,
155
+ );
141
156
  const DB_PATH = env("DB_PATH", "sema"); // → {DB_PATH}.sqlite
142
157
  const D = Number(env("D", "1024"));
143
158
  const SEED = Number(env("SEED", "7"));
@@ -147,7 +162,10 @@ const SEED = Number(env("SEED", "7"));
147
162
  // deposit checkpoint, nor silently disable checkpointing. The tail (a run that
148
163
  // learns less than one interval, or the remainder past the last interval) is
149
164
  // always saved by finish() at exit — a complete point.
150
- const CHECKPOINT_BYTES = Math.max(1_000_000, Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000);
165
+ const CHECKPOINT_BYTES = Math.max(
166
+ 1_000_000,
167
+ Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000,
168
+ );
151
169
  const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir of *.zip
152
170
  const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
153
171
  const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
@@ -182,8 +200,10 @@ let lastThrottleLog = 0;
182
200
  // carries the running total to/past the ceiling (that item is still counted).
183
201
  const MAX_MB = Number(env("MAX_MB", "Infinity"));
184
202
  if (isNaN(MAX_MB) || MAX_MB < 0) {
185
- process.stderr.write(`fatal: MAX_MB must be a non-negative number or "Infinity"\n`);
186
- process.exit(1);
203
+ process.stderr.write(
204
+ `fatal: MAX_MB must be a non-negative number or "Infinity"\n`,
205
+ );
206
+ process.exit(1);
187
207
  }
188
208
  const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
189
209
  // ── CohereLabs/aya_dataset (the second training stage, after SmolSent) ──
@@ -195,14 +215,20 @@ const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
195
215
  // web-standard Blob byte source — no whole-file-in-memory load. AYA=0 disables
196
216
  // the stage; AYA_URL overrides the Parquet source.
197
217
  const AYA = env("AYA", "1") !== "0";
198
- const AYA_URL = env("AYA_URL", "https://huggingface.co/datasets/CohereLabs/aya_dataset/resolve/main/data/train-00000-of-00001.parquet");
218
+ const AYA_URL = env(
219
+ "AYA_URL",
220
+ "https://huggingface.co/datasets/CohereLabs/aya_dataset/resolve/main/data/train-00000-of-00001.parquet",
221
+ );
199
222
  // The resume id of the Aya stage, kept in the same completed-files set as the
200
223
  // other stages, so one store records the whole curriculum.
201
224
  const AYA_ID = "aya::dataset";
202
225
  // A single Aya field this many chars or longer is skipped: inputs/targets range
203
226
  // up to ~3.3M chars, and a multi-MB "pair" is documentation/dump noise, not a
204
227
  // cognitive example.
205
- const MAX_AYA_FIELD_CHARS = Math.max(10_000, Math.floor(Number(env("MAX_AYA_FIELD_KB", "256")) * 1000) || 256_000);
228
+ const MAX_AYA_FIELD_CHARS = Math.max(
229
+ 10_000,
230
+ Math.floor(Number(env("MAX_AYA_FIELD_KB", "256")) * 1000) || 256_000,
231
+ );
206
232
  // ── OpenAssistant/oasst2 (the fourth training stage, after Aya) ──
207
233
  // oasst2 is a corpus of human↔assistant conversation TREES. Its richest, most
208
234
  // stream-friendly artifact is "<date>_oasst2_ready.trees.jsonl.gz": one JSON
@@ -215,16 +241,25 @@ const MAX_AYA_FIELD_CHARS = Math.max(10_000, Math.floor(Number(env("MAX_AYA_FIEL
215
241
  // least two full user→assistant exchanges) — single Q→A trees are skipped, by
216
242
  // design. OASST=0 disables the stage; OASST_URL overrides the source.
217
243
  const OASST = env("OASST", "1") !== "0";
218
- const OASST_URL = env("OASST_URL", "https://huggingface.co/datasets/OpenAssistant/oasst2/resolve/main/2023-11-05_oasst2_ready.trees.jsonl.gz");
244
+ const OASST_URL = env(
245
+ "OASST_URL",
246
+ "https://huggingface.co/datasets/OpenAssistant/oasst2/resolve/main/2023-11-05_oasst2_ready.trees.jsonl.gz",
247
+ );
219
248
  // The resume id of the oasst2 stage, in the same completed-files set as the
220
249
  // other stages, so one store records the whole curriculum.
221
250
  const OASST_ID = "oasst2::trees";
222
251
  // Multi-turn threshold: a conversation must have at least this many turns to be
223
252
  // trained (4 = user→assistant→user→assistant, the smallest real multi-turn).
224
- const OASST_MIN_TURNS = Math.max(2, Math.floor(Number(env("OASST_MIN_TURNS", "4"))) || 4);
253
+ const OASST_MIN_TURNS = Math.max(
254
+ 2,
255
+ Math.floor(Number(env("OASST_MIN_TURNS", "4"))) || 4,
256
+ );
225
257
  // Skip a tree whose decoded JSON line exceeds this (a pathological record); the
226
258
  // real maximum is far smaller, so this only guards against corruption.
227
- const MAX_OASST_LINE_CHARS = Math.max(100_000, Math.floor(Number(env("MAX_OASST_LINE_MB", "8")) * 1_000_000) || 8_000_000);
259
+ const MAX_OASST_LINE_CHARS = Math.max(
260
+ 100_000,
261
+ Math.floor(Number(env("MAX_OASST_LINE_MB", "8")) * 1_000_000) || 8_000_000,
262
+ );
228
263
  // ── MuskumPillerum/General-Knowledge (the fourth training stage, after oasst2) ──
229
264
  // A ~37.6k-row general-knowledge Q&A set: each row is a single {Question, Answer}
230
265
  // pair. A row is a pure RELATION (question → answer), so it becomes exactly ONE
@@ -232,13 +267,19 @@ const MAX_OASST_LINE_CHARS = Math.max(100_000, Math.floor(Number(env("MAX_OASST_
232
267
  // file (output.json); we DOWNLOAD it and stream the array. GENKNOW=0 disables
233
268
  // the stage; GENKNOW_URL overrides the source.
234
269
  const GENKNOW = env("GENKNOW", "1") !== "0";
235
- const GENKNOW_URL = env("GENKNOW_URL", "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json");
270
+ const GENKNOW_URL = env(
271
+ "GENKNOW_URL",
272
+ "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json",
273
+ );
236
274
  // The resume id of the General-Knowledge stage, in the same completed-files set
237
275
  // as the other stages, so one store records the whole curriculum.
238
276
  const GENKNOW_ID = "genknow::qa";
239
277
  // A Question/Answer longer than this is skipped (answers run to a few hundred
240
278
  // chars; this only guards against a corrupt/runaway field).
241
- const MAX_GENKNOW_CHARS = Math.max(4_000, Math.floor(Number(env("MAX_GENKNOW_KB", "64")) * 1000) || 64_000);
279
+ const MAX_GENKNOW_CHARS = Math.max(
280
+ 4_000,
281
+ Math.floor(Number(env("MAX_GENKNOW_KB", "64")) * 1000) || 64_000,
282
+ );
242
283
  // ═══════════════════════════════════════════════════════════════════════
243
284
  // §2 Terminal + formatting helpers
244
285
  // ═══════════════════════════════════════════════════════════════════════
@@ -249,91 +290,105 @@ const YEL = `${CSI}33m`, RED = `${CSI}31m`;
249
290
  const HIDE = `${CSI}?25l`, SHOW = `${CSI}?25h`;
250
291
  /** Sleep `ms`, but wake early if the shutdown signal fires — so a long back-off
251
292
  * (e.g. a rate-limit wait) never swallows Ctrl+C. Resolves either way. */
252
- const waitMs = (ms) => new Promise((resolve) => {
253
- if (shutdown.signal.aborted)
254
- return resolve();
293
+ const waitMs = (ms) =>
294
+ new Promise((resolve) => {
295
+ if (shutdown.signal.aborted) {
296
+ return resolve();
297
+ }
255
298
  // NOTE: the timer is deliberately NOT unref'd — an unref'd timer does not
256
299
  // keep the event loop alive, so a pending wait (e.g. the pace between page
257
300
  // requests, or a rate-limit back-off) would let Node exit early and the run
258
301
  // would "do nothing and close". The listener lets a shutdown wake it early.
259
302
  const t = setTimeout(done, ms);
260
303
  function done() {
261
- clearTimeout(t);
262
- shutdown.signal.removeEventListener("abort", done);
263
- resolve();
304
+ clearTimeout(t);
305
+ shutdown.signal.removeEventListener("abort", done);
306
+ resolve();
264
307
  }
265
308
  shutdown.signal.addEventListener("abort", done, { once: true });
266
- });
309
+ });
267
310
  /** Resolve `p`, but reject with a TimeoutError if it takes longer than `ms`.
268
311
  * The underlying promise is left to settle on its own (we just stop waiting),
269
312
  * so a slow black-box call can never wedge the caller. */
270
313
  function withTimeout(p, ms, label = "operation") {
271
- return new Promise((resolve, reject) => {
272
- const t = setTimeout(() => {
273
- const e = new Error(`${label} timed out after ${ms}ms`);
274
- e.name = "TimeoutError";
275
- reject(e);
276
- }, ms);
277
- if (typeof t.unref === "function")
278
- t.unref();
279
- p.then((v) => {
280
- clearTimeout(t);
281
- resolve(v);
282
- }, (e) => {
283
- clearTimeout(t);
284
- reject(e);
285
- });
314
+ return new Promise((resolve, reject) => {
315
+ const t = setTimeout(() => {
316
+ const e = new Error(`${label} timed out after ${ms}ms`);
317
+ e.name = "TimeoutError";
318
+ reject(e);
319
+ }, ms);
320
+ if (typeof t.unref === "function") {
321
+ t.unref();
322
+ }
323
+ p.then((v) => {
324
+ clearTimeout(t);
325
+ resolve(v);
326
+ }, (e) => {
327
+ clearTimeout(t);
328
+ reject(e);
286
329
  });
330
+ });
287
331
  }
288
332
  /** Human-readable duration from seconds. */
289
333
  function dur(seconds) {
290
- if (!isFinite(seconds) || seconds < 0)
291
- return "--";
292
- const h = Math.floor(seconds / 3600);
293
- const m = Math.floor((seconds % 3600) / 60);
294
- const s = Math.floor(seconds % 60);
295
- if (h > 0)
296
- return `${h}h ${m}m ${s}s`;
297
- if (m > 0)
298
- return `${m}m ${s}s`;
299
- return `${s}s`;
334
+ if (!isFinite(seconds) || seconds < 0) {
335
+ return "--";
336
+ }
337
+ const h = Math.floor(seconds / 3600);
338
+ const m = Math.floor((seconds % 3600) / 60);
339
+ const s = Math.floor(seconds % 60);
340
+ if (h > 0) {
341
+ return `${h}h ${m}m ${s}s`;
342
+ }
343
+ if (m > 0) {
344
+ return `${m}m ${s}s`;
345
+ }
346
+ return `${s}s`;
300
347
  }
301
348
  /** Human-readable byte size. */
302
349
  function bytes(n) {
303
- if (!isFinite(n) || n < 0)
304
- return "--";
305
- if (n < 1024)
306
- return `${n} B`;
307
- if (n < 1e6)
308
- return `${(n / 1024).toFixed(1)} KB`;
309
- if (n < 1e9)
310
- return `${(n / 1e6).toFixed(1)} MB`;
311
- return `${(n / 1e9).toFixed(2)} GB`;
350
+ if (!isFinite(n) || n < 0) {
351
+ return "--";
352
+ }
353
+ if (n < 1024) {
354
+ return `${n} B`;
355
+ }
356
+ if (n < 1e6) {
357
+ return `${(n / 1024).toFixed(1)} KB`;
358
+ }
359
+ if (n < 1e9) {
360
+ return `${(n / 1e6).toFixed(1)} MB`;
361
+ }
362
+ return `${(n / 1e9).toFixed(2)} GB`;
312
363
  }
313
364
  /** Short count: 1234567 → "1.23M". */
314
365
  function num(n) {
315
- if (n >= 1e9)
316
- return `${(n / 1e9).toFixed(2)}B`;
317
- if (n >= 1e6)
318
- return `${(n / 1e6).toFixed(2)}M`;
319
- if (n >= 1e3)
320
- return `${(n / 1e3).toFixed(1)}K`;
321
- return String(n);
366
+ if (n >= 1e9) {
367
+ return `${(n / 1e9).toFixed(2)}B`;
368
+ }
369
+ if (n >= 1e6) {
370
+ return `${(n / 1e6).toFixed(2)}M`;
371
+ }
372
+ if (n >= 1e3) {
373
+ return `${(n / 1e3).toFixed(1)}K`;
374
+ }
375
+ return String(n);
322
376
  }
323
377
  const int = (n) => Math.round(n).toLocaleString("en-US");
324
378
  const clamp01 = (f) => Math.max(0, Math.min(1, f));
325
379
  const pct = (f) => `${(clamp01(f) * 100).toFixed(1)}%`;
326
380
  /** A progress bar of width `w` filled to fraction `frac`. */
327
381
  function bar(w, frac) {
328
- const filled = Math.round(clamp01(frac) * w);
329
- return `${GRN}${"█".repeat(filled)}${GREY}${"░".repeat(w - filled)}${R}`;
382
+ const filled = Math.round(clamp01(frac) * w);
383
+ return `${GRN}${"█".repeat(filled)}${GREY}${"░".repeat(w - filled)}${R}`;
330
384
  }
331
385
  /** Collapse whitespace and clip to `max` chars with an ellipsis. */
332
386
  function clip(text, max) {
333
- const t = text.replace(/\s+/g, " ").trim();
334
- if (max < 1)
335
- return "";
336
- return t.length <= max ? t : t.slice(0, max - 1) + "…";
387
+ const t = text.replace(/\s+/g, " ").trim();
388
+ if (max < 1) {
389
+ return "";
390
+ }
391
+ return t.length <= max ? t : t.slice(0, max - 1) + "…";
337
392
  }
338
393
  /** Retry `fn` with exponential backoff.
339
394
  *
@@ -350,46 +405,46 @@ function clip(text, max) {
350
405
  * `onFail` is called after each non-throttle failed attempt; `onThrottle` after
351
406
  * each throttle wait (for a "waiting…" notice). */
352
407
  async function retry(label, fn, tries, onFail, onThrottle) {
353
- let wait = 1000, last = "", throttleWait = 1000, throttleHits = 0;
354
- for (let attempt = 1; attempt <= tries;) {
355
- if (shutdown.signal.aborted) {
356
- const e = new Error("aborted");
357
- e.fatal = true;
358
- throw e;
359
- }
360
- try {
361
- return await fn();
362
- }
363
- catch (e) {
364
- const err = e;
365
- if (err.name === "AbortError" || err.fatal)
366
- throw err;
367
- // Rate-limited / overloaded: wait it out. Does NOT advance `attempt`, so a
368
- // busy server can never exhaust the retry budget and drop the request.
369
- if (err.throttle && !shutdown.signal.aborted) {
370
- throttleHits++;
371
- // Honour Retry-After when the server sent one; else exponential back-off
372
- // with jitter, capped, so a fleet of requests does not resynchronise.
373
- const base = err.retryAfterMs && err.retryAfterMs > 0
374
- ? err.retryAfterMs
375
- : throttleWait;
376
- const ms = Math.min(base, 60_000) +
377
- Math.floor(base * 0.25 * Math.random());
378
- onThrottle?.(ms);
379
- await waitMs(ms);
380
- throttleWait = Math.min(throttleWait * 2, 60_000);
381
- continue;
382
- }
383
- last = err.message;
384
- onFail?.(attempt, err);
385
- attempt++;
386
- if (attempt <= tries) {
387
- await waitMs(wait);
388
- wait = Math.min(wait * 2, 30_000);
389
- }
390
- }
408
+ let wait = 1000, last = "", throttleWait = 1000, throttleHits = 0;
409
+ for (let attempt = 1; attempt <= tries;) {
410
+ if (shutdown.signal.aborted) {
411
+ const e = new Error("aborted");
412
+ e.fatal = true;
413
+ throw e;
391
414
  }
392
- throw new Error(`${label} failed after ${tries} attempts: ${last}`);
415
+ try {
416
+ return await fn();
417
+ } catch (e) {
418
+ const err = e;
419
+ if (err.name === "AbortError" || err.fatal) {
420
+ throw err;
421
+ }
422
+ // Rate-limited / overloaded: wait it out. Does NOT advance `attempt`, so a
423
+ // busy server can never exhaust the retry budget and drop the request.
424
+ if (err.throttle && !shutdown.signal.aborted) {
425
+ throttleHits++;
426
+ // Honour Retry-After when the server sent one; else exponential back-off
427
+ // with jitter, capped, so a fleet of requests does not resynchronise.
428
+ const base = err.retryAfterMs && err.retryAfterMs > 0
429
+ ? err.retryAfterMs
430
+ : throttleWait;
431
+ const ms = Math.min(base, 60_000) +
432
+ Math.floor(base * 0.25 * Math.random());
433
+ onThrottle?.(ms);
434
+ await waitMs(ms);
435
+ throttleWait = Math.min(throttleWait * 2, 60_000);
436
+ continue;
437
+ }
438
+ last = err.message;
439
+ onFail?.(attempt, err);
440
+ attempt++;
441
+ if (attempt <= tries) {
442
+ await waitMs(wait);
443
+ wait = Math.min(wait * 2, 30_000);
444
+ }
445
+ }
446
+ }
447
+ throw new Error(`${label} failed after ${tries} attempts: ${last}`);
393
448
  }
394
449
  /** Classify a non-OK HTTP response into an {@link HttpError} for {@link retry}:
395
450
  * • 429 / 503 → THROTTLE (rate-limited / overloaded): retried indefinitely,
@@ -398,26 +453,25 @@ async function retry(label, fn, tries, onFail, onThrottle) {
398
453
  * • other 4xx → FATAL: a real client error (404, 401, …) — not retried.
399
454
  * Never throttles forever silently: the wait is interruptible by shutdown. */
400
455
  function httpError(res) {
401
- const err = new Error(`HTTP ${res.status}`);
402
- if (res.status === 429 || res.status === 503) {
403
- err.throttle = true;
404
- const ra = res.headers.get("retry-after");
405
- if (ra) {
406
- const secs = Number(ra);
407
- if (Number.isFinite(secs))
408
- err.retryAfterMs = Math.max(0, secs * 1000);
409
- else {
410
- const when = Date.parse(ra);
411
- if (Number.isFinite(when)) {
412
- err.retryAfterMs = Math.max(0, when - Date.now());
413
- }
414
- }
415
- }
456
+ const err = new Error(`HTTP ${res.status}`);
457
+ if (res.status === 429 || res.status === 503) {
458
+ err.throttle = true;
459
+ const ra = res.headers.get("retry-after");
460
+ if (ra) {
461
+ const secs = Number(ra);
462
+ if (Number.isFinite(secs)) {
463
+ err.retryAfterMs = Math.max(0, secs * 1000);
464
+ } else {
465
+ const when = Date.parse(ra);
466
+ if (Number.isFinite(when)) {
467
+ err.retryAfterMs = Math.max(0, when - Date.now());
468
+ }
469
+ }
416
470
  }
417
- else if (res.status < 500) {
418
- err.fatal = true; // genuine client error — do not retry
419
- } // other 5xx: neither fatal nor throttle → ordinary bounded retry
420
- return err;
471
+ } else if (res.status < 500) {
472
+ err.fatal = true; // genuine client error — do not retry
473
+ } // other 5xx: neither fatal nor throttle → ordinary bounded retry
474
+ return err;
421
475
  }
422
476
  /** GET a URL and parse JSON, with the shared retry policy: rate-limits (429/503)
423
477
  * WAIT indefinitely (surfaced to the progress log via onThrottleWait, throttled
@@ -425,18 +479,25 @@ function httpError(res) {
425
479
  * DOWNLOAD_TRIES. Used by every datasets-server API stage so all share the same
426
480
  * never-drop-on-throttle behaviour. */
427
481
  async function getJson(url, label) {
428
- return retry(label, async () => {
429
- const res = await fetch(url, { signal: shutdown.signal });
430
- if (res.ok)
431
- return res.json();
432
- throw httpError(res);
433
- }, DOWNLOAD_TRIES, undefined, (ms) => {
434
- const now = Date.now();
435
- if (onThrottleWait && now - lastThrottleLog > 3000) {
436
- lastThrottleLog = now;
437
- onThrottleWait(ms, label);
438
- }
439
- });
482
+ return retry(
483
+ label,
484
+ async () => {
485
+ const res = await fetch(url, { signal: shutdown.signal });
486
+ if (res.ok) {
487
+ return res.json();
488
+ }
489
+ throw httpError(res);
490
+ },
491
+ DOWNLOAD_TRIES,
492
+ undefined,
493
+ (ms) => {
494
+ const now = Date.now();
495
+ if (onThrottleWait && now - lastThrottleLog > 3000) {
496
+ lastThrottleLog = now;
497
+ onThrottleWait(ms, label);
498
+ }
499
+ },
500
+ );
440
501
  }
441
502
  // ═══════════════════════════════════════════════════════════════════════
442
503
  // §3 Cache + download helpers (a downloaded file is bounded by MAX_CACHE_GB)
@@ -448,144 +509,161 @@ async function getJson(url, label) {
448
509
  /** A cheap HEAD to learn a download's size (for the cache ceiling and a real
449
510
  * ETA). Rate-limits wait; other 4xx is fatal; total failure → 0. */
450
511
  async function headSize(url) {
451
- return retry(`HEAD ${url}`, async () => {
452
- const res = await fetch(url, { method: "HEAD", signal: shutdown.signal });
453
- if (res.ok)
454
- return Number(res.headers.get("content-length")) || 0;
455
- throw httpError(res);
456
- }, 4);
512
+ return retry(`HEAD ${url}`, async () => {
513
+ const res = await fetch(url, { method: "HEAD", signal: shutdown.signal });
514
+ if (res.ok) {
515
+ return Number(res.headers.get("content-length")) || 0;
516
+ }
517
+ throw httpError(res);
518
+ }, 4);
457
519
  }
458
520
  function cacheSize() {
459
- if (!existsSync(CACHE_DIR))
460
- return 0;
461
- let total = 0;
462
- for (const name of readdirSync(CACHE_DIR)) {
463
- try {
464
- total += statSync(join(CACHE_DIR, name)).size;
465
- }
466
- catch { /* raced with a delete */ }
467
- }
468
- return total;
521
+ if (!existsSync(CACHE_DIR)) {
522
+ return 0;
523
+ }
524
+ let total = 0;
525
+ for (const name of readdirSync(CACHE_DIR)) {
526
+ try {
527
+ total += statSync(join(CACHE_DIR, name)).size;
528
+ } catch { /* raced with a delete */ }
529
+ }
530
+ return total;
469
531
  }
470
532
  /** Block until there is room for a file of `fileBytes` under the ceiling.
471
533
  * A single file larger than the whole ceiling can never "fit", so we let it
472
534
  * through (it is deleted right after processing) rather than wait forever. */
473
535
  async function ensureCacheRoom(fileBytes, warn) {
474
- mkdirSync(CACHE_DIR, { recursive: true });
475
- if (fileBytes >= MAX_CACHE_BYTES)
476
- return;
477
- let warned = false;
478
- // Stop waiting the moment a shutdown is requested — the abort signal unblocks
479
- // a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
480
- while (!shutdown.signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES) {
481
- if (!warned) {
482
- warn?.(`${YEL}⚠${R} cache at ${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling waiting for room…`);
483
- warned = true;
484
- }
485
- await waitMs(5_000);
536
+ mkdirSync(CACHE_DIR, { recursive: true });
537
+ if (fileBytes >= MAX_CACHE_BYTES) {
538
+ return;
539
+ }
540
+ let warned = false;
541
+ // Stop waiting the moment a shutdown is requested the abort signal unblocks
542
+ // a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
543
+ while (
544
+ !shutdown.signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES
545
+ ) {
546
+ if (!warned) {
547
+ warn?.(
548
+ `${YEL}⚠${R} cache at ${
549
+ (MAX_CACHE_BYTES / 1e9).toFixed(0)
550
+ } GB ceiling — waiting for room…`,
551
+ );
552
+ warned = true;
486
553
  }
554
+ await waitMs(5_000);
555
+ }
487
556
  }
488
557
  // ═══════════════════════════════════════════════════════════════════════
489
558
  // §5 Download (streamed to disk, with retry + cleanup on failure)
490
559
  // ═══════════════════════════════════════════════════════════════════════
491
- async function downloadFile(url, destPath, tries = DOWNLOAD_TRIES, onFail, onProgress) {
492
- const partPath = destPath + PART_SUFFIX;
493
- await retry(`download ${basename(destPath)}`, async () => {
494
- // Abort promptly on shutdown rather than waiting out a slow socket.
495
- if (shutdown.signal.aborted) {
496
- const e = new Error("aborted");
497
- e.fatal = true;
498
- throw e;
499
- }
500
- const res = await fetch(url, { signal: shutdown.signal });
501
- if (!res.ok)
502
- throw httpError(res);
503
- if (!res.body)
504
- throw new Error("empty response body");
505
- const total = Number(res.headers.get("content-length")) || 0;
506
- let done = 0;
507
- // Stream straight to a ".part" sibling using pure WHATWG streams. A
508
- // TransformStream meters progress; pipeTo into a WritableStream gives REAL
509
- // backpressure natively — the sink's write() returns a promise the
510
- // readable side awaits, so a fast server can never outrun the disk (no
511
- // whole-file heap buffering). The sink wraps a single raw fs descriptor
512
- // (the one capability the web platform lacks); writing to disk is the only
513
- // Node operation in the whole pipeline. The final, valid file only ever
514
- // appears via the atomic rename below, so a crash mid-transfer can never
515
- // leave a truncated file at the real path.
516
- const meter = new TransformStream({
517
- transform(chunk, controller) {
518
- done += chunk.length;
519
- onProgress?.(done, total);
520
- controller.enqueue(chunk);
521
- },
522
- });
523
- const fd = openSync(partPath, "w");
524
- let closed = false;
525
- const closeFd = () => {
526
- if (closed)
527
- return;
528
- closed = true;
529
- try {
530
- closeSync(fd);
531
- }
532
- catch { /* already closed */ }
533
- };
534
- const sink = new WritableStream({
535
- write(chunk) {
536
- // writeSync drains the whole chunk before returning, so the readable
537
- // side is paused for exactly as long as the disk needs — backpressure.
538
- let off = 0;
539
- while (off < chunk.length) {
540
- off += writeSync(fd, chunk, off, chunk.length - off);
541
- }
542
- },
543
- close() {
544
- fsyncSync(fd); // durable bytes before the rename promotes them
545
- closeFd();
546
- },
547
- abort() {
548
- closeFd();
549
- },
550
- });
560
+ async function downloadFile(
561
+ url,
562
+ destPath,
563
+ tries = DOWNLOAD_TRIES,
564
+ onFail,
565
+ onProgress,
566
+ ) {
567
+ const partPath = destPath + PART_SUFFIX;
568
+ await retry(
569
+ `download ${basename(destPath)}`,
570
+ async () => {
571
+ // Abort promptly on shutdown rather than waiting out a slow socket.
572
+ if (shutdown.signal.aborted) {
573
+ const e = new Error("aborted");
574
+ e.fatal = true;
575
+ throw e;
576
+ }
577
+ const res = await fetch(url, { signal: shutdown.signal });
578
+ if (!res.ok) {
579
+ throw httpError(res);
580
+ }
581
+ if (!res.body) {
582
+ throw new Error("empty response body");
583
+ }
584
+ const total = Number(res.headers.get("content-length")) || 0;
585
+ let done = 0;
586
+ // Stream straight to a ".part" sibling using pure WHATWG streams. A
587
+ // TransformStream meters progress; pipeTo into a WritableStream gives REAL
588
+ // backpressure natively — the sink's write() returns a promise the
589
+ // readable side awaits, so a fast server can never outrun the disk (no
590
+ // whole-file heap buffering). The sink wraps a single raw fs descriptor
591
+ // (the one capability the web platform lacks); writing to disk is the only
592
+ // Node operation in the whole pipeline. The final, valid file only ever
593
+ // appears via the atomic rename below, so a crash mid-transfer can never
594
+ // leave a truncated file at the real path.
595
+ const meter = new TransformStream({
596
+ transform(chunk, controller) {
597
+ done += chunk.length;
598
+ onProgress?.(done, total);
599
+ controller.enqueue(chunk);
600
+ },
601
+ });
602
+ const fd = openSync(partPath, "w");
603
+ let closed = false;
604
+ const closeFd = () => {
605
+ if (closed) {
606
+ return;
607
+ }
608
+ closed = true;
551
609
  try {
552
- await res.body.pipeThrough(meter).pipeTo(sink, {
553
- signal: shutdown.signal,
554
- });
555
- }
556
- catch (e) {
557
- // pipeTo's abort() ran the sink's abort() (closing the descriptor); if
558
- // it didn't (a non-abort throw), make sure the descriptor is not leaked.
559
- closeFd();
560
- try {
561
- unlinkSync(partPath);
562
- }
563
- catch { /* best effort */ }
564
- throw e;
565
- }
566
- // Optional integrity guard: when the server advertised a size, a complete
567
- // file must match it. A short read (silent truncation) is retried rather
568
- // than promoted, so the parser never sees a partial ZIP.
610
+ closeSync(fd);
611
+ } catch { /* already closed */ }
612
+ };
613
+ const sink = new WritableStream({
614
+ write(chunk) {
615
+ // writeSync drains the whole chunk before returning, so the readable
616
+ // side is paused for exactly as long as the disk needs backpressure.
617
+ let off = 0;
618
+ while (off < chunk.length) {
619
+ off += writeSync(fd, chunk, off, chunk.length - off);
620
+ }
621
+ },
622
+ close() {
623
+ fsyncSync(fd); // durable bytes before the rename promotes them
624
+ closeFd();
625
+ },
626
+ abort() {
627
+ closeFd();
628
+ },
629
+ });
630
+ try {
631
+ await res.body.pipeThrough(meter).pipeTo(sink, {
632
+ signal: shutdown.signal,
633
+ });
634
+ } catch (e) {
635
+ // pipeTo's abort() ran the sink's abort() (closing the descriptor); if
636
+ // it didn't (a non-abort throw), make sure the descriptor is not leaked.
637
+ closeFd();
569
638
  try {
570
- const got = statSync(partPath).size;
571
- if (total > 0 && got !== total) {
572
- try {
573
- unlinkSync(partPath);
574
- }
575
- catch { /* best effort */ }
576
- throw new Error(`size mismatch: got ${got}, expected ${total}`);
577
- }
578
- }
579
- catch (e) {
580
- if (e instanceof Error && e.message.startsWith("size mismatch")) {
581
- throw e;
582
- }
583
- // statSync failure is non-fatal here; the rename below will surface it.
584
- }
585
- // Atomic publish: rename is atomic within a filesystem, so the final path
586
- // flips from "absent" to "complete" in one step — never an in-between.
587
- renameSync(partPath, destPath);
588
- }, tries, onFail);
639
+ unlinkSync(partPath);
640
+ } catch { /* best effort */ }
641
+ throw e;
642
+ }
643
+ // Optional integrity guard: when the server advertised a size, a complete
644
+ // file must match it. A short read (silent truncation) is retried rather
645
+ // than promoted, so the parser never sees a partial ZIP.
646
+ try {
647
+ const got = statSync(partPath).size;
648
+ if (total > 0 && got !== total) {
649
+ try {
650
+ unlinkSync(partPath);
651
+ } catch { /* best effort */ }
652
+ throw new Error(`size mismatch: got ${got}, expected ${total}`);
653
+ }
654
+ } catch (e) {
655
+ if (e instanceof Error && e.message.startsWith("size mismatch")) {
656
+ throw e;
657
+ }
658
+ // statSync failure is non-fatal here; the rename below will surface it.
659
+ }
660
+ // Atomic publish: rename is atomic within a filesystem, so the final path
661
+ // flips from "absent" to "complete" in one step — never an in-between.
662
+ renameSync(partPath, destPath);
663
+ },
664
+ tries,
665
+ onFail,
666
+ );
589
667
  }
590
668
  const isEpisode = (it) => typeof it !== "string";
591
669
  /** Build the accumulated-context episodes of a turn sequence: each successive
@@ -593,109 +671,125 @@ const isEpisode = (it) => typeof it !== "string";
593
671
  * the same cumulative-context shape a multi-turn conversation deposits, so the
594
672
  * store learns to continue a growing context. */
595
673
  function accumulate(turns) {
596
- const out = [];
597
- for (let i = 1; i < turns.length; i++) {
598
- out.push({ context: turns.slice(0, i).join("\n"), continuation: turns[i] });
599
- }
600
- return out;
674
+ const out = [];
675
+ for (let i = 1; i < turns.length; i++) {
676
+ out.push({ context: turns.slice(0, i).join("\n"), continuation: turns[i] });
677
+ }
678
+ return out;
601
679
  }
602
680
  /** Dedup + trim a concept's items: drop empty/degenerate pairs and exact
603
681
  * repeats so a concept never deposits the same form twice. */
604
682
  export function refineItems(items) {
605
- const out = [];
606
- const seen = new Set();
607
- for (const it of items) {
608
- if (!isEpisode(it)) {
609
- const exp = it.trim();
610
- const key = "E:" + exp;
611
- if (exp && !seen.has(key)) {
612
- seen.add(key);
613
- out.push(exp);
614
- }
615
- continue;
616
- }
617
- const ctx = it.context.trim();
618
- const cont = it.continuation.trim();
619
- if (!ctx || !cont || ctx === cont)
620
- continue;
621
- const key = "P:" + ctx + "\u0000" + cont;
622
- if (seen.has(key))
623
- continue;
683
+ const out = [];
684
+ const seen = new Set();
685
+ for (const it of items) {
686
+ if (!isEpisode(it)) {
687
+ const exp = it.trim();
688
+ const key = "E:" + exp;
689
+ if (exp && !seen.has(key)) {
624
690
  seen.add(key);
625
- out.push({ context: ctx, continuation: cont });
691
+ out.push(exp);
692
+ }
693
+ continue;
626
694
  }
627
- return out;
695
+ const ctx = it.context.trim();
696
+ const cont = it.continuation.trim();
697
+ if (!ctx || !cont || ctx === cont) {
698
+ continue;
699
+ }
700
+ const key = "P:" + ctx + "\u0000" + cont;
701
+ if (seen.has(key)) {
702
+ continue;
703
+ }
704
+ seen.add(key);
705
+ out.push({ context: ctx, continuation: cont });
706
+ }
707
+ return out;
628
708
  }
629
709
  /** Normalize a raw datasets-server row into a SmolSentRow, or null when it lacks
630
710
  * both sides or a side is implausibly large (a dump, not a sentence). */
631
711
  export function toSmolSentRow(row) {
632
- if (!row || typeof row !== "object")
633
- return null;
634
- const r = row;
635
- const src = typeof r.src === "string" ? r.src.trim() : "";
636
- // `trg` is a single string in smolsent; tolerate a list form defensively.
637
- const trgRaw = Array.isArray(r.trgs) ? r.trgs[0] : r.trg;
638
- const trg = typeof trgRaw === "string" ? trgRaw.trim() : "";
639
- if (!src || !trg)
640
- return null;
641
- if (src.length > MAX_SMOLSENT_CHARS || trg.length > MAX_SMOLSENT_CHARS)
642
- return null;
643
- const sl = typeof r.sl === "string" ? r.sl.trim() : "";
644
- const tl = typeof r.tl === "string" ? r.tl.trim() : "";
645
- return { src, trg, sl, tl };
712
+ if (!row || typeof row !== "object") {
713
+ return null;
714
+ }
715
+ const r = row;
716
+ const src = typeof r.src === "string" ? r.src.trim() : "";
717
+ // `trg` is a single string in smolsent; tolerate a list form defensively.
718
+ const trgRaw = Array.isArray(r.trgs) ? r.trgs[0] : r.trg;
719
+ const trg = typeof trgRaw === "string" ? trgRaw.trim() : "";
720
+ if (!src || !trg) {
721
+ return null;
722
+ }
723
+ if (src.length > MAX_SMOLSENT_CHARS || trg.length > MAX_SMOLSENT_CHARS) {
724
+ return null;
725
+ }
726
+ const sl = typeof r.sl === "string" ? r.sl.trim() : "";
727
+ const tl = typeof r.tl === "string" ? r.tl.trim() : "";
728
+ return { src, trg, sl, tl };
646
729
  }
647
730
  /** Translate ONE SmolSent pair into SEMA facts: the two sentences are one
648
731
  * meaning in two languages, so bind them BOTH ways. refineItems drops the
649
732
  * degenerate case where src === trg. */
650
733
  export function smolSentRowToItems(row) {
651
- const { src, trg } = row;
652
- return refineItems([
653
- { context: src, continuation: trg },
654
- { context: trg, continuation: src },
655
- ]);
734
+ const { src, trg } = row;
735
+ return refineItems([
736
+ { context: src, continuation: trg },
737
+ { context: trg, continuation: src },
738
+ ]);
656
739
  }
657
740
  /** Normalize a raw datasets-server row object into an AyaRow, or null when it
658
741
  * lacks a usable prompt/answer or a field is implausibly large (a dump, not a
659
742
  * cognitive example). Trims surrounding whitespace; keeps inner text verbatim
660
743
  * (human prose, possibly multi-paragraph). */
661
744
  export function toAyaRow(row) {
662
- if (!row || typeof row !== "object")
663
- return null;
664
- const r = row;
665
- const inputs = typeof r.inputs === "string" ? r.inputs.trim() : "";
666
- const targets = typeof r.targets === "string" ? r.targets.trim() : "";
667
- if (!inputs || !targets)
668
- return null;
669
- if (inputs.length > MAX_AYA_FIELD_CHARS || targets.length > MAX_AYA_FIELD_CHARS)
670
- return null;
671
- const language = typeof r.language === "string" ? r.language.trim() : "";
672
- return { inputs, targets, language };
745
+ if (!row || typeof row !== "object") {
746
+ return null;
747
+ }
748
+ const r = row;
749
+ const inputs = typeof r.inputs === "string" ? r.inputs.trim() : "";
750
+ const targets = typeof r.targets === "string" ? r.targets.trim() : "";
751
+ if (!inputs || !targets) {
752
+ return null;
753
+ }
754
+ if (
755
+ inputs.length > MAX_AYA_FIELD_CHARS || targets.length > MAX_AYA_FIELD_CHARS
756
+ ) {
757
+ return null;
758
+ }
759
+ const language = typeof r.language === "string" ? r.language.trim() : "";
760
+ return { inputs, targets, language };
673
761
  }
674
762
  /** Translate ONE Aya row into SEMA training items. A row is a single human
675
763
  * (question → answer) exchange — exactly one FACT, the (inputs → targets) edge.
676
764
  * No standalone-answer experience and no one-exchange "cumulative" walk: a lone
677
765
  * Q→A is not multi-turn, and both would only replicate the same edge. */
678
766
  export function ayaRowToItems(row) {
679
- const { inputs, targets } = row;
680
- return refineItems([{ context: inputs, continuation: targets }]);
767
+ const { inputs, targets } = row;
768
+ return refineItems([{ context: inputs, continuation: targets }]);
681
769
  }
682
770
  /** Collapse a conversation tree to ONE linear path: at each node, descend into
683
771
  * its best-ranked, non-deleted reply (rank 0 preferred; unranked sorts last).
684
772
  * Returns the ordered turns (already strictly alternating in this corpus). */
685
773
  export function bestOasstPath(root) {
686
- const turns = [];
687
- let node = root;
688
- while (node) {
689
- const text = typeof node.text === "string" ? node.text.trim() : "";
690
- if (text)
691
- turns.push({ role: String(node.role ?? "?"), text });
692
- const live = (node.replies ?? []).filter((r) => r && !r.deleted && typeof r.text === "string" && r.text.trim() !== "");
693
- if (live.length === 0)
694
- break;
695
- live.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
696
- node = live[0];
774
+ const turns = [];
775
+ let node = root;
776
+ while (node) {
777
+ const text = typeof node.text === "string" ? node.text.trim() : "";
778
+ if (text) {
779
+ turns.push({ role: String(node.role ?? "?"), text });
780
+ }
781
+ const live = (node.replies ?? []).filter((r) =>
782
+ r && !r.deleted && typeof r.text === "string" && r.text.trim() !== ""
783
+ );
784
+ if (live.length === 0) {
785
+ break;
697
786
  }
698
- return turns;
787
+ live.sort((a, b) =>
788
+ (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER)
789
+ );
790
+ node = live[0];
791
+ }
792
+ return turns;
699
793
  }
700
794
  /** Translate ONE multi-turn oasst2 conversation into SEMA training items.
701
795
  *
@@ -717,41 +811,47 @@ export function bestOasstPath(root) {
717
811
  * Returns [] for a conversation below the multi-turn threshold, so callers can
718
812
  * simply skip empties. */
719
813
  export function oasstConversationToItems(turns) {
720
- if (turns.length < OASST_MIN_TURNS)
721
- return []; // not multi-turn — skip
722
- return refineItems(accumulate(turns.map((t) => t.text)));
814
+ if (turns.length < OASST_MIN_TURNS) {
815
+ return []; // not multi-turn — skip
816
+ }
817
+ return refineItems(accumulate(turns.map((t) => t.text)));
723
818
  }
724
819
  /** Turn a source value into clean prose: decode the literal "\n"/"\t"/"\r"
725
820
  * two-character escapes the source JSON left in the text, collapse the runs of
726
821
  * whitespace that creates, and trim. */
727
822
  function unescapePlain(s) {
728
- return s
729
- .replace(/\\r\\n|\\n|\\r/g, "\n")
730
- .replace(/\\t/g, " ")
731
- .replace(/[ \t]+/g, " ")
732
- .replace(/\n{3,}/g, "\n\n")
733
- .trim();
823
+ return s
824
+ .replace(/\\r\\n|\\n|\\r/g, "\n")
825
+ .replace(/\\t/g, " ")
826
+ .replace(/[ \t]+/g, " ")
827
+ .replace(/\n{3,}/g, "\n\n")
828
+ .trim();
734
829
  }
735
830
  /** Normalize a raw datasets-server row into a GenKnowRow, or null when it lacks
736
831
  * a usable question/answer or a side is implausibly large (corruption). */
737
832
  export function toGenKnowRow(row) {
738
- if (!row || typeof row !== "object")
739
- return null;
740
- const r = row;
741
- const question = typeof r.Question === "string"
742
- ? unescapePlain(r.Question)
743
- : "";
744
- const answer = typeof r.Answer === "string" ? unescapePlain(r.Answer) : "";
745
- if (!question || !answer)
746
- return null;
747
- if (question.length > MAX_GENKNOW_CHARS || answer.length > MAX_GENKNOW_CHARS)
748
- return null;
749
- return { question, answer };
833
+ if (!row || typeof row !== "object") {
834
+ return null;
835
+ }
836
+ const r = row;
837
+ const question = typeof r.Question === "string"
838
+ ? unescapePlain(r.Question)
839
+ : "";
840
+ const answer = typeof r.Answer === "string" ? unescapePlain(r.Answer) : "";
841
+ if (!question || !answer) {
842
+ return null;
843
+ }
844
+ if (
845
+ question.length > MAX_GENKNOW_CHARS || answer.length > MAX_GENKNOW_CHARS
846
+ ) {
847
+ return null;
848
+ }
849
+ return { question, answer };
750
850
  }
751
851
  /** Translate ONE General-Knowledge row into SEMA items: exactly one
752
852
  * (question → answer) FACT. refineItems drops a degenerate question === answer. */
753
853
  export function genKnowRowToItems(row) {
754
- return refineItems([{ context: row.question, continuation: row.answer }]);
854
+ return refineItems([{ context: row.question, continuation: row.answer }]);
755
855
  }
756
856
  // ═══════════════════════════════════════════════════════════════════════
757
857
  // §7 Ingestion
@@ -767,20 +867,23 @@ const ENC = new TextEncoder();
767
867
  /** Content size of a training item in UTF-8 bytes — the same quantity the
768
868
  * scaling suite (14-scaling.test.mjs) measures as KB/s: for an episode the
769
869
  * context plus the continuation, for a bare experience its own text. */
770
- const itemBytes = (it) => isEpisode(it)
870
+ const itemBytes = (it) =>
871
+ isEpisode(it)
771
872
  ? ENC.encode(it.context).length + ENC.encode(it.continuation).length
772
873
  : ENC.encode(it).length;
773
874
  async function ingestItems(ci, items, onItem, sample) {
774
- for (const it of items) {
775
- if (isEpisode(it))
776
- await ci.ingest(it.context, it.continuation);
777
- else
778
- await ci.ingest(it);
779
- sample?.(it);
780
- if (!(await onItem(itemBytes(it))))
781
- return false; // stop requested
782
- }
783
- return true;
875
+ for (const it of items) {
876
+ if (isEpisode(it)) {
877
+ await ci.ingest(it.context, it.continuation);
878
+ } else {
879
+ await ci.ingest(it);
880
+ }
881
+ sample?.(it);
882
+ if (!(await onItem(itemBytes(it)))) {
883
+ return false; // stop requested
884
+ }
885
+ }
886
+ return true;
784
887
  }
785
888
  // ── §7a′ oasst2 — stream the gzipped JSONL of trees and deposit multi-turn ──
786
889
  //
@@ -791,462 +894,495 @@ async function ingestItems(ci, items, onItem, sample) {
791
894
  // line that fails to parse (or is oversize) is counted skipped and the stream
792
895
  // continues; a cap/signal stops cleanly at a conversation boundary.
793
896
  async function processOasst(filePath, ci, onExample, sample) {
794
- const blob = await openAsBlob(filePath);
795
- const reader = blob.stream()
796
- .pipeThrough(new DecompressionStream("gzip"))
797
- .pipeThrough(new TextDecoderStream())
798
- .getReader();
799
- let examples = 0;
800
- let skipped = 0; // malformed/oversize lines
801
- let multi = 0; // multi-turn conversations deposited
802
- let leftover = "";
803
- let droppingLine = false;
804
- const processLine = async (line) => {
805
- if (!line.trim())
806
- return true;
807
- let tree;
808
- try {
809
- tree = JSON.parse(line);
810
- }
811
- catch {
812
- skipped++;
813
- return true;
814
- }
815
- if (!tree.prompt)
816
- return true;
817
- const turns = bestOasstPath(tree.prompt);
818
- const items = oasstConversationToItems(turns); // [] when not multi-turn
819
- if (items.length === 0)
820
- return true; // single-turn / empty — skipped
821
- multi++;
822
- return ingestItems(ci, items, async (contentBytes) => {
823
- examples++;
824
- return onExample(contentBytes);
825
- }, sample);
826
- };
897
+ const blob = await openAsBlob(filePath);
898
+ const reader = blob.stream()
899
+ .pipeThrough(new DecompressionStream("gzip"))
900
+ .pipeThrough(new TextDecoderStream())
901
+ .getReader();
902
+ let examples = 0;
903
+ let skipped = 0; // malformed/oversize lines
904
+ let multi = 0; // multi-turn conversations deposited
905
+ let leftover = "";
906
+ let droppingLine = false;
907
+ const processLine = async (line) => {
908
+ if (!line.trim()) {
909
+ return true;
910
+ }
911
+ let tree;
827
912
  try {
828
- while (true) {
829
- const { done, value } = await reader.read();
830
- if (done)
831
- break;
832
- let chunk = value;
833
- for (;;) {
834
- const nl = chunk.indexOf("\n");
835
- if (nl < 0) {
836
- if (!droppingLine) {
837
- if (leftover.length + chunk.length > MAX_OASST_LINE_CHARS) {
838
- leftover = "";
839
- droppingLine = true;
840
- skipped++;
841
- }
842
- else
843
- leftover += chunk;
844
- }
845
- break;
846
- }
847
- const part = chunk.slice(0, nl);
848
- chunk = chunk.slice(nl + 1);
849
- if (droppingLine) {
850
- droppingLine = false;
851
- leftover = "";
852
- continue;
853
- }
854
- if (leftover.length + part.length > MAX_OASST_LINE_CHARS) {
855
- leftover = "";
856
- skipped++;
857
- continue;
858
- }
859
- const line = leftover + part;
860
- leftover = "";
861
- if (!(await processLine(line))) {
862
- return { examples, stopped: true, skipped, multi };
863
- }
864
- }
865
- }
866
- if (!droppingLine && leftover.trim()) {
867
- if (!(await processLine(leftover))) {
868
- return { examples, stopped: true, skipped, multi };
913
+ tree = JSON.parse(line);
914
+ } catch {
915
+ skipped++;
916
+ return true;
917
+ }
918
+ if (!tree.prompt) {
919
+ return true;
920
+ }
921
+ const turns = bestOasstPath(tree.prompt);
922
+ const items = oasstConversationToItems(turns); // [] when not multi-turn
923
+ if (items.length === 0) {
924
+ return true; // single-turn / empty — skipped
925
+ }
926
+ multi++;
927
+ return ingestItems(ci, items, async (contentBytes) => {
928
+ examples++;
929
+ return onExample(contentBytes);
930
+ }, sample);
931
+ };
932
+ try {
933
+ while (true) {
934
+ const { done, value } = await reader.read();
935
+ if (done) {
936
+ break;
937
+ }
938
+ let chunk = value;
939
+ for (;;) {
940
+ const nl = chunk.indexOf("\n");
941
+ if (nl < 0) {
942
+ if (!droppingLine) {
943
+ if (leftover.length + chunk.length > MAX_OASST_LINE_CHARS) {
944
+ leftover = "";
945
+ droppingLine = true;
946
+ skipped++;
947
+ } else {
948
+ leftover += chunk;
869
949
  }
870
- }
871
- return { examples, stopped: false, skipped, multi };
950
+ }
951
+ break;
952
+ }
953
+ const part = chunk.slice(0, nl);
954
+ chunk = chunk.slice(nl + 1);
955
+ if (droppingLine) {
956
+ droppingLine = false;
957
+ leftover = "";
958
+ continue;
959
+ }
960
+ if (leftover.length + part.length > MAX_OASST_LINE_CHARS) {
961
+ leftover = "";
962
+ skipped++;
963
+ continue;
964
+ }
965
+ const line = leftover + part;
966
+ leftover = "";
967
+ if (!(await processLine(line))) {
968
+ return { examples, stopped: true, skipped, multi };
969
+ }
970
+ }
872
971
  }
873
- finally {
874
- try {
875
- reader.releaseLock();
876
- }
877
- catch { /* best effort */ }
972
+ if (!droppingLine && leftover.trim()) {
973
+ if (!(await processLine(leftover))) {
974
+ return { examples, stopped: true, skipped, multi };
975
+ }
878
976
  }
977
+ return { examples, stopped: false, skipped, multi };
978
+ } finally {
979
+ try {
980
+ reader.releaseLock();
981
+ } catch { /* best effort */ }
982
+ }
879
983
  }
880
984
  /** Discover the SmolSent per-pair JSONL files from the HF repo tree, restricted
881
985
  * to SMOLSENT_PAIRS (basenames without .jsonl) when set. Each entry is the
882
986
  * repo-relative path, e.g. "smolsent/ha_en.jsonl". */
883
987
  async function listSmolSentFiles() {
884
- // The dataset id ("owner/name") is a PATH here, so its "/" must not be
885
- // percent-encoded. `recursive=true` returns every file under smolsent/.
886
- const url = `https://huggingface.co/api/datasets/${SMOLSENT_DATASET}` +
887
- `/tree/main/smolsent?recursive=true`;
888
- const body = await getJson(url, `GET smol tree`);
889
- const paths = Array.isArray(body)
890
- ? body
891
- .filter((e) => e?.type === "file" && /\.jsonl$/i.test(e?.path))
892
- .map((e) => String(e.path))
893
- : [];
894
- paths.sort();
895
- if (!SMOLSENT_PAIRS.length)
896
- return paths;
897
- const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
898
- return paths.filter((p) => want.has(basename(p).replace(/\.jsonl$/i, "")));
988
+ // The dataset id ("owner/name") is a PATH here, so its "/" must not be
989
+ // percent-encoded. `recursive=true` returns every file under smolsent/.
990
+ const url = `https://huggingface.co/api/datasets/${SMOLSENT_DATASET}` +
991
+ `/tree/main/smolsent?recursive=true`;
992
+ const body = await getJson(url, `GET smol tree`);
993
+ const paths = Array.isArray(body)
994
+ ? body
995
+ .filter((e) => e?.type === "file" && /\.jsonl$/i.test(e?.path))
996
+ .map((e) => String(e.path))
997
+ : [];
998
+ paths.sort();
999
+ if (!SMOLSENT_PAIRS.length) {
1000
+ return paths;
1001
+ }
1002
+ const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
1003
+ return paths.filter((p) => want.has(basename(p).replace(/\.jsonl$/i, "")));
899
1004
  }
900
1005
  /** Stream a plain-JSONL file from disk, deposit each parsed row via `toItems`.
901
1006
  * Lines are split without buffering the whole file; an oversize/malformed line
902
1007
  * is counted skipped and the stream continues. Shared by SmolSent (and any
903
1008
  * future JSONL source). */
904
- async function processJsonl(filePath, toItems, ci, onExample, sample, maxLineChars) {
905
- const blob = await openAsBlob(filePath);
906
- const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
907
- let examples = 0, skipped = 0, leftover = "", dropping = false;
908
- const processLine = async (line) => {
909
- if (!line.trim())
910
- return true;
911
- let row;
912
- try {
913
- row = JSON.parse(line);
914
- }
915
- catch {
916
- skipped++;
917
- return true;
918
- }
919
- const items = toItems(row);
920
- if (!items || items.length === 0) {
921
- skipped++;
922
- return true;
923
- }
924
- return ingestItems(ci, items, async (contentBytes) => {
925
- examples++;
926
- return onExample(contentBytes);
927
- }, sample);
928
- };
1009
+ async function processJsonl(
1010
+ filePath,
1011
+ toItems,
1012
+ ci,
1013
+ onExample,
1014
+ sample,
1015
+ maxLineChars,
1016
+ ) {
1017
+ const blob = await openAsBlob(filePath);
1018
+ const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
1019
+ let examples = 0, skipped = 0, leftover = "", dropping = false;
1020
+ const processLine = async (line) => {
1021
+ if (!line.trim()) {
1022
+ return true;
1023
+ }
1024
+ let row;
929
1025
  try {
930
- while (true) {
931
- const { done, value } = await reader.read();
932
- if (done)
933
- break;
934
- let chunk = value;
935
- for (;;) {
936
- const nl = chunk.indexOf("\n");
937
- if (nl < 0) {
938
- if (!dropping) {
939
- if (leftover.length + chunk.length > maxLineChars) {
940
- leftover = "";
941
- dropping = true;
942
- skipped++;
943
- }
944
- else
945
- leftover += chunk;
946
- }
947
- break;
948
- }
949
- const part = chunk.slice(0, nl);
950
- chunk = chunk.slice(nl + 1);
951
- if (dropping) {
952
- dropping = false;
953
- leftover = "";
954
- continue;
955
- }
956
- if (leftover.length + part.length > maxLineChars) {
957
- leftover = "";
958
- skipped++;
959
- continue;
960
- }
961
- const line = leftover + part;
962
- leftover = "";
963
- if (!(await processLine(line))) {
964
- return { examples, stopped: true, skipped };
965
- }
966
- }
967
- }
968
- if (!dropping && leftover.trim()) {
969
- if (!(await processLine(leftover))) {
970
- return { examples, stopped: true, skipped };
1026
+ row = JSON.parse(line);
1027
+ } catch {
1028
+ skipped++;
1029
+ return true;
1030
+ }
1031
+ const items = toItems(row);
1032
+ if (!items || items.length === 0) {
1033
+ skipped++;
1034
+ return true;
1035
+ }
1036
+ return ingestItems(ci, items, async (contentBytes) => {
1037
+ examples++;
1038
+ return onExample(contentBytes);
1039
+ }, sample);
1040
+ };
1041
+ try {
1042
+ while (true) {
1043
+ const { done, value } = await reader.read();
1044
+ if (done) {
1045
+ break;
1046
+ }
1047
+ let chunk = value;
1048
+ for (;;) {
1049
+ const nl = chunk.indexOf("\n");
1050
+ if (nl < 0) {
1051
+ if (!dropping) {
1052
+ if (leftover.length + chunk.length > maxLineChars) {
1053
+ leftover = "";
1054
+ dropping = true;
1055
+ skipped++;
1056
+ } else {
1057
+ leftover += chunk;
971
1058
  }
972
- }
973
- return { examples, stopped: false, skipped };
1059
+ }
1060
+ break;
1061
+ }
1062
+ const part = chunk.slice(0, nl);
1063
+ chunk = chunk.slice(nl + 1);
1064
+ if (dropping) {
1065
+ dropping = false;
1066
+ leftover = "";
1067
+ continue;
1068
+ }
1069
+ if (leftover.length + part.length > maxLineChars) {
1070
+ leftover = "";
1071
+ skipped++;
1072
+ continue;
1073
+ }
1074
+ const line = leftover + part;
1075
+ leftover = "";
1076
+ if (!(await processLine(line))) {
1077
+ return { examples, stopped: true, skipped };
1078
+ }
1079
+ }
974
1080
  }
975
- finally {
976
- try {
977
- reader.releaseLock();
978
- }
979
- catch { /* best effort */ }
1081
+ if (!dropping && leftover.trim()) {
1082
+ if (!(await processLine(leftover))) {
1083
+ return { examples, stopped: true, skipped };
1084
+ }
980
1085
  }
1086
+ return { examples, stopped: false, skipped };
1087
+ } finally {
1088
+ try {
1089
+ reader.releaseLock();
1090
+ } catch { /* best effort */ }
1091
+ }
981
1092
  }
982
1093
  /** Read a downloaded Parquet file row-group by row-group with hyparquet (+Snappy
983
1094
  * from hyparquet-compressors) over a web-standard Blob byte source, depositing
984
1095
  * each row via `toItems`. Only one row-group is materialised at a time, so a
985
1096
  * multi-hundred-MB file never loads whole into memory. */
986
1097
  async function processParquet(filePath, toItems, ci, onExample, sample) {
987
- const blob = await openAsBlob(filePath);
988
- const file = {
989
- byteLength: blob.size,
990
- slice: async (start, end) => await blob.slice(start, end ?? blob.size).arrayBuffer(),
991
- };
992
- const meta = await parquetMetadataAsync(file);
993
- let examples = 0, skipped = 0;
994
- let rowStart = 0;
995
- for (const rg of meta.row_groups) {
996
- if (shutdown.signal.aborted)
997
- return { examples, stopped: true, skipped };
998
- const rgRows = Number(rg.num_rows);
999
- const rowEnd = rowStart + rgRows;
1000
- // Materialise exactly one row-group, then deposit its rows.
1001
- const rows = await parquetReadObjects({
1002
- file,
1003
- compressors,
1004
- rowStart,
1005
- rowEnd,
1006
- });
1007
- rowStart = rowEnd;
1008
- for (const row of rows) {
1009
- const items = toItems(row);
1010
- if (!items || items.length === 0) {
1011
- skipped++;
1012
- continue;
1013
- }
1014
- const ok = await ingestItems(ci, items, async (contentBytes) => {
1015
- examples++;
1016
- return onExample(contentBytes);
1017
- }, sample);
1018
- if (!ok)
1019
- return { examples, stopped: true, skipped };
1020
- }
1098
+ const blob = await openAsBlob(filePath);
1099
+ const file = {
1100
+ byteLength: blob.size,
1101
+ slice: async (start, end) =>
1102
+ await blob.slice(start, end ?? blob.size).arrayBuffer(),
1103
+ };
1104
+ const meta = await parquetMetadataAsync(file);
1105
+ let examples = 0, skipped = 0;
1106
+ let rowStart = 0;
1107
+ for (const rg of meta.row_groups) {
1108
+ if (shutdown.signal.aborted) {
1109
+ return { examples, stopped: true, skipped };
1021
1110
  }
1022
- return { examples, stopped: false, skipped };
1111
+ const rgRows = Number(rg.num_rows);
1112
+ const rowEnd = rowStart + rgRows;
1113
+ // Materialise exactly one row-group, then deposit its rows.
1114
+ const rows = await parquetReadObjects({
1115
+ file,
1116
+ compressors,
1117
+ rowStart,
1118
+ rowEnd,
1119
+ });
1120
+ rowStart = rowEnd;
1121
+ for (const row of rows) {
1122
+ const items = toItems(row);
1123
+ if (!items || items.length === 0) {
1124
+ skipped++;
1125
+ continue;
1126
+ }
1127
+ const ok = await ingestItems(ci, items, async (contentBytes) => {
1128
+ examples++;
1129
+ return onExample(contentBytes);
1130
+ }, sample);
1131
+ if (!ok) {
1132
+ return { examples, stopped: true, skipped };
1133
+ }
1134
+ }
1135
+ }
1136
+ return { examples, stopped: false, skipped };
1023
1137
  }
1024
1138
  /** Read a downloaded JSON-array file (General-Knowledge output.json) and deposit
1025
1139
  * each element via `toItems`. The array is small enough (~16 MB) to parse whole;
1026
1140
  * a huge file would be rejected by the cache ceiling long before this. */
1027
1141
  async function processJsonArray(filePath, toItems, ci, onExample, sample) {
1028
- const blob = await openAsBlob(filePath);
1029
- let arr;
1030
- try {
1031
- arr = JSON.parse(await blob.text());
1142
+ const blob = await openAsBlob(filePath);
1143
+ let arr;
1144
+ try {
1145
+ arr = JSON.parse(await blob.text());
1146
+ } catch (e) {
1147
+ throw new Error(`invalid JSON: ${e.message}`);
1148
+ }
1149
+ const rows = Array.isArray(arr) ? arr : [];
1150
+ let examples = 0, skipped = 0;
1151
+ for (const row of rows) {
1152
+ if (shutdown.signal.aborted) {
1153
+ return { examples, stopped: true, skipped };
1032
1154
  }
1033
- catch (e) {
1034
- throw new Error(`invalid JSON: ${e.message}`);
1155
+ const items = toItems(row);
1156
+ if (!items || items.length === 0) {
1157
+ skipped++;
1158
+ continue;
1035
1159
  }
1036
- const rows = Array.isArray(arr) ? arr : [];
1037
- let examples = 0, skipped = 0;
1038
- for (const row of rows) {
1039
- if (shutdown.signal.aborted)
1040
- return { examples, stopped: true, skipped };
1041
- const items = toItems(row);
1042
- if (!items || items.length === 0) {
1043
- skipped++;
1044
- continue;
1045
- }
1046
- const ok = await ingestItems(ci, items, async (contentBytes) => {
1047
- examples++;
1048
- return onExample(contentBytes);
1049
- }, sample);
1050
- if (!ok)
1051
- return { examples, stopped: true, skipped };
1160
+ const ok = await ingestItems(ci, items, async (contentBytes) => {
1161
+ examples++;
1162
+ return onExample(contentBytes);
1163
+ }, sample);
1164
+ if (!ok) {
1165
+ return { examples, stopped: true, skipped };
1052
1166
  }
1053
- return { examples, stopped: false, skipped };
1167
+ }
1168
+ return { examples, stopped: false, skipped };
1054
1169
  }
1055
1170
  /** A prompt/expected pair to display for an item. */
1056
1171
  function promptOf(it) {
1057
- return isEpisode(it)
1058
- ? { prompt: it.context, expected: it.continuation, kind: "episode" }
1059
- : { prompt: it.slice(0, 200), expected: null, kind: "experience" };
1172
+ return isEpisode(it)
1173
+ ? { prompt: it.context, expected: it.continuation, kind: "episode" }
1174
+ : { prompt: it.slice(0, 200), expected: null, kind: "experience" };
1060
1175
  }
1061
1176
  /** A coarse, honest similarity between an expected continuation and SEMA's
1062
1177
  * recall. Both are normalized (lowercased, whitespace-collapsed) and compared
1063
1178
  * by the longest shared leading run plus token overlap, so the verdict is a
1064
1179
  * heuristic signal of recall quality rather than a brittle fixed-prefix test. */
1065
1180
  function recallSimilarity(expected, response) {
1066
- const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
1067
- const a = norm(expected), b = norm(response);
1068
- if (!a || !b)
1069
- return 0;
1070
- let lead = 0;
1071
- const lim = Math.min(a.length, b.length);
1072
- while (lead < lim && a[lead] === b[lead])
1073
- lead++;
1074
- const leadFrac = lead / Math.max(1, Math.min(a.length, b.length));
1075
- const ta = new Set(a.split(" ")), tb = new Set(b.split(" "));
1076
- let inter = 0;
1077
- for (const w of ta)
1078
- if (tb.has(w))
1079
- inter++;
1080
- const jac = inter / Math.max(1, ta.size + tb.size - inter);
1081
- return Math.max(leadFrac, jac);
1181
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
1182
+ const a = norm(expected), b = norm(response);
1183
+ if (!a || !b) {
1184
+ return 0;
1185
+ }
1186
+ let lead = 0;
1187
+ const lim = Math.min(a.length, b.length);
1188
+ while (lead < lim && a[lead] === b[lead]) {
1189
+ lead++;
1190
+ }
1191
+ const leadFrac = lead / Math.max(1, Math.min(a.length, b.length));
1192
+ const ta = new Set(a.split(" ")), tb = new Set(b.split(" "));
1193
+ let inter = 0;
1194
+ for (const w of ta) {
1195
+ if (tb.has(w)) {
1196
+ inter++;
1197
+ }
1198
+ }
1199
+ const jac = inter / Math.max(1, ta.size + tb.size - inter);
1200
+ return Math.max(leadFrac, jac);
1082
1201
  }
1083
1202
  /** A framed recall sample. Pinned in the panel on a TTY (so the most recent
1084
1203
  * example is always on screen) and logged once per checkpoint when piped. */
1085
1204
  function renderInferenceBox(prompt, expected, response, kind, checkpointN) {
1086
- const W = 68;
1087
- const hr = `${DIM}${"─".repeat(W)}${R}`;
1088
- const title = kind === "episode"
1089
- ? "latest recall"
1090
- : "latest recall (experience)";
1091
- const head = `${title} · checkpoint #${checkpointN} `;
1092
- const shown = response.trim() ? response : "(empty)";
1093
- const lines = [
1094
- `${B}╭─ ${head}${"─".repeat(Math.max(0, W - 2 - head.length))}╮${R}`,
1095
- `${B}│${R} ${hr}`,
1096
- `${B}│${R} ${CYAN}${B}Context:${R} ${clip(prompt, W - 13)}`,
1097
- ];
1098
- if (expected) {
1099
- lines.push(`${B}│${R} ${YEL}${B}Expected:${R} ${clip(expected, W - 13)}`);
1100
- }
1101
- lines.push(`${B}│${R} ${GRN}${B}SEMA:${R} ${clip(shown, W - 13)}`);
1102
- lines.push(`${B}│${R} ${hr}`);
1103
- let verdict;
1104
- if (expected) {
1105
- const sim = recallSimilarity(expected, response);
1106
- const pctStr = `${Math.round(sim * 100)}%`;
1107
- verdict = sim >= 0.6
1108
- ? `${GRN}✓${R} recall close to expected ${DIM}(~${pctStr} overlap)${R}`
1109
- : sim >= 0.25
1110
- ? `${YEL}△${R} partial recall ${DIM}(~${pctStr} overlap)${R}`
1111
- : `${RED}✗${R} recall diverges ${DIM}(~${pctStr} overlap)${R}`;
1112
- }
1113
- else {
1114
- verdict = `${DIM}·${R} plain experience — no expected answer`;
1115
- }
1116
- lines.push(`${B}│${R} ${verdict}`);
1117
- lines.push(`${B}╰${"".repeat(W)}╯${R}`);
1118
- return lines.join("\n");
1205
+ const W = 68;
1206
+ const hr = `${DIM}${"─".repeat(W)}${R}`;
1207
+ const title = kind === "episode"
1208
+ ? "latest recall"
1209
+ : "latest recall (experience)";
1210
+ const head = `${title} · checkpoint #${checkpointN} `;
1211
+ const shown = response.trim() ? response : "(empty)";
1212
+ const lines = [
1213
+ `${B}╭─ ${head}${"─".repeat(Math.max(0, W - 2 - head.length))}╮${R}`,
1214
+ `${B}│${R} ${hr}`,
1215
+ `${B}│${R} ${CYAN}${B}Context:${R} ${clip(prompt, W - 13)}`,
1216
+ ];
1217
+ if (expected) {
1218
+ lines.push(`${B}│${R} ${YEL}${B}Expected:${R} ${clip(expected, W - 13)}`);
1219
+ }
1220
+ lines.push(`${B}│${R} ${GRN}${B}SEMA:${R} ${clip(shown, W - 13)}`);
1221
+ lines.push(`${B}│${R} ${hr}`);
1222
+ let verdict;
1223
+ if (expected) {
1224
+ const sim = recallSimilarity(expected, response);
1225
+ const pctStr = `${Math.round(sim * 100)}%`;
1226
+ verdict = sim >= 0.6
1227
+ ? `${GRN}✓${R} recall close to expected ${DIM}(~${pctStr} overlap)${R}`
1228
+ : sim >= 0.25
1229
+ ? `${YEL}△${R} partial recall ${DIM}(~${pctStr} overlap)${R}`
1230
+ : `${RED}✗${R} recall diverges ${DIM}(~${pctStr} overlap)${R}`;
1231
+ } else {
1232
+ verdict = `${DIM}·${R} plain experience — no expected answer`;
1233
+ }
1234
+ lines.push(`${B}│${R} ${verdict}`);
1235
+ lines.push(`${B}╰${"─".repeat(W)}╯${R}`);
1236
+ return lines.join("\n");
1119
1237
  }
1120
1238
  function renderPanel(s) {
1121
- const targetKnown = isFinite(s.target);
1122
- // Primary progress: by learned-content bytes when a MAX_MB target is set,
1123
- // else by how far we are through the corpus on disk (bytes) — so the default
1124
- // unbounded run still shows a real fraction and a real ETA.
1125
- const frac = targetKnown
1126
- ? (s.target > 0 ? s.trainedBytes / s.target : 0)
1127
- : (s.bytesTotal > 0 ? s.bytesDone / s.bytesTotal : 0);
1128
- const etaStr = (() => {
1129
- if (targetKnown) {
1130
- return s.trainedRate > 0
1131
- ? dur((s.target - s.trainedBytes) / s.trainedRate)
1132
- : "∞";
1133
- }
1134
- if (s.bytesTotal > 0 && s.bytesRate > 0) {
1135
- return dur((s.bytesTotal - s.bytesDone) / s.bytesRate);
1136
- }
1137
- return "∞";
1138
- })();
1139
- const fileFrac = s.fileTotal > 0 ? s.fileIndex / s.fileTotal : 0;
1140
- let actIcon = `${DIM}·${R}`, actText = "waiting…";
1141
- if (s.activity === "download") {
1142
- actIcon = `${CYAN}⬇${R}`;
1143
- const name = s.filePath;
1144
- const total = s.dlTotal > 0 ? s.dlTotal : s.fileSize;
1145
- if (total > 0 && s.dlDone > 0) {
1146
- const dlFrac = clamp01(s.dlDone / total);
1147
- actText =
1148
- `downloading ${name} ${bar(18, dlFrac)} ${B}${pct(dlFrac)}${R}` +
1149
- ` ${DIM}${bytes(s.dlDone)}/${bytes(total)}${R}`;
1150
- if (s.dlSpeed > 0)
1151
- actText += ` ${DIM}@ ${bytes(s.dlSpeed)}/s${R}`;
1152
- }
1153
- else {
1154
- actText = total > 0
1155
- ? `downloading ${name} · ${bytes(total)}…`
1156
- : `downloading ${name}…`;
1157
- }
1239
+ const targetKnown = isFinite(s.target);
1240
+ // Primary progress: by learned-content bytes when a MAX_MB target is set,
1241
+ // else by how far we are through the corpus on disk (bytes) — so the default
1242
+ // unbounded run still shows a real fraction and a real ETA.
1243
+ const frac = targetKnown
1244
+ ? (s.target > 0 ? s.trainedBytes / s.target : 0)
1245
+ : (s.bytesTotal > 0 ? s.bytesDone / s.bytesTotal : 0);
1246
+ const etaStr = (() => {
1247
+ if (targetKnown) {
1248
+ return s.trainedRate > 0
1249
+ ? dur((s.target - s.trainedBytes) / s.trainedRate)
1250
+ : "∞";
1251
+ }
1252
+ if (s.bytesTotal > 0 && s.bytesRate > 0) {
1253
+ return dur((s.bytesTotal - s.bytesDone) / s.bytesRate);
1158
1254
  }
1159
- else if (s.activity === "process") {
1160
- actIcon = `${GRN}✓${R}`;
1161
- actText = `processing ${s.filePath} · ${int(s.fileExamples)} examples so far`;
1162
- }
1163
- const targetStr = targetKnown ? bytes(s.target) : "";
1164
- const headExamples = targetKnown
1165
- ? `${CYAN}${bytes(s.trainedBytes)}${R} / ${targetStr} learned ${DIM}·${R} ${int(s.exampleCount)} examples`
1166
- : `${CYAN}${int(s.exampleCount)}${R} examples`;
1167
- const corpusInfo = s.bytesTotal > 0
1168
- ? `${B}📦${R} ${bytes(s.bytesDone)}/${bytes(s.bytesTotal)} (${pct(s.bytesDone / s.bytesTotal)})`
1169
- : `${B}📦${R} ${bytes(s.bytesDone)} processed`;
1170
- const fileInfo = s.fileTotal > 0
1171
- ? `${B}🌐${R} ${s.fileIndex}/${s.fileTotal} (${pct(fileFrac)})`
1172
- : `${B}🌐${R} ${s.fileIndex} languages`;
1173
- const panel = [
1174
- `${B}╭${R}${B} sema train${R} ${DIM}·${R} SmolSent+Aya+oasst2 ${DIM}·${R} ` +
1175
- `D=${D} ${DIM}·${R} seed=${SEED} ${DIM}·${R} ` +
1176
- `store=${basename(DB_PATH)}.sqlite\n${B}╰${R} target=${CYAN}${targetStr}${R} ` +
1177
- `learned ${DIM}·${R} checkpoint every ${bytes(CHECKPOINT_BYTES)}`,
1178
- `\n${bar(40, frac)} ${B}${pct(frac)}${R} ${headExamples}`,
1179
- `\n${B}⚡${R} ${bytes(s.trainedRate)}/s learned ${B}🧠${R} ${bytes(s.trainedBytes)} content ${B}⏱${R} ${dur(s.elapsedS)} elapsed ${B}🕐${R} ${etaStr} ETA`,
1180
- `${fileInfo} ${corpusInfo} ${B}🗄${R} ${num(s.storeEntries)} entries ` +
1181
- `${B}💾${R} cache ${bytes(s.cacheBytes)}`,
1182
- `\n${actIcon} ${actText}`,
1183
- ].join("");
1184
- return s.lastSample ? `${panel}\n${s.lastSample}` : panel;
1255
+ return "";
1256
+ })();
1257
+ const fileFrac = s.fileTotal > 0 ? s.fileIndex / s.fileTotal : 0;
1258
+ let actIcon = `${DIM}·${R}`, actText = "waiting…";
1259
+ if (s.activity === "download") {
1260
+ actIcon = `${CYAN}⬇${R}`;
1261
+ const name = s.filePath;
1262
+ const total = s.dlTotal > 0 ? s.dlTotal : s.fileSize;
1263
+ if (total > 0 && s.dlDone > 0) {
1264
+ const dlFrac = clamp01(s.dlDone / total);
1265
+ actText =
1266
+ `downloading ${name} ${bar(18, dlFrac)} ${B}${pct(dlFrac)}${R}` +
1267
+ ` ${DIM}${bytes(s.dlDone)}/${bytes(total)}${R}`;
1268
+ if (s.dlSpeed > 0) {
1269
+ actText += ` ${DIM}@ ${bytes(s.dlSpeed)}/s${R}`;
1270
+ }
1271
+ } else {
1272
+ actText = total > 0
1273
+ ? `downloading ${name} · ${bytes(total)}…`
1274
+ : `downloading ${name}…`;
1275
+ }
1276
+ } else if (s.activity === "process") {
1277
+ actIcon = `${GRN}✓${R}`;
1278
+ actText = `processing ${s.filePath} · ${
1279
+ int(s.fileExamples)
1280
+ } examples so far`;
1281
+ }
1282
+ const targetStr = targetKnown ? bytes(s.target) : "∞";
1283
+ const headExamples = targetKnown
1284
+ ? `${CYAN}${bytes(s.trainedBytes)}${R} / ${targetStr} learned ${DIM}·${R} ${
1285
+ int(s.exampleCount)
1286
+ } examples`
1287
+ : `${CYAN}${int(s.exampleCount)}${R} examples`;
1288
+ const corpusInfo = s.bytesTotal > 0
1289
+ ? `${B}📦${R} ${bytes(s.bytesDone)}/${bytes(s.bytesTotal)} (${
1290
+ pct(s.bytesDone / s.bytesTotal)
1291
+ })`
1292
+ : `${B}📦${R} ${bytes(s.bytesDone)} processed`;
1293
+ const fileInfo = s.fileTotal > 0
1294
+ ? `${B}🌐${R} ${s.fileIndex}/${s.fileTotal} (${pct(fileFrac)})`
1295
+ : `${B}🌐${R} ${s.fileIndex} languages`;
1296
+ const panel = [
1297
+ `${B}╭${R}${B} sema train${R} ${DIM}·${R} SmolSent+Aya+oasst2 ${DIM}·${R} ` +
1298
+ `D=${D} ${DIM}·${R} seed=${SEED} ${DIM}·${R} ` +
1299
+ `store=${
1300
+ basename(DB_PATH)
1301
+ }.sqlite\n${B}╰${R} target=${CYAN}${targetStr}${R} ` +
1302
+ `learned ${DIM}·${R} checkpoint every ${bytes(CHECKPOINT_BYTES)}`,
1303
+ `\n${bar(40, frac)} ${B}${pct(frac)}${R} ${headExamples}`,
1304
+ `\n${B}⚡${R} ${bytes(s.trainedRate)}/s learned ${B}🧠${R} ${
1305
+ bytes(s.trainedBytes)
1306
+ } content ${B}⏱${R} ${dur(s.elapsedS)} elapsed ${B}🕐${R} ${etaStr} ETA`,
1307
+ `${fileInfo} ${corpusInfo} ${B}🗄${R} ${num(s.storeEntries)} entries ` +
1308
+ `${B}💾${R} cache ${bytes(s.cacheBytes)}`,
1309
+ `\n${actIcon} ${actText}`,
1310
+ ].join("");
1311
+ return s.lastSample ? `${panel}\n${s.lastSample}` : panel;
1185
1312
  }
1186
1313
  /** A live panel pinned to the bottom of stderr. On a TTY it redraws in place,
1187
1314
  * clearing only its own lines; logs are flushed into the scrollback above it.
1188
1315
  * Off a TTY (piped/CI) the panel is suppressed and a plain status line is
1189
1316
  * emitted occasionally, so logs stay clean and parseable. */
1190
1317
  class Progress {
1191
- lines = 0; // height of the panel currently on screen
1192
- lastPaint = 0;
1193
- lastStatus = 0;
1194
- last = null;
1195
- tty = process.stderr.isTTY === true;
1196
- /** True when attached to an interactive terminal (panel is live). */
1197
- get interactive() {
1198
- return this.tty;
1199
- }
1200
- /** Cursor sequence that returns to the top of the panel and clears it. */
1201
- clearPanel() {
1202
- if (this.lines <= 0)
1203
- return "";
1204
- const up = this.lines - 1; // cursor is on the panel's last line
1205
- return (up > 0 ? `${CSI}${up}F` : "\r") + `${CSI}0J`;
1206
- }
1207
- render(s, force = false) {
1208
- this.last = s;
1209
- const now = Date.now();
1210
- if (!force && now - this.lastPaint < PROGRESS_MS)
1211
- return;
1212
- this.lastPaint = now;
1213
- if (!this.tty) {
1214
- if (force || now - this.lastStatus >= 10_000) {
1215
- this.lastStatus = now;
1216
- const targetKnown = isFinite(s.target);
1217
- const where = s.bytesTotal > 0
1218
- ? ` ${pct(s.bytesDone / s.bytesTotal)} of corpus`
1219
- : "";
1220
- process.stderr.write(`[sema] ${bytes(s.trainedBytes)}${targetKnown ? "/" + bytes(s.target) : ""} learned · ${int(s.exampleCount)} examples · ` +
1221
- `${bytes(s.trainedRate)}/s · lang ${s.fileIndex}/${s.fileTotal}${where} · ` +
1222
- `${num(s.storeEntries)} entries\n`);
1223
- }
1224
- return;
1225
- }
1226
- const text = renderPanel(s);
1227
- process.stderr.write(`${this.clearPanel()}${HIDE}${text}`);
1228
- this.lines = text.split("\n").length;
1229
- }
1230
- /** Emit a line (or block) into the scrollback above the panel; the panel is
1231
- * redrawn immediately beneath it so it never disappears between frames. */
1232
- log(msg) {
1233
- if (!this.tty) {
1234
- process.stderr.write(`${msg}\n`);
1235
- return;
1236
- }
1237
- let out = `${this.clearPanel()}${msg}\n`;
1238
- this.lines = 0;
1239
- if (this.last) {
1240
- const text = renderPanel(this.last);
1241
- out += `${HIDE}${text}`;
1242
- this.lines = text.split("\n").length;
1243
- }
1244
- process.stderr.write(out);
1318
+ lines = 0; // height of the panel currently on screen
1319
+ lastPaint = 0;
1320
+ lastStatus = 0;
1321
+ last = null;
1322
+ tty = process.stderr.isTTY === true;
1323
+ /** True when attached to an interactive terminal (panel is live). */
1324
+ get interactive() {
1325
+ return this.tty;
1326
+ }
1327
+ /** Cursor sequence that returns to the top of the panel and clears it. */
1328
+ clearPanel() {
1329
+ if (this.lines <= 0) {
1330
+ return "";
1331
+ }
1332
+ const up = this.lines - 1; // cursor is on the panel's last line
1333
+ return (up > 0 ? `${CSI}${up}F` : "\r") + `${CSI}0J`;
1334
+ }
1335
+ render(s, force = false) {
1336
+ this.last = s;
1337
+ const now = Date.now();
1338
+ if (!force && now - this.lastPaint < PROGRESS_MS) {
1339
+ return;
1340
+ }
1341
+ this.lastPaint = now;
1342
+ if (!this.tty) {
1343
+ if (force || now - this.lastStatus >= 10_000) {
1344
+ this.lastStatus = now;
1345
+ const targetKnown = isFinite(s.target);
1346
+ const where = s.bytesTotal > 0
1347
+ ? ` ${pct(s.bytesDone / s.bytesTotal)} of corpus`
1348
+ : "";
1349
+ process.stderr.write(
1350
+ `[sema] ${bytes(s.trainedBytes)}${
1351
+ targetKnown ? "/" + bytes(s.target) : ""
1352
+ } learned · ${int(s.exampleCount)} examples · ` +
1353
+ `${
1354
+ bytes(s.trainedRate)
1355
+ }/s · lang ${s.fileIndex}/${s.fileTotal}${where} · ` +
1356
+ `${num(s.storeEntries)} entries\n`,
1357
+ );
1358
+ }
1359
+ return;
1360
+ }
1361
+ const text = renderPanel(s);
1362
+ process.stderr.write(`${this.clearPanel()}${HIDE}${text}`);
1363
+ this.lines = text.split("\n").length;
1364
+ }
1365
+ /** Emit a line (or block) into the scrollback above the panel; the panel is
1366
+ * redrawn immediately beneath it so it never disappears between frames. */
1367
+ log(msg) {
1368
+ if (!this.tty) {
1369
+ process.stderr.write(`${msg}\n`);
1370
+ return;
1371
+ }
1372
+ let out = `${this.clearPanel()}${msg}\n`;
1373
+ this.lines = 0;
1374
+ if (this.last) {
1375
+ const text = renderPanel(this.last);
1376
+ out += `${HIDE}${text}`;
1377
+ this.lines = text.split("\n").length;
1245
1378
  }
1246
- dispose() {
1247
- if (this.tty)
1248
- process.stderr.write(`${SHOW}\n`);
1379
+ process.stderr.write(out);
1380
+ }
1381
+ dispose() {
1382
+ if (this.tty) {
1383
+ process.stderr.write(`${SHOW}\n`);
1249
1384
  }
1385
+ }
1250
1386
  }
1251
1387
  // ═══════════════════════════════════════════════════════════════════════
1252
1388
  // §9 Progress persistence (inside the store — resume from the store alone)
@@ -1257,960 +1393,1092 @@ const META_TRAINED_BYTES = "train.trainedContentBytes";
1257
1393
  const META_BYTES = "train.totalBytesProcessed";
1258
1394
  const META_CORPUS_BYTES = "train.totalCorpusBytes";
1259
1395
  async function loadProgress(store) {
1260
- try {
1261
- const raw = await store.getMeta(META_COMPLETED);
1262
- const deps = await store.getMeta(META_DEPOSITS);
1263
- const b = await store.getMeta(META_BYTES);
1264
- if (raw !== null && deps !== null && b !== null) {
1265
- const completedFiles = JSON.parse(raw);
1266
- if (Array.isArray(completedFiles)) {
1267
- const trained = await store.getMeta(META_TRAINED_BYTES);
1268
- const corpus = await store.getMeta(META_CORPUS_BYTES);
1269
- return {
1270
- completedFiles,
1271
- depositCount: Number(deps) || 0,
1272
- trainedContentBytes: Number(trained) || 0,
1273
- totalBytesProcessed: Number(b) || 0,
1274
- totalCorpusBytes: Number(corpus) || 0,
1275
- };
1276
- }
1277
- }
1396
+ try {
1397
+ const raw = await store.getMeta(META_COMPLETED);
1398
+ const deps = await store.getMeta(META_DEPOSITS);
1399
+ const b = await store.getMeta(META_BYTES);
1400
+ if (raw !== null && deps !== null && b !== null) {
1401
+ const completedFiles = JSON.parse(raw);
1402
+ if (Array.isArray(completedFiles)) {
1403
+ const trained = await store.getMeta(META_TRAINED_BYTES);
1404
+ const corpus = await store.getMeta(META_CORPUS_BYTES);
1405
+ return {
1406
+ completedFiles,
1407
+ depositCount: Number(deps) || 0,
1408
+ trainedContentBytes: Number(trained) || 0,
1409
+ totalBytesProcessed: Number(b) || 0,
1410
+ totalCorpusBytes: Number(corpus) || 0,
1411
+ };
1412
+ }
1278
1413
  }
1279
- catch { /* corrupt/missing — start fresh */ }
1280
- return {
1281
- completedFiles: [],
1282
- depositCount: 0,
1283
- trainedContentBytes: 0,
1284
- totalBytesProcessed: 0,
1285
- totalCorpusBytes: 0,
1286
- };
1414
+ } catch { /* corrupt/missing — start fresh */ }
1415
+ return {
1416
+ completedFiles: [],
1417
+ depositCount: 0,
1418
+ trainedContentBytes: 0,
1419
+ totalBytesProcessed: 0,
1420
+ totalCorpusBytes: 0,
1421
+ };
1287
1422
  }
1288
1423
  async function saveProgress(store, p) {
1289
- await store.setMeta(META_COMPLETED, JSON.stringify(p.completedFiles));
1290
- await store.setMeta(META_DEPOSITS, String(p.depositCount));
1291
- await store.setMeta(META_TRAINED_BYTES, String(p.trainedContentBytes));
1292
- await store.setMeta(META_BYTES, String(p.totalBytesProcessed));
1293
- await store.setMeta(META_CORPUS_BYTES, String(p.totalCorpusBytes));
1294
- await store.setMeta("train.updatedAt", new Date().toISOString());
1295
- store.commit();
1424
+ await store.setMeta(META_COMPLETED, JSON.stringify(p.completedFiles));
1425
+ await store.setMeta(META_DEPOSITS, String(p.depositCount));
1426
+ await store.setMeta(META_TRAINED_BYTES, String(p.trainedContentBytes));
1427
+ await store.setMeta(META_BYTES, String(p.totalBytesProcessed));
1428
+ await store.setMeta(META_CORPUS_BYTES, String(p.totalCorpusBytes));
1429
+ await store.setMeta("train.updatedAt", new Date().toISOString());
1430
+ store.commit();
1296
1431
  }
1297
1432
  // ═══════════════════════════════════════════════════════════════════════
1298
1433
  // §10 Main
1299
1434
  // ═══════════════════════════════════════════════════════════════════════
1300
1435
  async function main() {
1301
- // The vector indices' single memory knob (MiB) — sizes both the SQLite page
1302
- // cache and the decoded-code LRU of each index. Training is HNSW-insert
1303
- // bound at scale (profiled at >80% of ingest wall-clock on a 5.5M-vector
1304
- // store, nearly all of it cache-missing code reads), and inserts walk the
1305
- // graph, so a cache that covers the indexed codes cuts wall-clock directly
1306
- // (measured ~1.6× at 1 GB vs 64 MB on a 2M-node store). 1 GB caches ~5.8M
1307
- // D=1024 codes — the whole content index of a 350 MB-corpus store — and is
1308
- // the default; override with VECTOR_CACHE_MB (e.g. 256 on a small-RAM
1309
- // machine, 64 for the library default).
1310
- const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "1024")));
1311
- const store = new SQliteStore({
1312
- path: DB_PATH,
1313
- D,
1314
- vectorCacheMb: VECTOR_CACHE_MB,
1315
- });
1316
- // The store IS the model: memories, progress, and metadata all persist in
1317
- // it, so a resumed run just reopens the same store and continues. Guard
1318
- // against a changed D/SEED by comparing against what a previous run recorded.
1319
- const mind = new Mind({ seed: SEED, store });
1320
- // Pre-fill the vector indices' RAM caches with sequential scans (bounded by
1321
- // VECTOR_CACHE_MB). A resumed run over a large store otherwise spends its
1322
- // first minutes warming those caches through random point reads — the
1323
- // ingest hot path is cache-miss bound until then. Seconds, once, up front.
1324
- if (VECTOR_CACHE_MB > 0) {
1325
- const t = Date.now();
1326
- const warmed = await store.warmVectorCaches();
1327
- if (warmed > 0) {
1328
- process.stderr.write(` warmed vector caches: ${num(warmed)} rows in ${dur((Date.now() - t) / 1000)}\n`);
1329
- }
1436
+ // The vector indices' single memory knob (MiB) — sizes both the SQLite page
1437
+ // cache and the decoded-code LRU of each index. Training is HNSW-insert
1438
+ // bound at scale (profiled at >80% of ingest wall-clock on a 5.5M-vector
1439
+ // store, nearly all of it cache-missing code reads), and inserts walk the
1440
+ // graph, so a cache that covers the indexed codes cuts wall-clock directly
1441
+ // (measured ~1.6× at 1 GB vs 64 MB on a 2M-node store). 1 GB caches ~5.8M
1442
+ // D=1024 codes — the whole content index of a 350 MB-corpus store — and is
1443
+ // the default; override with VECTOR_CACHE_MB (e.g. 256 on a small-RAM
1444
+ // machine, 64 for the library default).
1445
+ const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "1024")));
1446
+ const store = new SQliteStore({
1447
+ path: DB_PATH,
1448
+ D,
1449
+ vectorCacheMb: VECTOR_CACHE_MB,
1450
+ });
1451
+ // The store IS the model: memories, progress, and metadata all persist in
1452
+ // it, so a resumed run just reopens the same store and continues. Guard
1453
+ // against a changed D/SEED by comparing against what a previous run recorded.
1454
+ const mind = new Mind({ seed: SEED, store });
1455
+ // Pre-fill the vector indices' RAM caches with sequential scans (bounded by
1456
+ // VECTOR_CACHE_MB). A resumed run over a large store otherwise spends its
1457
+ // first minutes warming those caches through random point reads — the
1458
+ // ingest hot path is cache-miss bound until then. Seconds, once, up front.
1459
+ if (VECTOR_CACHE_MB > 0) {
1460
+ const t = Date.now();
1461
+ const warmed = await store.warmVectorCaches();
1462
+ if (warmed > 0) {
1463
+ process.stderr.write(
1464
+ ` warmed vector caches: ${num(warmed)} rows in ${
1465
+ dur((Date.now() - t) / 1000)
1466
+ }\n`,
1467
+ );
1330
1468
  }
1331
- const ci = new CachedIngest(mind);
1332
- const prevD = await store.getMeta("train.D");
1333
- const prevSeed = await store.getMeta("train.seed");
1334
- if ((prevD && Number(prevD) !== D) || (prevSeed && Number(prevSeed) !== SEED)) {
1335
- process.stderr.write(`fatal: D/SEED changed (store has D=${prevD} seed=${prevSeed}, ` +
1336
- `requested D=${D} seed=${SEED}). Delete ${DB_PATH}.sqlite ` +
1337
- `to start fresh.\n`);
1338
- process.exit(1);
1339
- }
1340
- await store.setMeta("train.dataset", "SmolSent+Aya+oasst2");
1341
- await store.setMeta("train.D", String(D));
1342
- await store.setMeta("train.seed", String(SEED));
1343
- await store.setMeta("train.createdAt", new Date().toISOString());
1344
- // ── counters & sampling ──
1345
- let depositCount = 0;
1346
- let trainedContentBytes = 0;
1347
- let bytesSinceCkpt = 0;
1348
- let checkpointNum = 0;
1349
- let totalBytesProcessed = 0;
1350
- let totalCorpusBytes = 0;
1351
- const langTally = {};
1352
- const t0 = Date.now();
1353
- // Reservoir sample: one uniformly-random item from the current window, shown
1354
- // in the recall box at each checkpoint.
1355
- let sampleItem = null;
1356
- let seenInWindow = 0;
1357
- const sample = (it) => {
1358
- seenInWindow++;
1359
- if (Math.random() < 1 / seenInWindow)
1360
- sampleItem = it;
1361
- };
1362
- // ── progress panel ──
1363
- const progress = new Progress();
1364
- // Surface rate-limit waits from the low-level fetch retries into the live log,
1365
- // so a 429 back-off reads as "waiting", never a silent hang or a dropped file.
1366
- onThrottleWait = (ms, label) => {
1367
- progress.log(` ${YEL}⏳${R} rate-limited (${label}); waiting ${(ms / 1000).toFixed(1)}s and retrying — not skipping`);
1368
- };
1369
- const state = {
1370
- exampleCount: 0,
1371
- target: MAX_BYTES,
1372
- elapsedS: 0,
1373
- trainedBytes: 0,
1374
- trainedRate: 0,
1375
- bytesDone: 0,
1376
- bytesTotal: 0,
1377
- bytesRate: 0,
1378
- fileIndex: 0,
1379
- fileTotal: 0,
1380
- filePath: "",
1381
- fileSize: 0,
1382
- fileExamples: 0,
1383
- activity: "idle",
1384
- dlSpeed: 0,
1385
- dlDone: 0,
1386
- dlTotal: 0,
1387
- storeEntries: 0,
1388
- cacheBytes: 0,
1389
- lastSample: null,
1390
- };
1391
- // store.size() is async; refresh it on a slow cadence so the hot loop and
1392
- // the repaint never block on a query.
1393
- let cachedEntries = 0;
1394
- let sizeInFlight = false;
1395
- const refreshSize = () => {
1396
- if (sizeInFlight)
1397
- return;
1398
- sizeInFlight = true;
1399
- void mind.store.size()
1400
- .then((n) => (cachedEntries = n))
1401
- .catch(() => undefined)
1402
- .finally(() => (sizeInFlight = false));
1403
- };
1404
- // Cache size changes only at download/delete boundaries — recompute it
1405
- // lazily rather than statting the dir on every deposit.
1406
- let cachedCacheBytes = 0;
1407
- let lastCacheUpdate = 0;
1408
- // Live download progress for the panel.
1409
- let dlSlot = null;
1410
- // Rolling throughput: a short EMA over wall-clock windows, so the headline
1411
- // figures reflect CURRENT speed rather than a lifetime average diluted by the
1412
- // listing and download phases (which train nothing).
1413
- let rateT = t0;
1414
- let rateTrained = 0;
1415
- let rateBytes = 0;
1416
- const syncState = () => {
1417
- const now = Date.now();
1418
- state.exampleCount = depositCount;
1419
- state.trainedBytes = trainedContentBytes;
1420
- state.elapsedS = (now - t0) / 1000;
1421
- state.storeEntries = cachedEntries;
1422
- state.bytesDone = totalBytesProcessed;
1423
- state.bytesTotal = totalCorpusBytes;
1424
- if (dlSlot && state.activity === "download") {
1425
- state.dlDone = dlSlot.done;
1426
- state.dlTotal = dlSlot.total;
1427
- const ds = (now - dlSlot.t0) / 1000;
1428
- state.dlSpeed = ds > 0.2 ? dlSlot.done / ds : 0;
1429
- }
1430
- else {
1431
- state.dlDone = 0;
1432
- state.dlTotal = 0;
1433
- }
1434
- const dt = (now - rateT) / 1000;
1435
- if (dt >= 0.5) {
1436
- const instTrained = (trainedContentBytes - rateTrained) / dt;
1437
- const instByte = (totalBytesProcessed - rateBytes) / dt;
1438
- const a = 0.3; // EMA weight on the newest sample
1439
- state.trainedRate = state.trainedRate === 0
1440
- ? instTrained
1441
- : state.trainedRate * (1 - a) + instTrained * a;
1442
- state.bytesRate = state.bytesRate === 0
1443
- ? instByte
1444
- : state.bytesRate * (1 - a) + instByte * a;
1445
- rateT = now;
1446
- rateTrained = trainedContentBytes;
1447
- rateBytes = totalBytesProcessed;
1448
- }
1449
- if (now - lastCacheUpdate > 2000) {
1450
- cachedCacheBytes = cacheSize();
1451
- lastCacheUpdate = now;
1452
- }
1453
- state.cacheBytes = cachedCacheBytes;
1454
- };
1455
- const tick = (force = false) => {
1456
- syncState();
1457
- progress.render(state, force);
1458
- };
1459
- const paintTimer = setInterval(() => {
1460
- refreshSize();
1461
- tick(false);
1462
- }, PROGRESS_MS);
1463
- if (typeof paintTimer.unref === "function")
1464
- paintTimer.unref();
1465
- // ── keep-alive: the process must never exit on its own mid-training ──
1466
- // The CPU-bound processing phase (perceive + intern + the batched vector-index
1467
- // writes) hands control back to the event loop between batches via the store's
1468
- // yieldToEventLoop(), which parks on an UNREF'd setImmediate so the library
1469
- // never holds a process open by itself. node:sqlite is synchronous and the
1470
- // vector index is in-memory, so the store's awaits resolve as microtasks with
1471
- // no I/O handle, and the paint timer above is unref'd too. That leaves a window
1472
- // — a batch flush that fires while we're processing an in-memory chunk, not
1473
- // awaiting a disk read — in which the ONLY pending work is that unref'd
1474
- // setImmediate and NOTHING is ref'd. Node's rule is to exit when only unref'd
1475
- // handles remain, WITHOUT running them: the yield's continuation never fires,
1476
- // main() is abandoned, and the process exits 0 silently mid-file no error for
1477
- // the fault-tolerance to catch. This one ref'd (NOT unref'd) timer guarantees a
1478
- // live handle for the whole run, so the loop can never drain from under a
1479
- // pending yield. Every real exit is an explicit process.exit() (finish(), the
1480
- // shutdown watchdog, the second-signal path, the fatal catch), so keeping this
1481
- // handle alive never delays a genuine shutdown; finish() clears it before that
1482
- // final exit for tidiness. Same lesson as waitMs above (deliberately un-unref'd).
1483
- const keepAlive = setInterval(() => { }, 1 << 30);
1484
- const checkpoint = () => mind.save();
1485
- /** Run index maintenance: compact (remove garbage) then repair (fill gaps).
1486
- * Both are idempotent running twice produces the same result as once.
1487
- * Compaction frees index space first; repair then adds back the bridges
1488
- * whose gists were evicted before indexing, completing the coverage that
1489
- * incremental bridge promotion alone cannot guarantee.
1490
- *
1491
- * Logs the number of entries removed/added so a run that silently degrades
1492
- * (growing compaction count, or repair never recovering anything) is
1493
- * visible in the training log. */
1494
- const runIndexMaintenance = async () => {
1495
- if (!INDEX_MAINTENANCE)
1496
- return;
1497
- try {
1498
- const removed = await mind.store.compactContentIndex();
1499
- if (removed > 0) {
1500
- progress.log(` ${DIM}index compact: removed ${int(removed)} isolated entries${R}`);
1501
- }
1502
- }
1503
- catch (err) {
1504
- progress.log(` ${YEL}⚠ index compact failed${R}: ${err instanceof Error ? err.message : String(err)}`);
1505
- }
1506
- try {
1507
- const added = await mind.repairContentIndex();
1508
- if (added > 0) {
1509
- progress.log(` ${GRN}index repair: added ${int(added)} missing bridges${R}`);
1510
- }
1511
- }
1512
- catch (err) {
1513
- progress.log(` ${YEL}index repair failed${R}: ${err instanceof Error ? err.message : String(err)}`);
1514
- }
1515
- };
1516
- // The checkpoint recall is a best-effort diagnostic. It is time-bounded so a
1517
- // slow/large store can never freeze the deposit loop, and guarded so a still
1518
- // running recall is never stacked on top of another.
1519
- let inferBusy = false;
1520
- const runRecall = async (item, n) => {
1521
- if (inferBusy)
1522
- return;
1523
- inferBusy = true;
1469
+ }
1470
+ const ci = new CachedIngest(mind);
1471
+ const prevD = await store.getMeta("train.D");
1472
+ const prevSeed = await store.getMeta("train.seed");
1473
+ if (
1474
+ (prevD && Number(prevD) !== D) || (prevSeed && Number(prevSeed) !== SEED)
1475
+ ) {
1476
+ process.stderr.write(
1477
+ `fatal: D/SEED changed (store has D=${prevD} seed=${prevSeed}, ` +
1478
+ `requested D=${D} seed=${SEED}). Delete ${DB_PATH}.sqlite ` +
1479
+ `to start fresh.\n`,
1480
+ );
1481
+ process.exit(1);
1482
+ }
1483
+ await store.setMeta("train.dataset", "SmolSent+Aya+oasst2");
1484
+ await store.setMeta("train.D", String(D));
1485
+ await store.setMeta("train.seed", String(SEED));
1486
+ await store.setMeta("train.createdAt", new Date().toISOString());
1487
+ // ── counters & sampling ──
1488
+ let depositCount = 0;
1489
+ let trainedContentBytes = 0;
1490
+ let bytesSinceCkpt = 0;
1491
+ let checkpointNum = 0;
1492
+ let totalBytesProcessed = 0;
1493
+ let totalCorpusBytes = 0;
1494
+ const langTally = {};
1495
+ const t0 = Date.now();
1496
+ // Reservoir sample: one uniformly-random item from the current window, shown
1497
+ // in the recall box at each checkpoint.
1498
+ let sampleItem = null;
1499
+ let seenInWindow = 0;
1500
+ const sample = (it) => {
1501
+ seenInWindow++;
1502
+ if (Math.random() < 1 / seenInWindow) {
1503
+ sampleItem = it;
1504
+ }
1505
+ };
1506
+ // ── progress panel ──
1507
+ const progress = new Progress();
1508
+ // Surface rate-limit waits from the low-level fetch retries into the live log,
1509
+ // so a 429 back-off reads as "waiting", never a silent hang or a dropped file.
1510
+ onThrottleWait = (ms, label) => {
1511
+ progress.log(
1512
+ ` ${YEL}⏳${R} rate-limited (${label}); waiting ${
1513
+ (ms / 1000).toFixed(1)
1514
+ }s and retrying — not skipping`,
1515
+ );
1516
+ };
1517
+ const state = {
1518
+ exampleCount: 0,
1519
+ target: MAX_BYTES,
1520
+ elapsedS: 0,
1521
+ trainedBytes: 0,
1522
+ trainedRate: 0,
1523
+ bytesDone: 0,
1524
+ bytesTotal: 0,
1525
+ bytesRate: 0,
1526
+ fileIndex: 0,
1527
+ fileTotal: 0,
1528
+ filePath: "",
1529
+ fileSize: 0,
1530
+ fileExamples: 0,
1531
+ activity: "idle",
1532
+ dlSpeed: 0,
1533
+ dlDone: 0,
1534
+ dlTotal: 0,
1535
+ storeEntries: 0,
1536
+ cacheBytes: 0,
1537
+ lastSample: null,
1538
+ };
1539
+ // store.size() is async; refresh it on a slow cadence so the hot loop and
1540
+ // the repaint never block on a query.
1541
+ let cachedEntries = 0;
1542
+ let sizeInFlight = false;
1543
+ const refreshSize = () => {
1544
+ if (sizeInFlight) {
1545
+ return;
1546
+ }
1547
+ sizeInFlight = true;
1548
+ void mind.store.size()
1549
+ .then((n) => (cachedEntries = n))
1550
+ .catch(() => undefined)
1551
+ .finally(() => (sizeInFlight = false));
1552
+ };
1553
+ // Cache size changes only at download/delete boundaries — recompute it
1554
+ // lazily rather than statting the dir on every deposit.
1555
+ let cachedCacheBytes = 0;
1556
+ let lastCacheUpdate = 0;
1557
+ // Live download progress for the panel.
1558
+ let dlSlot = null;
1559
+ // Rolling throughput: a short EMA over wall-clock windows, so the headline
1560
+ // figures reflect CURRENT speed rather than a lifetime average diluted by the
1561
+ // listing and download phases (which train nothing).
1562
+ let rateT = t0;
1563
+ let rateTrained = 0;
1564
+ let rateBytes = 0;
1565
+ const syncState = () => {
1566
+ const now = Date.now();
1567
+ state.exampleCount = depositCount;
1568
+ state.trainedBytes = trainedContentBytes;
1569
+ state.elapsedS = (now - t0) / 1000;
1570
+ state.storeEntries = cachedEntries;
1571
+ state.bytesDone = totalBytesProcessed;
1572
+ state.bytesTotal = totalCorpusBytes;
1573
+ if (dlSlot && state.activity === "download") {
1574
+ state.dlDone = dlSlot.done;
1575
+ state.dlTotal = dlSlot.total;
1576
+ const ds = (now - dlSlot.t0) / 1000;
1577
+ state.dlSpeed = ds > 0.2 ? dlSlot.done / ds : 0;
1578
+ } else {
1579
+ state.dlDone = 0;
1580
+ state.dlTotal = 0;
1581
+ }
1582
+ const dt = (now - rateT) / 1000;
1583
+ if (dt >= 0.5) {
1584
+ const instTrained = (trainedContentBytes - rateTrained) / dt;
1585
+ const instByte = (totalBytesProcessed - rateBytes) / dt;
1586
+ const a = 0.3; // EMA weight on the newest sample
1587
+ state.trainedRate = state.trainedRate === 0
1588
+ ? instTrained
1589
+ : state.trainedRate * (1 - a) + instTrained * a;
1590
+ state.bytesRate = state.bytesRate === 0
1591
+ ? instByte
1592
+ : state.bytesRate * (1 - a) + instByte * a;
1593
+ rateT = now;
1594
+ rateTrained = trainedContentBytes;
1595
+ rateBytes = totalBytesProcessed;
1596
+ }
1597
+ if (now - lastCacheUpdate > 2000) {
1598
+ cachedCacheBytes = cacheSize();
1599
+ lastCacheUpdate = now;
1600
+ }
1601
+ state.cacheBytes = cachedCacheBytes;
1602
+ };
1603
+ const tick = (force = false) => {
1604
+ syncState();
1605
+ progress.render(state, force);
1606
+ };
1607
+ const paintTimer = setInterval(() => {
1608
+ refreshSize();
1609
+ tick(false);
1610
+ }, PROGRESS_MS);
1611
+ if (typeof paintTimer.unref === "function") {
1612
+ paintTimer.unref();
1613
+ }
1614
+ // ── keep-alive: the process must never exit on its own mid-training ──
1615
+ // The CPU-bound processing phase (perceive + intern + the batched vector-index
1616
+ // writes) hands control back to the event loop between batches via the store's
1617
+ // yieldToEventLoop(), which parks on an UNREF'd setImmediate so the library
1618
+ // never holds a process open by itself. node:sqlite is synchronous and the
1619
+ // vector index is in-memory, so the store's awaits resolve as microtasks with
1620
+ // no I/O handle, and the paint timer above is unref'd too. That leaves a window
1621
+ // a batch flush that fires while we're processing an in-memory chunk, not
1622
+ // awaiting a disk read — in which the ONLY pending work is that unref'd
1623
+ // setImmediate and NOTHING is ref'd. Node's rule is to exit when only unref'd
1624
+ // handles remain, WITHOUT running them: the yield's continuation never fires,
1625
+ // main() is abandoned, and the process exits 0 silently mid-file — no error for
1626
+ // the fault-tolerance to catch. This one ref'd (NOT unref'd) timer guarantees a
1627
+ // live handle for the whole run, so the loop can never drain from under a
1628
+ // pending yield. Every real exit is an explicit process.exit() (finish(), the
1629
+ // shutdown watchdog, the second-signal path, the fatal catch), so keeping this
1630
+ // handle alive never delays a genuine shutdown; finish() clears it before that
1631
+ // final exit for tidiness. Same lesson as waitMs above (deliberately un-unref'd).
1632
+ const keepAlive = setInterval(() => {}, 1 << 30);
1633
+ const checkpoint = () => mind.save();
1634
+ /** Run index maintenance: compact (remove garbage) then repair (fill gaps).
1635
+ * Both are idempotent — running twice produces the same result as once.
1636
+ * Compaction frees index space first; repair then adds back the bridges
1637
+ * whose gists were evicted before indexing, completing the coverage that
1638
+ * incremental bridge promotion alone cannot guarantee.
1639
+ *
1640
+ * Logs the number of entries removed/added so a run that silently degrades
1641
+ * (growing compaction count, or repair never recovering anything) is
1642
+ * visible in the training log. */
1643
+ const runIndexMaintenance = async () => {
1644
+ if (!INDEX_MAINTENANCE) {
1645
+ return;
1646
+ }
1647
+ try {
1648
+ const removed = await mind.store.compactContentIndex();
1649
+ if (removed > 0) {
1650
+ progress.log(
1651
+ ` ${DIM}index compact: removed ${int(removed)} isolated entries${R}`,
1652
+ );
1653
+ }
1654
+ } catch (err) {
1655
+ progress.log(
1656
+ ` ${YEL}⚠ index compact failed${R}: ${
1657
+ err instanceof Error ? err.message : String(err)
1658
+ }`,
1659
+ );
1660
+ }
1661
+ try {
1662
+ const added = await mind.repairContentIndex();
1663
+ if (added > 0) {
1664
+ progress.log(
1665
+ ` ${GRN}index repair: added ${int(added)} missing bridges${R}`,
1666
+ );
1667
+ }
1668
+ } catch (err) {
1669
+ progress.log(
1670
+ ` ${YEL}⚠ index repair failed${R}: ${
1671
+ err instanceof Error ? err.message : String(err)
1672
+ }`,
1673
+ );
1674
+ }
1675
+ };
1676
+ // The checkpoint recall is a best-effort diagnostic. It is time-bounded so a
1677
+ // slow/large store can never freeze the deposit loop, and guarded so a still
1678
+ // running recall is never stacked on top of another.
1679
+ let inferBusy = false;
1680
+ const runRecall = async (item, n) => {
1681
+ if (inferBusy) {
1682
+ return;
1683
+ }
1684
+ inferBusy = true;
1685
+ try {
1686
+ const info = promptOf(item);
1687
+ const r = await withTimeout(
1688
+ mind.respond(info.prompt),
1689
+ INFER_TIMEOUT_MS,
1690
+ "recall",
1691
+ );
1692
+ const resp = new TextDecoder().decode(r.bytes).replace(/\u0000+/g, "");
1693
+ const box = renderInferenceBox(
1694
+ info.prompt,
1695
+ info.expected,
1696
+ resp,
1697
+ info.kind,
1698
+ n,
1699
+ );
1700
+ state.lastSample = box;
1701
+ if (!progress.interactive) {
1702
+ progress.log(box);
1703
+ }
1704
+ tick(true);
1705
+ } catch (err) {
1706
+ progress.log(
1707
+ ` ${DIM}· checkpoint #${n} recall skipped: ${
1708
+ err instanceof Error ? err.message : String(err)
1709
+ }${R}`,
1710
+ );
1711
+ } finally {
1712
+ inferBusy = false;
1713
+ }
1714
+ };
1715
+ // ── graceful shutdown (always leaves the store consistent) ──
1716
+ let stopRequested = false;
1717
+ let stopReason = "interrupted";
1718
+ let finishing = false;
1719
+ const finish = async (why) => {
1720
+ if (finishing) {
1721
+ return;
1722
+ }
1723
+ finishing = true;
1724
+ shutdown.abort(); // unblock any straggling fetch/pipeTo
1725
+ tick(true);
1726
+ await store.setMeta("train.completedAt", new Date().toISOString());
1727
+ await store.setMeta("train.totalDeposits", String(depositCount));
1728
+ await store.setMeta("train.totalTrainedBytes", String(trainedContentBytes));
1729
+ await store.setMeta("train.totalBytes", String(totalBytesProcessed));
1730
+ await store.setMeta("train.totalCorpusBytes", String(totalCorpusBytes));
1731
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
1732
+ try {
1733
+ await runIndexMaintenance();
1734
+ await checkpoint();
1735
+ } catch (err) {
1736
+ process.stderr.write(
1737
+ `\n ${YEL}⚠ final checkpoint failed${R}: ${
1738
+ err instanceof Error ? err.message : String(err)
1739
+ }\n`,
1740
+ );
1741
+ }
1742
+ clearInterval(paintTimer);
1743
+ clearInterval(keepAlive);
1744
+ progress.dispose();
1745
+ const elapsedS = (Date.now() - t0) / 1000;
1746
+ const elapsed = dur(elapsedS);
1747
+ const avgRate = elapsedS > 0 ? trainedContentBytes / elapsedS : 0;
1748
+ const tally = Object.entries(langTally)
1749
+ .sort((a, b) => b[1] - a[1])
1750
+ .map(([k, v]) => `${k}:${int(v)}`)
1751
+ .join(", ");
1752
+ let entries = depositCount;
1753
+ try {
1754
+ entries = await mind.store.size();
1755
+ } catch { /* best effort */ }
1756
+ console.log(
1757
+ `\n${GRN}✓${R} ${why}. ${basename(DB_PATH)}.sqlite: ` +
1758
+ `${int(entries)} entries, ${int(depositCount)} examples, ` +
1759
+ `${bytes(trainedContentBytes)} content learned ` +
1760
+ `${DIM}(${bytes(avgRate)}/s avg)${R}, ` +
1761
+ `${bytes(totalBytesProcessed)} corpus processed, ${elapsed} elapsed.` +
1762
+ (tally ? `\n ${DIM}per language:${R} ${tally}` : ""),
1763
+ );
1764
+ try {
1765
+ await store.close();
1766
+ } catch { /* best effort */ }
1767
+ process.exit(0);
1768
+ };
1769
+ const requestStop = (reason) => {
1770
+ if (stopRequested) {
1771
+ process.stderr.write(`\n${YEL}⚠ second signal — exiting now${R}\n`);
1772
+ process.stderr.write(SHOW);
1773
+ process.exit(130);
1774
+ }
1775
+ stopRequested = true;
1776
+ stopReason = reason;
1777
+ shutdown.abort();
1778
+ progress.log(` ${YEL}⏸${R} ${reason} — finishing current item, saving…`);
1779
+ const watchdog = setTimeout(() => {
1780
+ process.stderr.write(
1781
+ `\n${YEL}⚠ shutdown watchdog fired — forcing exit${R}\n`,
1782
+ );
1783
+ process.stderr.write(SHOW);
1784
+ process.exit(130);
1785
+ }, 60_000);
1786
+ if (typeof watchdog.unref === "function") {
1787
+ watchdog.unref();
1788
+ }
1789
+ };
1790
+ process.on("SIGINT", () => requestStop("interrupted"));
1791
+ process.on("SIGTERM", () => requestStop("terminated"));
1792
+ // ── fail-safe: a dropped connection must never kill a long run ──
1793
+ process.on("unhandledRejection", (reason) => {
1794
+ progress.log(
1795
+ ` ${YEL}⚠ unhandled rejection${R}: ${
1796
+ reason instanceof Error ? reason.message : String(reason)
1797
+ }`,
1798
+ );
1799
+ });
1800
+ process.on("uncaughtException", (err) => {
1801
+ const code = err?.code ?? err?.cause?.code;
1802
+ if (err.message === "terminated" || code === "UND_ERR_SOCKET") {
1803
+ progress.log(` ${YEL}⚠ connection error (ignored)${R}: ${err.message}`);
1804
+ return;
1805
+ }
1806
+ process.stderr.write(
1807
+ `\n${RED}uncaught exception${R}: ${err.message}\n${err.stack ?? ""}\n`,
1808
+ );
1809
+ try {
1810
+ void store.setMeta("train.crashedAt", new Date().toISOString());
1811
+ void store.setMeta("train.crashError", err.message);
1812
+ void store.setMeta("train.totalDeposits", String(depositCount));
1813
+ } catch { /* best effort */ }
1814
+ process.exit(1);
1815
+ });
1816
+ // ── per-example callback: gates MAX_MB, drives checkpoints + samples ──
1817
+ const onDeposit = async (contentBytes) => {
1818
+ depositCount++;
1819
+ trainedContentBytes += contentBytes;
1820
+ bytesSinceCkpt += contentBytes;
1821
+ state.fileExamples++;
1822
+ if (bytesSinceCkpt >= CHECKPOINT_BYTES) {
1823
+ bytesSinceCkpt %= CHECKPOINT_BYTES;
1824
+ const n = ++checkpointNum;
1825
+ const item = sampleItem;
1826
+ sampleItem = null;
1827
+ seenInWindow = 0;
1828
+ if (item) {
1829
+ await runRecall(item, n);
1830
+ }
1831
+ try {
1832
+ await runIndexMaintenance();
1833
+ await checkpoint();
1834
+ } catch (err) {
1835
+ progress.log(
1836
+ ` ${YEL}⚠ checkpoint failed${R}: ${
1837
+ err instanceof Error ? err.message : String(err)
1838
+ }`,
1839
+ );
1840
+ }
1841
+ tick(true);
1842
+ } else {
1843
+ tick();
1844
+ }
1845
+ // Stop AFTER the deposit is counted/displayed so the final item is never
1846
+ // lost from the totals. A pending signal stops at this same boundary, so a
1847
+ // clean shutdown and a MAX_MB cap unwind through identical, tested code.
1848
+ return !stopRequested && trainedContentBytes < MAX_BYTES;
1849
+ };
1850
+ const cacheWarn = (m) => progress.log(` ${m}`);
1851
+ /** Acquire a source file: reuse a cached copy, else download `url` into the
1852
+ * cache under `destName` (atomic, retried, rate-limit-tolerant, shows live
1853
+ * byte progress). Returns the local path, or null on a non-abort failure
1854
+ * (logged). `label` names the file in the panel/log. */
1855
+ const acquire = async (url, destName, label) => {
1856
+ const dest = join(CACHE_DIR, destName);
1857
+ if (existsSync(dest)) {
1858
+ progress.log(` ${GRN}✓${R} ${label} ${DIM}(cached)${R}`);
1859
+ return dest;
1860
+ }
1861
+ let size = 0;
1862
+ try {
1863
+ size = await headSize(url);
1864
+ } catch { /* unknown — proceed without a cache-room reservation */ }
1865
+ state.activity = "download";
1866
+ state.filePath = label;
1867
+ state.fileSize = size;
1868
+ const slot = { done: 0, total: size, t0: Date.now() };
1869
+ dlSlot = slot;
1870
+ tick(true);
1871
+ try {
1872
+ await ensureCacheRoom(size, cacheWarn);
1873
+ slot.t0 = Date.now();
1874
+ await downloadFile(
1875
+ url,
1876
+ dest,
1877
+ DOWNLOAD_TRIES,
1878
+ (n, e) =>
1879
+ progress.log(
1880
+ ` ${YEL}⚠${R} ${label} download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`,
1881
+ ),
1882
+ (done, total) => {
1883
+ slot.done = done;
1884
+ if (total > 0) {
1885
+ slot.total = total;
1886
+ }
1887
+ },
1888
+ );
1889
+ } catch (e) {
1890
+ dlSlot = null;
1891
+ if (stopRequested || e?.name === "AbortError") {
1892
+ return null;
1893
+ }
1894
+ progress.log(` ${RED}✗${R} ${label} download failed: ${e.message}`);
1895
+ try {
1896
+ unlinkSync(dest);
1897
+ } catch { /* best effort */ }
1898
+ return null;
1899
+ }
1900
+ dlSlot = null;
1901
+ const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
1902
+ const sz = statSync(dest).size;
1903
+ progress.log(
1904
+ ` ${CYAN}⬇${R} ${label} ${bytes(sz)} ${DIM}${dur(dlS)} @ ${
1905
+ bytes(sz / dlS)
1906
+ }/s${R}`,
1907
+ );
1908
+ return dest;
1909
+ };
1910
+ // ── §10a SmolSent stage (the FIRST stage) ──
1911
+ //
1912
+ // Downloads each SmolSent per-pair JSONL file and streams its lines. Resume is
1913
+ // per-file: a fully-consumed file is recorded in completedFiles
1914
+ // ("smolsent::<name>"); an interrupted file is re-streamed from the top on
1915
+ // resume (re-deposition is idempotent). LOCAL_PATH may hold pre-downloaded
1916
+ // smolsent *.jsonl files.
1917
+ const smolToItems = (row) => {
1918
+ const r = toSmolSentRow(row);
1919
+ return r ? smolSentRowToItems(r) : null;
1920
+ };
1921
+ const trainSmolSent = async () => {
1922
+ if (!SMOLSENT) {
1923
+ return;
1924
+ }
1925
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) {
1926
+ return;
1927
+ }
1928
+ // Work-list: local *.jsonl in LOCAL_PATH, else the repo's smolsent/ files.
1929
+ let files;
1930
+ if (LOCAL_PATH) {
1931
+ files = readdirSync(LOCAL_PATH)
1932
+ .filter((f) => /\.jsonl$/i.test(f))
1933
+ .sort()
1934
+ .map((f) => ({
1935
+ id: `${SMOLSENT_ID}::${f}`,
1936
+ name: f,
1937
+ local: join(LOCAL_PATH, f),
1938
+ }));
1939
+ if (SMOLSENT_PAIRS.length) {
1940
+ const want = new Set(
1941
+ SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")),
1942
+ );
1943
+ files = files.filter((f) => want.has(f.name.replace(/\.jsonl$/i, "")));
1944
+ }
1945
+ } else {
1946
+ let paths;
1947
+ try {
1948
+ paths = await listSmolSentFiles();
1949
+ } catch (e) {
1950
+ if (stopRequested || e?.name === "AbortError") {
1951
+ return;
1952
+ }
1953
+ progress.log(
1954
+ ` ${RED}✗${R} SmolSent file listing failed: ${e.message}`,
1955
+ );
1956
+ return;
1957
+ }
1958
+ files = paths.map((path) => ({
1959
+ id: `${SMOLSENT_ID}::${basename(path)}`,
1960
+ name: basename(path),
1961
+ // owner/name and the file path are URL PATH segments — do not encode "/".
1962
+ url:
1963
+ `https://huggingface.co/datasets/${SMOLSENT_DATASET}/resolve/main/${path}`,
1964
+ }));
1965
+ }
1966
+ if (files.length === 0) {
1967
+ progress.log(` ${DIM}· no SmolSent files found — skipping${R}`);
1968
+ return;
1969
+ }
1970
+ const p = await loadProgress(store);
1971
+ const done = new Set(p.completedFiles);
1972
+ const remaining = files.filter((f) => !done.has(f.id));
1973
+ if (remaining.length === 0) {
1974
+ progress.log(` ${DIM}· SmolSent already trained — skipping${R}`);
1975
+ return;
1976
+ }
1977
+ state.fileTotal = files.length;
1978
+ progress.log(
1979
+ ` ${GRN}✓${R} SmolSent: ${remaining.length}/${files.length} translation file(s) to train`,
1980
+ );
1981
+ let idx = 0;
1982
+ for (const f of files) {
1983
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) {
1984
+ break;
1985
+ }
1986
+ idx++;
1987
+ if (done.has(f.id)) {
1988
+ continue;
1989
+ }
1990
+ // Acquire (download or reuse), then stream the JSONL.
1991
+ let path = f.local ?? "";
1992
+ let downloaded = false;
1993
+ if (!path) {
1994
+ const got = await acquire(
1995
+ f.url,
1996
+ f.id.replace(/[^A-Za-z0-9._-]+/g, "_"),
1997
+ `SmolSent ${f.name}`,
1998
+ );
1999
+ if (!got) {
2000
+ if (stopRequested) {
2001
+ break;
2002
+ }
2003
+ continue; // a single failed file never aborts the stage
2004
+ }
2005
+ path = got;
2006
+ downloaded = true;
2007
+ }
2008
+ // Accumulate known corpus bytes so the progress bar shows a
2009
+ // meaningful ETA — grows as each file's size is discovered.
2010
+ try {
2011
+ totalCorpusBytes += statSync(path).size;
2012
+ } catch { /* best effort */ }
2013
+ state.activity = "process";
2014
+ state.fileIndex = idx;
2015
+ state.filePath = `SmolSent ${f.name}`;
2016
+ state.fileExamples = 0;
2017
+ tick(true);
2018
+ const p0 = Date.now();
2019
+ let res;
2020
+ try {
2021
+ res = await processJsonl(
2022
+ path,
2023
+ smolToItems,
2024
+ ci,
2025
+ onDeposit,
2026
+ sample,
2027
+ MAX_SMOLSENT_CHARS * 4,
2028
+ );
2029
+ } catch (e) {
2030
+ if (stopRequested || e?.name === "AbortError") {
2031
+ break;
2032
+ }
2033
+ progress.log(
2034
+ ` ${RED}✗${R} SmolSent ${f.name} parse failed: ${e.message}`,
2035
+ );
2036
+ if (downloaded) {
2037
+ try {
2038
+ unlinkSync(path);
2039
+ } catch { /* best effort */ }
2040
+ }
2041
+ continue;
2042
+ }
2043
+ langTally["smolsent"] = (langTally["smolsent"] ?? 0) + res.examples;
2044
+ progress.log(
2045
+ ` ${GRN}✓${R} ${
2046
+ f.name.replace(/\.jsonl$/i, "")
2047
+ } ${DIM}[translation]${R} → ${int(res.examples)} facts ${DIM}in ${
2048
+ dur((Date.now() - p0) / 1000)
2049
+ }${R}` +
2050
+ (res.skipped
2051
+ ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
2052
+ : "") +
2053
+ (res.stopped ? ` ${YEL}(stopped early)${R}` : ""),
2054
+ );
2055
+ if (!res.stopped) {
1524
2056
  try {
1525
- const info = promptOf(item);
1526
- const r = await withTimeout(mind.respond(info.prompt), INFER_TIMEOUT_MS, "recall");
1527
- const resp = new TextDecoder().decode(r.bytes).replace(/\u0000+/g, "");
1528
- const box = renderInferenceBox(info.prompt, info.expected, resp, info.kind, n);
1529
- state.lastSample = box;
1530
- if (!progress.interactive)
1531
- progress.log(box);
1532
- tick(true);
1533
- }
1534
- catch (err) {
1535
- progress.log(` ${DIM}· checkpoint #${n} recall skipped: ${err instanceof Error ? err.message : String(err)}${R}`);
1536
- }
1537
- finally {
1538
- inferBusy = false;
1539
- }
1540
- };
1541
- // ── graceful shutdown (always leaves the store consistent) ──
1542
- let stopRequested = false;
1543
- let stopReason = "interrupted";
1544
- let finishing = false;
1545
- const finish = async (why) => {
1546
- if (finishing)
1547
- return;
1548
- finishing = true;
1549
- shutdown.abort(); // unblock any straggling fetch/pipeTo
1550
- tick(true);
1551
- await store.setMeta("train.completedAt", new Date().toISOString());
1552
- await store.setMeta("train.totalDeposits", String(depositCount));
1553
- await store.setMeta("train.totalTrainedBytes", String(trainedContentBytes));
1554
- await store.setMeta("train.totalBytes", String(totalBytesProcessed));
1555
- await store.setMeta("train.totalCorpusBytes", String(totalCorpusBytes));
2057
+ totalBytesProcessed += statSync(path).size;
2058
+ } catch { /* best effort */ }
2059
+ if (downloaded) {
2060
+ try {
2061
+ unlinkSync(path);
2062
+ } catch { /* best effort */ }
2063
+ }
2064
+ done.add(f.id);
2065
+ p.completedFiles.push(f.id);
2066
+ }
2067
+ try {
2068
+ await saveProgress(store, {
2069
+ completedFiles: p.completedFiles,
2070
+ depositCount,
2071
+ trainedContentBytes,
2072
+ totalBytesProcessed,
2073
+ totalCorpusBytes,
2074
+ });
1556
2075
  await store.setMeta("train.langTally", JSON.stringify(langTally));
2076
+ } catch { /* best effort — finish() will retry */ }
2077
+ if (res.stopped) {
2078
+ break; // cap/signal — leave file un-completed for resume
2079
+ }
2080
+ }
2081
+ };
2082
+ // ── §10b Aya Dataset stage (runs AFTER SmolSent) ──
2083
+ //
2084
+ // Downloads the one train Parquet file and reads it row-group by row-group
2085
+ // (hyparquet + Snappy) — one (inputs → targets) fact per row. Marked complete
2086
+ // only when fully consumed; an interrupted run re-reads from the top on resume
2087
+ // (re-deposition is idempotent). LOCAL_PATH may hold a pre-downloaded *.parquet.
2088
+ const ayaToItems = (row) => {
2089
+ const r = toAyaRow(row);
2090
+ return r ? ayaRowToItems(r) : null;
2091
+ };
2092
+ const trainAya = async () => {
2093
+ if (!AYA) {
2094
+ return;
2095
+ }
2096
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) {
2097
+ return;
2098
+ }
2099
+ const p = await loadProgress(store);
2100
+ if (p.completedFiles.includes(AYA_ID)) {
2101
+ progress.log(` ${DIM}· Aya Dataset already trained — skipping${R}`);
2102
+ return;
2103
+ }
2104
+ let path = "", downloaded = false;
2105
+ if (LOCAL_PATH) {
2106
+ const hit = readdirSync(LOCAL_PATH).find((f) =>
2107
+ /aya.*\.parquet$/i.test(f) || /\.parquet$/i.test(f)
2108
+ );
2109
+ if (!hit) {
2110
+ progress.log(
2111
+ ` ${DIM}· no Aya *.parquet in ${LOCAL_PATH} — skipping${R}`,
2112
+ );
2113
+ return;
2114
+ }
2115
+ path = join(LOCAL_PATH, hit);
2116
+ } else {
2117
+ const got = await acquire(AYA_URL, "aya_train.parquet", "Aya Dataset");
2118
+ if (!got) {
2119
+ return;
2120
+ }
2121
+ path = got;
2122
+ downloaded = true;
2123
+ }
2124
+ try {
2125
+ totalCorpusBytes += statSync(path).size;
2126
+ } catch { /* best effort */ }
2127
+ state.fileTotal = 1;
2128
+ state.fileIndex = 1;
2129
+ state.activity = "process";
2130
+ state.filePath = "Aya Dataset";
2131
+ state.fileExamples = 0;
2132
+ tick(true);
2133
+ const p0 = Date.now();
2134
+ let res;
2135
+ try {
2136
+ res = await processParquet(path, ayaToItems, ci, onDeposit, sample);
2137
+ } catch (e) {
2138
+ if (stopRequested || e?.name === "AbortError") {
2139
+ return;
2140
+ }
2141
+ progress.log(` ${RED}✗${R} Aya processing failed: ${e.message}`);
2142
+ return;
2143
+ }
2144
+ langTally["aya"] = (langTally["aya"] ?? 0) + res.examples;
2145
+ progress.log(
2146
+ ` ${GRN}✓${R} Aya Dataset ${DIM}[multilingual chat]${R} → ${
2147
+ int(res.examples)
2148
+ } facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
2149
+ (res.skipped
2150
+ ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
2151
+ : "") +
2152
+ (res.stopped ? ` ${YEL}(stopped early)${R}` : ""),
2153
+ );
2154
+ if (!res.stopped) {
2155
+ try {
2156
+ totalBytesProcessed += statSync(path).size;
2157
+ } catch { /* best effort */ }
2158
+ if (downloaded) {
1557
2159
  try {
1558
- await runIndexMaintenance();
1559
- await checkpoint();
1560
- }
1561
- catch (err) {
1562
- process.stderr.write(`\n ${YEL}⚠ final checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}\n`);
1563
- }
1564
- clearInterval(paintTimer);
1565
- clearInterval(keepAlive);
1566
- progress.dispose();
1567
- const elapsedS = (Date.now() - t0) / 1000;
1568
- const elapsed = dur(elapsedS);
1569
- const avgRate = elapsedS > 0 ? trainedContentBytes / elapsedS : 0;
1570
- const tally = Object.entries(langTally)
1571
- .sort((a, b) => b[1] - a[1])
1572
- .map(([k, v]) => `${k}:${int(v)}`)
1573
- .join(", ");
1574
- let entries = depositCount;
1575
- try {
1576
- entries = await mind.store.size();
1577
- }
1578
- catch { /* best effort */ }
1579
- console.log(`\n${GRN}✓${R} ${why}. ${basename(DB_PATH)}.sqlite: ` +
1580
- `${int(entries)} entries, ${int(depositCount)} examples, ` +
1581
- `${bytes(trainedContentBytes)} content learned ` +
1582
- `${DIM}(${bytes(avgRate)}/s avg)${R}, ` +
1583
- `${bytes(totalBytesProcessed)} corpus processed, ${elapsed} elapsed.` +
1584
- (tally ? `\n ${DIM}per language:${R} ${tally}` : ""));
1585
- try {
1586
- await store.close();
1587
- }
1588
- catch { /* best effort */ }
1589
- process.exit(0);
1590
- };
1591
- const requestStop = (reason) => {
1592
- if (stopRequested) {
1593
- process.stderr.write(`\n${YEL}⚠ second signal — exiting now${R}\n`);
1594
- process.stderr.write(SHOW);
1595
- process.exit(130);
1596
- }
1597
- stopRequested = true;
1598
- stopReason = reason;
1599
- shutdown.abort();
1600
- progress.log(` ${YEL}⏸${R} ${reason} — finishing current item, saving…`);
1601
- const watchdog = setTimeout(() => {
1602
- process.stderr.write(`\n${YEL}⚠ shutdown watchdog fired — forcing exit${R}\n`);
1603
- process.stderr.write(SHOW);
1604
- process.exit(130);
1605
- }, 60_000);
1606
- if (typeof watchdog.unref === "function")
1607
- watchdog.unref();
1608
- };
1609
- process.on("SIGINT", () => requestStop("interrupted"));
1610
- process.on("SIGTERM", () => requestStop("terminated"));
1611
- // ── fail-safe: a dropped connection must never kill a long run ──
1612
- process.on("unhandledRejection", (reason) => {
1613
- progress.log(` ${YEL}⚠ unhandled rejection${R}: ${reason instanceof Error ? reason.message : String(reason)}`);
1614
- });
1615
- process.on("uncaughtException", (err) => {
1616
- const code = err?.code ?? err?.cause?.code;
1617
- if (err.message === "terminated" || code === "UND_ERR_SOCKET") {
1618
- progress.log(` ${YEL}⚠ connection error (ignored)${R}: ${err.message}`);
1619
- return;
1620
- }
1621
- process.stderr.write(`\n${RED}uncaught exception${R}: ${err.message}\n${err.stack ?? ""}\n`);
1622
- try {
1623
- void store.setMeta("train.crashedAt", new Date().toISOString());
1624
- void store.setMeta("train.crashError", err.message);
1625
- void store.setMeta("train.totalDeposits", String(depositCount));
1626
- }
1627
- catch { /* best effort */ }
1628
- process.exit(1);
1629
- });
1630
- // ── per-example callback: gates MAX_MB, drives checkpoints + samples ──
1631
- const onDeposit = async (contentBytes) => {
1632
- depositCount++;
1633
- trainedContentBytes += contentBytes;
1634
- bytesSinceCkpt += contentBytes;
1635
- state.fileExamples++;
1636
- if (bytesSinceCkpt >= CHECKPOINT_BYTES) {
1637
- bytesSinceCkpt %= CHECKPOINT_BYTES;
1638
- const n = ++checkpointNum;
1639
- const item = sampleItem;
1640
- sampleItem = null;
1641
- seenInWindow = 0;
1642
- if (item)
1643
- await runRecall(item, n);
1644
- try {
1645
- await runIndexMaintenance();
1646
- await checkpoint();
1647
- }
1648
- catch (err) {
1649
- progress.log(` ${YEL}⚠ checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}`);
1650
- }
1651
- tick(true);
1652
- }
1653
- else {
1654
- tick();
1655
- }
1656
- // Stop AFTER the deposit is counted/displayed so the final item is never
1657
- // lost from the totals. A pending signal stops at this same boundary, so a
1658
- // clean shutdown and a MAX_MB cap unwind through identical, tested code.
1659
- return !stopRequested && trainedContentBytes < MAX_BYTES;
1660
- };
1661
- const cacheWarn = (m) => progress.log(` ${m}`);
1662
- /** Acquire a source file: reuse a cached copy, else download `url` into the
1663
- * cache under `destName` (atomic, retried, rate-limit-tolerant, shows live
1664
- * byte progress). Returns the local path, or null on a non-abort failure
1665
- * (logged). `label` names the file in the panel/log. */
1666
- const acquire = async (url, destName, label) => {
1667
- const dest = join(CACHE_DIR, destName);
1668
- if (existsSync(dest)) {
1669
- progress.log(` ${GRN}✓${R} ${label} ${DIM}(cached)${R}`);
1670
- return dest;
1671
- }
2160
+ unlinkSync(path);
2161
+ } catch { /* best effort */ }
2162
+ }
2163
+ p.completedFiles.push(AYA_ID);
2164
+ }
2165
+ try {
2166
+ await saveProgress(store, {
2167
+ completedFiles: p.completedFiles,
2168
+ depositCount,
2169
+ trainedContentBytes,
2170
+ totalBytesProcessed,
2171
+ totalCorpusBytes,
2172
+ });
2173
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
2174
+ } catch { /* best effort — finish() will retry */ }
2175
+ };
2176
+ // ── §10c oasst2 stage (multi-turn conversations; runs AFTER Aya; both modes) ──
2177
+ //
2178
+ // Resolves the source (a local *trees*.jsonl.gz in
2179
+ // LOCAL_PATH, else the gzip downloaded to the cache), streams it, and marks
2180
+ // OASST_ID complete only when fully consumed (an interrupted run re-streams
2181
+ // from the top; re-deposition is idempotent). Only multi-turn conversations
2182
+ // are deposited single Q→A trees are skipped inside processOasst.
2183
+ const trainOasst = async () => {
2184
+ if (!OASST) {
2185
+ return;
2186
+ }
2187
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) {
2188
+ return;
2189
+ }
2190
+ const p = await loadProgress(store);
2191
+ if (p.completedFiles.includes(OASST_ID)) {
2192
+ progress.log(` ${DIM}· oasst2 already trained — skipping${R}`);
2193
+ return;
2194
+ }
2195
+ let gzPath = "";
2196
+ let downloaded = false;
2197
+ if (LOCAL_PATH) {
2198
+ const hit = readdirSync(LOCAL_PATH).find((f) =>
2199
+ /oasst.*trees.*\.jsonl\.gz$/i.test(f) || /oasst.*\.jsonl\.gz$/i.test(f)
2200
+ );
2201
+ if (!hit) {
2202
+ progress.log(
2203
+ ` ${DIM}· no oasst2 *trees*.jsonl.gz in ${LOCAL_PATH} — skipping${R}`,
2204
+ );
2205
+ return;
2206
+ }
2207
+ gzPath = join(LOCAL_PATH, hit);
2208
+ } else {
2209
+ const dest = join(CACHE_DIR, "oasst2_ready.trees.jsonl.gz");
2210
+ if (existsSync(dest)) {
2211
+ gzPath = dest; // reuse a copy left by a previous interrupted run
2212
+ progress.log(` ${GRN}✓${R} oasst2 trees ${DIM}(cached)${R}`);
2213
+ } else {
1672
2214
  let size = 0;
1673
2215
  try {
1674
- size = await headSize(url);
1675
- }
1676
- catch { /* unknown — proceed without a cache-room reservation */ }
2216
+ size = await headSize(OASST_URL);
2217
+ } catch { /* unknown — proceed without a cache-room reservation */ }
1677
2218
  state.activity = "download";
1678
- state.filePath = label;
2219
+ state.filePath = "oasst2 trees";
1679
2220
  state.fileSize = size;
1680
2221
  const slot = { done: 0, total: size, t0: Date.now() };
1681
2222
  dlSlot = slot;
1682
2223
  tick(true);
1683
2224
  try {
1684
- await ensureCacheRoom(size, cacheWarn);
1685
- slot.t0 = Date.now();
1686
- await downloadFile(url, dest, DOWNLOAD_TRIES, (n, e) => progress.log(` ${YEL}⚠${R} ${label} download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`), (done, total) => {
1687
- slot.done = done;
1688
- if (total > 0)
1689
- slot.total = total;
1690
- });
1691
- }
1692
- catch (e) {
1693
- dlSlot = null;
1694
- if (stopRequested || e?.name === "AbortError")
1695
- return null;
1696
- progress.log(` ${RED}✗${R} ${label} download failed: ${e.message}`);
1697
- try {
1698
- unlinkSync(dest);
1699
- }
1700
- catch { /* best effort */ }
1701
- return null;
2225
+ await ensureCacheRoom(size, cacheWarn);
2226
+ slot.t0 = Date.now();
2227
+ await downloadFile(
2228
+ OASST_URL,
2229
+ dest,
2230
+ DOWNLOAD_TRIES,
2231
+ (n, e) =>
2232
+ progress.log(
2233
+ ` ${YEL}⚠${R} oasst2 download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`,
2234
+ ),
2235
+ (done, total) => {
2236
+ slot.done = done;
2237
+ if (total > 0) {
2238
+ slot.total = total;
2239
+ }
2240
+ },
2241
+ );
2242
+ } catch (e) {
2243
+ dlSlot = null;
2244
+ if (stopRequested || e?.name === "AbortError") {
2245
+ return;
2246
+ }
2247
+ progress.log(` ${RED}✗${R} oasst2 download failed: ${e.message}`);
2248
+ try {
2249
+ unlinkSync(dest);
2250
+ } catch { /* best effort */ }
2251
+ return;
1702
2252
  }
1703
2253
  dlSlot = null;
1704
2254
  const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
1705
2255
  const sz = statSync(dest).size;
1706
- progress.log(` ${CYAN}⬇${R} ${label} ${bytes(sz)} ${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`);
1707
- return dest;
1708
- };
1709
- // ── §10a SmolSent stage (the FIRST stage) ──
1710
- //
1711
- // Downloads each SmolSent per-pair JSONL file and streams its lines. Resume is
1712
- // per-file: a fully-consumed file is recorded in completedFiles
1713
- // ("smolsent::<name>"); an interrupted file is re-streamed from the top on
1714
- // resume (re-deposition is idempotent). LOCAL_PATH may hold pre-downloaded
1715
- // smolsent *.jsonl files.
1716
- const smolToItems = (row) => {
1717
- const r = toSmolSentRow(row);
1718
- return r ? smolSentRowToItems(r) : null;
1719
- };
1720
- const trainSmolSent = async () => {
1721
- if (!SMOLSENT)
1722
- return;
1723
- if (trainedContentBytes >= MAX_BYTES || stopRequested)
1724
- return;
1725
- // Work-list: local *.jsonl in LOCAL_PATH, else the repo's smolsent/ files.
1726
- let files;
1727
- if (LOCAL_PATH) {
1728
- files = readdirSync(LOCAL_PATH)
1729
- .filter((f) => /\.jsonl$/i.test(f))
1730
- .sort()
1731
- .map((f) => ({
1732
- id: `${SMOLSENT_ID}::${f}`,
1733
- name: f,
1734
- local: join(LOCAL_PATH, f),
1735
- }));
1736
- if (SMOLSENT_PAIRS.length) {
1737
- const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
1738
- files = files.filter((f) => want.has(f.name.replace(/\.jsonl$/i, "")));
1739
- }
1740
- }
1741
- else {
1742
- let paths;
1743
- try {
1744
- paths = await listSmolSentFiles();
1745
- }
1746
- catch (e) {
1747
- if (stopRequested || e?.name === "AbortError")
1748
- return;
1749
- progress.log(` ${RED}✗${R} SmolSent file listing failed: ${e.message}`);
1750
- return;
1751
- }
1752
- files = paths.map((path) => ({
1753
- id: `${SMOLSENT_ID}::${basename(path)}`,
1754
- name: basename(path),
1755
- // owner/name and the file path are URL PATH segments — do not encode "/".
1756
- url: `https://huggingface.co/datasets/${SMOLSENT_DATASET}/resolve/main/${path}`,
1757
- }));
1758
- }
1759
- if (files.length === 0) {
1760
- progress.log(` ${DIM}· no SmolSent files found — skipping${R}`);
1761
- return;
1762
- }
1763
- const p = await loadProgress(store);
1764
- const done = new Set(p.completedFiles);
1765
- const remaining = files.filter((f) => !done.has(f.id));
1766
- if (remaining.length === 0) {
1767
- progress.log(` ${DIM}· SmolSent already trained — skipping${R}`);
1768
- return;
1769
- }
1770
- state.fileTotal = files.length;
1771
- progress.log(` ${GRN}✓${R} SmolSent: ${remaining.length}/${files.length} translation file(s) to train`);
1772
- let idx = 0;
1773
- for (const f of files) {
1774
- if (trainedContentBytes >= MAX_BYTES || stopRequested)
1775
- break;
1776
- idx++;
1777
- if (done.has(f.id))
1778
- continue;
1779
- // Acquire (download or reuse), then stream the JSONL.
1780
- let path = f.local ?? "";
1781
- let downloaded = false;
1782
- if (!path) {
1783
- const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `SmolSent ${f.name}`);
1784
- if (!got) {
1785
- if (stopRequested)
1786
- break;
1787
- continue; // a single failed file never aborts the stage
1788
- }
1789
- path = got;
1790
- downloaded = true;
1791
- }
1792
- // Accumulate known corpus bytes so the progress bar shows a
1793
- // meaningful ETA — grows as each file's size is discovered.
1794
- try {
1795
- totalCorpusBytes += statSync(path).size;
1796
- }
1797
- catch { /* best effort */ }
1798
- state.activity = "process";
1799
- state.fileIndex = idx;
1800
- state.filePath = `SmolSent ${f.name}`;
1801
- state.fileExamples = 0;
1802
- tick(true);
1803
- const p0 = Date.now();
1804
- let res;
1805
- try {
1806
- res = await processJsonl(path, smolToItems, ci, onDeposit, sample, MAX_SMOLSENT_CHARS * 4);
1807
- }
1808
- catch (e) {
1809
- if (stopRequested || e?.name === "AbortError")
1810
- break;
1811
- progress.log(` ${RED}✗${R} SmolSent ${f.name} parse failed: ${e.message}`);
1812
- if (downloaded) {
1813
- try {
1814
- unlinkSync(path);
1815
- }
1816
- catch { /* best effort */ }
1817
- }
1818
- continue;
1819
- }
1820
- langTally["smolsent"] = (langTally["smolsent"] ?? 0) + res.examples;
1821
- progress.log(` ${GRN}✓${R} ${f.name.replace(/\.jsonl$/i, "")} ${DIM}[translation]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
1822
- (res.skipped
1823
- ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
1824
- : "") +
1825
- (res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
1826
- if (!res.stopped) {
1827
- try {
1828
- totalBytesProcessed += statSync(path).size;
1829
- }
1830
- catch { /* best effort */ }
1831
- if (downloaded) {
1832
- try {
1833
- unlinkSync(path);
1834
- }
1835
- catch { /* best effort */ }
1836
- }
1837
- done.add(f.id);
1838
- p.completedFiles.push(f.id);
1839
- }
1840
- try {
1841
- await saveProgress(store, {
1842
- completedFiles: p.completedFiles,
1843
- depositCount,
1844
- trainedContentBytes,
1845
- totalBytesProcessed,
1846
- totalCorpusBytes,
1847
- });
1848
- await store.setMeta("train.langTally", JSON.stringify(langTally));
1849
- }
1850
- catch { /* best effort — finish() will retry */ }
1851
- if (res.stopped)
1852
- break; // cap/signal — leave file un-completed for resume
1853
- }
1854
- };
1855
- // ── §10b Aya Dataset stage (runs AFTER SmolSent) ──
1856
- //
1857
- // Downloads the one train Parquet file and reads it row-group by row-group
1858
- // (hyparquet + Snappy) — one (inputs → targets) fact per row. Marked complete
1859
- // only when fully consumed; an interrupted run re-reads from the top on resume
1860
- // (re-deposition is idempotent). LOCAL_PATH may hold a pre-downloaded *.parquet.
1861
- const ayaToItems = (row) => {
1862
- const r = toAyaRow(row);
1863
- return r ? ayaRowToItems(r) : null;
1864
- };
1865
- const trainAya = async () => {
1866
- if (!AYA)
1867
- return;
1868
- if (trainedContentBytes >= MAX_BYTES || stopRequested)
1869
- return;
1870
- const p = await loadProgress(store);
1871
- if (p.completedFiles.includes(AYA_ID)) {
1872
- progress.log(` ${DIM}· Aya Dataset already trained — skipping${R}`);
1873
- return;
1874
- }
1875
- let path = "", downloaded = false;
1876
- if (LOCAL_PATH) {
1877
- const hit = readdirSync(LOCAL_PATH).find((f) => /aya.*\.parquet$/i.test(f) || /\.parquet$/i.test(f));
1878
- if (!hit) {
1879
- progress.log(` ${DIM}· no Aya *.parquet in ${LOCAL_PATH} — skipping${R}`);
1880
- return;
1881
- }
1882
- path = join(LOCAL_PATH, hit);
1883
- }
1884
- else {
1885
- const got = await acquire(AYA_URL, "aya_train.parquet", "Aya Dataset");
1886
- if (!got)
1887
- return;
1888
- path = got;
1889
- downloaded = true;
1890
- }
1891
- try {
1892
- totalCorpusBytes += statSync(path).size;
1893
- }
1894
- catch { /* best effort */ }
1895
- state.fileTotal = 1;
1896
- state.fileIndex = 1;
1897
- state.activity = "process";
1898
- state.filePath = "Aya Dataset";
1899
- state.fileExamples = 0;
1900
- tick(true);
1901
- const p0 = Date.now();
1902
- let res;
1903
- try {
1904
- res = await processParquet(path, ayaToItems, ci, onDeposit, sample);
1905
- }
1906
- catch (e) {
1907
- if (stopRequested || e?.name === "AbortError")
1908
- return;
1909
- progress.log(` ${RED}✗${R} Aya processing failed: ${e.message}`);
1910
- return;
1911
- }
1912
- langTally["aya"] = (langTally["aya"] ?? 0) + res.examples;
1913
- progress.log(` ${GRN}✓${R} Aya Dataset ${DIM}[multilingual chat]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
1914
- (res.skipped
1915
- ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
1916
- : "") +
1917
- (res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
1918
- if (!res.stopped) {
1919
- try {
1920
- totalBytesProcessed += statSync(path).size;
1921
- }
1922
- catch { /* best effort */ }
1923
- if (downloaded) {
1924
- try {
1925
- unlinkSync(path);
1926
- }
1927
- catch { /* best effort */ }
1928
- }
1929
- p.completedFiles.push(AYA_ID);
1930
- }
1931
- try {
1932
- await saveProgress(store, {
1933
- completedFiles: p.completedFiles,
1934
- depositCount,
1935
- trainedContentBytes,
1936
- totalBytesProcessed,
1937
- totalCorpusBytes,
1938
- });
1939
- await store.setMeta("train.langTally", JSON.stringify(langTally));
1940
- }
1941
- catch { /* best effort — finish() will retry */ }
1942
- };
1943
- // ── §10c oasst2 stage (multi-turn conversations; runs AFTER Aya; both modes) ──
1944
- //
1945
- // Resolves the source (a local *trees*.jsonl.gz in
1946
- // LOCAL_PATH, else the gzip downloaded to the cache), streams it, and marks
1947
- // OASST_ID complete only when fully consumed (an interrupted run re-streams
1948
- // from the top; re-deposition is idempotent). Only multi-turn conversations
1949
- // are deposited — single Q→A trees are skipped inside processOasst.
1950
- const trainOasst = async () => {
1951
- if (!OASST)
1952
- return;
1953
- if (trainedContentBytes >= MAX_BYTES || stopRequested)
1954
- return;
1955
- const p = await loadProgress(store);
1956
- if (p.completedFiles.includes(OASST_ID)) {
1957
- progress.log(` ${DIM}· oasst2 already trained — skipping${R}`);
1958
- return;
1959
- }
1960
- let gzPath = "";
1961
- let downloaded = false;
1962
- if (LOCAL_PATH) {
1963
- const hit = readdirSync(LOCAL_PATH).find((f) => /oasst.*trees.*\.jsonl\.gz$/i.test(f) || /oasst.*\.jsonl\.gz$/i.test(f));
1964
- if (!hit) {
1965
- progress.log(` ${DIM}· no oasst2 *trees*.jsonl.gz in ${LOCAL_PATH} — skipping${R}`);
1966
- return;
1967
- }
1968
- gzPath = join(LOCAL_PATH, hit);
1969
- }
1970
- else {
1971
- const dest = join(CACHE_DIR, "oasst2_ready.trees.jsonl.gz");
1972
- if (existsSync(dest)) {
1973
- gzPath = dest; // reuse a copy left by a previous interrupted run
1974
- progress.log(` ${GRN}✓${R} oasst2 trees ${DIM}(cached)${R}`);
1975
- }
1976
- else {
1977
- let size = 0;
1978
- try {
1979
- size = await headSize(OASST_URL);
1980
- }
1981
- catch { /* unknown — proceed without a cache-room reservation */ }
1982
- state.activity = "download";
1983
- state.filePath = "oasst2 trees";
1984
- state.fileSize = size;
1985
- const slot = { done: 0, total: size, t0: Date.now() };
1986
- dlSlot = slot;
1987
- tick(true);
1988
- try {
1989
- await ensureCacheRoom(size, cacheWarn);
1990
- slot.t0 = Date.now();
1991
- await downloadFile(OASST_URL, dest, DOWNLOAD_TRIES, (n, e) => progress.log(` ${YEL}⚠${R} oasst2 download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`), (done, total) => {
1992
- slot.done = done;
1993
- if (total > 0)
1994
- slot.total = total;
1995
- });
1996
- }
1997
- catch (e) {
1998
- dlSlot = null;
1999
- if (stopRequested || e?.name === "AbortError")
2000
- return;
2001
- progress.log(` ${RED}✗${R} oasst2 download failed: ${e.message}`);
2002
- try {
2003
- unlinkSync(dest);
2004
- }
2005
- catch { /* best effort */ }
2006
- return;
2007
- }
2008
- dlSlot = null;
2009
- const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
2010
- const sz = statSync(dest).size;
2011
- progress.log(` ${CYAN}⬇${R} oasst2 trees ${bytes(sz)} ` +
2012
- `${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`);
2013
- gzPath = dest;
2014
- downloaded = true;
2015
- }
2016
- }
2017
- try {
2018
- totalCorpusBytes += statSync(gzPath).size;
2019
- }
2020
- catch { /* best effort */ }
2021
- state.fileTotal = 1;
2022
- state.fileIndex = 1;
2023
- // Stream the trees.
2024
- state.activity = "process";
2025
- state.filePath = "oasst2 (multi-turn)";
2026
- state.fileExamples = 0;
2027
- tick(true);
2028
- const p0 = Date.now();
2029
- let result;
2030
- try {
2031
- result = await processOasst(gzPath, ci, onDeposit, sample);
2032
- }
2033
- catch (e) {
2034
- if (stopRequested || e?.name === "AbortError")
2035
- return;
2036
- progress.log(` ${RED}✗${R} oasst2 processing failed: ${e.message}`);
2037
- return;
2038
- }
2039
- const { examples, stopped, skipped, multi } = result;
2040
- langTally["oasst2"] = (langTally["oasst2"] ?? 0) + examples;
2041
- progress.log(` ${GRN}✓${R} oasst2 ${DIM}[multi-turn chat]${R} → ${int(examples)} examples from ${int(multi)} conversation(s) ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
2042
- (skipped
2043
- ? ` ${YEL}· ${int(skipped)} malformed line(s) skipped${R}`
2044
- : "") +
2045
- (stopped ? ` ${YEL}(stopped early)${R}` : ""));
2046
- // Only mark complete (and reclaim the cache) when fully consumed.
2047
- if (!stopped) {
2048
- try {
2049
- totalBytesProcessed += statSync(gzPath).size;
2050
- }
2051
- catch { /* best effort */ }
2052
- if (downloaded) {
2053
- try {
2054
- unlinkSync(gzPath);
2055
- }
2056
- catch { /* best effort */ }
2057
- }
2058
- p.completedFiles.push(OASST_ID);
2059
- }
2060
- try {
2061
- await saveProgress(store, {
2062
- completedFiles: p.completedFiles,
2063
- depositCount,
2064
- trainedContentBytes,
2065
- totalBytesProcessed,
2066
- totalCorpusBytes,
2067
- });
2068
- await store.setMeta("train.langTally", JSON.stringify(langTally));
2069
- }
2070
- catch { /* best effort — finish() will retry */ }
2071
- };
2072
- // ── §10d General-Knowledge stage (runs AFTER oasst2) ──
2073
- //
2074
- // Downloads the single JSON-array file (output.json) and deposits each
2075
- // {Question, Answer} as one fact. Marked complete only when fully consumed.
2076
- // LOCAL_PATH may hold a pre-downloaded *.json.
2077
- const genToItems = (row) => {
2078
- const r = toGenKnowRow(row);
2079
- return r ? genKnowRowToItems(r) : null;
2080
- };
2081
- const trainGenKnow = async () => {
2082
- if (!GENKNOW)
2083
- return;
2084
- if (trainedContentBytes >= MAX_BYTES || stopRequested)
2085
- return;
2086
- const p = await loadProgress(store);
2087
- if (p.completedFiles.includes(GENKNOW_ID)) {
2088
- progress.log(` ${DIM}· General-Knowledge already trained — skipping${R}`);
2089
- return;
2090
- }
2091
- let path = "", downloaded = false;
2092
- if (LOCAL_PATH) {
2093
- const hit = readdirSync(LOCAL_PATH).find((f) => /general.*knowledge.*\.json$/i.test(f) || /output\.json$/i.test(f));
2094
- if (!hit) {
2095
- progress.log(` ${DIM}· no General-Knowledge *.json in ${LOCAL_PATH} — skipping${R}`);
2096
- return;
2097
- }
2098
- path = join(LOCAL_PATH, hit);
2099
- }
2100
- else {
2101
- const got = await acquire(GENKNOW_URL, "general_knowledge.json", "General-Knowledge");
2102
- if (!got)
2103
- return;
2104
- path = got;
2105
- downloaded = true;
2106
- }
2107
- try {
2108
- totalCorpusBytes += statSync(path).size;
2109
- }
2110
- catch { /* best effort */ }
2111
- state.fileTotal = 1;
2112
- state.fileIndex = 1;
2113
- state.activity = "process";
2114
- state.filePath = "General-Knowledge";
2115
- state.fileExamples = 0;
2116
- tick(true);
2117
- const p0 = Date.now();
2118
- let res;
2119
- try {
2120
- res = await processJsonArray(path, genToItems, ci, onDeposit, sample);
2121
- }
2122
- catch (e) {
2123
- if (stopRequested || e?.name === "AbortError")
2124
- return;
2125
- progress.log(` ${RED}✗${R} General-Knowledge processing failed: ${e.message}`);
2126
- return;
2127
- }
2128
- langTally["genknow"] = (langTally["genknow"] ?? 0) + res.examples;
2129
- progress.log(` ${GRN}✓${R} General-Knowledge ${DIM}[Q&A facts]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
2130
- (res.skipped
2131
- ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
2132
- : "") +
2133
- (res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
2134
- if (!res.stopped) {
2135
- try {
2136
- totalBytesProcessed += statSync(path).size;
2137
- }
2138
- catch { /* best effort */ }
2139
- if (downloaded) {
2140
- try {
2141
- unlinkSync(path);
2142
- }
2143
- catch { /* best effort */ }
2144
- }
2145
- p.completedFiles.push(GENKNOW_ID);
2146
- }
2256
+ progress.log(
2257
+ ` ${CYAN}⬇${R} oasst2 trees ${bytes(sz)} ` +
2258
+ `${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`,
2259
+ );
2260
+ gzPath = dest;
2261
+ downloaded = true;
2262
+ }
2263
+ }
2264
+ try {
2265
+ totalCorpusBytes += statSync(gzPath).size;
2266
+ } catch { /* best effort */ }
2267
+ state.fileTotal = 1;
2268
+ state.fileIndex = 1;
2269
+ // Stream the trees.
2270
+ state.activity = "process";
2271
+ state.filePath = "oasst2 (multi-turn)";
2272
+ state.fileExamples = 0;
2273
+ tick(true);
2274
+ const p0 = Date.now();
2275
+ let result;
2276
+ try {
2277
+ result = await processOasst(gzPath, ci, onDeposit, sample);
2278
+ } catch (e) {
2279
+ if (stopRequested || e?.name === "AbortError") {
2280
+ return;
2281
+ }
2282
+ progress.log(` ${RED}✗${R} oasst2 processing failed: ${e.message}`);
2283
+ return;
2284
+ }
2285
+ const { examples, stopped, skipped, multi } = result;
2286
+ langTally["oasst2"] = (langTally["oasst2"] ?? 0) + examples;
2287
+ progress.log(
2288
+ ` ${GRN}✓${R} oasst2 ${DIM}[multi-turn chat]${R} ${
2289
+ int(examples)
2290
+ } examples from ${int(multi)} conversation(s) ${DIM}in ${
2291
+ dur((Date.now() - p0) / 1000)
2292
+ }${R}` +
2293
+ (skipped
2294
+ ? ` ${YEL}· ${int(skipped)} malformed line(s) skipped${R}`
2295
+ : "") +
2296
+ (stopped ? ` ${YEL}(stopped early)${R}` : ""),
2297
+ );
2298
+ // Only mark complete (and reclaim the cache) when fully consumed.
2299
+ if (!stopped) {
2300
+ try {
2301
+ totalBytesProcessed += statSync(gzPath).size;
2302
+ } catch { /* best effort */ }
2303
+ if (downloaded) {
2147
2304
  try {
2148
- await saveProgress(store, {
2149
- completedFiles: p.completedFiles,
2150
- depositCount,
2151
- trainedContentBytes,
2152
- totalBytesProcessed,
2153
- totalCorpusBytes,
2154
- });
2155
- await store.setMeta("train.langTally", JSON.stringify(langTally));
2156
- }
2157
- catch { /* best effort — finish() will retry */ }
2158
- };
2159
- // ── §10 Train the curriculum (resume-aware; one store records all stages) ──
2160
- //
2161
- // Every source is paged from an HTTP API (SmolSent, Aya) or a single
2162
- // downloaded file (oasst2) there is no per-file ZIP loop. Each stage closure
2163
- // reads the authoritative completed-set from the store, skips itself when
2164
- // already done, and persists its own progress, so the whole curriculum resumes
2165
- // from the store alone. LOCAL_PATH lets oasst2 read a local *.jsonl.gz.
2305
+ unlinkSync(gzPath);
2306
+ } catch { /* best effort */ }
2307
+ }
2308
+ p.completedFiles.push(OASST_ID);
2309
+ }
2310
+ try {
2311
+ await saveProgress(store, {
2312
+ completedFiles: p.completedFiles,
2313
+ depositCount,
2314
+ trainedContentBytes,
2315
+ totalBytesProcessed,
2316
+ totalCorpusBytes,
2317
+ });
2318
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
2319
+ } catch { /* best effort finish() will retry */ }
2320
+ };
2321
+ // ── §10d General-Knowledge stage (runs AFTER oasst2) ──
2322
+ //
2323
+ // Downloads the single JSON-array file (output.json) and deposits each
2324
+ // {Question, Answer} as one fact. Marked complete only when fully consumed.
2325
+ // LOCAL_PATH may hold a pre-downloaded *.json.
2326
+ const genToItems = (row) => {
2327
+ const r = toGenKnowRow(row);
2328
+ return r ? genKnowRowToItems(r) : null;
2329
+ };
2330
+ const trainGenKnow = async () => {
2331
+ if (!GENKNOW) {
2332
+ return;
2333
+ }
2334
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) {
2335
+ return;
2336
+ }
2337
+ const p = await loadProgress(store);
2338
+ if (p.completedFiles.includes(GENKNOW_ID)) {
2339
+ progress.log(
2340
+ ` ${DIM}· General-Knowledge already trained — skipping${R}`,
2341
+ );
2342
+ return;
2343
+ }
2344
+ let path = "", downloaded = false;
2345
+ if (LOCAL_PATH) {
2346
+ const hit = readdirSync(LOCAL_PATH).find((f) =>
2347
+ /general.*knowledge.*\.json$/i.test(f) || /output\.json$/i.test(f)
2348
+ );
2349
+ if (!hit) {
2350
+ progress.log(
2351
+ ` ${DIM}· no General-Knowledge *.json in ${LOCAL_PATH} — skipping${R}`,
2352
+ );
2353
+ return;
2354
+ }
2355
+ path = join(LOCAL_PATH, hit);
2356
+ } else {
2357
+ const got = await acquire(
2358
+ GENKNOW_URL,
2359
+ "general_knowledge.json",
2360
+ "General-Knowledge",
2361
+ );
2362
+ if (!got) {
2363
+ return;
2364
+ }
2365
+ path = got;
2366
+ downloaded = true;
2367
+ }
2368
+ try {
2369
+ totalCorpusBytes += statSync(path).size;
2370
+ } catch { /* best effort */ }
2371
+ state.fileTotal = 1;
2372
+ state.fileIndex = 1;
2373
+ state.activity = "process";
2374
+ state.filePath = "General-Knowledge";
2375
+ state.fileExamples = 0;
2166
2376
  tick(true);
2167
- // ── resume — restore counters and the per-source tally from the store ──
2168
- const prog = await loadProgress(store);
2169
- depositCount = prog.depositCount;
2170
- trainedContentBytes = prog.trainedContentBytes;
2171
- totalBytesProcessed = prog.totalBytesProcessed;
2172
- totalCorpusBytes = prog.totalCorpusBytes;
2173
- rateTrained = trainedContentBytes;
2174
- rateBytes = totalBytesProcessed;
2377
+ const p0 = Date.now();
2378
+ let res;
2175
2379
  try {
2176
- const t = await store.getMeta("train.langTally");
2177
- if (t) {
2178
- const parsed = JSON.parse(t);
2179
- if (parsed && typeof parsed === "object") {
2180
- for (const [k, v] of Object.entries(parsed)) {
2181
- langTally[k] = Number(v) || 0;
2182
- }
2183
- }
2184
- }
2380
+ res = await processJsonArray(path, genToItems, ci, onDeposit, sample);
2381
+ } catch (e) {
2382
+ if (stopRequested || e?.name === "AbortError") {
2383
+ return;
2384
+ }
2385
+ progress.log(
2386
+ ` ${RED}✗${R} General-Knowledge processing failed: ${e.message}`,
2387
+ );
2388
+ return;
2389
+ }
2390
+ langTally["genknow"] = (langTally["genknow"] ?? 0) + res.examples;
2391
+ progress.log(
2392
+ ` ${GRN}✓${R} General-Knowledge ${DIM}[Q&A facts]${R} → ${
2393
+ int(res.examples)
2394
+ } facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
2395
+ (res.skipped
2396
+ ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
2397
+ : "") +
2398
+ (res.stopped ? ` ${YEL}(stopped early)${R}` : ""),
2399
+ );
2400
+ if (!res.stopped) {
2401
+ try {
2402
+ totalBytesProcessed += statSync(path).size;
2403
+ } catch { /* best effort */ }
2404
+ if (downloaded) {
2405
+ try {
2406
+ unlinkSync(path);
2407
+ } catch { /* best effort */ }
2408
+ }
2409
+ p.completedFiles.push(GENKNOW_ID);
2410
+ }
2411
+ try {
2412
+ await saveProgress(store, {
2413
+ completedFiles: p.completedFiles,
2414
+ depositCount,
2415
+ trainedContentBytes,
2416
+ totalBytesProcessed,
2417
+ totalCorpusBytes,
2418
+ });
2419
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
2420
+ } catch { /* best effort — finish() will retry */ }
2421
+ };
2422
+ // ── §10 Train the curriculum (resume-aware; one store records all stages) ──
2423
+ //
2424
+ // Every source is paged from an HTTP API (SmolSent, Aya) or a single
2425
+ // downloaded file (oasst2) — there is no per-file ZIP loop. Each stage closure
2426
+ // reads the authoritative completed-set from the store, skips itself when
2427
+ // already done, and persists its own progress, so the whole curriculum resumes
2428
+ // from the store alone. LOCAL_PATH lets oasst2 read a local *.jsonl.gz.
2429
+ tick(true);
2430
+ // ── resume — restore counters and the per-source tally from the store ──
2431
+ const prog = await loadProgress(store);
2432
+ depositCount = prog.depositCount;
2433
+ trainedContentBytes = prog.trainedContentBytes;
2434
+ totalBytesProcessed = prog.totalBytesProcessed;
2435
+ totalCorpusBytes = prog.totalCorpusBytes;
2436
+ rateTrained = trainedContentBytes;
2437
+ rateBytes = totalBytesProcessed;
2438
+ try {
2439
+ const t = await store.getMeta("train.langTally");
2440
+ if (t) {
2441
+ const parsed = JSON.parse(t);
2442
+ if (parsed && typeof parsed === "object") {
2443
+ for (const [k, v] of Object.entries(parsed)) {
2444
+ langTally[k] = Number(v) || 0;
2445
+ }
2446
+ }
2185
2447
  }
2186
- catch { /* fresh tally */ }
2187
- if (prog.completedFiles.length > 0) {
2188
- progress.log(` ${CYAN}↻${R} resuming: ${prog.completedFiles.length} stage-unit(s) done, ` +
2189
- `${int(depositCount)} examples, ${bytes(trainedContentBytes)} learned`);
2190
- }
2191
- // Stage 1 (SmolSent translation facts), 2 (Aya multilingual chat), 3 (oasst2
2192
- // multi-turn), 4 (General-Knowledge Q&A facts). Each is skipped on a resume
2193
- // that already finished it.
2194
- if (!stopRequested)
2195
- await trainSmolSent();
2196
- if (!stopRequested)
2197
- await trainAya();
2198
- if (!stopRequested)
2199
- await trainOasst();
2200
- if (!stopRequested)
2201
- await trainGenKnow();
2202
- await finish(stopRequested ? stopReason : "done");
2448
+ } catch { /* fresh tally */ }
2449
+ if (prog.completedFiles.length > 0) {
2450
+ progress.log(
2451
+ ` ${CYAN}↻${R} resuming: ${prog.completedFiles.length} stage-unit(s) done, ` +
2452
+ `${int(depositCount)} examples, ${bytes(trainedContentBytes)} learned`,
2453
+ );
2454
+ }
2455
+ // Stage 1 (SmolSent translation facts), 2 (Aya multilingual chat), 3 (oasst2
2456
+ // multi-turn), 4 (General-Knowledge Q&A facts). Each is skipped on a resume
2457
+ // that already finished it.
2458
+ if (!stopRequested) {
2459
+ await trainSmolSent();
2460
+ }
2461
+ if (!stopRequested) {
2462
+ await trainAya();
2463
+ }
2464
+ if (!stopRequested) {
2465
+ await trainOasst();
2466
+ }
2467
+ if (!stopRequested) {
2468
+ await trainGenKnow();
2469
+ }
2470
+ await finish(stopRequested ? stopReason : "done");
2203
2471
  }
2204
2472
  // ═══════════════════════════════════════════════════════════════════════
2205
2473
  // §11 Entry point — only run when invoked directly, so importing the parser
2206
2474
  // functions above (e.g. for tests) never starts training.
2207
2475
  // ═══════════════════════════════════════════════════════════════════════
2208
2476
  const isMain = import.meta.url === `file://${process.argv[1]}` ||
2209
- process.argv[1]?.endsWith("train_base.js");
2477
+ process.argv[1]?.endsWith("train_base.js");
2210
2478
  if (isMain) {
2211
- main().catch((e) => {
2212
- process.stderr.write(SHOW);
2213
- console.error(`\n${RED}fatal:${R}`, e);
2214
- process.exit(1);
2215
- });
2479
+ main().catch((e) => {
2480
+ process.stderr.write(SHOW);
2481
+ console.error(`\n${RED}fatal:${R}`, e);
2482
+ process.exit(1);
2483
+ });
2216
2484
  }