@hviana/sema 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +43 -4
  2. package/DATASETS.md +160 -0
  3. package/README.md +12 -0
  4. package/dist/example/train_base/cache.d.ts +35 -0
  5. package/dist/example/train_base/cache.js +211 -0
  6. package/dist/example/train_base/config.d.ts +21 -0
  7. package/dist/example/train_base/config.js +94 -0
  8. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  9. package/dist/example/train_base/corpora/aya.js +76 -0
  10. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  11. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  12. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  13. package/dist/example/train_base/corpora/genknow.js +83 -0
  14. package/dist/example/train_base/corpora/index.d.ts +29 -0
  15. package/dist/example/train_base/corpora/index.js +81 -0
  16. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  17. package/dist/example/train_base/corpora/massive.js +98 -0
  18. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  19. package/dist/example/train_base/corpora/oasst2.js +120 -0
  20. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  21. package/dist/example/train_base/corpora/smolsent.js +156 -0
  22. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  23. package/dist/example/train_base/corpora/soda.js +113 -0
  24. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  25. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  26. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  27. package/dist/example/train_base/corpora/wiki2.js +132 -0
  28. package/dist/example/train_base/corpus.d.ts +88 -0
  29. package/dist/example/train_base/corpus.js +65 -0
  30. package/dist/example/train_base/discovery.d.ts +48 -0
  31. package/dist/example/train_base/discovery.js +143 -0
  32. package/dist/example/train_base/http.d.ts +82 -0
  33. package/dist/example/train_base/http.js +219 -0
  34. package/dist/example/train_base/items.d.ts +46 -0
  35. package/dist/example/train_base/items.js +98 -0
  36. package/dist/example/train_base/main.d.ts +4 -0
  37. package/dist/example/train_base/main.js +207 -0
  38. package/dist/example/train_base/progress.d.ts +34 -0
  39. package/dist/example/train_base/progress.js +114 -0
  40. package/dist/example/train_base/readers.d.ts +125 -0
  41. package/dist/example/train_base/readers.js +391 -0
  42. package/dist/example/train_base/runtime.d.ts +115 -0
  43. package/dist/example/train_base/runtime.js +637 -0
  44. package/dist/example/train_base/stage.d.ts +3 -0
  45. package/dist/example/train_base/stage.js +246 -0
  46. package/dist/example/train_base/ui.d.ts +88 -0
  47. package/dist/example/train_base/ui.js +272 -0
  48. package/dist/src/geometry.d.ts +20 -0
  49. package/dist/src/geometry.js +22 -0
  50. package/dist/src/mind/attention.d.ts +6 -0
  51. package/dist/src/mind/attention.js +44 -4
  52. package/dist/src/mind/learning.js +134 -50
  53. package/dist/src/mind/mechanisms/cast.js +45 -1
  54. package/dist/src/mind/mind.d.ts +7 -2
  55. package/dist/src/mind/mind.js +15 -3
  56. package/dist/src/mind/reasoning.js +59 -5
  57. package/dist/src/mind/recognition.js +29 -3
  58. package/dist/src/mind/traverse.d.ts +16 -0
  59. package/dist/src/mind/traverse.js +18 -0
  60. package/dist/src/store-sqlite.d.ts +4 -0
  61. package/dist/src/store-sqlite.js +47 -0
  62. package/dist/src/store.d.ts +7 -0
  63. package/example/train_base/cache.ts +251 -0
  64. package/example/train_base/config.ts +128 -0
  65. package/example/train_base/corpora/aya.ts +106 -0
  66. package/example/train_base/corpora/converted-parquet.ts +64 -0
  67. package/example/train_base/corpora/genknow.ts +114 -0
  68. package/example/train_base/corpora/index.ts +88 -0
  69. package/example/train_base/corpora/massive.ts +111 -0
  70. package/example/train_base/corpora/oasst2.ts +163 -0
  71. package/example/train_base/corpora/smolsent.ts +203 -0
  72. package/example/train_base/corpora/soda.ts +130 -0
  73. package/example/train_base/corpora/taskmaster.ts +217 -0
  74. package/example/train_base/corpora/wiki2.ts +190 -0
  75. package/example/train_base/corpus.ts +150 -0
  76. package/example/train_base/discovery.ts +203 -0
  77. package/example/train_base/http.ts +284 -0
  78. package/example/train_base/items.ts +118 -0
  79. package/example/train_base/main.ts +240 -0
  80. package/example/train_base/progress.ts +149 -0
  81. package/example/train_base/readers.ts +505 -0
  82. package/example/train_base/runtime.ts +894 -0
  83. package/example/train_base/stage.ts +276 -0
  84. package/example/train_base/ui.ts +333 -0
  85. package/jsr.json +1 -1
  86. package/package.json +2 -4
  87. package/src/geometry.ts +23 -0
  88. package/src/mind/attention.ts +54 -1
  89. package/src/mind/learning.ts +137 -43
  90. package/src/mind/mechanisms/cast.ts +48 -1
  91. package/src/mind/mind.ts +13 -2
  92. package/src/mind/reasoning.ts +64 -5
  93. package/src/mind/recognition.ts +29 -3
  94. package/src/mind/traverse.ts +19 -0
  95. package/src/store-sqlite.ts +53 -0
  96. package/src/store.ts +28 -0
  97. package/test/13-conversation.test.mjs +1 -1
  98. package/test/29-counterfactual.test.mjs +43 -6
  99. package/test/77-company-saturation.test.mjs +302 -0
  100. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  101. package/test/84-composed-answer-honesty.test.mjs +137 -0
  102. package/test/85-answered-directly.test.mjs +126 -0
  103. package/test/86-cast-voices-committed.test.mjs +164 -0
  104. package/test/87-codominant-commitment.test.mjs +250 -0
  105. package/test/88-dependency-footprint.test.mjs +99 -0
  106. package/dist/example/train_base.d.ts +0 -93
  107. package/dist/example/train_base.js +0 -2269
  108. package/example/train_base.ts +0 -2735
@@ -0,0 +1,637 @@
1
+ // train_base/runtime.ts — the RUN: counters, the live panel, the deposit gate,
2
+ // checkpointing, file acquisition, and the two ways a run can end.
3
+ //
4
+ // Everything a training stage needs is reachable through ONE explicit context
5
+ // object. That is the same shape the engine uses for its own machinery — free
6
+ // functions over a `MindContext` rather than methods with hidden `this` state
7
+ // (AGENTS.md §3) — and it is what lets a stage be written, read and moved
8
+ // without dragging a closure the size of a file behind it.
9
+ //
10
+ // The counters live on `ctx.counters` rather than as closure variables for one
11
+ // concrete reason: a stage must be able to read the running total (to honour
12
+ // MAX_MB) and add to it (per deposit) from another module. A captured `let`
13
+ // cannot cross that boundary; a field on a shared object can.
14
+ import { CACHE_DIR, CHECKPOINT_BYTES, DB_PATH, DOWNLOAD_TRIES, INFER_TIMEOUT_MS, MAX_BYTES, PROGRESS_MS, STALL_MS, } from "./config.js";
15
+ import { headSize, throttleNotifier, withTimeout, } from "./http.js";
16
+ import { cacheSize, downloadFile, ensureCacheRoom } from "./cache.js";
17
+ import { loadProgress, runIndexMaintenance, saveProgress, } from "./progress.js";
18
+ import { bytes, CYAN, DIM, dur, GRN, int, Progress, promptOf, R, RED, renderInferenceBox, SHOW, YEL, } from "./ui.js";
19
+ import { existsSync, statSync, unlinkSync } from "node:fs";
20
+ import { basename, join } from "node:path";
21
+ /** A single process-wide abort signal. SIGINT/SIGTERM aborts it, which cancels
22
+ * every in-flight fetch immediately (instead of waiting out a slow socket), so
23
+ * Ctrl+C is responsive even mid-download. The deposit loop also polls it to
24
+ * stop cleanly at the next item boundary, leaving the store consistent. */
25
+ export const shutdown = new AbortController();
26
+ export function createRuntime(opts) {
27
+ const { store, mind, ci } = opts;
28
+ // ── counters & sampling ──
29
+ const counters = {
30
+ depositCount: 0,
31
+ trainedContentBytes: 0,
32
+ totalBytesProcessed: 0,
33
+ totalCorpusBytes: 0,
34
+ langTally: {},
35
+ rowsTaken: {},
36
+ };
37
+ let bytesSinceCkpt = 0;
38
+ let checkpointNum = 0;
39
+ const t0 = Date.now();
40
+ // Reservoir sample: one uniformly-random item from the current window, shown
41
+ // in the recall box at each checkpoint.
42
+ let sampleItem = null;
43
+ let seenInWindow = 0;
44
+ const sample = (it) => {
45
+ seenInWindow++;
46
+ if (Math.random() < 1 / seenInWindow)
47
+ sampleItem = it;
48
+ };
49
+ // ── progress panel ──
50
+ const progress = new Progress(opts.title);
51
+ // Surface rate-limit waits from the low-level fetch retries into the live log,
52
+ // so a 429 back-off reads as "waiting", never a silent hang or a dropped file.
53
+ const onThrottle = throttleNotifier((ms, label) => {
54
+ beat(); // being told to wait is the server answering, not a hang
55
+ progress.log(` ${YEL}⏳${R} rate-limited (${label}); waiting ${(ms / 1000).toFixed(1)}s and retrying — not skipping`);
56
+ });
57
+ const http = { signal: shutdown.signal, onThrottle };
58
+ const state = {
59
+ exampleCount: 0,
60
+ target: MAX_BYTES,
61
+ elapsedS: 0,
62
+ trainedBytes: 0,
63
+ trainedRate: 0,
64
+ bytesDone: 0,
65
+ bytesTotal: 0,
66
+ bytesRate: 0,
67
+ fileIndex: 0,
68
+ fileTotal: 0,
69
+ unitNoun: "unit(s)",
70
+ filePath: "",
71
+ fileSize: 0,
72
+ fileExamples: 0,
73
+ activity: "idle",
74
+ dlSpeed: 0,
75
+ dlDone: 0,
76
+ dlTotal: 0,
77
+ storeEntries: 0,
78
+ cacheBytes: 0,
79
+ lastSample: null,
80
+ };
81
+ // store.size() is async; refresh it on a slow cadence so the hot loop and
82
+ // the repaint never block on a query.
83
+ let cachedEntries = 0;
84
+ let sizeInFlight = false;
85
+ const refreshSize = () => {
86
+ if (sizeInFlight)
87
+ return;
88
+ sizeInFlight = true;
89
+ void mind.store.size()
90
+ .then((n) => (cachedEntries = n))
91
+ .catch(() => undefined)
92
+ .finally(() => (sizeInFlight = false));
93
+ };
94
+ // Cache size changes only at download/delete boundaries — recompute it
95
+ // lazily rather than statting the dir on every deposit.
96
+ let cachedCacheBytes = 0;
97
+ let lastCacheUpdate = 0;
98
+ // Live download progress for the panel.
99
+ let dlSlot = null;
100
+ // Rolling throughput: a short EMA over wall-clock windows, so the headline
101
+ // figures reflect CURRENT speed rather than a lifetime average diluted by the
102
+ // listing and download phases (which train nothing).
103
+ let rateT = t0;
104
+ let rateTrained = 0;
105
+ let rateBytes = 0;
106
+ const syncState = () => {
107
+ const now = Date.now();
108
+ state.exampleCount = counters.depositCount;
109
+ state.trainedBytes = counters.trainedContentBytes;
110
+ state.elapsedS = (now - t0) / 1000;
111
+ state.storeEntries = cachedEntries;
112
+ state.bytesDone = counters.totalBytesProcessed;
113
+ state.bytesTotal = counters.totalCorpusBytes;
114
+ if (dlSlot && state.activity === "download") {
115
+ state.dlDone = dlSlot.done;
116
+ state.dlTotal = dlSlot.total;
117
+ const ds = (now - dlSlot.t0) / 1000;
118
+ state.dlSpeed = ds > 0.2 ? dlSlot.done / ds : 0;
119
+ }
120
+ else {
121
+ state.dlDone = 0;
122
+ state.dlTotal = 0;
123
+ }
124
+ const dt = (now - rateT) / 1000;
125
+ if (dt >= 0.5) {
126
+ const instTrained = (counters.trainedContentBytes - rateTrained) / dt;
127
+ const instByte = (counters.totalBytesProcessed - rateBytes) / dt;
128
+ const a = 0.3; // EMA weight on the newest sample
129
+ state.trainedRate = state.trainedRate === 0
130
+ ? instTrained
131
+ : state.trainedRate * (1 - a) + instTrained * a;
132
+ state.bytesRate = state.bytesRate === 0
133
+ ? instByte
134
+ : state.bytesRate * (1 - a) + instByte * a;
135
+ rateT = now;
136
+ rateTrained = counters.trainedContentBytes;
137
+ rateBytes = counters.totalBytesProcessed;
138
+ }
139
+ if (now - lastCacheUpdate > 2000) {
140
+ cachedCacheBytes = cacheSize();
141
+ lastCacheUpdate = now;
142
+ }
143
+ state.cacheBytes = cachedCacheBytes;
144
+ };
145
+ const tick = (force = false) => {
146
+ syncState();
147
+ progress.render(state, force);
148
+ };
149
+ const paintTimer = setInterval(() => {
150
+ refreshSize();
151
+ tick(false);
152
+ }, PROGRESS_MS);
153
+ if (typeof paintTimer.unref === "function")
154
+ paintTimer.unref();
155
+ // ── keep-alive: the process must never exit on its own mid-training ──
156
+ // The CPU-bound processing phase (perceive + intern + the batched vector-index
157
+ // writes) hands control back to the event loop between batches via the store's
158
+ // yieldToEventLoop(), which parks on an UNREF'd setImmediate so the library
159
+ // never holds a process open by itself. node:sqlite is synchronous and the
160
+ // vector index is in-memory, so the store's awaits resolve as microtasks with
161
+ // no I/O handle, and the paint timer above is unref'd too. That leaves a window
162
+ // — a batch flush that fires while we're processing an in-memory chunk, not
163
+ // awaiting a disk read — in which the ONLY pending work is that unref'd
164
+ // setImmediate and NOTHING is ref'd. Node's rule is to exit when only unref'd
165
+ // handles remain, WITHOUT running them: the yield's continuation never fires,
166
+ // the run is abandoned, and the process exits 0 silently mid-file — no error
167
+ // for the fault-tolerance to catch. This one ref'd (NOT unref'd) timer
168
+ // guarantees a live handle for the whole run, so the loop can never drain from
169
+ // under a pending yield. Every real exit is an explicit process.exit()
170
+ // (finish(), the shutdown watchdog, the second-signal path, the fatal catch),
171
+ // so keeping this handle alive never delays a genuine shutdown; finish()
172
+ // clears it before that final exit for tidiness. Same lesson as waitMs
173
+ // (deliberately un-unref'd).
174
+ const keepAlive = setInterval(() => { }, 1 << 30);
175
+ // ── stall watchdog: a hang must become a resume, never a zombie ──
176
+ //
177
+ // The two deliberate design choices above — swallowing dropped-connection
178
+ // errors so a long run survives them, and a ref'd keep-alive so the loop can
179
+ // never drain from under a pending yield — combine into one failure mode:
180
+ // an error that escapes to `uncaughtException` and leaves an await unsettled
181
+ // gives a live process that will never make progress again. Nothing else in
182
+ // the run can notice that, because noticing requires an outside view.
183
+ //
184
+ // So: any progress at all is a beat. If none arrives for STALL_MS, exit
185
+ // non-zero. Resume costs at most the work since the last checkpoint, and the
186
+ // durable cursor means that work is bounded and re-done exactly once.
187
+ let lastBeat = Date.now();
188
+ let quiet = 0; // depth of legitimately deposit-free work
189
+ const beat = () => (lastBeat = Date.now());
190
+ /** Run `fn` without the watchdog counting its silence. For work that is
191
+ * bounded and known to deposit nothing: index maintenance, the recall. */
192
+ const unwatched = async (fn) => {
193
+ quiet++;
194
+ try {
195
+ return await fn();
196
+ }
197
+ finally {
198
+ quiet--;
199
+ beat();
200
+ }
201
+ };
202
+ let swallowed = 0; // uncaught errors the run chose to survive
203
+ if (STALL_MS > 0) {
204
+ const stallTimer = setInterval(() => {
205
+ if (quiet > 0)
206
+ return beat();
207
+ if (Date.now() - lastBeat < STALL_MS)
208
+ return;
209
+ process.stderr.write(SHOW);
210
+ process.stderr.write(`\n${RED}✗ no progress for ${Math.round((Date.now() - lastBeat) / 60_000)} min${R} — the run is not advancing, so it is exiting to be resumed.\n` +
211
+ ` Nothing is lost: training continues from the last checkpoint.\n` +
212
+ (swallowed > 0
213
+ ? ` ${swallowed} error(s) were swallowed earlier; the last one` +
214
+ ` probably left an operation unfinished.\n`
215
+ : "") +
216
+ ` Set STALL_MIN=0 to disable this check.\n`);
217
+ process.exit(75); // EX_TEMPFAIL: "try again", for a supervisor
218
+ }, 30_000);
219
+ if (typeof stallTimer.unref === "function")
220
+ stallTimer.unref();
221
+ }
222
+ const checkpoint = () => mind.save();
223
+ const maintain = () => runIndexMaintenance(mind, (m) => progress.log(m));
224
+ // The checkpoint recall is a best-effort diagnostic. It is time-bounded so a
225
+ // slow/large store can never freeze the deposit loop, and guarded so a still
226
+ // running recall is never stacked on top of another.
227
+ let inferBusy = false;
228
+ const runRecall = async (item, n) => {
229
+ if (inferBusy)
230
+ return;
231
+ inferBusy = true;
232
+ try {
233
+ const info = promptOf(item);
234
+ const r = await unwatched(() => withTimeout(mind.respond(info.prompt), INFER_TIMEOUT_MS, "recall"));
235
+ const resp = new TextDecoder().decode(r.bytes).replace(/\u0000+/g, "");
236
+ const box = renderInferenceBox(info.prompt, info.expected, resp, info.kind, n);
237
+ state.lastSample = box;
238
+ if (!progress.interactive)
239
+ progress.log(box);
240
+ tick(true);
241
+ }
242
+ catch (err) {
243
+ progress.log(` ${DIM}· checkpoint #${n} recall skipped: ${err instanceof Error ? err.message : String(err)}${R}`);
244
+ }
245
+ finally {
246
+ inferBusy = false;
247
+ }
248
+ };
249
+ // ── the in-flight unit, and the cursor that makes it resumable exactly ──
250
+ const META_CURSOR = "train.cursor";
251
+ /** The corpus currently depositing, so each deposit can be tallied to it as
252
+ * it happens rather than in a lump when the unit ends — a lump cannot be
253
+ * snapshotted mid-unit, and the cursor needs a tally that is true at the row
254
+ * it names. */
255
+ let activeCorpus = "";
256
+ /** Taken at a ROW BOUNDARY: the position, and the counters as they stood
257
+ * there. Both together, or the pair disagrees and the resume miscounts. */
258
+ let pending = null;
259
+ let resumeCursor = null;
260
+ const snapCursor = () => pending;
261
+ /** Write the cursor and COMMIT.
262
+ *
263
+ * The ordering here is the whole safety argument. `store.commit()` flushes
264
+ * the pending node/edge/halo buffers and only then commits the transaction
265
+ * the setMeta above joined — so the cursor and the deposits it counts become
266
+ * durable together, never the cursor first. A row behind the cursor is
267
+ * therefore always in the store, which is what makes skipping it on resume
268
+ * safe rather than a way to lose data. */
269
+ const saveCursor = async (c) => {
270
+ if (c)
271
+ await store.setMeta(META_CURSOR, JSON.stringify(c));
272
+ else
273
+ await store.setMeta(META_CURSOR, "");
274
+ store.commit();
275
+ };
276
+ // ── the two per-corpus tallies, which persist and restore identically ──
277
+ const META_LANG_TALLY = "train.langTally";
278
+ const META_ROWS_TAKEN = "train.rowsTaken";
279
+ /** Read a `{key: number}` meta map into `into`. A missing or corrupt value
280
+ * leaves `into` untouched, which is the fresh-store state. */
281
+ const loadTally = async (key, into) => {
282
+ try {
283
+ const raw = await store.getMeta(key);
284
+ if (!raw)
285
+ return;
286
+ const parsed = JSON.parse(raw);
287
+ if (!parsed || typeof parsed !== "object")
288
+ return;
289
+ for (const [k, v] of Object.entries(parsed))
290
+ into[k] = Number(v) || 0;
291
+ }
292
+ catch { /* fresh tally */ }
293
+ };
294
+ /** Write both tallies. `rowsTaken` is only written once something is IN it,
295
+ * so a curriculum with no budgeted corpus never grows the key at all. */
296
+ const saveTallies = async () => {
297
+ await store.setMeta(META_LANG_TALLY, JSON.stringify(counters.langTally));
298
+ if (Object.keys(counters.rowsTaken).length > 0) {
299
+ await store.setMeta(META_ROWS_TAKEN, JSON.stringify(counters.rowsTaken));
300
+ }
301
+ };
302
+ // ── graceful shutdown (always leaves the store consistent) ──
303
+ let finishing = false;
304
+ const ctx = {
305
+ store,
306
+ mind,
307
+ ci,
308
+ progress,
309
+ state,
310
+ counters,
311
+ http,
312
+ stopRequested: false,
313
+ stopReason: "interrupted",
314
+ tick,
315
+ sample,
316
+ // ── per-example callback: gates MAX_MB, drives checkpoints + samples ──
317
+ async onDeposit(contentBytes) {
318
+ beat(); // the primary sign of life
319
+ counters.depositCount++;
320
+ counters.trainedContentBytes += contentBytes;
321
+ // Tallied HERE rather than added in a lump when the unit finishes: a lump
322
+ // is invisible to a mid-unit cursor, and a resumed run would then restore
323
+ // a tally missing everything before the cursor.
324
+ if (activeCorpus) {
325
+ counters.langTally[activeCorpus] =
326
+ (counters.langTally[activeCorpus] ?? 0) + 1;
327
+ }
328
+ bytesSinceCkpt += contentBytes;
329
+ state.fileExamples++;
330
+ if (bytesSinceCkpt >= CHECKPOINT_BYTES) {
331
+ bytesSinceCkpt %= CHECKPOINT_BYTES;
332
+ const n = ++checkpointNum;
333
+ const item = sampleItem;
334
+ sampleItem = null;
335
+ seenInWindow = 0;
336
+ if (item)
337
+ await runRecall(item, n);
338
+ try {
339
+ await unwatched(async () => {
340
+ await maintain();
341
+ await checkpoint();
342
+ });
343
+ // A checkpoint is the natural resume point: the data is flushed, so
344
+ // the position and the counters that describe it can be recorded.
345
+ // Without this a unit could only be resumed from its first row.
346
+ await saveCursor(snapCursor());
347
+ }
348
+ catch (err) {
349
+ progress.log(` ${YEL}⚠ checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}`);
350
+ }
351
+ tick(true);
352
+ }
353
+ else {
354
+ tick();
355
+ }
356
+ // Stop AFTER the deposit is counted/displayed so the final item is never
357
+ // lost from the totals. A pending signal stops at this same boundary, so a
358
+ // clean shutdown and a MAX_MB cap unwind through identical, tested code.
359
+ return !ctx.stopRequested && counters.trainedContentBytes < MAX_BYTES;
360
+ },
361
+ get resumeCursor() {
362
+ return resumeCursor;
363
+ },
364
+ readCtx(opts) {
365
+ activeCorpus = opts.corpusId;
366
+ const rowsTakenSnap = () => opts.rowsTakenNow
367
+ ? { ...counters.rowsTaken, [opts.corpusId]: opts.rowsTakenNow() }
368
+ : { ...counters.rowsTaken };
369
+ // Seed at the resume point, so a run interrupted again before its first
370
+ // row boundary still records the skip it inherited rather than losing it.
371
+ pending = {
372
+ unitId: opts.unitId,
373
+ rows: opts.startRow ?? 0,
374
+ depositCount: counters.depositCount,
375
+ trainedContentBytes: counters.trainedContentBytes,
376
+ totalBytesProcessed: counters.totalBytesProcessed,
377
+ langTally: { ...counters.langTally },
378
+ rowsTaken: rowsTakenSnap(),
379
+ };
380
+ return {
381
+ ci,
382
+ onExample: ctx.onDeposit,
383
+ sample,
384
+ signal: shutdown.signal,
385
+ shouldStop: opts.shouldStop,
386
+ startRow: opts.startRow,
387
+ // Position AND counters captured in the same breath. Snapshotting the
388
+ // tallies costs two small object copies per row, against an ingest —
389
+ // not a cost worth trading correctness for.
390
+ onRowDone: (rows) => {
391
+ pending = {
392
+ unitId: opts.unitId,
393
+ rows,
394
+ depositCount: counters.depositCount,
395
+ trainedContentBytes: counters.trainedContentBytes,
396
+ totalBytesProcessed: counters.totalBytesProcessed,
397
+ langTally: { ...counters.langTally },
398
+ rowsTaken: rowsTakenSnap(),
399
+ };
400
+ },
401
+ };
402
+ },
403
+ /** Acquire a source file: reuse a cached copy, else download `url` into the
404
+ * cache under `destName` (atomic, retried, rate-limit-tolerant, shows live
405
+ * byte progress). Returns the local path, or null on a non-abort failure
406
+ * (logged). `label` names the file in the panel/log. */
407
+ async acquire(url, destName, label) {
408
+ const dest = join(CACHE_DIR, destName);
409
+ if (existsSync(dest)) {
410
+ progress.log(` ${GRN}✓${R} ${label} ${DIM}(cached)${R}`);
411
+ return { path: dest, cached: true };
412
+ }
413
+ let size = 0;
414
+ try {
415
+ size = await headSize(url, { signal: shutdown.signal });
416
+ }
417
+ catch { /* unknown — proceed without a cache-room reservation */ }
418
+ state.activity = "download";
419
+ state.filePath = label;
420
+ state.fileSize = size;
421
+ const slot = { done: 0, total: size, t0: Date.now() };
422
+ dlSlot = slot;
423
+ tick(true);
424
+ try {
425
+ await ensureCacheRoom(size, shutdown.signal, (m) => progress.log(` ${YEL}⚠${R} ${m}`));
426
+ slot.t0 = Date.now();
427
+ await downloadFile(url, dest, {
428
+ signal: shutdown.signal,
429
+ tries: DOWNLOAD_TRIES,
430
+ onFail: (n, e) => progress.log(` ${YEL}⚠${R} ${label} download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`),
431
+ onProgress: (done, total) => {
432
+ beat(); // bytes arriving is progress; a stalled socket is not
433
+ slot.done = done;
434
+ if (total > 0)
435
+ slot.total = total;
436
+ },
437
+ });
438
+ }
439
+ catch (e) {
440
+ dlSlot = null;
441
+ if (ctx.stopRequested || e?.name === "AbortError") {
442
+ return null;
443
+ }
444
+ progress.log(` ${RED}✗${R} ${label} download failed: ${e.message}`);
445
+ try {
446
+ unlinkSync(dest);
447
+ }
448
+ catch { /* best effort */ }
449
+ return null;
450
+ }
451
+ dlSlot = null;
452
+ const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
453
+ const sz = statSync(dest).size;
454
+ progress.log(` ${CYAN}⬇${R} ${label} ${bytes(sz)} ${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`);
455
+ return { path: dest, cached: false };
456
+ },
457
+ async persist(completedFiles, unitDone = false) {
458
+ // Best effort by contract: a failed write here is recoverable because
459
+ // finish() writes the same record again, and a resume that re-reads from
460
+ // the last durable cursor is harmless (deposition is idempotent).
461
+ try {
462
+ await saveProgress(store, {
463
+ completedFiles,
464
+ depositCount: counters.depositCount,
465
+ trainedContentBytes: counters.trainedContentBytes,
466
+ totalBytesProcessed: counters.totalBytesProcessed,
467
+ totalCorpusBytes: counters.totalCorpusBytes,
468
+ });
469
+ await saveTallies();
470
+ // A finished unit has no position inside it; an unfinished one records
471
+ // exactly where it stopped, so a graceful stop resumes with no re-read
472
+ // at all rather than from its last checkpoint.
473
+ await saveCursor(unitDone ? null : snapCursor());
474
+ if (unitDone) {
475
+ pending = null;
476
+ activeCorpus = "";
477
+ }
478
+ }
479
+ catch { /* best effort — finish() will retry */ }
480
+ },
481
+ async restore() {
482
+ const prog = await loadProgress(store);
483
+ counters.depositCount = prog.depositCount;
484
+ counters.trainedContentBytes = prog.trainedContentBytes;
485
+ counters.totalBytesProcessed = prog.totalBytesProcessed;
486
+ // NOT prog.totalCorpusBytes. This counter is the ETA's denominator and it
487
+ // grows as each unit's size is discovered, so restoring the stored figure
488
+ // and then adding to it counts every unit a resume RE-READS a second
489
+ // time — the corpus percentage and the ETA drift further out with each
490
+ // interruption. Seeding it from the bytes already processed is exact
491
+ // instead of approximate: units finished in earlier runs are accounted
492
+ // for by that total, and units this run opens add themselves as they are
493
+ // opened. When a unit completes, both counters gain its size, so the
494
+ // fraction closes on 1 rather than sliding.
495
+ counters.totalCorpusBytes = prog.totalBytesProcessed;
496
+ rateTrained = counters.trainedContentBytes;
497
+ rateBytes = counters.totalBytesProcessed;
498
+ await loadTally(META_LANG_TALLY, counters.langTally);
499
+ await loadTally(META_ROWS_TAKEN, counters.rowsTaken);
500
+ // A cursor for a unit that is NOT in completedFiles means the last run
501
+ // stopped inside it. Its counters are the ones that describe the store at
502
+ // that position, so they replace the running totals: the totals may
503
+ // include deposits made after the cursor, and those rows are about to be
504
+ // read again. Counting them from the totals AND from the re-read is
505
+ // exactly the double-count that inflated every resumed store.
506
+ try {
507
+ const raw = await store.getMeta(META_CURSOR);
508
+ const c = raw ? JSON.parse(raw) : null;
509
+ if (c?.unitId && !prog.completedFiles.includes(c.unitId)) {
510
+ resumeCursor = c;
511
+ counters.depositCount = c.depositCount;
512
+ counters.trainedContentBytes = c.trainedContentBytes;
513
+ counters.totalBytesProcessed = c.totalBytesProcessed;
514
+ counters.totalCorpusBytes = c.totalBytesProcessed;
515
+ counters.langTally = { ...c.langTally };
516
+ counters.rowsTaken = { ...c.rowsTaken };
517
+ rateTrained = counters.trainedContentBytes;
518
+ rateBytes = counters.totalBytesProcessed;
519
+ progress.log(` ${CYAN}↻${R} resuming inside ${c.unitId} at row ${int(c.rows)} ${DIM}(no re-reading)${R}`);
520
+ }
521
+ }
522
+ catch { /* no cursor — resume at unit granularity, as before */ }
523
+ if (prog.completedFiles.length > 0) {
524
+ progress.log(` ${CYAN}↻${R} resuming: ${prog.completedFiles.length} stage-unit(s) done, ` +
525
+ `${int(counters.depositCount)} examples, ${bytes(counters.trainedContentBytes)} learned`);
526
+ }
527
+ return prog;
528
+ },
529
+ async finish(why) {
530
+ if (finishing)
531
+ return;
532
+ finishing = true;
533
+ shutdown.abort(); // unblock any straggling fetch/pipeTo
534
+ tick(true);
535
+ // An interrupted run is not a completed one, in the store or in the exit
536
+ // code. Both used to say otherwise: `completedAt` was stamped on every
537
+ // path, and Ctrl+C exited 0, so nothing downstream — a supervisor, a
538
+ // later inspection of the store — could tell a finished curriculum from
539
+ // one that was killed halfway. A MAX_MB cap DOES count as finished: the
540
+ // run stopped where it was asked to, which is why it reports "done".
541
+ const completed = !ctx.stopRequested;
542
+ await store.setMeta(completed ? "train.completedAt" : "train.interruptedAt", new Date().toISOString());
543
+ await store.setMeta("train.totalDeposits", String(counters.depositCount));
544
+ await store.setMeta("train.totalTrainedBytes", String(counters.trainedContentBytes));
545
+ await store.setMeta("train.totalBytes", String(counters.totalBytesProcessed));
546
+ await store.setMeta("train.totalCorpusBytes", String(counters.totalCorpusBytes));
547
+ await saveTallies();
548
+ if (swallowed > 0) {
549
+ await store.setMeta("train.swallowedErrors", String(swallowed));
550
+ }
551
+ try {
552
+ await unwatched(async () => {
553
+ await maintain();
554
+ await checkpoint();
555
+ });
556
+ }
557
+ catch (err) {
558
+ process.stderr.write(`\n ${YEL}⚠ final checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}\n`);
559
+ }
560
+ clearInterval(paintTimer);
561
+ clearInterval(keepAlive);
562
+ progress.dispose();
563
+ const elapsedS = (Date.now() - t0) / 1000;
564
+ const elapsed = dur(elapsedS);
565
+ const avgRate = elapsedS > 0
566
+ ? counters.trainedContentBytes / elapsedS
567
+ : 0;
568
+ const tally = Object.entries(counters.langTally)
569
+ .sort((a, b) => b[1] - a[1])
570
+ .map(([k, v]) => `${k}:${int(v)}`)
571
+ .join(", ");
572
+ let entries = counters.depositCount;
573
+ try {
574
+ entries = await mind.store.size();
575
+ }
576
+ catch { /* best effort */ }
577
+ console.log(`\n${GRN}✓${R} ${why}. ${basename(DB_PATH)}.sqlite: ` +
578
+ `${int(entries)} entries, ${int(counters.depositCount)} examples, ` +
579
+ `${bytes(counters.trainedContentBytes)} content learned ` +
580
+ `${DIM}(${bytes(avgRate)}/s avg)${R}, ` +
581
+ `${bytes(counters.totalBytesProcessed)} corpus processed, ${elapsed} elapsed.` +
582
+ (tally ? `\n ${DIM}per language:${R} ${tally}` : ""));
583
+ try {
584
+ await store.close();
585
+ }
586
+ catch { /* best effort */ }
587
+ // 130 is the conventional "killed by SIGINT" code, and it is what the
588
+ // second-signal and watchdog paths already exit with — so all three ways
589
+ // an interrupted run can end now agree.
590
+ process.exit(completed ? 0 : 130);
591
+ },
592
+ };
593
+ const requestStop = (reason) => {
594
+ if (ctx.stopRequested) {
595
+ process.stderr.write(`\n${YEL}⚠ second signal — exiting now${R}\n`);
596
+ process.stderr.write(SHOW);
597
+ process.exit(130);
598
+ }
599
+ ctx.stopRequested = true;
600
+ ctx.stopReason = reason;
601
+ shutdown.abort();
602
+ progress.log(` ${YEL}⏸${R} ${reason} — finishing current item, saving…`);
603
+ const watchdog = setTimeout(() => {
604
+ process.stderr.write(`\n${YEL}⚠ shutdown watchdog fired — forcing exit${R}\n`);
605
+ process.stderr.write(SHOW);
606
+ process.exit(130);
607
+ }, 60_000);
608
+ if (typeof watchdog.unref === "function")
609
+ watchdog.unref();
610
+ };
611
+ process.on("SIGINT", () => requestStop("interrupted"));
612
+ process.on("SIGTERM", () => requestStop("terminated"));
613
+ // ── fail-safe: a dropped connection must never kill a long run ──
614
+ process.on("unhandledRejection", (reason) => {
615
+ progress.log(` ${YEL}⚠ unhandled rejection${R}: ${reason instanceof Error ? reason.message : String(reason)}`);
616
+ });
617
+ process.on("uncaughtException", (err) => {
618
+ const code = err?.code ?? err?.cause?.code;
619
+ if (err.message === "terminated" || code === "UND_ERR_SOCKET") {
620
+ // Survived on purpose — but COUNTED, because an error that leaves an
621
+ // operation unfinished shows up later as silence, and the watchdog's
622
+ // message should be able to say so.
623
+ swallowed++;
624
+ progress.log(` ${YEL}⚠ connection error (ignored, ${swallowed} so far)${R}: ${err.message}`);
625
+ return;
626
+ }
627
+ process.stderr.write(`\n${RED}uncaught exception${R}: ${err.message}\n${err.stack ?? ""}\n`);
628
+ try {
629
+ void store.setMeta("train.crashedAt", new Date().toISOString());
630
+ void store.setMeta("train.crashError", err.message);
631
+ void store.setMeta("train.totalDeposits", String(counters.depositCount));
632
+ }
633
+ catch { /* best effort */ }
634
+ process.exit(1);
635
+ });
636
+ return ctx;
637
+ }
@@ -0,0 +1,3 @@
1
+ import { type Corpus } from "./corpus.js";
2
+ import type { TrainCtx } from "./runtime.js";
3
+ export declare function runStage(ctx: TrainCtx, corpus: Corpus): Promise<void>;