@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.
- package/AGENTS.md +43 -4
- package/DATASETS.md +160 -0
- package/README.md +12 -0
- package/dist/example/train_base/cache.d.ts +35 -0
- package/dist/example/train_base/cache.js +211 -0
- package/dist/example/train_base/config.d.ts +21 -0
- package/dist/example/train_base/config.js +94 -0
- package/dist/example/train_base/corpora/aya.d.ts +19 -0
- package/dist/example/train_base/corpora/aya.js +76 -0
- package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
- package/dist/example/train_base/corpora/converted-parquet.js +44 -0
- package/dist/example/train_base/corpora/genknow.d.ts +14 -0
- package/dist/example/train_base/corpora/genknow.js +83 -0
- package/dist/example/train_base/corpora/index.d.ts +29 -0
- package/dist/example/train_base/corpora/index.js +81 -0
- package/dist/example/train_base/corpora/massive.d.ts +7 -0
- package/dist/example/train_base/corpora/massive.js +98 -0
- package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
- package/dist/example/train_base/corpora/oasst2.js +120 -0
- package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
- package/dist/example/train_base/corpora/smolsent.js +156 -0
- package/dist/example/train_base/corpora/soda.d.ts +12 -0
- package/dist/example/train_base/corpora/soda.js +113 -0
- package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
- package/dist/example/train_base/corpora/taskmaster.js +144 -0
- package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
- package/dist/example/train_base/corpora/wiki2.js +132 -0
- package/dist/example/train_base/corpus.d.ts +88 -0
- package/dist/example/train_base/corpus.js +65 -0
- package/dist/example/train_base/discovery.d.ts +48 -0
- package/dist/example/train_base/discovery.js +143 -0
- package/dist/example/train_base/http.d.ts +82 -0
- package/dist/example/train_base/http.js +219 -0
- package/dist/example/train_base/items.d.ts +46 -0
- package/dist/example/train_base/items.js +98 -0
- package/dist/example/train_base/main.d.ts +4 -0
- package/dist/example/train_base/main.js +207 -0
- package/dist/example/train_base/progress.d.ts +34 -0
- package/dist/example/train_base/progress.js +114 -0
- package/dist/example/train_base/readers.d.ts +125 -0
- package/dist/example/train_base/readers.js +391 -0
- package/dist/example/train_base/runtime.d.ts +115 -0
- package/dist/example/train_base/runtime.js +637 -0
- package/dist/example/train_base/stage.d.ts +3 -0
- package/dist/example/train_base/stage.js +246 -0
- package/dist/example/train_base/ui.d.ts +88 -0
- package/dist/example/train_base/ui.js +272 -0
- package/dist/src/geometry.d.ts +20 -0
- package/dist/src/geometry.js +22 -0
- package/dist/src/mind/attention.d.ts +6 -0
- package/dist/src/mind/attention.js +44 -4
- package/dist/src/mind/learning.js +134 -50
- package/dist/src/mind/mechanisms/cast.js +45 -1
- package/dist/src/mind/mind.d.ts +7 -2
- package/dist/src/mind/mind.js +15 -3
- package/dist/src/mind/reasoning.js +59 -5
- package/dist/src/mind/recognition.js +29 -3
- package/dist/src/mind/traverse.d.ts +16 -0
- package/dist/src/mind/traverse.js +18 -0
- package/dist/src/store-sqlite.d.ts +4 -0
- package/dist/src/store-sqlite.js +47 -0
- package/dist/src/store.d.ts +7 -0
- package/example/train_base/cache.ts +251 -0
- package/example/train_base/config.ts +128 -0
- package/example/train_base/corpora/aya.ts +106 -0
- package/example/train_base/corpora/converted-parquet.ts +64 -0
- package/example/train_base/corpora/genknow.ts +114 -0
- package/example/train_base/corpora/index.ts +88 -0
- package/example/train_base/corpora/massive.ts +111 -0
- package/example/train_base/corpora/oasst2.ts +163 -0
- package/example/train_base/corpora/smolsent.ts +203 -0
- package/example/train_base/corpora/soda.ts +130 -0
- package/example/train_base/corpora/taskmaster.ts +217 -0
- package/example/train_base/corpora/wiki2.ts +190 -0
- package/example/train_base/corpus.ts +150 -0
- package/example/train_base/discovery.ts +203 -0
- package/example/train_base/http.ts +284 -0
- package/example/train_base/items.ts +118 -0
- package/example/train_base/main.ts +240 -0
- package/example/train_base/progress.ts +149 -0
- package/example/train_base/readers.ts +505 -0
- package/example/train_base/runtime.ts +894 -0
- package/example/train_base/stage.ts +276 -0
- package/example/train_base/ui.ts +333 -0
- package/jsr.json +1 -1
- package/package.json +2 -4
- package/src/geometry.ts +23 -0
- package/src/mind/attention.ts +54 -1
- package/src/mind/learning.ts +137 -43
- package/src/mind/mechanisms/cast.ts +48 -1
- package/src/mind/mind.ts +13 -2
- package/src/mind/reasoning.ts +64 -5
- package/src/mind/recognition.ts +29 -3
- package/src/mind/traverse.ts +19 -0
- package/src/store-sqlite.ts +53 -0
- package/src/store.ts +28 -0
- package/test/13-conversation.test.mjs +1 -1
- package/test/29-counterfactual.test.mjs +43 -6
- package/test/77-company-saturation.test.mjs +302 -0
- package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
- package/test/84-composed-answer-honesty.test.mjs +137 -0
- package/test/85-answered-directly.test.mjs +126 -0
- package/test/86-cast-voices-committed.test.mjs +164 -0
- package/test/87-codominant-commitment.test.mjs +250 -0
- package/test/88-dependency-footprint.test.mjs +99 -0
- package/dist/example/train_base.d.ts +0 -93
- package/dist/example/train_base.js +0 -2269
- 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
|
+
}
|