@hviana/sema 0.5.8 → 0.5.9

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 (40) hide show
  1. package/AGENTS.md +23 -0
  2. package/DATASETS.md +159 -0
  3. package/README.md +12 -0
  4. package/dist/example/train_base.d.ts +73 -3
  5. package/dist/example/train_base.js +1000 -49
  6. package/dist/src/geometry.d.ts +20 -0
  7. package/dist/src/geometry.js +22 -0
  8. package/dist/src/mind/attention.d.ts +6 -0
  9. package/dist/src/mind/attention.js +44 -4
  10. package/dist/src/mind/learning.js +134 -50
  11. package/dist/src/mind/mechanisms/cast.js +45 -1
  12. package/dist/src/mind/mind.d.ts +6 -1
  13. package/dist/src/mind/mind.js +14 -2
  14. package/dist/src/mind/reasoning.js +59 -5
  15. package/dist/src/mind/recognition.js +29 -3
  16. package/dist/src/mind/traverse.d.ts +16 -0
  17. package/dist/src/mind/traverse.js +18 -0
  18. package/dist/src/store-sqlite.d.ts +4 -0
  19. package/dist/src/store-sqlite.js +47 -0
  20. package/dist/src/store.d.ts +7 -0
  21. package/example/train_base.ts +1193 -46
  22. package/jsr.json +1 -1
  23. package/package.json +1 -1
  24. package/src/geometry.ts +23 -0
  25. package/src/mind/attention.ts +54 -1
  26. package/src/mind/learning.ts +137 -43
  27. package/src/mind/mechanisms/cast.ts +48 -1
  28. package/src/mind/mind.ts +12 -1
  29. package/src/mind/reasoning.ts +64 -5
  30. package/src/mind/recognition.ts +29 -3
  31. package/src/mind/traverse.ts +19 -0
  32. package/src/store-sqlite.ts +53 -0
  33. package/src/store.ts +28 -0
  34. package/test/29-counterfactual.test.mjs +43 -6
  35. package/test/77-company-saturation.test.mjs +302 -0
  36. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  37. package/test/84-composed-answer-honesty.test.mjs +136 -0
  38. package/test/85-answered-directly.test.mjs +126 -0
  39. package/test/86-cast-voices-committed.test.mjs +164 -0
  40. package/test/87-codominant-commitment.test.mjs +250 -0
@@ -1,6 +1,13 @@
1
1
  //This file uses the Google SMOL dataset, made available under the CC BY 4.0 license.
2
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.
3
+ //
4
+ //A trained Sema store retains its training text VERBATIM, so distributing a
5
+ //store distributes these corpora and every upstream licence applies to it in
6
+ //full. Read DATASETS.md before adding a corpus here or publishing a store:
7
+ //it carries the per-corpus attribution a distributed store is required to
8
+ //travel with, and the two rules a candidate corpus must pass (no NonCommercial
9
+ //term, no ShareAlike term — checked against what the corpus was BUILT FROM,
10
+ //not merely against the repository's licence tag).
4
11
  //This file is a more appropriate training example for Sema.
5
12
  //Sema does not learn through repetition;
6
13
  //it does not require a massive database.
@@ -19,23 +26,42 @@
19
26
  //
20
27
  // Every source here is commercially licensable (cc-by-4.0 / apache-2.0).
21
28
  //
22
- // The curriculum runs in four stages, into ONE store:
29
+ // The curriculum runs in eight stages, into ONE store:
23
30
  // 1. SmolSent (google/smol) — sentence-level TRANSLATION pairs across 100+
24
31
  // low-resource languages; see §6c. Each pair is "two names for one meaning"
25
- // → bidirectional translation FACTS, the cross-language concept SEMA fuses
26
- // (cf. test/05-concepts.test.mjs).
32
+ // → a foreign→English translation FACT, so every language's rendering of a
33
+ // meaning converges on ONE English node — the cross-language concept SEMA
34
+ // fuses (cf. test/05-concepts.test.mjs). The reverse binding is NOT
35
+ // deposited by default; see SMOLSENT_DIRECTIONS for why.
27
36
  // 2. Aya Dataset — ~204k human prompt→completion pairs, 70+ languages; see §6d
28
37
  // → one (question → answer) FACT each.
29
38
  // 3. oasst2 — MULTI-TURN human↔assistant conversation trees; see §6e → the
30
39
  // accumulated-context walk (single-turn trees are skipped, by design).
31
- // 4. General-Knowledge (MuskumPillerum) — ~37.6k {Question, Answer} pairs; see
32
- // §6f one (question answer) FACT each.
40
+ // 4. Taskmaster 1–4 (google-research-datasets) — task-oriented DIALOGUE, the
41
+ // best-scoring corpora on the fold-unit recurrence benchmark that predicts
42
+ // halo health; see §6e′ → the accumulated-context walk, over turns merged
43
+ // per speaker.
44
+ // 5. 2WikiMultihopQA — the `evidences` (subject, relation, object) TRIPLES,
45
+ // the one stage aimed at COMPOSITION; see §6e″ → a relation fact plus a
46
+ // bare-subject PIVOT fact each. Its Wikipedia passages and its composed
47
+ // questions are deliberately NOT read.
48
+ // 6. SODA — social/commonsense DIALOGUE; see §6e‴ → the accumulated-context
49
+ // walk. Budgeted: its train split alone would otherwise contribute ~8M
50
+ // episodes against 662k for the whole current corpus.
51
+ // 7. MASSIVE — short intent utterances in 51 locales; see §6e⁗ → ONE bare
52
+ // experience each. Contributes recurring fold units, nothing relational.
53
+ // DISABLED BY DEFAULT — edge-less content was measured to manufacture
54
+ // answers where the store should stay silent.
55
+ // 8. General-Knowledge (MuskumPillerum) — ~37.6k {Question, Answer} pairs; see
56
+ // §6f → one (question → answer) FACT each. DISABLED BY DEFAULT on licence
57
+ // grounds; see DATASETS.md §3.2.
33
58
  // Each stage runs only after the previous one finishes, and is recorded in the
34
59
  // same completed-files set, so a single store resumes the whole curriculum.
35
60
  //
36
61
  // Every source is DOWNLOADED as a file and streamed from disk (never paged
37
62
  // row-by-row over an HTTP API — that was slow and rate-limited): SmolSent as
38
- // per-pair JSONL, oasst2 as a gzipped JSONL, General-Knowledge as a JSON array,
63
+ // per-pair JSONL, oasst2 as a gzipped JSONL, Taskmaster and General-Knowledge as
64
+ // JSON arrays,
39
65
  // and Aya as Snappy-Parquet read row-group by row-group with hyparquet (the one
40
66
  // case the web platform can't decode alone). Resume is per-file: a fully-
41
67
  // consumed file is marked complete; an interrupted one re-reads from the top
@@ -84,12 +110,21 @@
84
110
  // MAX_MB=500 node dist/example/train_base.js
85
111
  // CHECKPOINT_MB=250 node dist/example/train_base.js
86
112
  // SMOLSENT_PAIRS=ha_en,zu_en node dist/example/train_base.js # a subset of pairs
113
+ // SMOLSENT_DIRECTIONS=both node dist/example/train_base.js # also English->foreign
87
114
  // SMOLSENT=0 node dist/example/train_base.js # skip SmolSent stage
88
115
  // AYA=0 node dist/example/train_base.js # skip Aya stage
89
116
  // AYA_SPLIT=test node dist/example/train_base.js # small Aya slice
90
117
  // OASST=0 node dist/example/train_base.js # skip oasst2 stage
91
118
  // OASST_MIN_TURNS=6 node dist/example/train_base.js # deeper multi-turn only
92
- // GENKNOW=0 node dist/example/train_base.js # skip General-Knowledge
119
+ // GENKNOW=1 node dist/example/train_base.js # General-Knowledge (see DATASETS.md §3.2)
120
+ // PARQUET_BATCH_MB=8 node dist/example/train_base.js # smaller Parquet reads on a tight host
121
+ // TASKMASTER=0 node dist/example/train_base.js # skip Taskmaster stage
122
+ // TASKMASTER_SETS=TM-3-2020 node dist/example/train_base.js # one Taskmaster set
123
+ // WIKI2=0 node dist/example/train_base.js # skip 2Wiki triples stage
124
+ // SODA=0 node dist/example/train_base.js # skip the SODA stage
125
+ // MASSIVE=1 node dist/example/train_base.js # enable MASSIVE (off by default)
126
+ // SODA_MAX_DIALOGS=0 node dist/example/train_base.js # lift the SODA budget
127
+ // WIKI2_MAX_ROWS=50000 node dist/example/train_base.js # budget the 2Wiki stage
93
128
  // LOCAL_PATH=./base node dist/example/train_base.js # offline: *.jsonl/.parquet/.jsonl.gz/.json
94
129
  // DB_PATH=./data/sema node dist/example/train_base.js
95
130
  import { CachedIngest, Mind, SQliteStore } from "../src/index.js";
@@ -135,6 +170,40 @@ const SMOLSENT_PAIRS = (process.env.SMOLSENT_PAIRS ?? "")
135
170
  // The resume id PREFIX for the SmolSent stage; one completed-files entry per
136
171
  // file (e.g. "smolsent::ha_en.jsonl").
137
172
  const SMOLSENT_ID = "smolsent";
173
+ // Which direction(s) of a translation pair to deposit. Was effectively "both",
174
+ // and that is now the default NO longer, for a reason measured rather than
175
+ // assumed.
176
+ //
177
+ // SmolSent's English side is a SHARED POOL translated into every language: row
178
+ // id 0 of smolsent/ha_en.jsonl, zu_en.jsonl and am_en.jsonl all carry the SAME
179
+ // `trg` ("It allows me to work by following my vibes and ..."). The two
180
+ // directions are therefore not symmetric at all:
181
+ //
182
+ // src2trg (foreign -> English) many distinct contexts -> ONE shared
183
+ // continuation. Every language's rendering of
184
+ // a meaning converges on the same English
185
+ // node — the cross-language concept fusion
186
+ // this stage exists for.
187
+ // trg2src (English -> foreign) ONE context -> 100+ DIFFERENT continuations,
188
+ // one per language file. The same English
189
+ // sentence is deposited over and over with a
190
+ // different answer each time.
191
+ //
192
+ // So dropping trg2src is not merely a corpus-size economy (it halves the
193
+ // largest stage, which was 60.9% of all examples in the last trained store); it
194
+ // removes a genuine ambiguity pathology. Set SMOLSENT_DIRECTIONS=both to
195
+ // restore the old behaviour, or trg2src for English->foreign only.
196
+ //
197
+ // WHAT THE CUT DOES NOT DO, measured on a three-pair store: asking the English
198
+ // sentence still ANSWERS with a foreign rendering, because the engine can reach
199
+ // a shared continuation's predecessors on its own. What is removed is the
200
+ // DEPOSITED forward ambiguity — one context carrying ~100 competing
201
+ // continuations — not every reverse association.
202
+ const SMOLSENT_DIRECTIONS = env("SMOLSENT_DIRECTIONS", "src2trg")
203
+ .trim().toLowerCase();
204
+ const SMOLSENT_SRC2TRG = SMOLSENT_DIRECTIONS !== "trg2src";
205
+ const SMOLSENT_TRG2SRC = SMOLSENT_DIRECTIONS === "trg2src" ||
206
+ SMOLSENT_DIRECTIONS === "both";
138
207
  // A SmolSent side longer than this is skipped (a sentence pair is short; a huge
139
208
  // value is corruption, not a sentence).
140
209
  const MAX_SMOLSENT_CHARS = Math.max(2_000, Math.floor(Number(env("MAX_SMOLSENT_KB", "16")) * 1000) || 16_000);
@@ -148,6 +217,15 @@ const SEED = Number(env("SEED", "7"));
148
217
  // learns less than one interval, or the remainder past the last interval) is
149
218
  // always saved by finish() at exit — a complete point.
150
219
  const CHECKPOINT_BYTES = Math.max(1_000_000, Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000);
220
+ // Target size of ONE materialised Parquet read, in uncompressed source bytes.
221
+ // A row-GROUP is a layout choice made by whoever wrote the file, not a memory
222
+ // budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
223
+ // group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
224
+ // (666 MB). Reading "exactly one row-group" is therefore safe for the first and
225
+ // fatal for the others, so reads are sized in BYTES instead — see
226
+ // `parquetBatchRows`. Materialised JS objects cost several times their source
227
+ // bytes, hence a default well under available memory.
228
+ const PARQUET_BATCH_BYTES = Math.max(1_000_000, Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000);
151
229
  const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir of *.zip
152
230
  const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
153
231
  const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
@@ -228,13 +306,152 @@ const OASST_MIN_TURNS = Math.max(2, Math.floor(Number(env("OASST_MIN_TURNS", "4"
228
306
  // Skip a tree whose decoded JSON line exceeds this (a pathological record); the
229
307
  // real maximum is far smaller, so this only guards against corruption.
230
308
  const MAX_OASST_LINE_CHARS = Math.max(100_000, Math.floor(Number(env("MAX_OASST_LINE_MB", "8")) * 1_000_000) || 8_000_000);
309
+ // ── google-research-datasets/Taskmaster 1–4 (the dialogue stages) ──
310
+ // Four corpora of task-oriented dialogue, one shape between them: each file is a
311
+ // JSON ARRAY of conversations and each conversation carries
312
+ // `utterances: [{speaker, text, …}]`. TM-1 ships two files directly under its
313
+ // directory (self-dialogs, woz-dialogs); TM-2/3/4 ship theirs under `<set>/data`.
314
+ // They are the best-scoring corpora on the fold-unit recurrence benchmark that
315
+ // selects for halo health (TM-3 85.1%, TM-4 78.8%, TM-2 68.7%, TM-1 51.8%,
316
+ // against 23.2% for the incumbent SmolSent), and they are genuinely multi-turn
317
+ // where the incumbent multi-turn stage is not (TM-3 median 20 turns of ~43 B,
318
+ // against oasst2's median turn of 529 B).
319
+ //
320
+ // Served from GitHub raw, not Hugging Face: the HF mirrors are loading-script
321
+ // repos with no data files, and the official copies carry the CC BY 4.0 notice.
322
+ const TASKMASTER = env("TASKMASTER", "1") !== "0";
323
+ // Which sets to train, in order. Each is a directory in the Taskmaster repo.
324
+ const TASKMASTER_SETS = env("TASKMASTER_SETS", "TM-1-2019,TM-2-2020,TM-3-2020,TM-4-2024").split(",").map((s) => s.trim()).filter(Boolean);
325
+ const TASKMASTER_REPO = env("TASKMASTER_REPO", "google-research-datasets/Taskmaster");
326
+ const TASKMASTER_RAW = `https://raw.githubusercontent.com/${TASKMASTER_REPO}/master`;
327
+ // A conversation must have at least this many turns AFTER same-speaker merging.
328
+ // The default of 2 keeps every real exchange: unlike oasst2 — where a lone Q→A
329
+ // tree merely replicates the Aya stage's shape and is dropped — a two-turn
330
+ // task-oriented exchange is still task-oriented dialogue, and TM-4's dialogues
331
+ // are short by design (median 3.7 turns), so a higher bar would discard most of
332
+ // that set.
333
+ const TASKMASTER_MIN_TURNS = Math.max(2, Math.floor(Number(env("TASKMASTER_MIN_TURNS", "2"))) || 2);
334
+ // Skip a conversation carrying an implausibly long utterance (corruption). The
335
+ // measured maximum across TM-1/2/3/4 is 1,897 bytes, so this only guards.
336
+ const MAX_TASKMASTER_TURN_CHARS = Math.max(1_000, Math.floor(Number(env("MAX_TASKMASTER_TURN_KB", "32")) * 1000) || 32_000);
337
+ // ── 2WikiMultihopQA — the `evidences` TRIPLES only (the composition stage) ──
338
+ // Each row carries `evidences`: a JSON string of (subject, relation, object)
339
+ // triples that CHAIN — one triple's object is the next's subject. 72.5% of rows
340
+ // carry such a chain (measured over 4,000 rows), and those triples are the only
341
+ // representation measured to make Sema compose a two-hop answer at all.
342
+ //
343
+ // TWO COLUMNS ARE DELIBERATELY NOT READ, one for licence reasons and one for
344
+ // capability reasons:
345
+ // • `context` holds Wikipedia PROSE. The repo is Apache-2.0 but Wikipedia text
346
+ // is CC BY-SA, and a Sema store keeps text verbatim, so ingesting the
347
+ // passages would attach ShareAlike to every distributed store. The triples
348
+ // originate in Wikidata (CC0). See DATASETS.md §3.2/§4.
349
+ // • `question`/`answer` are the composed multi-hop QUESTION. Depositing those
350
+ // teaches the answer to that exact question and nothing else — it memorises
351
+ // rather than composes. They are used to EVALUATE this adapter, never as
352
+ // training input.
353
+ //
354
+ // Read from Hugging Face's auto-converted `refs/convert/parquet` branch, not
355
+ // from main: the main-branch train.parquet is written as ONE 167,454-row
356
+ // group (666 MB uncompressed) and a Parquet column chunk is per-group, so any
357
+ // read of it materialises the whole file. The converted branch uses uniform
358
+ // 10,000-row groups. See test/79-parquet-batching.test.mjs.
359
+ const WIKI2 = env("WIKI2", "1") !== "0";
360
+ const WIKI2_DATASET = env("WIKI2_DATASET", "xanhho/2WikiMultihopQA");
361
+ // Splits to train, in order. Only `train` by default: `validation`/`test` are
362
+ // the dataset's held-out sets and are what an honest evaluation of this
363
+ // adapter's composition rate has to be measured on.
364
+ const WIKI2_SPLITS = env("WIKI2_SPLITS", "train")
365
+ .split(",").map((s) => s.trim()).filter(Boolean);
366
+ // Reject a triple with an implausibly long field (corruption); real subjects and
367
+ // objects are entity names, and relations are Wikidata property labels.
368
+ // 0 = every row. The train split holds 167,454 rows at ~4.95 deposits each
369
+ // (~830k facts), so this is the knob that keeps 2Wiki proportionate to the rest
370
+ // of the curriculum in the same way SODA_MAX_DIALOGS does.
371
+ const WIKI2_MAX_ROWS = Math.max(0, Math.floor(Number(env("WIKI2_MAX_ROWS", "0"))) || 0);
372
+ const MAX_WIKI2_FIELD_CHARS = Math.max(100, Math.floor(Number(env("MAX_WIKI2_FIELD_KB", "2")) * 1000) || 2_000);
373
+ // ── allenai/soda (social dialogue) and AmazonScience/massive (short intents) ──
374
+ // Both are read from Hugging Face's auto-converted `refs/convert/parquet`
375
+ // branch. For SODA that is mandatory, not cosmetic: its main-branch
376
+ // train.parquet is ONE 1,191,582-row group (1.19 GB uncompressed), and a
377
+ // Parquet column chunk is per-group, so any read of it materialises the whole
378
+ // file — measured at 100% of a 689 MB file and 2 GB of heap for a 500-row read.
379
+ // The converted branch uses uniform 10,000-row groups.
380
+ //
381
+ // BOTH STAGES ARE BUDGETED, and that is a curriculum decision rather than an
382
+ // algorithmic cap. SODA's train split holds 1,191,582 dialogues which the
383
+ // cumulative walk would turn into ~8 MILLION episodes — against the 662,221
384
+ // deposits of the entire current corpus. Trained whole it would not join the
385
+ // mix, it would BE the mix, and corpus size is the quantity every scale problem
386
+ // in this engine is measured against. The default takes the first
387
+ // SODA_MAX_DIALOGS of them; set it to 0 to lift the budget.
388
+ const SODA = env("SODA", "1") !== "0";
389
+ const SODA_DATASET = env("SODA_DATASET", "allenai/soda");
390
+ const SODA_SPLITS = env("SODA_SPLITS", "train")
391
+ .split(",").map((s) => s.trim()).filter(Boolean);
392
+ // ~6.3 episodes per dialogue, so this budgets ~750k episodes — comparable to
393
+ // the Taskmaster stage and to Aya, which is the intended balance. 0 = no budget.
394
+ const SODA_MAX_DIALOGS = Math.max(0, Math.floor(Number(env("SODA_MAX_DIALOGS", "120000"))) || 0);
395
+ const MAX_SODA_TURN_CHARS = Math.max(1_000, Math.floor(Number(env("MAX_SODA_TURN_KB", "32")) * 1000) || 32_000);
396
+ // MASSIVE deposits BARE UTTERANCES — an experience, not an episode — and that
397
+ // is the only shape its data supports. Two richer shapes were considered and
398
+ // rejected on evidence:
399
+ // • Same-intent pairs as paraphrases. 49.1% of consecutive rows share
400
+ // (locale, intent), but they are NOT meaning-equivalent: intent 48 in mn-MN
401
+ // runs "wake me at nine on the fifth" next to "set an alarm two hours from
402
+ // now". Depositing that pair as an episode teaches a continuation that does
403
+ // not exist.
404
+ // • Same-id rows across locales. Those ARE translations of one another —
405
+ // which is exactly SmolSent's relation, and SmolSent scores worst of every
406
+ // corpus measured on fold-unit recurrence (23.2%) because cross-lingual
407
+ // pairs share no units.
408
+ // So the stage contributes recurring fold units and lexical coverage (65.1%
409
+ // recurring unit mass, median 29 B) and nothing relational. `annot_utt` carries
410
+ // slot markup ("[date : tavdahad] ...") and is never read.
411
+ // DISABLED BY DEFAULT, on evidence gathered after the stage was written. A bare
412
+ // experience deposits content with NO EDGE, and that cuts both ways. Measured on
413
+ // a three-pair dialogue store with and without six MASSIVE-style utterances:
414
+ //
415
+ // "set an alarm" without: "Sure, what size would you like?" (wrong)
416
+ // with: "set an alarm for seven" (better)
417
+ // "play music" without: "" (correct silence)
418
+ // with: "Yes, sweetened or unsweetened?" (wrong)
419
+ //
420
+ // So it displaces some wrong answers and manufactures others, INCLUDING turning
421
+ // a correct silence into a wrong answer — and honest silence is a stated
422
+ // property of this engine (AGENTS §2.13). On the mixed-curriculum store the
423
+ // same shape produced the fragment "nus" for "wake me up at nine am".
424
+ //
425
+ // That evidence is four probes on toy stores and is NOT conclusive; it is,
426
+ // however, the only evidence there is, and it points the wrong way. The stage
427
+ // stays implemented and one env var away. Turn it on (MASSIVE=1) once there is
428
+ // a real measurement showing the recurring fold units it contributes (72.3% of
429
+ // deposited unit mass) buy more than the spurious answers cost.
430
+ const MASSIVE = env("MASSIVE", "0") !== "0";
431
+ const MASSIVE_DATASET = env("MASSIVE_DATASET", "AmazonScience/massive");
432
+ // "all" is the config covering every locale in one set of shards.
433
+ const MASSIVE_CONFIG = env("MASSIVE_CONFIG", "all");
434
+ const MASSIVE_SPLITS = env("MASSIVE_SPLITS", "train")
435
+ .split(",").map((s) => s.trim()).filter(Boolean);
436
+ // 0 = every row (587,214 in `all`/train, ~17 MB of content).
437
+ const MASSIVE_MAX_ROWS = Math.max(0, Math.floor(Number(env("MASSIVE_MAX_ROWS", "0"))) || 0);
438
+ const MAX_MASSIVE_UTT_CHARS = Math.max(100, Math.floor(Number(env("MAX_MASSIVE_UTT_KB", "2")) * 1000) || 2_000);
231
439
  // ── MuskumPillerum/General-Knowledge (the fourth training stage, after oasst2) ──
232
440
  // A ~37.6k-row general-knowledge Q&A set: each row is a single {Question, Answer}
233
441
  // pair. A row is a pure RELATION (question → answer), so it becomes exactly ONE
234
442
  // FACT, identical in shape to the Aya stage. It ships as a single JSON array
235
- // file (output.json); we DOWNLOAD it and stream the array. GENKNOW=0 disables
236
- // the stage; GENKNOW_URL overrides the source.
237
- const GENKNOW = env("GENKNOW", "1") !== "0";
443
+ // file (output.json); we DOWNLOAD it and stream the array. GENKNOW_URL overrides
444
+ // the source.
445
+ //
446
+ // DISABLED BY DEFAULT ON LICENCE GROUNDS (2026-08-13). The HF repo carries NO
447
+ // licence tag and no licence in its card — an earlier header in this file
448
+ // claimed MIT without support — and its own dataset card states it "contains a
449
+ // subset of the alpaca dataset". Alpaca is CC BY-NC 4.0: NonCommercial, which
450
+ // conflicts with Sema's commercial licence. Because a Sema store retains its
451
+ // training text VERBATIM, an unlicensed corpus inside it makes the whole
452
+ // artifact undistributable. See DATASETS.md §3.2. GENKNOW=1 re-enables the
453
+ // stage for local, non-distributed experiments only.
454
+ const GENKNOW = env("GENKNOW", "0") !== "0";
238
455
  const GENKNOW_URL = env("GENKNOW_URL", "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json");
239
456
  // The resume id of the General-Knowledge stage, in the same completed-files set
240
457
  // as the other stages, so one store records the whole curriculum.
@@ -450,6 +667,13 @@ async function getJson(url, label) {
450
667
  // ═══════════════════════════════════════════════════════════════════════
451
668
  /** A cheap HEAD to learn a download's size (for the cache ceiling and a real
452
669
  * ETA). Rate-limits wait; other 4xx is fatal; total failure → 0. */
670
+ /** Advertised transfer size of `url`, used only to reserve cache room. Like any
671
+ * `content-length` this is the ON-THE-WIRE size, so for a content-coded source
672
+ * (GitHub raw gzips JSON ~14x) it UNDER-estimates the file that lands on disk.
673
+ * That is tolerable here because the cache ceiling is a budget, not a
674
+ * correctness property — a run may overshoot MAX_CACHE_GB by the compression
675
+ * ratio of one in-flight file, and each file is deleted as soon as it is
676
+ * consumed. It must NOT be reused as an integrity check; see downloadFile. */
453
677
  async function headSize(url) {
454
678
  return retry(`HEAD ${url}`, async () => {
455
679
  const res = await fetch(url, { method: "HEAD", signal: shutdown.signal });
@@ -505,7 +729,21 @@ async function downloadFile(url, destPath, tries = DOWNLOAD_TRIES, onFail, onPro
505
729
  throw httpError(res);
506
730
  if (!res.body)
507
731
  throw new Error("empty response body");
508
- const total = Number(res.headers.get("content-length")) || 0;
732
+ // `content-length` describes the bytes ON THE WIRE. When the server
733
+ // applied a content-coding, fetch hands us the DECODED body, so the
734
+ // header no longer describes what gets written to disk and the integrity
735
+ // guard below must not use it. Measured: raw.githubusercontent.com sends
736
+ // `content-encoding: gzip` with content-length 110,928 for a file that
737
+ // decodes to 1,607,931 bytes — a size check against that rejects every
738
+ // healthy download. (The bug stayed latent because Hugging Face sends
739
+ // `content-encoding: br` and NO content-length, leaving total = 0, which
740
+ // already disables the guard.)
741
+ const encoding = (res.headers.get("content-encoding") ?? "").trim()
742
+ .toLowerCase();
743
+ const decoded = encoding !== "" && encoding !== "identity";
744
+ const total = decoded
745
+ ? 0
746
+ : Number(res.headers.get("content-length")) || 0;
509
747
  let done = 0;
510
748
  // Stream straight to a ".part" sibling using pure WHATWG streams. A
511
749
  // TransformStream meters progress; pipeTo into a WritableStream gives REAL
@@ -566,9 +804,11 @@ async function downloadFile(url, destPath, tries = DOWNLOAD_TRIES, onFail, onPro
566
804
  catch { /* best effort */ }
567
805
  throw e;
568
806
  }
569
- // Optional integrity guard: when the server advertised a size, a complete
570
- // file must match it. A short read (silent truncation) is retried rather
571
- // than promoted, so the parser never sees a partial ZIP.
807
+ // Optional integrity guard: when the server advertised a size FOR THE
808
+ // BYTES WE WRITE (see the content-encoding note above `total` is 0 for
809
+ // a decoded body, which disables this), a complete file must match it. A
810
+ // short read (silent truncation) is retried rather than promoted, so the
811
+ // parser never sees a partial file.
572
812
  try {
573
813
  const got = statSync(partPath).size;
574
814
  if (total > 0 && got !== total) {
@@ -658,15 +898,20 @@ export function toSmolSentRow(row) {
658
898
  const tl = typeof r.tl === "string" ? r.tl.trim() : "";
659
899
  return { src, trg, sl, tl };
660
900
  }
661
- /** Translate ONE SmolSent pair into SEMA facts: the two sentences are one
662
- * meaning in two languages, so bind them BOTH ways. refineItems drops the
663
- * degenerate case where src === trg. */
901
+ /** Translate ONE SmolSent pair into SEMA facts. The two sentences are one
902
+ * meaning in two languages, but the two BINDINGS are not equally sound —
903
+ * SmolSent's English side is a shared pool translated into every language, so
904
+ * `trg -> src` gives one English context a different answer in every language
905
+ * file. See SMOLSENT_DIRECTIONS. refineItems drops the degenerate case where
906
+ * src === trg. */
664
907
  export function smolSentRowToItems(row) {
665
908
  const { src, trg } = row;
666
- return refineItems([
667
- { context: src, continuation: trg },
668
- { context: trg, continuation: src },
669
- ]);
909
+ const items = [];
910
+ if (SMOLSENT_SRC2TRG)
911
+ items.push({ context: src, continuation: trg });
912
+ if (SMOLSENT_TRG2SRC)
913
+ items.push({ context: trg, continuation: src });
914
+ return refineItems(items);
670
915
  }
671
916
  /** Normalize a raw datasets-server row object into an AyaRow, or null when it
672
917
  * lacks a usable prompt/answer or a field is implausibly large (a dump, not a
@@ -741,6 +986,186 @@ export function oasstConversationToItems(turns) {
741
986
  return []; // not multi-turn — skip
742
987
  return refineItems(accumulate(turns.map((t) => t.text)));
743
988
  }
989
+ /** Normalize ONE element of a Taskmaster data file into its turns, or null when
990
+ * it carries no usable utterance. Empty/whitespace-only utterances are dropped
991
+ * (TM-3 has a few); a single implausibly long utterance rejects the whole
992
+ * conversation as corrupt rather than depositing a dump. */
993
+ export function toTaskmasterTurns(row) {
994
+ if (!row || typeof row !== "object")
995
+ return null;
996
+ const utterances = row.utterances;
997
+ if (!Array.isArray(utterances))
998
+ return null;
999
+ const turns = [];
1000
+ for (const u of utterances) {
1001
+ if (!u || typeof u !== "object")
1002
+ continue;
1003
+ const r = u;
1004
+ const text = typeof r.text === "string" ? r.text.trim() : "";
1005
+ if (!text)
1006
+ continue;
1007
+ if (text.length > MAX_TASKMASTER_TURN_CHARS)
1008
+ return null;
1009
+ turns.push({
1010
+ speaker: String(r.speaker ?? "").trim().toUpperCase(),
1011
+ text,
1012
+ });
1013
+ }
1014
+ return turns.length ? turns : null;
1015
+ }
1016
+ /** Collapse consecutive same-speaker turns into one, joining with a space, and
1017
+ * return the bare texts in order. A turn with no speaker never merges with its
1018
+ * neighbour: an unlabelled row is of unknown origin, and joining two of them
1019
+ * would invent a contribution that may span two speakers. */
1020
+ export function mergeTaskmasterTurns(turns) {
1021
+ const out = [];
1022
+ let prev = "";
1023
+ for (const t of turns) {
1024
+ if (out.length > 0 && t.speaker !== "" && t.speaker === prev) {
1025
+ out[out.length - 1] += " " + t.text;
1026
+ }
1027
+ else {
1028
+ out.push(t.text);
1029
+ }
1030
+ prev = t.speaker;
1031
+ }
1032
+ return out;
1033
+ }
1034
+ /** Translate ONE Taskmaster conversation into SEMA training items: the
1035
+ * cumulative walk over its merged turns. Returns [] for a conversation below
1036
+ * TASKMASTER_MIN_TURNS, so callers can simply skip empties. */
1037
+ export function taskmasterConversationToItems(turns) {
1038
+ const texts = mergeTaskmasterTurns(turns);
1039
+ if (texts.length < TASKMASTER_MIN_TURNS)
1040
+ return [];
1041
+ return refineItems(accumulate(texts));
1042
+ }
1043
+ /** Normalize a 2Wiki row into its evidence triples, or null when it carries
1044
+ * none usable. `evidences` is a JSON STRING holding an array of 3-element
1045
+ * arrays; a row whose cell is absent, unparseable, or empty yields null.
1046
+ * Individual malformed or oversized triples are dropped without discarding the
1047
+ * row — one bad triple should not cost the others. */
1048
+ export function toWikiTriples(row) {
1049
+ if (!row || typeof row !== "object")
1050
+ return null;
1051
+ const cell = row.evidences;
1052
+ let parsed = cell;
1053
+ if (typeof cell === "string") {
1054
+ try {
1055
+ parsed = JSON.parse(cell);
1056
+ }
1057
+ catch {
1058
+ return null;
1059
+ }
1060
+ }
1061
+ if (!Array.isArray(parsed))
1062
+ return null;
1063
+ const out = [];
1064
+ for (const e of parsed) {
1065
+ if (!Array.isArray(e) || e.length < 3)
1066
+ continue;
1067
+ const subject = typeof e[0] === "string" ? e[0].trim() : "";
1068
+ const relation = typeof e[1] === "string" ? e[1].trim() : "";
1069
+ const object = typeof e[2] === "string" ? e[2].trim() : "";
1070
+ if (!subject || !relation || !object)
1071
+ continue;
1072
+ if (subject.length > MAX_WIKI2_FIELD_CHARS ||
1073
+ relation.length > MAX_WIKI2_FIELD_CHARS ||
1074
+ object.length > MAX_WIKI2_FIELD_CHARS)
1075
+ continue;
1076
+ out.push({ subject, relation, object });
1077
+ }
1078
+ return out.length ? out : null;
1079
+ }
1080
+ /** Render ONE triple as the prose fact Sema stores. Kept separate so the two
1081
+ * deposits below are guaranteed to share a byte-identical continuation: the
1082
+ * pivot fact only works if it leads to the SAME node the relation fact does. */
1083
+ export function wikiTripleSentence(t) {
1084
+ return `The ${t.relation} of ${t.subject} is ${t.object}.`;
1085
+ }
1086
+ /** Translate a row's triples into SEMA items: per triple, the relation fact and
1087
+ * the bare-subject PIVOT fact (see the section note above). refineItems drops
1088
+ * the duplicates this produces when a row states the same triple twice. */
1089
+ export function wikiTriplesToItems(triples) {
1090
+ const items = [];
1091
+ for (const t of triples) {
1092
+ const fact = wikiTripleSentence(t);
1093
+ items.push({ context: `${t.subject} ${t.relation}`, continuation: fact });
1094
+ items.push({ context: t.subject, continuation: fact });
1095
+ }
1096
+ return refineItems(items);
1097
+ }
1098
+ // ═══════════════════════════════════════════════════════════════════════
1099
+ // §6e‴ SODA parsing — a social dialogue row → SEMA items
1100
+ //
1101
+ // Each row carries `dialogue` (an array of turn strings) and `speakers` (the
1102
+ // speaker name per turn). The deposit is the cumulative walk over speaker-merged
1103
+ // turns, identical in shape to Taskmaster and oasst2 — turns are short (mean
1104
+ // 87 B) and dialogues average 7.3 turns, so the accumulated context stays well
1105
+ // inside the healthy range.
1106
+ //
1107
+ // `narrative`, `literal` and the ATOMIC-style `head`/`relation`/`tail` columns
1108
+ // are NOT deposited: they are the generation scaffolding SODA was distilled
1109
+ // from, they restate the dialogue in the third person, and depositing both a
1110
+ // dialogue and its paraphrased summary gives one meaning two shapes — which is
1111
+ // measured to SUPPRESS composition rather than help it.
1112
+ // ═══════════════════════════════════════════════════════════════════════
1113
+ /** Normalize a SODA row into its turns, or null when it carries no usable
1114
+ * dialogue. Speakers are optional (they only drive merging); an implausibly
1115
+ * long turn rejects the dialogue as corrupt. */
1116
+ export function toSodaTurns(row) {
1117
+ if (!row || typeof row !== "object")
1118
+ return null;
1119
+ const r = row;
1120
+ const dialogue = r.dialogue;
1121
+ if (!Array.isArray(dialogue))
1122
+ return null;
1123
+ const speakers = Array.isArray(r.speakers) ? r.speakers : [];
1124
+ const turns = [];
1125
+ for (let i = 0; i < dialogue.length; i++) {
1126
+ const text = typeof dialogue[i] === "string"
1127
+ ? dialogue[i].trim()
1128
+ : "";
1129
+ if (!text)
1130
+ continue;
1131
+ if (text.length > MAX_SODA_TURN_CHARS)
1132
+ return null;
1133
+ turns.push({
1134
+ speaker: String(speakers[i] ?? "").trim().toUpperCase(),
1135
+ text,
1136
+ });
1137
+ }
1138
+ return turns.length ? turns : null;
1139
+ }
1140
+ /** Translate ONE SODA dialogue into SEMA items: the cumulative walk over its
1141
+ * speaker-merged turns. Shares `mergeTaskmasterTurns` because the rule is the
1142
+ * same one — consecutive turns by one speaker are one contribution. */
1143
+ export function sodaDialogueToItems(turns) {
1144
+ const texts = mergeTaskmasterTurns(turns);
1145
+ if (texts.length < 2)
1146
+ return []; // not an exchange
1147
+ return refineItems(accumulate(texts));
1148
+ }
1149
+ // ═══════════════════════════════════════════════════════════════════════
1150
+ // §6e⁗ MASSIVE parsing — one short utterance → ONE SEMA experience
1151
+ //
1152
+ // See the constants note for why this deposits a bare experience and not a
1153
+ // relation: the two relational shapes this corpus appears to offer are both
1154
+ // false (same-intent rows are not paraphrases; same-id rows across locales are
1155
+ // translations, SmolSent's worst-scoring relation).
1156
+ // ═══════════════════════════════════════════════════════════════════════
1157
+ /** Translate ONE MASSIVE row into SEMA items: its bare utterance, as an
1158
+ * experience. `annot_utt` (slot-annotated) is deliberately not used — its
1159
+ * "[date : ...]" markup is not prose. Returns [] for an unusable row. */
1160
+ export function massiveRowToItems(row) {
1161
+ if (!row || typeof row !== "object")
1162
+ return [];
1163
+ const utt = row.utt;
1164
+ const text = typeof utt === "string" ? utt.trim() : "";
1165
+ if (!text || text.length > MAX_MASSIVE_UTT_CHARS)
1166
+ return [];
1167
+ return refineItems([text]);
1168
+ }
744
1169
  /** Turn a source value into clean prose: decode the literal "\n"/"\t"/"\r"
745
1170
  * two-character escapes the source JSON left in the text, collapse the runs of
746
1171
  * whitespace that creates, and trim. */
@@ -917,6 +1342,63 @@ async function listSmolSentFiles() {
917
1342
  const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
918
1343
  return paths.filter((p) => want.has(basename(p).replace(/\.jsonl$/i, "")));
919
1344
  }
1345
+ /** List the Taskmaster data files to train, in TASKMASTER_SETS order. Returns
1346
+ * repo-relative paths, e.g. "TM-3-2020/data/data_00.json".
1347
+ *
1348
+ * TM-2/3/4 keep their dialogue files under `<set>/data`, so everything there is
1349
+ * fair game. TM-1 has no `data` directory: its two dialogue files sit at the
1350
+ * set root NEXT TO `ontology.json` (a slot schema) and `sample.json` (a small
1351
+ * excerpt of self-dialogs). Neither is an array of conversations, and training
1352
+ * the excerpt would deposit a subset of TM-1 twice, so TM-1 is filtered to the
1353
+ * `*-dialogs.json` pair (self-dialogs, woz-dialogs). */
1354
+ async function listTaskmasterFiles() {
1355
+ const out = [];
1356
+ for (const set of TASKMASTER_SETS) {
1357
+ const rootOnly = /^TM-1\b/i.test(set);
1358
+ const dir = rootOnly ? set : `${set}/data`;
1359
+ const body = await getJson(`https://api.github.com/repos/${TASKMASTER_REPO}/contents/${dir}`, `GET Taskmaster ${dir}`);
1360
+ const names = Array.isArray(body)
1361
+ ? body
1362
+ .filter((e) => e?.type === "file" && /\.json$/i.test(e?.name))
1363
+ .map((e) => String(e.name))
1364
+ : [];
1365
+ names.sort();
1366
+ for (const name of names) {
1367
+ if (rootOnly && !/-dialogs\.json$/i.test(name))
1368
+ continue;
1369
+ out.push({ set, path: `${dir}/${name}` });
1370
+ }
1371
+ }
1372
+ return out;
1373
+ }
1374
+ /** List a dataset's Parquet shards on Hugging Face's auto-converted
1375
+ * `refs/convert/parquet` branch, restricted to `config` and to `splits`.
1376
+ *
1377
+ * Shared by 2Wiki, SODA and MASSIVE. The converted branch is used rather than
1378
+ * `main` because a dataset's own Parquet may be written as ONE giant row-group
1379
+ * (SODA's is 1,191,582 rows), and a column chunk is per-group, so reading any
1380
+ * part of it materialises all of it. The converted branch is uniformly
1381
+ * 10,000-row groups. See test/79-parquet-batching.test.mjs.
1382
+ *
1383
+ * Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
1384
+ * path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
1385
+ * whose "/" must not be. */
1386
+ async function listConvertedParquet(dataset, config, splits, label) {
1387
+ const body = await getJson(`https://huggingface.co/api/datasets/${dataset}` +
1388
+ `/tree/refs%2Fconvert%2Fparquet/${config}?recursive=true`, `GET ${label} tree`);
1389
+ const paths = Array.isArray(body)
1390
+ ? body
1391
+ .filter((e) => e?.type === "file" && /\.parquet$/i.test(e?.path))
1392
+ .map((e) => String(e.path))
1393
+ : [];
1394
+ paths.sort();
1395
+ const want = new Set(splits);
1396
+ return paths.filter((p) => {
1397
+ const parts = p.split("/");
1398
+ // The tree is rooted at `config`, so the split is the second-to-last part.
1399
+ return want.has(parts[parts.length - 2] ?? "");
1400
+ });
1401
+ }
920
1402
  /** Stream a plain-JSONL file from disk, deposit each parsed row via `toItems`.
921
1403
  * Lines are split without buffering the whole file; an oversize/malformed line
922
1404
  * is counted skipped and the stream continues. Shared by SmolSent (and any
@@ -999,11 +1481,51 @@ async function processJsonl(filePath, toItems, ci, onExample, sample, maxLineCha
999
1481
  catch { /* best effort */ }
1000
1482
  }
1001
1483
  }
1002
- /** Read a downloaded Parquet file row-group by row-group with hyparquet (+Snappy
1484
+ /** How many rows to materialise in one read from a row-group of `rgRows` rows
1485
+ * occupying `groupBytes` uncompressed bytes, under a `budgetBytes` target.
1486
+ *
1487
+ * The group's own footer statistics give the mean row width, so the batch
1488
+ * follows the CORPUS's row size rather than the writer's layout: wide rows
1489
+ * (SODA carries a whole dialogue per row) batch smaller than narrow ones at
1490
+ * the same memory cost. Never exceeds the group — a batch is a subdivision of
1491
+ * a group, never a span across two, because `parquetReadObjects` is given an
1492
+ * absolute row range and column chunks are per-group. Never returns 0, or the
1493
+ * read loop could not advance.
1494
+ *
1495
+ * A writer that omits `total_byte_size` yields `groupBytes <= 0`; the batch is
1496
+ * then the whole group, which is exactly the behaviour this replaced. That
1497
+ * fallback is safe for every file we read today (all three report it) and
1498
+ * degrades to the old memory profile rather than to a wrong result. */
1499
+ export function parquetBatchRows(rgRows, groupBytes, budgetBytes) {
1500
+ if (!(rgRows > 0))
1501
+ return 0; // empty group — the caller skips it
1502
+ if (!(groupBytes > 0) || !Number.isFinite(groupBytes))
1503
+ return rgRows;
1504
+ const perRow = groupBytes / rgRows;
1505
+ const fit = Math.floor(budgetBytes / perRow);
1506
+ return Math.min(rgRows, Math.max(1, fit));
1507
+ }
1508
+ /** Read a downloaded Parquet file in bounded row batches with hyparquet (+Snappy
1003
1509
  * from hyparquet-compressors) over a web-standard Blob byte source, depositing
1004
- * each row via `toItems`. Only one row-group is materialised at a time, so a
1005
- * multi-hundred-MB file never loads whole into memory. */
1006
- async function processParquet(filePath, toItems, ci, onExample, sample) {
1510
+ * each row via `toItems`. At most `PARQUET_BATCH_BYTES` of source rows are
1511
+ * materialised at a time, so neither a multi-hundred-MB file nor a file written
1512
+ * as ONE giant row-group loads whole into memory.
1513
+ *
1514
+ * Batching also makes a single-group file INTERRUPTIBLE: the abort check runs
1515
+ * per batch, where before a 1.19M-row group could not be cancelled at all. */
1516
+ async function processParquet(filePath, toItems, ci, onExample, sample,
1517
+ // Optional stage-level stop, checked per row and before each batch is
1518
+ // decoded. A stage BUDGET must stop the read rather than reject rows: left to
1519
+ // reject, a budgeted stage still DECODES every remaining row-group — 143,346
1520
+ // rows of one 86.7 MB SODA shard — and reports them as "unusable" when
1521
+ // nothing was wrong with them, which is a lie in the run log.
1522
+ //
1523
+ // Measured honestly: on that shard the wall time did NOT improve (2m 35s ->
1524
+ // 2m 37s), because a budgeted run is dominated by depositing the rows it DID
1525
+ // take, not by scanning past the ones it did not. The win here is a truthful
1526
+ // log and the CPU/allocation of ~143k skipped row decodes, not elapsed time.
1527
+ // A larger shard past a small budget is where the decode cost would show.
1528
+ shouldStop) {
1007
1529
  const blob = await openAsBlob(filePath);
1008
1530
  const file = {
1009
1531
  byteLength: blob.size,
@@ -1013,30 +1535,40 @@ async function processParquet(filePath, toItems, ci, onExample, sample) {
1013
1535
  let examples = 0, skipped = 0;
1014
1536
  let rowStart = 0;
1015
1537
  for (const rg of meta.row_groups) {
1016
- if (shutdown.signal.aborted)
1017
- return { examples, stopped: true, skipped };
1018
1538
  const rgRows = Number(rg.num_rows);
1019
- const rowEnd = rowStart + rgRows;
1020
- // Materialise exactly one row-group, then deposit its rows.
1021
- const rows = await parquetReadObjects({
1022
- file,
1023
- compressors,
1024
- rowStart,
1025
- rowEnd,
1026
- });
1027
- rowStart = rowEnd;
1028
- for (const row of rows) {
1029
- const items = toItems(row);
1030
- if (!items || items.length === 0) {
1031
- skipped++;
1032
- continue;
1033
- }
1034
- const ok = await ingestItems(ci, items, async (contentBytes) => {
1035
- examples++;
1036
- return onExample(contentBytes);
1037
- }, sample);
1038
- if (!ok)
1539
+ const rgEnd = rowStart + rgRows;
1540
+ const batchRows = parquetBatchRows(rgRows, Number(rg.total_byte_size ?? 0), PARQUET_BATCH_BYTES);
1541
+ if (batchRows <= 0)
1542
+ continue; // empty group
1543
+ // Materialise one bounded batch at a time, then deposit its rows.
1544
+ while (rowStart < rgEnd) {
1545
+ if (shutdown.signal.aborted)
1039
1546
  return { examples, stopped: true, skipped };
1547
+ if (shouldStop?.())
1548
+ return { examples, stopped: true, skipped };
1549
+ const rowEnd = Math.min(rowStart + batchRows, rgEnd);
1550
+ const rows = await parquetReadObjects({
1551
+ file,
1552
+ compressors,
1553
+ rowStart,
1554
+ rowEnd,
1555
+ });
1556
+ rowStart = rowEnd;
1557
+ for (const row of rows) {
1558
+ if (shouldStop?.())
1559
+ return { examples, stopped: true, skipped };
1560
+ const items = toItems(row);
1561
+ if (!items || items.length === 0) {
1562
+ skipped++;
1563
+ continue;
1564
+ }
1565
+ const ok = await ingestItems(ci, items, async (contentBytes) => {
1566
+ examples++;
1567
+ return onExample(contentBytes);
1568
+ }, sample);
1569
+ if (!ok)
1570
+ return { examples, stopped: true, skipped };
1571
+ }
1040
1572
  }
1041
1573
  }
1042
1574
  return { examples, stopped: false, skipped };
@@ -1360,7 +1892,7 @@ async function main() {
1360
1892
  `to start fresh.\n`);
1361
1893
  process.exit(1);
1362
1894
  }
1363
- await store.setMeta("train.dataset", "SmolSent+Aya+oasst2");
1895
+ await store.setMeta("train.dataset", "SmolSent+Aya+oasst2+Taskmaster+2Wiki+SODA+MASSIVE");
1364
1896
  await store.setMeta("train.D", String(D));
1365
1897
  await store.setMeta("train.seed", String(SEED));
1366
1898
  await store.setMeta("train.createdAt", new Date().toISOString());
@@ -2131,6 +2663,417 @@ async function main() {
2131
2663
  const r = toGenKnowRow(row);
2132
2664
  return r ? genKnowRowToItems(r) : null;
2133
2665
  };
2666
+ // ── §10d′ Taskmaster 1–4 stage (task-oriented dialogue; runs AFTER oasst2) ──
2667
+ //
2668
+ // Lists the repo's data files, then for each: download, parse the JSON array,
2669
+ // deposit one cumulative walk per conversation. Per-FILE resume ids, like
2670
+ // SmolSent — an interrupted file is re-read from the top on resume, and
2671
+ // re-deposition is idempotent. LOCAL_PATH/taskmaster/ may hold pre-downloaded
2672
+ // *.json (a subdirectory, because these share the .json extension with the
2673
+ // General-Knowledge source and must not be confused with it).
2674
+ const tmToItems = (row) => {
2675
+ const turns = toTaskmasterTurns(row);
2676
+ if (!turns)
2677
+ return null;
2678
+ const items = taskmasterConversationToItems(turns); // [] when too short
2679
+ return items.length ? items : null;
2680
+ };
2681
+ const trainTaskmaster = async () => {
2682
+ if (!TASKMASTER)
2683
+ return;
2684
+ if (trainedContentBytes >= MAX_BYTES || stopRequested)
2685
+ return;
2686
+ let files;
2687
+ if (LOCAL_PATH) {
2688
+ const dir = join(LOCAL_PATH, "taskmaster");
2689
+ let names = [];
2690
+ try {
2691
+ names = readdirSync(dir).filter((f) => /\.json$/i.test(f))
2692
+ .sort();
2693
+ }
2694
+ catch { /* no local taskmaster dir */ }
2695
+ if (names.length === 0) {
2696
+ progress.log(` ${DIM}· no Taskmaster *.json in ${dir} — skipping${R}`);
2697
+ return;
2698
+ }
2699
+ files = names.map((f) => ({
2700
+ id: `taskmaster::${f}`,
2701
+ name: f,
2702
+ local: join(dir, f),
2703
+ }));
2704
+ }
2705
+ else {
2706
+ let listed;
2707
+ try {
2708
+ listed = await listTaskmasterFiles();
2709
+ }
2710
+ catch (e) {
2711
+ if (stopRequested || e?.name === "AbortError")
2712
+ return;
2713
+ progress.log(` ${RED}✗${R} Taskmaster file listing failed: ${e.message}`);
2714
+ return;
2715
+ }
2716
+ files = listed.map((f) => ({
2717
+ id: `taskmaster::${f.path}`,
2718
+ name: `${f.set}/${basename(f.path)}`,
2719
+ url: `${TASKMASTER_RAW}/${f.path}`,
2720
+ }));
2721
+ }
2722
+ if (files.length === 0) {
2723
+ progress.log(` ${DIM}· no Taskmaster files found — skipping${R}`);
2724
+ return;
2725
+ }
2726
+ const p = await loadProgress(store);
2727
+ const done = new Set(p.completedFiles);
2728
+ const remaining = files.filter((f) => !done.has(f.id));
2729
+ if (remaining.length === 0) {
2730
+ progress.log(` ${DIM}· Taskmaster already trained — skipping${R}`);
2731
+ return;
2732
+ }
2733
+ state.fileTotal = files.length;
2734
+ progress.log(` ${GRN}✓${R} Taskmaster: ${remaining.length}/${files.length} dialogue file(s) to train`);
2735
+ let idx = 0;
2736
+ for (const f of files) {
2737
+ if (trainedContentBytes >= MAX_BYTES || stopRequested)
2738
+ break;
2739
+ idx++;
2740
+ if (done.has(f.id))
2741
+ continue;
2742
+ let path = f.local ?? "";
2743
+ let downloaded = false;
2744
+ if (!path) {
2745
+ const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `Taskmaster ${f.name}`);
2746
+ if (!got) {
2747
+ if (stopRequested)
2748
+ break;
2749
+ continue; // a single failed file never aborts the stage
2750
+ }
2751
+ path = got;
2752
+ downloaded = true;
2753
+ }
2754
+ try {
2755
+ totalCorpusBytes += statSync(path).size;
2756
+ }
2757
+ catch { /* best effort */ }
2758
+ state.activity = "process";
2759
+ state.fileIndex = idx;
2760
+ state.filePath = `Taskmaster ${f.name}`;
2761
+ state.fileExamples = 0;
2762
+ tick(true);
2763
+ const p0 = Date.now();
2764
+ let res;
2765
+ try {
2766
+ res = await processJsonArray(path, tmToItems, ci, onDeposit, sample);
2767
+ }
2768
+ catch (e) {
2769
+ if (stopRequested || e?.name === "AbortError")
2770
+ break;
2771
+ progress.log(` ${RED}✗${R} Taskmaster ${f.name} parse failed: ${e.message}`);
2772
+ if (downloaded) {
2773
+ try {
2774
+ unlinkSync(path);
2775
+ }
2776
+ catch { /* best effort */ }
2777
+ }
2778
+ continue;
2779
+ }
2780
+ langTally["taskmaster"] = (langTally["taskmaster"] ?? 0) + res.examples;
2781
+ progress.log(` ${GRN}✓${R} ${f.name} ${DIM}[task dialogue]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
2782
+ (res.skipped
2783
+ ? ` ${YEL}· ${int(res.skipped)} unusable conversation(s) skipped${R}`
2784
+ : "") +
2785
+ (res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
2786
+ if (!res.stopped) {
2787
+ try {
2788
+ totalBytesProcessed += statSync(path).size;
2789
+ }
2790
+ catch { /* best effort */ }
2791
+ if (downloaded) {
2792
+ try {
2793
+ unlinkSync(path);
2794
+ }
2795
+ catch { /* best effort */ }
2796
+ }
2797
+ done.add(f.id);
2798
+ p.completedFiles.push(f.id);
2799
+ }
2800
+ try {
2801
+ await saveProgress(store, {
2802
+ completedFiles: p.completedFiles,
2803
+ depositCount,
2804
+ trainedContentBytes,
2805
+ totalBytesProcessed,
2806
+ totalCorpusBytes,
2807
+ });
2808
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
2809
+ }
2810
+ catch { /* best effort — finish() will retry */ }
2811
+ if (res.stopped)
2812
+ break; // cap/signal — leave file un-completed for resume
2813
+ }
2814
+ };
2815
+ // ── §10d″ 2WikiMultihopQA stage (composition; runs AFTER Taskmaster) ──
2816
+ //
2817
+ // Only the `evidences` column is ever touched — see §6e″ for why `context`
2818
+ // and `question`/`answer` are not.
2819
+ const wiki2ToItems = (row) => {
2820
+ const triples = toWikiTriples(row);
2821
+ if (!triples)
2822
+ return null;
2823
+ const items = wikiTriplesToItems(triples);
2824
+ return items.length ? items : null;
2825
+ };
2826
+ const trainWiki2 = () => runConvertedParquetStage({
2827
+ enabled: WIKI2,
2828
+ label: "2Wiki",
2829
+ tally: "2wiki",
2830
+ kind: "relation triples",
2831
+ dataset: WIKI2_DATASET,
2832
+ config: "default",
2833
+ splits: WIKI2_SPLITS,
2834
+ localDir: "2wiki",
2835
+ maxRows: WIKI2_MAX_ROWS,
2836
+ toItems: wiki2ToItems,
2837
+ });
2838
+ // ── §10d‴ Converted-Parquet stage runner (2Wiki, SODA, MASSIVE) ──
2839
+ //
2840
+ // These three differ only in their name, their row adapter and their row
2841
+ // budget, so they share one runner instead of three copies of the per-shard
2842
+ // loop. Resume is per SHARD, as everywhere else.
2843
+ //
2844
+ // The BUDGET is applied here rather than inside an adapter because it is a
2845
+ // curriculum decision about corpus MIX, not a property of a row: SODA's train
2846
+ // split would otherwise contribute ~8M episodes against the 662,221 deposits
2847
+ // of the whole current corpus. A budgeted stage never marks its remaining
2848
+ // shards complete, so raising the budget later resumes rather than restarts.
2849
+ const runConvertedParquetStage = async (opts) => {
2850
+ if (!opts.enabled)
2851
+ return;
2852
+ if (trainedContentBytes >= MAX_BYTES || stopRequested)
2853
+ return;
2854
+ let files;
2855
+ if (LOCAL_PATH) {
2856
+ const dir = join(LOCAL_PATH, opts.localDir);
2857
+ let names = [];
2858
+ try {
2859
+ names = readdirSync(dir).filter((f) => /\.parquet$/i.test(f))
2860
+ .sort();
2861
+ }
2862
+ catch { /* no local dir for this stage */ }
2863
+ if (names.length === 0) {
2864
+ progress.log(` ${DIM}· no ${opts.label} *.parquet in ${dir} — skipping${R}`);
2865
+ return;
2866
+ }
2867
+ files = names.map((f) => ({
2868
+ id: `${opts.tally}::${f}`,
2869
+ name: f,
2870
+ local: join(dir, f),
2871
+ }));
2872
+ }
2873
+ else {
2874
+ let paths;
2875
+ try {
2876
+ paths = await listConvertedParquet(opts.dataset, opts.config, opts.splits, opts.label);
2877
+ }
2878
+ catch (e) {
2879
+ if (stopRequested || e?.name === "AbortError")
2880
+ return;
2881
+ progress.log(` ${RED}✗${R} ${opts.label} file listing failed: ${e.message}`);
2882
+ return;
2883
+ }
2884
+ files = paths.map((path) => ({
2885
+ id: `${opts.tally}::${path}`,
2886
+ name: path,
2887
+ url: `https://huggingface.co/datasets/${opts.dataset}` +
2888
+ `/resolve/refs%2Fconvert%2Fparquet/${path}`,
2889
+ }));
2890
+ }
2891
+ if (files.length === 0) {
2892
+ progress.log(` ${DIM}· no ${opts.label} files found — skipping${R}`);
2893
+ return;
2894
+ }
2895
+ const p = await loadProgress(store);
2896
+ const done = new Set(p.completedFiles);
2897
+ // A budget-limited stage never marks its later shards complete — that is
2898
+ // what lets a raised budget resume instead of restarting. But it also means
2899
+ // "every shard complete" is NOT how such a stage finishes, so without a
2900
+ // marker of its own a satisfied budget would re-read and re-deposit its
2901
+ // rows on every subsequent run: harmless to the store (deposition is
2902
+ // idempotent) but it repeats the work and double-counts langTally.
2903
+ //
2904
+ // The marker carries the budget it was satisfied AT, so raising the budget
2905
+ // still resumes: a bigger budget does not match the marker and the stage
2906
+ // runs again, re-reading rows it already holds (idempotent) and adding the
2907
+ // new ones.
2908
+ const budgetMark = opts.maxRows > 0
2909
+ ? `${opts.tally}::budget=${opts.maxRows}`
2910
+ : "";
2911
+ if (budgetMark && done.has(budgetMark)) {
2912
+ progress.log(` ${DIM}· ${opts.label} budget of ${int(opts.maxRows)} row(s) already met — skipping${R}`);
2913
+ return;
2914
+ }
2915
+ const remaining = files.filter((f) => !done.has(f.id));
2916
+ if (remaining.length === 0) {
2917
+ progress.log(` ${DIM}· ${opts.label} already trained — skipping${R}`);
2918
+ return;
2919
+ }
2920
+ state.fileTotal = files.length;
2921
+ progress.log(` ${GRN}✓${R} ${opts.label}: ${remaining.length}/${files.length} shard(s) to train` +
2922
+ (opts.maxRows > 0
2923
+ ? ` ${DIM}(budget ${int(opts.maxRows)} rows)${R}`
2924
+ : ""));
2925
+ // The budget spans the whole stage, not one shard, so it is counted here.
2926
+ let rowsTaken = 0;
2927
+ const spent = () => opts.maxRows > 0 && rowsTaken >= opts.maxRows;
2928
+ const budgeted = (row) => {
2929
+ const items = opts.toItems(row);
2930
+ if (!items || items.length === 0)
2931
+ return null;
2932
+ rowsTaken++;
2933
+ return items;
2934
+ };
2935
+ let idx = 0;
2936
+ for (const f of files) {
2937
+ if (trainedContentBytes >= MAX_BYTES || stopRequested)
2938
+ break;
2939
+ if (opts.maxRows > 0 && rowsTaken >= opts.maxRows)
2940
+ break;
2941
+ idx++;
2942
+ if (done.has(f.id))
2943
+ continue;
2944
+ let path = f.local ?? "";
2945
+ let downloaded = false;
2946
+ if (!path) {
2947
+ const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `${opts.label} ${f.name}`);
2948
+ if (!got) {
2949
+ if (stopRequested)
2950
+ break;
2951
+ continue; // a single failed shard never aborts the stage
2952
+ }
2953
+ path = got;
2954
+ downloaded = true;
2955
+ }
2956
+ try {
2957
+ totalCorpusBytes += statSync(path).size;
2958
+ }
2959
+ catch { /* best effort */ }
2960
+ state.activity = "process";
2961
+ state.fileIndex = idx;
2962
+ state.filePath = `${opts.label} ${f.name}`;
2963
+ state.fileExamples = 0;
2964
+ tick(true);
2965
+ const p0 = Date.now();
2966
+ const before = rowsTaken;
2967
+ let res;
2968
+ try {
2969
+ res = await processParquet(path, budgeted, ci, onDeposit, sample, spent);
2970
+ }
2971
+ catch (e) {
2972
+ if (stopRequested || e?.name === "AbortError")
2973
+ break;
2974
+ progress.log(` ${RED}✗${R} ${opts.label} ${f.name} parse failed: ${e.message}`);
2975
+ if (downloaded) {
2976
+ try {
2977
+ unlinkSync(path);
2978
+ }
2979
+ catch { /* best effort */ }
2980
+ }
2981
+ continue;
2982
+ }
2983
+ // A shard cut short by the BUDGET is not "done" — leave it resumable so a
2984
+ // later run with a bigger budget continues instead of starting over.
2985
+ const hitBudget = spent();
2986
+ langTally[opts.tally] = (langTally[opts.tally] ?? 0) + res.examples;
2987
+ progress.log(` ${GRN}✓${R} ${f.name} ${DIM}[${opts.kind}]${R} → ${int(res.examples)} facts ${DIM}from ${int(rowsTaken - before)} row(s) in ${dur((Date.now() - p0) / 1000)}${R}` +
2988
+ (res.skipped
2989
+ ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
2990
+ : "") +
2991
+ (hitBudget
2992
+ ? ` ${YEL}(budget reached)${R}`
2993
+ : res.stopped
2994
+ ? ` ${YEL}(stopped early)${R}`
2995
+ : ""));
2996
+ if (!res.stopped && !hitBudget) {
2997
+ try {
2998
+ totalBytesProcessed += statSync(path).size;
2999
+ }
3000
+ catch { /* best effort */ }
3001
+ if (downloaded) {
3002
+ try {
3003
+ unlinkSync(path);
3004
+ }
3005
+ catch { /* best effort */ }
3006
+ }
3007
+ done.add(f.id);
3008
+ p.completedFiles.push(f.id);
3009
+ }
3010
+ try {
3011
+ await saveProgress(store, {
3012
+ completedFiles: p.completedFiles,
3013
+ depositCount,
3014
+ trainedContentBytes,
3015
+ totalBytesProcessed,
3016
+ totalCorpusBytes,
3017
+ });
3018
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
3019
+ }
3020
+ catch { /* best effort — finish() will retry */ }
3021
+ // A budget stop is not a cap/signal stop: the stage is finished, so fall
3022
+ // out of the loop rather than treating it as an interruption.
3023
+ if (res.stopped && !hitBudget)
3024
+ break;
3025
+ }
3026
+ // Record a satisfied budget so the next run skips this stage instead of
3027
+ // re-reading it. Only when the budget was actually reached: a stage that
3028
+ // ran out of shards first is complete by the normal per-shard rule, and a
3029
+ // stage cut short by MAX_MB or Ctrl+C must stay resumable.
3030
+ if (budgetMark && spent() && !stopRequested && !done.has(budgetMark)) {
3031
+ p.completedFiles.push(budgetMark);
3032
+ try {
3033
+ await saveProgress(store, {
3034
+ completedFiles: p.completedFiles,
3035
+ depositCount,
3036
+ trainedContentBytes,
3037
+ totalBytesProcessed,
3038
+ totalCorpusBytes,
3039
+ });
3040
+ }
3041
+ catch { /* best effort — finish() will retry */ }
3042
+ }
3043
+ };
3044
+ const trainSoda = () => runConvertedParquetStage({
3045
+ enabled: SODA,
3046
+ label: "SODA",
3047
+ tally: "soda",
3048
+ kind: "social dialogue",
3049
+ dataset: SODA_DATASET,
3050
+ config: "default",
3051
+ splits: SODA_SPLITS,
3052
+ localDir: "soda",
3053
+ maxRows: SODA_MAX_DIALOGS,
3054
+ toItems: (row) => {
3055
+ const turns = toSodaTurns(row);
3056
+ if (!turns)
3057
+ return null;
3058
+ const items = sodaDialogueToItems(turns);
3059
+ return items.length ? items : null;
3060
+ },
3061
+ });
3062
+ const trainMassive = () => runConvertedParquetStage({
3063
+ enabled: MASSIVE,
3064
+ label: "MASSIVE",
3065
+ tally: "massive",
3066
+ kind: "short intents",
3067
+ dataset: MASSIVE_DATASET,
3068
+ config: MASSIVE_CONFIG,
3069
+ splits: MASSIVE_SPLITS,
3070
+ localDir: "massive",
3071
+ maxRows: MASSIVE_MAX_ROWS,
3072
+ toItems: (row) => {
3073
+ const items = massiveRowToItems(row);
3074
+ return items.length ? items : null;
3075
+ },
3076
+ });
2134
3077
  const trainGenKnow = async () => {
2135
3078
  if (!GENKNOW)
2136
3079
  return;
@@ -2250,6 +3193,14 @@ async function main() {
2250
3193
  await trainAya();
2251
3194
  if (!stopRequested)
2252
3195
  await trainOasst();
3196
+ if (!stopRequested)
3197
+ await trainTaskmaster();
3198
+ if (!stopRequested)
3199
+ await trainWiki2();
3200
+ if (!stopRequested)
3201
+ await trainSoda();
3202
+ if (!stopRequested)
3203
+ await trainMassive();
2253
3204
  if (!stopRequested)
2254
3205
  await trainGenKnow();
2255
3206
  await finish(stopRequested ? stopReason : "done");