@hviana/sema 0.5.9 → 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 (79) hide show
  1. package/AGENTS.md +20 -4
  2. package/DATASETS.md +12 -11
  3. package/dist/example/train_base/cache.d.ts +35 -0
  4. package/dist/example/train_base/cache.js +211 -0
  5. package/dist/example/train_base/config.d.ts +21 -0
  6. package/dist/example/train_base/config.js +94 -0
  7. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  8. package/dist/example/train_base/corpora/aya.js +76 -0
  9. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  10. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  11. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  12. package/dist/example/train_base/corpora/genknow.js +83 -0
  13. package/dist/example/train_base/corpora/index.d.ts +29 -0
  14. package/dist/example/train_base/corpora/index.js +81 -0
  15. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  16. package/dist/example/train_base/corpora/massive.js +98 -0
  17. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  18. package/dist/example/train_base/corpora/oasst2.js +120 -0
  19. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  20. package/dist/example/train_base/corpora/smolsent.js +156 -0
  21. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  22. package/dist/example/train_base/corpora/soda.js +113 -0
  23. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  24. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  25. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  26. package/dist/example/train_base/corpora/wiki2.js +132 -0
  27. package/dist/example/train_base/corpus.d.ts +88 -0
  28. package/dist/example/train_base/corpus.js +65 -0
  29. package/dist/example/train_base/discovery.d.ts +48 -0
  30. package/dist/example/train_base/discovery.js +143 -0
  31. package/dist/example/train_base/http.d.ts +82 -0
  32. package/dist/example/train_base/http.js +219 -0
  33. package/dist/example/train_base/items.d.ts +46 -0
  34. package/dist/example/train_base/items.js +98 -0
  35. package/dist/example/train_base/main.d.ts +4 -0
  36. package/dist/example/train_base/main.js +207 -0
  37. package/dist/example/train_base/progress.d.ts +34 -0
  38. package/dist/example/train_base/progress.js +114 -0
  39. package/dist/example/train_base/readers.d.ts +125 -0
  40. package/dist/example/train_base/readers.js +391 -0
  41. package/dist/example/train_base/runtime.d.ts +115 -0
  42. package/dist/example/train_base/runtime.js +637 -0
  43. package/dist/example/train_base/stage.d.ts +3 -0
  44. package/dist/example/train_base/stage.js +246 -0
  45. package/dist/example/train_base/ui.d.ts +88 -0
  46. package/dist/example/train_base/ui.js +272 -0
  47. package/dist/src/mind/mind.d.ts +1 -1
  48. package/dist/src/mind/mind.js +1 -1
  49. package/example/train_base/cache.ts +251 -0
  50. package/example/train_base/config.ts +128 -0
  51. package/example/train_base/corpora/aya.ts +106 -0
  52. package/example/train_base/corpora/converted-parquet.ts +64 -0
  53. package/example/train_base/corpora/genknow.ts +114 -0
  54. package/example/train_base/corpora/index.ts +88 -0
  55. package/example/train_base/corpora/massive.ts +111 -0
  56. package/example/train_base/corpora/oasst2.ts +163 -0
  57. package/example/train_base/corpora/smolsent.ts +203 -0
  58. package/example/train_base/corpora/soda.ts +130 -0
  59. package/example/train_base/corpora/taskmaster.ts +217 -0
  60. package/example/train_base/corpora/wiki2.ts +190 -0
  61. package/example/train_base/corpus.ts +150 -0
  62. package/example/train_base/discovery.ts +203 -0
  63. package/example/train_base/http.ts +284 -0
  64. package/example/train_base/items.ts +118 -0
  65. package/example/train_base/main.ts +240 -0
  66. package/example/train_base/progress.ts +149 -0
  67. package/example/train_base/readers.ts +505 -0
  68. package/example/train_base/runtime.ts +894 -0
  69. package/example/train_base/stage.ts +276 -0
  70. package/example/train_base/ui.ts +333 -0
  71. package/jsr.json +1 -1
  72. package/package.json +2 -4
  73. package/src/mind/mind.ts +1 -1
  74. package/test/13-conversation.test.mjs +1 -1
  75. package/test/84-composed-answer-honesty.test.mjs +2 -1
  76. package/test/88-dependency-footprint.test.mjs +99 -0
  77. package/dist/example/train_base.d.ts +0 -163
  78. package/dist/example/train_base.js +0 -3220
  79. package/example/train_base.ts +0 -3882
@@ -0,0 +1,251 @@
1
+ // train_base/cache.ts — the durable disk cache and the download sink.
2
+ //
3
+ // This is the ONE place the trainer needs Node rather than the web platform:
4
+ // every other byte in the pipeline moves through fetch, WHATWG streams,
5
+ // DecompressionStream, TextDecoderStream and Blob, but writing a file is the
6
+ // single capability the web platform does not expose. So the sink below wraps a
7
+ // raw fs descriptor, and nothing else here does.
8
+ //
9
+ // Two invariants the rest of the trainer relies on:
10
+ // • ATOMIC — a download streams to "<file>.part", is fsync'd, then renamed
11
+ // into place. A file at its final path is, by construction, complete, so an
12
+ // interrupted download can never be mistaken for a cached one.
13
+ // • BOUNDED — a download blocks under the MAX_CACHE_GB ceiling, and a fully
14
+ // processed file is deleted by its caller immediately.
15
+
16
+ import {
17
+ CACHE_DIR,
18
+ CACHE_WAIT_MS,
19
+ MAX_CACHE_BYTES,
20
+ PART_SUFFIX,
21
+ } from "./config.js";
22
+ import { httpError, retry, waitMs } from "./http.js";
23
+ import {
24
+ closeSync,
25
+ existsSync,
26
+ fsyncSync,
27
+ mkdirSync,
28
+ openSync,
29
+ readdirSync,
30
+ renameSync,
31
+ statSync,
32
+ unlinkSync,
33
+ writeSync,
34
+ } from "node:fs";
35
+ import { basename, join } from "node:path";
36
+
37
+ /** Delete every orphaned "<file>.part" in the cache, returning how many were
38
+ * removed and the bytes they held.
39
+ *
40
+ * A .part file at rest is by definition the debris of a download that never
41
+ * finished — the rename that promotes one is the last step of `downloadFile`,
42
+ * so a live .part exists only while THIS process is writing it. Sweeping at
43
+ * startup is therefore safe, and it is load-bearing rather than cosmetic:
44
+ * `cacheSize` deliberately counts .part files (an in-flight download really
45
+ * does occupy the disk), so debris left by a killed run consumes ceiling
46
+ * budget that nothing would ever free, and `ensureCacheRoom` would wait for
47
+ * room that cannot appear.
48
+ *
49
+ * The one assumption is that a cache directory belongs to ONE run at a time.
50
+ * That was already true — two trainers sharing CACHE_DIR would write the same
51
+ * .part path — so this adds no constraint that did not exist. */
52
+ export function sweepPartials(): { files: number; bytes: number } {
53
+ const out = { files: 0, bytes: 0 };
54
+ if (!existsSync(CACHE_DIR)) return out;
55
+ for (const name of readdirSync(CACHE_DIR)) {
56
+ if (!name.endsWith(PART_SUFFIX)) continue;
57
+ const p = join(CACHE_DIR, name);
58
+ try {
59
+ const size = statSync(p).size;
60
+ unlinkSync(p);
61
+ out.files++;
62
+ out.bytes += size;
63
+ } catch { /* raced with another delete — nothing to reclaim */ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** Total bytes currently held in the cache directory — INCLUDING any .part
69
+ * file, because an in-flight download occupies the disk like any other file.
70
+ * Orphaned ones are removed by {@link sweepPartials} at startup. */
71
+ export function cacheSize(): number {
72
+ if (!existsSync(CACHE_DIR)) return 0;
73
+ let total = 0;
74
+ for (const name of readdirSync(CACHE_DIR)) {
75
+ try {
76
+ total += statSync(join(CACHE_DIR, name)).size;
77
+ } catch { /* raced with a delete */ }
78
+ }
79
+ return total;
80
+ }
81
+
82
+ /** Block until there is room for a file of `fileBytes` under the ceiling.
83
+ * A single file larger than the whole ceiling can never "fit", so we let it
84
+ * through (it is deleted right after processing) rather than wait forever. */
85
+ export async function ensureCacheRoom(
86
+ fileBytes: number,
87
+ signal: AbortSignal,
88
+ warn?: (msg: string) => void,
89
+ maxWaitMs = CACHE_WAIT_MS,
90
+ ): Promise<void> {
91
+ mkdirSync(CACHE_DIR, { recursive: true });
92
+ if (fileBytes >= MAX_CACHE_BYTES) return;
93
+ let warned = false;
94
+ const until = Date.now() + maxWaitMs;
95
+ // Stop waiting the moment a shutdown is requested — the abort signal unblocks
96
+ // a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
97
+ while (!signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES) {
98
+ // BOUNDED. Room appears when this run consumes and deletes a file, so a
99
+ // cache already over the ceiling with nothing left to consume — stale files
100
+ // from another run, a ceiling set below one corpus — would otherwise wait
101
+ // for room that cannot arrive, forever, after a single warning line.
102
+ if (Date.now() >= until) {
103
+ throw new Error(
104
+ `cache still full after ${Math.round(maxWaitMs / 60_000)} min ` +
105
+ `(${(cacheSize() / 1e9).toFixed(1)} GB of a ` +
106
+ `${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling) — raise ` +
107
+ `MAX_CACHE_GB or clear ${CACHE_DIR}`,
108
+ );
109
+ }
110
+ if (!warned) {
111
+ warn?.(
112
+ `cache at ${
113
+ (MAX_CACHE_BYTES / 1e9).toFixed(0)
114
+ } GB ceiling — waiting for room…`,
115
+ );
116
+ warned = true;
117
+ }
118
+ await waitMs(5_000, signal);
119
+ }
120
+ }
121
+
122
+ export interface DownloadOptions {
123
+ signal: AbortSignal;
124
+ tries: number;
125
+ onFail?: (attempt: number, err: Error) => void;
126
+ onProgress?: (done: number, total: number) => void;
127
+ }
128
+
129
+ /** Stream `url` to `destPath`, atomically and with backpressure. */
130
+ export async function downloadFile(
131
+ url: string,
132
+ destPath: string,
133
+ opts: DownloadOptions,
134
+ ): Promise<void> {
135
+ const partPath = destPath + PART_SUFFIX;
136
+ await retry(
137
+ `download ${basename(destPath)}`,
138
+ async () => {
139
+ // Abort promptly on shutdown rather than waiting out a slow socket.
140
+ if (opts.signal.aborted) {
141
+ const e: Error & { fatal?: boolean } = new Error("aborted");
142
+ e.fatal = true;
143
+ throw e;
144
+ }
145
+ const res = await fetch(url, { signal: opts.signal });
146
+ if (!res.ok) throw httpError(res);
147
+ if (!res.body) throw new Error("empty response body");
148
+
149
+ // `content-length` describes the bytes ON THE WIRE. When the server
150
+ // applied a content-coding, fetch hands us the DECODED body, so the
151
+ // header no longer describes what gets written to disk and the integrity
152
+ // guard below must not use it. Measured: raw.githubusercontent.com sends
153
+ // `content-encoding: gzip` with content-length 110,928 for a file that
154
+ // decodes to 1,607,931 bytes — a size check against that rejects every
155
+ // healthy download. (The bug stayed latent because Hugging Face sends
156
+ // `content-encoding: br` and NO content-length, leaving total = 0, which
157
+ // already disables the guard.)
158
+ const encoding = (res.headers.get("content-encoding") ?? "").trim()
159
+ .toLowerCase();
160
+ const decoded = encoding !== "" && encoding !== "identity";
161
+ const total = decoded
162
+ ? 0
163
+ : Number(res.headers.get("content-length")) || 0;
164
+ let done = 0;
165
+
166
+ // Stream straight to a ".part" sibling using pure WHATWG streams. A
167
+ // TransformStream meters progress; pipeTo into a WritableStream gives REAL
168
+ // backpressure natively — the sink's write() returns a promise the
169
+ // readable side awaits, so a fast server can never outrun the disk (no
170
+ // whole-file heap buffering). The sink wraps a single raw fs descriptor
171
+ // (the one capability the web platform lacks); writing to disk is the only
172
+ // Node operation in the whole pipeline. The final, valid file only ever
173
+ // appears via the atomic rename below, so a crash mid-transfer can never
174
+ // leave a truncated file at the real path.
175
+ const meter = new TransformStream<Uint8Array, Uint8Array>({
176
+ transform(chunk, controller) {
177
+ done += chunk.length;
178
+ opts.onProgress?.(done, total);
179
+ controller.enqueue(chunk);
180
+ },
181
+ });
182
+
183
+ const fd = openSync(partPath, "w");
184
+ let closed = false;
185
+ const closeFd = () => {
186
+ if (closed) return;
187
+ closed = true;
188
+ try {
189
+ closeSync(fd);
190
+ } catch { /* already closed */ }
191
+ };
192
+ const sink = new WritableStream<Uint8Array>({
193
+ write(chunk) {
194
+ // writeSync drains the whole chunk before returning, so the readable
195
+ // side is paused for exactly as long as the disk needs — backpressure.
196
+ let off = 0;
197
+ while (off < chunk.length) {
198
+ off += writeSync(fd, chunk, off, chunk.length - off);
199
+ }
200
+ },
201
+ close() {
202
+ fsyncSync(fd); // durable bytes before the rename promotes them
203
+ closeFd();
204
+ },
205
+ abort() {
206
+ closeFd();
207
+ },
208
+ });
209
+
210
+ try {
211
+ await res.body.pipeThrough(meter).pipeTo(sink, {
212
+ signal: opts.signal,
213
+ });
214
+ } catch (e) {
215
+ // pipeTo's abort() ran the sink's abort() (closing the descriptor); if
216
+ // it didn't (a non-abort throw), make sure the descriptor is not leaked.
217
+ closeFd();
218
+ try {
219
+ unlinkSync(partPath);
220
+ } catch { /* best effort */ }
221
+ throw e;
222
+ }
223
+
224
+ // Optional integrity guard: when the server advertised a size FOR THE
225
+ // BYTES WE WRITE (see the content-encoding note above — `total` is 0 for
226
+ // a decoded body, which disables this), a complete file must match it. A
227
+ // short read (silent truncation) is retried rather than promoted, so the
228
+ // parser never sees a partial file.
229
+ try {
230
+ const got = statSync(partPath).size;
231
+ if (total > 0 && got !== total) {
232
+ try {
233
+ unlinkSync(partPath);
234
+ } catch { /* best effort */ }
235
+ throw new Error(`size mismatch: got ${got}, expected ${total}`);
236
+ }
237
+ } catch (e) {
238
+ if (e instanceof Error && e.message.startsWith("size mismatch")) {
239
+ throw e;
240
+ }
241
+ // statSync failure is non-fatal here; the rename below will surface it.
242
+ }
243
+
244
+ // Atomic publish: rename is atomic within a filesystem, so the final path
245
+ // flips from "absent" to "complete" in one step — never an in-between.
246
+ renameSync(partPath, destPath);
247
+ },
248
+ opts.tries,
249
+ { signal: opts.signal, onFail: opts.onFail },
250
+ );
251
+ }
@@ -0,0 +1,128 @@
1
+ // train_base/config.ts — RUN-LEVEL configuration, all from the environment.
2
+ //
3
+ // Only knobs that describe the RUN live here: the store, the checkpoint cadence,
4
+ // the cache ceiling, the read budgets, the caps. A knob that describes ONE
5
+ // CORPUS (which pairs of SmolSent, how many SODA dialogues, how long an Aya
6
+ // field may be) belongs next to that corpus's adapter, together with the
7
+ // evidence that fixed its default — see AGENTS.md §2.16: a comment carries the
8
+ // constraint, and a constraint is only readable beside the code it constrains.
9
+
10
+ import { join } from "node:path";
11
+
12
+ /** Read an environment variable, or `d` when it is unset. */
13
+ export const env = (k: string, d: string): string => process.env[k] ?? d;
14
+
15
+ export const DB_PATH = env("DB_PATH", "sema"); // → {DB_PATH}.sqlite
16
+ export const D = Number(env("D", "1024"));
17
+ export const SEED = Number(env("SEED", "7"));
18
+
19
+ // Checkpoint cadence is measured in LEARNED CONTENT, not deposits: a snapshot
20
+ // every CHECKPOINT_MB megabytes of trained UTF-8 content (decimal MB, matching
21
+ // the bytes() helper). A floor of 1 MB: a zero/NaN value must not make every
22
+ // deposit checkpoint, nor silently disable checkpointing. The tail (a run that
23
+ // learns less than one interval, or the remainder past the last interval) is
24
+ // always saved by finish() at exit — a complete point.
25
+ export const CHECKPOINT_BYTES = Math.max(
26
+ 1_000_000,
27
+ Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000,
28
+ );
29
+
30
+ // Target size of ONE materialised Parquet read, in uncompressed source bytes.
31
+ // A row-GROUP is a layout choice made by whoever wrote the file, not a memory
32
+ // budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
33
+ // group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
34
+ // (666 MB). Reading "exactly one row-group" is therefore safe for the first and
35
+ // fatal for the others, so reads are sized in BYTES instead — see
36
+ // `parquetBatchRows`. Materialised JS objects cost several times their source
37
+ // bytes, hence a default well under available memory.
38
+ export const PARQUET_BATCH_BYTES = Math.max(
39
+ 1_000_000,
40
+ Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000,
41
+ );
42
+
43
+ export const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir
44
+ export const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
45
+ export const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
46
+ export const PROGRESS_MS = Number(env("PROGRESS_MS", "250")); // panel cadence
47
+
48
+ // Index maintenance at checkpoints: compact (remove garbage), repair (fill
49
+ // gaps), then refresh the canonical-form index (equivalence-class resolution —
50
+ // src/canon.ts). All three are idempotent batch operations (the canon build is
51
+ // additionally incremental via the store's `canon.upto` cursor);
52
+ // INDEX_MAINTENANCE=0 disables.
53
+ export const INDEX_MAINTENANCE = env("INDEX_MAINTENANCE", "1") !== "0";
54
+ export const DOWNLOAD_TRIES = 5;
55
+
56
+ // In-progress downloads are written to a sibling "<dest>.part" and atomically
57
+ // renamed into place only after the bytes are fully flushed to disk. The cache
58
+ // invariant is therefore absolute: a file at its final path is, by definition,
59
+ // complete. Partial transfers (a crash, a kill, a dropped socket) leave only a
60
+ // .part file, which is never fed to the parser and is swept at startup by
61
+ // cache.ts's sweepPartials() — without which the debris would consume cache
62
+ // ceiling that nothing frees.
63
+ export const PART_SUFFIX = ".part";
64
+
65
+ // The checkpoint recall is a best-effort diagnostic — it must NEVER stall
66
+ // training. We bound it so a slow/large store cannot freeze the deposit loop.
67
+ export const INFER_TIMEOUT_MS = Number(env("INFER_TIMEOUT_MS", "15000"));
68
+
69
+ // How long the run may make NO progress before it gives up and exits non-zero.
70
+ //
71
+ // A long training run's worst failure is not a crash — a crash resumes. It is a
72
+ // HANG: the uncaught-exception handler deliberately swallows dropped-connection
73
+ // errors so a long run survives them, and the keep-alive timer deliberately
74
+ // holds the process open; together, an error that escapes and leaves an await
75
+ // unsettled produces a live process that will never do anything again. No error,
76
+ // no exit, and a supervisor that sees a healthy pid. Exiting instead turns that
77
+ // into a resume, which costs at most the work since the last checkpoint.
78
+ //
79
+ // "Progress" is any deposit, downloaded chunk, or rate-limit wait; time inside
80
+ // index maintenance and the checkpoint recall does not count against it, since
81
+ // those legitimately deposit nothing. Generous by default — this is a
82
+ // last-resort backstop, not a latency budget. 0 disables it.
83
+ export const STALL_MS = Math.max(
84
+ 0,
85
+ Math.floor(Number(env("STALL_MIN", "15")) * 60_000) || 900_000,
86
+ );
87
+
88
+ // How long a download may wait for room under the cache ceiling before failing
89
+ // the unit instead of waiting forever. The wait exists so a bounded cache can
90
+ // throttle a fast source; it is not meant to outlast the run. The unit stays
91
+ // resumable, so a genuine ceiling problem costs a retry, not the corpus.
92
+ export const CACHE_WAIT_MS = Math.max(
93
+ 60_000,
94
+ Math.floor(Number(env("CACHE_WAIT_MIN", "10")) * 60_000) || 600_000,
95
+ );
96
+
97
+ // The vector indices' memory knob (MiB) — each index's SQLite page cache.
98
+ // The IVF index routes inserts through a RAM-resident pivot table and
99
+ // appends to chunk blobs, so this cache mostly serves query-time cluster
100
+ // scans; 256 MiB comfortably covers the probed working set of a trained
101
+ // store. Override with VECTOR_CACHE_MB (64 is the library default).
102
+ export const VECTOR_CACHE_MB = Math.max(
103
+ 0,
104
+ Number(env("VECTOR_CACHE_MB", "256")),
105
+ );
106
+
107
+ // Page cache for the MAIN DAG database (node/kid/edge/contain tables).
108
+ // Training issues millions of content-addressed point probes per session
109
+ // against a GB-scale file; the library default (64 MiB) is sized for a
110
+ // small machine — a training box affords more. Override with
111
+ // SQLITE_CACHE_MB.
112
+ export const SQLITE_CACHE_MB = Math.max(
113
+ 0,
114
+ Number(env("SQLITE_CACHE_MB", "256")),
115
+ );
116
+
117
+ // Optional ceiling on how much LEARNED CONTENT to train, in megabytes (decimal,
118
+ // like CHECKPOINT_MB). Default Infinity = unbounded. The cap is checked against
119
+ // trainedContentBytes after each deposit, so a run stops at the first item that
120
+ // carries the running total to/past the ceiling (that item is still counted).
121
+ export const MAX_MB = Number(env("MAX_MB", "Infinity"));
122
+ if (isNaN(MAX_MB) || MAX_MB < 0) {
123
+ process.stderr.write(
124
+ `fatal: MAX_MB must be a non-negative number or "Infinity"\n`,
125
+ );
126
+ process.exit(1);
127
+ }
128
+ export const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
@@ -0,0 +1,106 @@
1
+ // train_base/corpora/aya.ts — CohereLabs/aya_dataset prompt→completion pairs
2
+ //
3
+ // Knobs, the row adapter, and the stage descriptor for ONE corpus, together
4
+ // with the evidence that fixed each default. A corpus file owns everything
5
+ // source-specific; the contract it fills is ../corpus.ts and the loop that runs
6
+ // it is ../stage.ts.
7
+
8
+ import { env } from "../config.js";
9
+ import { refineItems, type TrainingItem } from "../items.js";
10
+ import { parquet } from "../readers.js";
11
+ import { type Corpus, singleUnit } from "../corpus.js";
12
+
13
+ // ── CohereLabs/aya_dataset (the second training stage, after SmolSent) ──
14
+ // The Aya Dataset is ~204k HUMAN-annotated prompt→completion pairs across 70+
15
+ // languages, each a clean (inputs → targets) fact in a named language. It ships
16
+ // ONLY as Snappy-compressed Parquet (no JSONL/CSV). We DOWNLOAD the one train
17
+ // Parquet file and read it row-group by row-group with `hyparquet` (a pure-JS,
18
+ // dependency-free Parquet reader) + `hyparquet-compressors` (Snappy) over a
19
+ // web-standard Blob byte source — no whole-file-in-memory load. AYA=0 disables
20
+ // the stage; AYA_URL overrides the Parquet source.
21
+ const AYA = env("AYA", "1") !== "0";
22
+ const AYA_URL = env(
23
+ "AYA_URL",
24
+ "https://huggingface.co/datasets/CohereLabs/aya_dataset/resolve/main/data/train-00000-of-00001.parquet",
25
+ );
26
+ // A single Aya field this many chars or longer is skipped: inputs/targets range
27
+ // up to ~3.3M chars, and a multi-MB "pair" is documentation/dump noise, not a
28
+ // cognitive example.
29
+ const MAX_AYA_FIELD_CHARS = Math.max(
30
+ 10_000,
31
+ Math.floor(Number(env("MAX_AYA_FIELD_KB", "256")) * 1000) || 256_000,
32
+ );
33
+
34
+ // ═══════════════════════════════════════════════════════════════════════
35
+ // §6d Aya Dataset parsing — a human prompt→completion row → SEMA items
36
+ //
37
+ // Each Aya row is a single human-written (inputs → targets) pair in a named
38
+ // language, e.g. {inputs:"Qual é a capital da Índia?", targets:"Nova Déli.",
39
+ // language:"Portuguese", …}. That is already the canonical SEMA fact (ask →
40
+ // answer), so the translation is direct: exactly ONE (question → answer) fact.
41
+ // reasoning/scratch-work fields do not exist in this corpus, so nothing is
42
+ // stripped; the text is human prose already.
43
+
44
+ /** One normalized Aya row. */
45
+ export interface AyaRow {
46
+ inputs: string;
47
+ targets: string;
48
+ language: string;
49
+ }
50
+
51
+ /** Normalize a raw datasets-server row object into an AyaRow, or null when it
52
+ * lacks a usable prompt/answer or a field is implausibly large (a dump, not a
53
+ * cognitive example). Trims surrounding whitespace; keeps inner text verbatim
54
+ * (human prose, possibly multi-paragraph). */
55
+ export function toAyaRow(
56
+ row: unknown,
57
+ maxChars = MAX_AYA_FIELD_CHARS,
58
+ ): AyaRow | null {
59
+ if (!row || typeof row !== "object") return null;
60
+ const r = row as Record<string, unknown>;
61
+ const inputs = typeof r.inputs === "string" ? r.inputs.trim() : "";
62
+ const targets = typeof r.targets === "string" ? r.targets.trim() : "";
63
+ if (!inputs || !targets) return null;
64
+ if (
65
+ inputs.length > maxChars || targets.length > maxChars
66
+ ) return null;
67
+ const language = typeof r.language === "string" ? r.language.trim() : "";
68
+ return { inputs, targets, language };
69
+ }
70
+
71
+ /** Translate ONE Aya row into SEMA training items. A row is a single human
72
+ * (question → answer) exchange — exactly one FACT, the (inputs → targets) edge.
73
+ * No standalone-answer experience and no one-exchange "cumulative" walk: a lone
74
+ * Q→A is not multi-turn, and both would only replicate the same edge. */
75
+ export function ayaRowToItems(row: AyaRow): TrainingItem[] {
76
+ const { inputs, targets } = row;
77
+ return refineItems([{ context: inputs, continuation: targets }]);
78
+ }
79
+
80
+ export const aya: Corpus = {
81
+ id: "aya",
82
+ label: "Aya Dataset",
83
+ kind: "multilingual chat",
84
+ enabled: AYA,
85
+ // The three columns toAyaRow reads, out of six. Kept for the same reason as
86
+ // the other Parquet stages — the read states what the adapter uses — though
87
+ // here it is nearly free rather than a saving: measured on the train file,
88
+ // 238 MB uncompressed across all six and 233 MB for these three (98.2%). The
89
+ // dropped columns are ids and annotation metadata, so there is little to
90
+ // drop.
91
+ read: parquet({ columns: ["inputs", "targets", "language"] }),
92
+ toItems: (row) => {
93
+ const r = toAyaRow(row);
94
+ return r ? ayaRowToItems(r) : null;
95
+ },
96
+ discover: singleUnit({
97
+ // Resume id "aya::dataset" — the string this store already records.
98
+ key: "dataset",
99
+ label: "Aya Dataset",
100
+ display: "Aya Dataset",
101
+ url: AYA_URL,
102
+ dest: "aya_train.parquet",
103
+ localMatch: [/aya.*\.parquet$/i, /\.parquet$/i],
104
+ localWhat: "Aya *.parquet",
105
+ }),
106
+ };
@@ -0,0 +1,64 @@
1
+ // train_base/corpora/converted-parquet.ts — the work-list shape three corpora
2
+ // share.
3
+ //
4
+ // Read from Hugging Face's auto-converted `refs/convert/parquet` branch, not
5
+ // from main: a dataset's own Parquet may be written as ONE giant row-group
6
+ // (SODA's is 1,191,582 rows) and a column chunk is per-group, so any read of it
7
+ // materialises the whole file. The converted branch uses uniform 10,000-row
8
+ // groups.
9
+
10
+ import { LOCAL_PATH } from "../config.js";
11
+ import { hfConvertedParquet, localFiles } from "../discovery.js";
12
+ import { localDir, type Unit } from "../corpus.js";
13
+ import type { TrainCtx } from "../runtime.js";
14
+ import { DIM, R, YEL } from "../ui.js";
15
+ import { join } from "node:path";
16
+
17
+ /** The work-list of a corpus read from Hugging Face's auto-converted
18
+ * `refs/convert/parquet` branch. Shared by 2Wiki, SODA and MASSIVE: they
19
+ * differ in their dataset, their budget and their adapter — nothing else. */
20
+ export function convertedParquetUnits(opts: {
21
+ id: string;
22
+ label: string;
23
+ dataset: string;
24
+ config: string;
25
+ splits: string[];
26
+ /** Subdirectory of LOCAL_PATH holding pre-downloaded shards. */
27
+ localSub: string;
28
+ }): (ctx: TrainCtx) => Promise<Unit[] | null> {
29
+ return async (ctx: TrainCtx) => {
30
+ if (LOCAL_PATH) {
31
+ const dir = localDir(opts.localSub);
32
+ const names = localFiles(dir, /\.parquet$/i);
33
+ if (names.length === 0) {
34
+ ctx.progress.log(
35
+ ` ${DIM}· no ${opts.label} *.parquet in ${dir} — skipping${R}`,
36
+ );
37
+ return null;
38
+ }
39
+ return names.map((n) => ({
40
+ key: n.path,
41
+ name: n.path,
42
+ display: `${opts.label} ${n.path}`,
43
+ local: join(dir, n.path),
44
+ bytes: n.size,
45
+ }));
46
+ }
47
+ const paths = await hfConvertedParquet(
48
+ opts.dataset,
49
+ opts.config,
50
+ opts.splits,
51
+ opts.label,
52
+ ctx.http,
53
+ (m) => ctx.progress.log(` ${YEL}⚠${R} ${m}`),
54
+ );
55
+ return paths.map(({ path, size }) => ({
56
+ key: path,
57
+ name: path,
58
+ display: `${opts.label} ${path}`,
59
+ url: `https://huggingface.co/datasets/${opts.dataset}` +
60
+ `/resolve/refs%2Fconvert%2Fparquet/${path}`,
61
+ bytes: size,
62
+ }));
63
+ };
64
+ }
@@ -0,0 +1,114 @@
1
+ // train_base/corpora/genknow.ts — MuskumPillerum/General-Knowledge Q&A
2
+ //
3
+ // Knobs, the row adapter, and the stage descriptor for ONE corpus, together
4
+ // with the evidence that fixed each default. A corpus file owns everything
5
+ // source-specific; the contract it fills is ../corpus.ts and the loop that runs
6
+ // it is ../stage.ts.
7
+
8
+ import { env } from "../config.js";
9
+ import { refineItems, type TrainingItem } from "../items.js";
10
+ import { jsonArray } from "../readers.js";
11
+ import { type Corpus, singleUnit } from "../corpus.js";
12
+
13
+ // ── MuskumPillerum/General-Knowledge (the fourth training stage, after oasst2) ──
14
+ // A ~37.6k-row general-knowledge Q&A set: each row is a single {Question, Answer}
15
+ // pair. A row is a pure RELATION (question → answer), so it becomes exactly ONE
16
+ // FACT, identical in shape to the Aya stage. It ships as a single JSON array
17
+ // file (output.json); we DOWNLOAD it and stream the array. GENKNOW_URL overrides
18
+ // the source.
19
+ //
20
+ // DISABLED BY DEFAULT ON LICENCE GROUNDS (2026-08-13). The HF repo carries NO
21
+ // licence tag and no licence in its card — an earlier header in this file
22
+ // claimed MIT without support — and its own dataset card states it "contains a
23
+ // subset of the alpaca dataset". Alpaca is CC BY-NC 4.0: NonCommercial, which
24
+ // conflicts with Sema's commercial licence. Because a Sema store retains its
25
+ // training text VERBATIM, an unlicensed corpus inside it makes the whole
26
+ // artifact undistributable. See DATASETS.md §3.2. GENKNOW=1 re-enables the
27
+ // stage for local, non-distributed experiments only.
28
+ const GENKNOW = env("GENKNOW", "0") !== "0";
29
+ const GENKNOW_URL = env(
30
+ "GENKNOW_URL",
31
+ "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json",
32
+ );
33
+ // A Question/Answer longer than this is skipped (answers run to a few hundred
34
+ // chars; this only guards against a corrupt/runaway field).
35
+ const MAX_GENKNOW_CHARS = Math.max(
36
+ 4_000,
37
+ Math.floor(Number(env("MAX_GENKNOW_KB", "64")) * 1000) || 64_000,
38
+ );
39
+
40
+ // ═══════════════════════════════════════════════════════════════════════
41
+ // §6f General-Knowledge parsing — a {Question, Answer} row → SEMA fact
42
+ //
43
+ // Each row is a single general-knowledge question with one answer — a pure
44
+ // RELATION (question → answer), so it becomes exactly ONE FACT, like the Aya
45
+ // stage. No experience (a fact is possible) and no cumulative walk (a lone Q&A
46
+ // is not multi-turn). The source over-escapes newlines (a literal "\n" two-char
47
+ // sequence) and leaves trailing whitespace, so answers are un-escaped and
48
+ // trimmed to plain prose before deposit.
49
+ // ═══════════════════════════════════════════════════════════════════════
50
+
51
+ /** One normalized General-Knowledge row. */
52
+ export interface GenKnowRow {
53
+ question: string;
54
+ answer: string;
55
+ }
56
+
57
+ /** Turn a source value into clean prose: decode the literal "\n"/"\t"/"\r"
58
+ * two-character escapes the source JSON left in the text, collapse the runs of
59
+ * whitespace that creates, and trim. */
60
+ function unescapePlain(s: string): string {
61
+ return s
62
+ .replace(/\\r\\n|\\n|\\r/g, "\n")
63
+ .replace(/\\t/g, " ")
64
+ .replace(/[ \t]+/g, " ")
65
+ .replace(/\n{3,}/g, "\n\n")
66
+ .trim();
67
+ }
68
+
69
+ /** Normalize a raw datasets-server row into a GenKnowRow, or null when it lacks
70
+ * a usable question/answer or a side is implausibly large (corruption). */
71
+ export function toGenKnowRow(
72
+ row: unknown,
73
+ maxChars = MAX_GENKNOW_CHARS,
74
+ ): GenKnowRow | null {
75
+ if (!row || typeof row !== "object") return null;
76
+ const r = row as Record<string, unknown>;
77
+ const question = typeof r.Question === "string"
78
+ ? unescapePlain(r.Question)
79
+ : "";
80
+ const answer = typeof r.Answer === "string" ? unescapePlain(r.Answer) : "";
81
+ if (!question || !answer) return null;
82
+ if (
83
+ question.length > maxChars || answer.length > maxChars
84
+ ) return null;
85
+ return { question, answer };
86
+ }
87
+
88
+ /** Translate ONE General-Knowledge row into SEMA items: exactly one
89
+ * (question → answer) FACT. refineItems drops a degenerate question === answer. */
90
+ export function genKnowRowToItems(row: GenKnowRow): TrainingItem[] {
91
+ return refineItems([{ context: row.question, continuation: row.answer }]);
92
+ }
93
+
94
+ export const genknow: Corpus = {
95
+ id: "genknow",
96
+ label: "General-Knowledge",
97
+ kind: "Q&A facts",
98
+ enabled: GENKNOW,
99
+ read: jsonArray(),
100
+ toItems: (row) => {
101
+ const r = toGenKnowRow(row);
102
+ return r ? genKnowRowToItems(r) : null;
103
+ },
104
+ discover: singleUnit({
105
+ // Resume id "genknow::qa" — the string this store already records.
106
+ key: "qa",
107
+ label: "General-Knowledge",
108
+ display: "General-Knowledge",
109
+ url: GENKNOW_URL,
110
+ dest: "general_knowledge.json",
111
+ localMatch: [/general.*knowledge.*\.json$/i, /output\.json$/i],
112
+ localWhat: "General-Knowledge *.json",
113
+ }),
114
+ };