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