@hviana/sema 0.5.7 → 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 (44) hide show
  1. package/AGENTS.md +23 -0
  2. package/DATASETS.md +159 -0
  3. package/HOW_IT_WORKS.md +74 -0
  4. package/README.md +12 -0
  5. package/dist/example/train_base.d.ts +73 -3
  6. package/dist/example/train_base.js +1000 -49
  7. package/dist/src/geometry.d.ts +20 -0
  8. package/dist/src/geometry.js +22 -0
  9. package/dist/src/mind/articulation.js +15 -2
  10. package/dist/src/mind/attention.d.ts +6 -0
  11. package/dist/src/mind/attention.js +44 -4
  12. package/dist/src/mind/learning.js +250 -3
  13. package/dist/src/mind/mechanisms/cast.js +45 -1
  14. package/dist/src/mind/mind.d.ts +6 -1
  15. package/dist/src/mind/mind.js +14 -2
  16. package/dist/src/mind/reasoning.js +59 -5
  17. package/dist/src/mind/recognition.js +29 -3
  18. package/dist/src/mind/traverse.d.ts +34 -0
  19. package/dist/src/mind/traverse.js +42 -0
  20. package/dist/src/store-sqlite.d.ts +4 -0
  21. package/dist/src/store-sqlite.js +47 -0
  22. package/dist/src/store.d.ts +7 -0
  23. package/example/train_base.ts +1193 -46
  24. package/jsr.json +1 -1
  25. package/package.json +1 -1
  26. package/src/geometry.ts +23 -0
  27. package/src/mind/articulation.ts +16 -2
  28. package/src/mind/attention.ts +54 -1
  29. package/src/mind/learning.ts +253 -4
  30. package/src/mind/mechanisms/cast.ts +48 -1
  31. package/src/mind/mind.ts +12 -1
  32. package/src/mind/reasoning.ts +64 -5
  33. package/src/mind/recognition.ts +29 -3
  34. package/src/mind/traverse.ts +48 -0
  35. package/src/store-sqlite.ts +53 -0
  36. package/src/store.ts +28 -0
  37. package/test/29-counterfactual.test.mjs +43 -6
  38. package/test/76-type-level-company.test.mjs +342 -0
  39. package/test/77-company-saturation.test.mjs +302 -0
  40. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  41. package/test/84-composed-answer-honesty.test.mjs +136 -0
  42. package/test/85-answered-directly.test.mjs +126 -0
  43. package/test/86-cast-voices-committed.test.mjs +164 -0
  44. 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
 
5
12
  //This file is a more appropriate training example for Sema.
6
13
  //Sema does not learn through repetition;
@@ -21,23 +28,42 @@
21
28
  //
22
29
  // Every source here is commercially licensable (cc-by-4.0 / apache-2.0).
23
30
  //
24
- // The curriculum runs in four stages, into ONE store:
31
+ // The curriculum runs in eight stages, into ONE store:
25
32
  // 1. SmolSent (google/smol) — sentence-level TRANSLATION pairs across 100+
26
33
  // low-resource languages; see §6c. Each pair is "two names for one meaning"
27
- // → bidirectional translation FACTS, the cross-language concept SEMA fuses
28
- // (cf. test/05-concepts.test.mjs).
34
+ // → a foreign→English translation FACT, so every language's rendering of a
35
+ // meaning converges on ONE English node — the cross-language concept SEMA
36
+ // fuses (cf. test/05-concepts.test.mjs). The reverse binding is NOT
37
+ // deposited by default; see SMOLSENT_DIRECTIONS for why.
29
38
  // 2. Aya Dataset — ~204k human prompt→completion pairs, 70+ languages; see §6d
30
39
  // → one (question → answer) FACT each.
31
40
  // 3. oasst2 — MULTI-TURN human↔assistant conversation trees; see §6e → the
32
41
  // accumulated-context walk (single-turn trees are skipped, by design).
33
- // 4. General-Knowledge (MuskumPillerum) — ~37.6k {Question, Answer} pairs; see
34
- // §6f one (question answer) FACT each.
42
+ // 4. Taskmaster 1–4 (google-research-datasets) — task-oriented DIALOGUE, the
43
+ // best-scoring corpora on the fold-unit recurrence benchmark that predicts
44
+ // halo health; see §6e′ → the accumulated-context walk, over turns merged
45
+ // per speaker.
46
+ // 5. 2WikiMultihopQA — the `evidences` (subject, relation, object) TRIPLES,
47
+ // the one stage aimed at COMPOSITION; see §6e″ → a relation fact plus a
48
+ // bare-subject PIVOT fact each. Its Wikipedia passages and its composed
49
+ // questions are deliberately NOT read.
50
+ // 6. SODA — social/commonsense DIALOGUE; see §6e‴ → the accumulated-context
51
+ // walk. Budgeted: its train split alone would otherwise contribute ~8M
52
+ // episodes against 662k for the whole current corpus.
53
+ // 7. MASSIVE — short intent utterances in 51 locales; see §6e⁗ → ONE bare
54
+ // experience each. Contributes recurring fold units, nothing relational.
55
+ // DISABLED BY DEFAULT — edge-less content was measured to manufacture
56
+ // answers where the store should stay silent.
57
+ // 8. General-Knowledge (MuskumPillerum) — ~37.6k {Question, Answer} pairs; see
58
+ // §6f → one (question → answer) FACT each. DISABLED BY DEFAULT on licence
59
+ // grounds; see DATASETS.md §3.2.
35
60
  // Each stage runs only after the previous one finishes, and is recorded in the
36
61
  // same completed-files set, so a single store resumes the whole curriculum.
37
62
  //
38
63
  // Every source is DOWNLOADED as a file and streamed from disk (never paged
39
64
  // row-by-row over an HTTP API — that was slow and rate-limited): SmolSent as
40
- // per-pair JSONL, oasst2 as a gzipped JSONL, General-Knowledge as a JSON array,
65
+ // per-pair JSONL, oasst2 as a gzipped JSONL, Taskmaster and General-Knowledge as
66
+ // JSON arrays,
41
67
  // and Aya as Snappy-Parquet read row-group by row-group with hyparquet (the one
42
68
  // case the web platform can't decode alone). Resume is per-file: a fully-
43
69
  // consumed file is marked complete; an interrupted one re-reads from the top
@@ -86,12 +112,21 @@
86
112
  // MAX_MB=500 node dist/example/train_base.js
87
113
  // CHECKPOINT_MB=250 node dist/example/train_base.js
88
114
  // SMOLSENT_PAIRS=ha_en,zu_en node dist/example/train_base.js # a subset of pairs
115
+ // SMOLSENT_DIRECTIONS=both node dist/example/train_base.js # also English->foreign
89
116
  // SMOLSENT=0 node dist/example/train_base.js # skip SmolSent stage
90
117
  // AYA=0 node dist/example/train_base.js # skip Aya stage
91
118
  // AYA_SPLIT=test node dist/example/train_base.js # small Aya slice
92
119
  // OASST=0 node dist/example/train_base.js # skip oasst2 stage
93
120
  // OASST_MIN_TURNS=6 node dist/example/train_base.js # deeper multi-turn only
94
- // GENKNOW=0 node dist/example/train_base.js # skip General-Knowledge
121
+ // GENKNOW=1 node dist/example/train_base.js # General-Knowledge (see DATASETS.md §3.2)
122
+ // PARQUET_BATCH_MB=8 node dist/example/train_base.js # smaller Parquet reads on a tight host
123
+ // TASKMASTER=0 node dist/example/train_base.js # skip Taskmaster stage
124
+ // TASKMASTER_SETS=TM-3-2020 node dist/example/train_base.js # one Taskmaster set
125
+ // WIKI2=0 node dist/example/train_base.js # skip 2Wiki triples stage
126
+ // SODA=0 node dist/example/train_base.js # skip the SODA stage
127
+ // MASSIVE=1 node dist/example/train_base.js # enable MASSIVE (off by default)
128
+ // SODA_MAX_DIALOGS=0 node dist/example/train_base.js # lift the SODA budget
129
+ // WIKI2_MAX_ROWS=50000 node dist/example/train_base.js # budget the 2Wiki stage
95
130
  // LOCAL_PATH=./base node dist/example/train_base.js # offline: *.jsonl/.parquet/.jsonl.gz/.json
96
131
  // DB_PATH=./data/sema node dist/example/train_base.js
97
132
 
@@ -153,6 +188,40 @@ const SMOLSENT_PAIRS = (process.env.SMOLSENT_PAIRS ?? "")
153
188
  // The resume id PREFIX for the SmolSent stage; one completed-files entry per
154
189
  // file (e.g. "smolsent::ha_en.jsonl").
155
190
  const SMOLSENT_ID = "smolsent";
191
+ // Which direction(s) of a translation pair to deposit. Was effectively "both",
192
+ // and that is now the default NO longer, for a reason measured rather than
193
+ // assumed.
194
+ //
195
+ // SmolSent's English side is a SHARED POOL translated into every language: row
196
+ // id 0 of smolsent/ha_en.jsonl, zu_en.jsonl and am_en.jsonl all carry the SAME
197
+ // `trg` ("It allows me to work by following my vibes and ..."). The two
198
+ // directions are therefore not symmetric at all:
199
+ //
200
+ // src2trg (foreign -> English) many distinct contexts -> ONE shared
201
+ // continuation. Every language's rendering of
202
+ // a meaning converges on the same English
203
+ // node — the cross-language concept fusion
204
+ // this stage exists for.
205
+ // trg2src (English -> foreign) ONE context -> 100+ DIFFERENT continuations,
206
+ // one per language file. The same English
207
+ // sentence is deposited over and over with a
208
+ // different answer each time.
209
+ //
210
+ // So dropping trg2src is not merely a corpus-size economy (it halves the
211
+ // largest stage, which was 60.9% of all examples in the last trained store); it
212
+ // removes a genuine ambiguity pathology. Set SMOLSENT_DIRECTIONS=both to
213
+ // restore the old behaviour, or trg2src for English->foreign only.
214
+ //
215
+ // WHAT THE CUT DOES NOT DO, measured on a three-pair store: asking the English
216
+ // sentence still ANSWERS with a foreign rendering, because the engine can reach
217
+ // a shared continuation's predecessors on its own. What is removed is the
218
+ // DEPOSITED forward ambiguity — one context carrying ~100 competing
219
+ // continuations — not every reverse association.
220
+ const SMOLSENT_DIRECTIONS = env("SMOLSENT_DIRECTIONS", "src2trg")
221
+ .trim().toLowerCase();
222
+ const SMOLSENT_SRC2TRG = SMOLSENT_DIRECTIONS !== "trg2src";
223
+ const SMOLSENT_TRG2SRC = SMOLSENT_DIRECTIONS === "trg2src" ||
224
+ SMOLSENT_DIRECTIONS === "both";
156
225
  // A SmolSent side longer than this is skipped (a sentence pair is short; a huge
157
226
  // value is corruption, not a sentence).
158
227
  const MAX_SMOLSENT_CHARS = Math.max(
@@ -173,6 +242,18 @@ const CHECKPOINT_BYTES = Math.max(
173
242
  1_000_000,
174
243
  Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000,
175
244
  );
245
+ // Target size of ONE materialised Parquet read, in uncompressed source bytes.
246
+ // A row-GROUP is a layout choice made by whoever wrote the file, not a memory
247
+ // budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
248
+ // group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
249
+ // (666 MB). Reading "exactly one row-group" is therefore safe for the first and
250
+ // fatal for the others, so reads are sized in BYTES instead — see
251
+ // `parquetBatchRows`. Materialised JS objects cost several times their source
252
+ // bytes, hence a default well under available memory.
253
+ const PARQUET_BATCH_BYTES = Math.max(
254
+ 1_000_000,
255
+ Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000,
256
+ );
176
257
  const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir of *.zip
177
258
  const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
178
259
  const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
@@ -276,13 +357,187 @@ const MAX_OASST_LINE_CHARS = Math.max(
276
357
  Math.floor(Number(env("MAX_OASST_LINE_MB", "8")) * 1_000_000) || 8_000_000,
277
358
  );
278
359
 
360
+ // ── google-research-datasets/Taskmaster 1–4 (the dialogue stages) ──
361
+ // Four corpora of task-oriented dialogue, one shape between them: each file is a
362
+ // JSON ARRAY of conversations and each conversation carries
363
+ // `utterances: [{speaker, text, …}]`. TM-1 ships two files directly under its
364
+ // directory (self-dialogs, woz-dialogs); TM-2/3/4 ship theirs under `<set>/data`.
365
+ // They are the best-scoring corpora on the fold-unit recurrence benchmark that
366
+ // selects for halo health (TM-3 85.1%, TM-4 78.8%, TM-2 68.7%, TM-1 51.8%,
367
+ // against 23.2% for the incumbent SmolSent), and they are genuinely multi-turn
368
+ // where the incumbent multi-turn stage is not (TM-3 median 20 turns of ~43 B,
369
+ // against oasst2's median turn of 529 B).
370
+ //
371
+ // Served from GitHub raw, not Hugging Face: the HF mirrors are loading-script
372
+ // repos with no data files, and the official copies carry the CC BY 4.0 notice.
373
+ const TASKMASTER = env("TASKMASTER", "1") !== "0";
374
+ // Which sets to train, in order. Each is a directory in the Taskmaster repo.
375
+ const TASKMASTER_SETS = env(
376
+ "TASKMASTER_SETS",
377
+ "TM-1-2019,TM-2-2020,TM-3-2020,TM-4-2024",
378
+ ).split(",").map((s) => s.trim()).filter(Boolean);
379
+ const TASKMASTER_REPO = env(
380
+ "TASKMASTER_REPO",
381
+ "google-research-datasets/Taskmaster",
382
+ );
383
+ const TASKMASTER_RAW =
384
+ `https://raw.githubusercontent.com/${TASKMASTER_REPO}/master`;
385
+ // A conversation must have at least this many turns AFTER same-speaker merging.
386
+ // The default of 2 keeps every real exchange: unlike oasst2 — where a lone Q→A
387
+ // tree merely replicates the Aya stage's shape and is dropped — a two-turn
388
+ // task-oriented exchange is still task-oriented dialogue, and TM-4's dialogues
389
+ // are short by design (median 3.7 turns), so a higher bar would discard most of
390
+ // that set.
391
+ const TASKMASTER_MIN_TURNS = Math.max(
392
+ 2,
393
+ Math.floor(Number(env("TASKMASTER_MIN_TURNS", "2"))) || 2,
394
+ );
395
+ // Skip a conversation carrying an implausibly long utterance (corruption). The
396
+ // measured maximum across TM-1/2/3/4 is 1,897 bytes, so this only guards.
397
+ const MAX_TASKMASTER_TURN_CHARS = Math.max(
398
+ 1_000,
399
+ Math.floor(Number(env("MAX_TASKMASTER_TURN_KB", "32")) * 1000) || 32_000,
400
+ );
401
+
402
+ // ── 2WikiMultihopQA — the `evidences` TRIPLES only (the composition stage) ──
403
+ // Each row carries `evidences`: a JSON string of (subject, relation, object)
404
+ // triples that CHAIN — one triple's object is the next's subject. 72.5% of rows
405
+ // carry such a chain (measured over 4,000 rows), and those triples are the only
406
+ // representation measured to make Sema compose a two-hop answer at all.
407
+ //
408
+ // TWO COLUMNS ARE DELIBERATELY NOT READ, one for licence reasons and one for
409
+ // capability reasons:
410
+ // • `context` holds Wikipedia PROSE. The repo is Apache-2.0 but Wikipedia text
411
+ // is CC BY-SA, and a Sema store keeps text verbatim, so ingesting the
412
+ // passages would attach ShareAlike to every distributed store. The triples
413
+ // originate in Wikidata (CC0). See DATASETS.md §3.2/§4.
414
+ // • `question`/`answer` are the composed multi-hop QUESTION. Depositing those
415
+ // teaches the answer to that exact question and nothing else — it memorises
416
+ // rather than composes. They are used to EVALUATE this adapter, never as
417
+ // training input.
418
+ //
419
+ // Read from Hugging Face's auto-converted `refs/convert/parquet` branch, not
420
+ // from main: the main-branch train.parquet is written as ONE 167,454-row
421
+ // group (666 MB uncompressed) and a Parquet column chunk is per-group, so any
422
+ // read of it materialises the whole file. The converted branch uses uniform
423
+ // 10,000-row groups. See test/79-parquet-batching.test.mjs.
424
+ const WIKI2 = env("WIKI2", "1") !== "0";
425
+ const WIKI2_DATASET = env("WIKI2_DATASET", "xanhho/2WikiMultihopQA");
426
+ // Splits to train, in order. Only `train` by default: `validation`/`test` are
427
+ // the dataset's held-out sets and are what an honest evaluation of this
428
+ // adapter's composition rate has to be measured on.
429
+ const WIKI2_SPLITS = env("WIKI2_SPLITS", "train")
430
+ .split(",").map((s) => s.trim()).filter(Boolean);
431
+ // Reject a triple with an implausibly long field (corruption); real subjects and
432
+ // objects are entity names, and relations are Wikidata property labels.
433
+ // 0 = every row. The train split holds 167,454 rows at ~4.95 deposits each
434
+ // (~830k facts), so this is the knob that keeps 2Wiki proportionate to the rest
435
+ // of the curriculum in the same way SODA_MAX_DIALOGS does.
436
+ const WIKI2_MAX_ROWS = Math.max(
437
+ 0,
438
+ Math.floor(Number(env("WIKI2_MAX_ROWS", "0"))) || 0,
439
+ );
440
+ const MAX_WIKI2_FIELD_CHARS = Math.max(
441
+ 100,
442
+ Math.floor(Number(env("MAX_WIKI2_FIELD_KB", "2")) * 1000) || 2_000,
443
+ );
444
+
445
+ // ── allenai/soda (social dialogue) and AmazonScience/massive (short intents) ──
446
+ // Both are read from Hugging Face's auto-converted `refs/convert/parquet`
447
+ // branch. For SODA that is mandatory, not cosmetic: its main-branch
448
+ // train.parquet is ONE 1,191,582-row group (1.19 GB uncompressed), and a
449
+ // Parquet column chunk is per-group, so any read of it materialises the whole
450
+ // file — measured at 100% of a 689 MB file and 2 GB of heap for a 500-row read.
451
+ // The converted branch uses uniform 10,000-row groups.
452
+ //
453
+ // BOTH STAGES ARE BUDGETED, and that is a curriculum decision rather than an
454
+ // algorithmic cap. SODA's train split holds 1,191,582 dialogues which the
455
+ // cumulative walk would turn into ~8 MILLION episodes — against the 662,221
456
+ // deposits of the entire current corpus. Trained whole it would not join the
457
+ // mix, it would BE the mix, and corpus size is the quantity every scale problem
458
+ // in this engine is measured against. The default takes the first
459
+ // SODA_MAX_DIALOGS of them; set it to 0 to lift the budget.
460
+ const SODA = env("SODA", "1") !== "0";
461
+ const SODA_DATASET = env("SODA_DATASET", "allenai/soda");
462
+ const SODA_SPLITS = env("SODA_SPLITS", "train")
463
+ .split(",").map((s) => s.trim()).filter(Boolean);
464
+ // ~6.3 episodes per dialogue, so this budgets ~750k episodes — comparable to
465
+ // the Taskmaster stage and to Aya, which is the intended balance. 0 = no budget.
466
+ const SODA_MAX_DIALOGS = Math.max(
467
+ 0,
468
+ Math.floor(Number(env("SODA_MAX_DIALOGS", "120000"))) || 0,
469
+ );
470
+ const MAX_SODA_TURN_CHARS = Math.max(
471
+ 1_000,
472
+ Math.floor(Number(env("MAX_SODA_TURN_KB", "32")) * 1000) || 32_000,
473
+ );
474
+
475
+ // MASSIVE deposits BARE UTTERANCES — an experience, not an episode — and that
476
+ // is the only shape its data supports. Two richer shapes were considered and
477
+ // rejected on evidence:
478
+ // • Same-intent pairs as paraphrases. 49.1% of consecutive rows share
479
+ // (locale, intent), but they are NOT meaning-equivalent: intent 48 in mn-MN
480
+ // runs "wake me at nine on the fifth" next to "set an alarm two hours from
481
+ // now". Depositing that pair as an episode teaches a continuation that does
482
+ // not exist.
483
+ // • Same-id rows across locales. Those ARE translations of one another —
484
+ // which is exactly SmolSent's relation, and SmolSent scores worst of every
485
+ // corpus measured on fold-unit recurrence (23.2%) because cross-lingual
486
+ // pairs share no units.
487
+ // So the stage contributes recurring fold units and lexical coverage (65.1%
488
+ // recurring unit mass, median 29 B) and nothing relational. `annot_utt` carries
489
+ // slot markup ("[date : tavdahad] ...") and is never read.
490
+ // DISABLED BY DEFAULT, on evidence gathered after the stage was written. A bare
491
+ // experience deposits content with NO EDGE, and that cuts both ways. Measured on
492
+ // a three-pair dialogue store with and without six MASSIVE-style utterances:
493
+ //
494
+ // "set an alarm" without: "Sure, what size would you like?" (wrong)
495
+ // with: "set an alarm for seven" (better)
496
+ // "play music" without: "" (correct silence)
497
+ // with: "Yes, sweetened or unsweetened?" (wrong)
498
+ //
499
+ // So it displaces some wrong answers and manufactures others, INCLUDING turning
500
+ // a correct silence into a wrong answer — and honest silence is a stated
501
+ // property of this engine (AGENTS §2.13). On the mixed-curriculum store the
502
+ // same shape produced the fragment "nus" for "wake me up at nine am".
503
+ //
504
+ // That evidence is four probes on toy stores and is NOT conclusive; it is,
505
+ // however, the only evidence there is, and it points the wrong way. The stage
506
+ // stays implemented and one env var away. Turn it on (MASSIVE=1) once there is
507
+ // a real measurement showing the recurring fold units it contributes (72.3% of
508
+ // deposited unit mass) buy more than the spurious answers cost.
509
+ const MASSIVE = env("MASSIVE", "0") !== "0";
510
+ const MASSIVE_DATASET = env("MASSIVE_DATASET", "AmazonScience/massive");
511
+ // "all" is the config covering every locale in one set of shards.
512
+ const MASSIVE_CONFIG = env("MASSIVE_CONFIG", "all");
513
+ const MASSIVE_SPLITS = env("MASSIVE_SPLITS", "train")
514
+ .split(",").map((s) => s.trim()).filter(Boolean);
515
+ // 0 = every row (587,214 in `all`/train, ~17 MB of content).
516
+ const MASSIVE_MAX_ROWS = Math.max(
517
+ 0,
518
+ Math.floor(Number(env("MASSIVE_MAX_ROWS", "0"))) || 0,
519
+ );
520
+ const MAX_MASSIVE_UTT_CHARS = Math.max(
521
+ 100,
522
+ Math.floor(Number(env("MAX_MASSIVE_UTT_KB", "2")) * 1000) || 2_000,
523
+ );
524
+
279
525
  // ── MuskumPillerum/General-Knowledge (the fourth training stage, after oasst2) ──
280
526
  // A ~37.6k-row general-knowledge Q&A set: each row is a single {Question, Answer}
281
527
  // pair. A row is a pure RELATION (question → answer), so it becomes exactly ONE
282
528
  // FACT, identical in shape to the Aya stage. It ships as a single JSON array
283
- // file (output.json); we DOWNLOAD it and stream the array. GENKNOW=0 disables
284
- // the stage; GENKNOW_URL overrides the source.
285
- const GENKNOW = env("GENKNOW", "1") !== "0";
529
+ // file (output.json); we DOWNLOAD it and stream the array. GENKNOW_URL overrides
530
+ // the source.
531
+ //
532
+ // DISABLED BY DEFAULT ON LICENCE GROUNDS (2026-08-13). The HF repo carries NO
533
+ // licence tag and no licence in its card — an earlier header in this file
534
+ // claimed MIT without support — and its own dataset card states it "contains a
535
+ // subset of the alpaca dataset". Alpaca is CC BY-NC 4.0: NonCommercial, which
536
+ // conflicts with Sema's commercial licence. Because a Sema store retains its
537
+ // training text VERBATIM, an unlicensed corpus inside it makes the whole
538
+ // artifact undistributable. See DATASETS.md §3.2. GENKNOW=1 re-enables the
539
+ // stage for local, non-distributed experiments only.
540
+ const GENKNOW = env("GENKNOW", "0") !== "0";
286
541
  const GENKNOW_URL = env(
287
542
  "GENKNOW_URL",
288
543
  "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json",
@@ -535,6 +790,13 @@ async function getJson(url: string, label: string): Promise<any> {
535
790
 
536
791
  /** A cheap HEAD to learn a download's size (for the cache ceiling and a real
537
792
  * ETA). Rate-limits wait; other 4xx is fatal; total failure → 0. */
793
+ /** Advertised transfer size of `url`, used only to reserve cache room. Like any
794
+ * `content-length` this is the ON-THE-WIRE size, so for a content-coded source
795
+ * (GitHub raw gzips JSON ~14x) it UNDER-estimates the file that lands on disk.
796
+ * That is tolerable here because the cache ceiling is a budget, not a
797
+ * correctness property — a run may overshoot MAX_CACHE_GB by the compression
798
+ * ratio of one in-flight file, and each file is deleted as soon as it is
799
+ * consumed. It must NOT be reused as an integrity check; see downloadFile. */
538
800
  async function headSize(url: string): Promise<number> {
539
801
  return retry(`HEAD ${url}`, async () => {
540
802
  const res = await fetch(url, { method: "HEAD", signal: shutdown.signal });
@@ -606,7 +868,21 @@ async function downloadFile(
606
868
  if (!res.ok) throw httpError(res);
607
869
  if (!res.body) throw new Error("empty response body");
608
870
 
609
- const total = Number(res.headers.get("content-length")) || 0;
871
+ // `content-length` describes the bytes ON THE WIRE. When the server
872
+ // applied a content-coding, fetch hands us the DECODED body, so the
873
+ // header no longer describes what gets written to disk and the integrity
874
+ // guard below must not use it. Measured: raw.githubusercontent.com sends
875
+ // `content-encoding: gzip` with content-length 110,928 for a file that
876
+ // decodes to 1,607,931 bytes — a size check against that rejects every
877
+ // healthy download. (The bug stayed latent because Hugging Face sends
878
+ // `content-encoding: br` and NO content-length, leaving total = 0, which
879
+ // already disables the guard.)
880
+ const encoding = (res.headers.get("content-encoding") ?? "").trim()
881
+ .toLowerCase();
882
+ const decoded = encoding !== "" && encoding !== "identity";
883
+ const total = decoded
884
+ ? 0
885
+ : Number(res.headers.get("content-length")) || 0;
610
886
  let done = 0;
611
887
 
612
888
  // Stream straight to a ".part" sibling using pure WHATWG streams. A
@@ -667,9 +943,11 @@ async function downloadFile(
667
943
  throw e;
668
944
  }
669
945
 
670
- // Optional integrity guard: when the server advertised a size, a complete
671
- // file must match it. A short read (silent truncation) is retried rather
672
- // than promoted, so the parser never sees a partial ZIP.
946
+ // Optional integrity guard: when the server advertised a size FOR THE
947
+ // BYTES WE WRITE (see the content-encoding note above `total` is 0 for
948
+ // a decoded body, which disables this), a complete file must match it. A
949
+ // short read (silent truncation) is retried rather than promoted, so the
950
+ // parser never sees a partial file.
673
951
  try {
674
952
  const got = statSync(partPath).size;
675
953
  if (total > 0 && got !== total) {
@@ -795,15 +1073,18 @@ export function toSmolSentRow(row: unknown): SmolSentRow | null {
795
1073
  return { src, trg, sl, tl };
796
1074
  }
797
1075
 
798
- /** Translate ONE SmolSent pair into SEMA facts: the two sentences are one
799
- * meaning in two languages, so bind them BOTH ways. refineItems drops the
800
- * degenerate case where src === trg. */
1076
+ /** Translate ONE SmolSent pair into SEMA facts. The two sentences are one
1077
+ * meaning in two languages, but the two BINDINGS are not equally sound —
1078
+ * SmolSent's English side is a shared pool translated into every language, so
1079
+ * `trg -> src` gives one English context a different answer in every language
1080
+ * file. See SMOLSENT_DIRECTIONS. refineItems drops the degenerate case where
1081
+ * src === trg. */
801
1082
  export function smolSentRowToItems(row: SmolSentRow): TrainingItem[] {
802
1083
  const { src, trg } = row;
803
- return refineItems([
804
- { context: src, continuation: trg },
805
- { context: trg, continuation: src },
806
- ]);
1084
+ const items: TrainingItem[] = [];
1085
+ if (SMOLSENT_SRC2TRG) items.push({ context: src, continuation: trg });
1086
+ if (SMOLSENT_TRG2SRC) items.push({ context: trg, continuation: src });
1087
+ return refineItems(items);
807
1088
  }
808
1089
 
809
1090
  // ═══════════════════════════════════════════════════════════════════════
@@ -926,6 +1207,268 @@ export function oasstConversationToItems(turns: OasstTurn[]): TrainingItem[] {
926
1207
  return refineItems(accumulate(turns.map((t) => t.text)));
927
1208
  }
928
1209
 
1210
+ // ═══════════════════════════════════════════════════════════════════════
1211
+ // §6e′ Taskmaster 1–4 parsing — a conversation ARRAY ELEMENT → SEMA items
1212
+ //
1213
+ // One adapter serves all four sets: every Taskmaster conversation, in every
1214
+ // set, is `{conversation_id, …, utterances: [{speaker, text, …}]}`.
1215
+ //
1216
+ // ONLY `utterances[].text` IS READ, and that is a licence-adjacent correctness
1217
+ // property, not a stylistic one. TM-3 and TM-4 also carry an `instructions`
1218
+ // field holding the crowd-worker's task template — page after page of
1219
+ // `{{HIDE movie_1 name.movie No Time To Die}}`, `{{CHECK confirm_natural …}}`
1220
+ // and `var_theater_1` placeholders. That is authoring scaffolding, not
1221
+ // dialogue, and depositing it would teach the store template noise as prose.
1222
+ // Reading only `utterances[].text` excludes it structurally. Verified against
1223
+ // the real files: across TM-2 (13,953 turns), TM-3 (24,059) and TM-4 (786),
1224
+ // utterance text contains ZERO `var_*` placeholders and ZERO `{{ }}` markers —
1225
+ // the scaffolding never leaks out of `instructions`.
1226
+ //
1227
+ // CONSECUTIVE SAME-SPEAKER TURNS ARE MERGED. Taskmaster splits one speaker's
1228
+ // contribution across several indexed utterances ("I can help you with your
1229
+ // movie search." / "Where are you located?" are two ASSISTANT rows), which is
1230
+ // an artifact of the collection UI. Left unmerged, the cumulative walk deposits
1231
+ // a turn boundary in the middle of one speaker's contribution and teaches it as
1232
+ // a hand-off. Measured share of turns absorbed by merging: TM-1 17.7%,
1233
+ // TM-2 11.9%, TM-3 0.8%, TM-4 0.0% — so this is load-bearing for the older sets
1234
+ // and a no-op for the newer ones. Speaker names are compared case-insensitively
1235
+ // because TM-1/2 use USER/ASSISTANT and TM-3/4 use user/assistant.
1236
+ //
1237
+ // The deposit shape is the cumulative walk (§6e's `accumulate`), identical to
1238
+ // oasst2: each turn is the continuation of ALL prior turns, bare text, no role
1239
+ // labels. It is the right shape here for the same reason and at a far healthier
1240
+ // size — merged turns run p50 34–45 B (p90 ~100 B) and the accumulated context
1241
+ // p50 301–532 B (p90 ~1.1 KB), against oasst2's median SINGLE turn of 529 B.
1242
+ // ═══════════════════════════════════════════════════════════════════════
1243
+
1244
+ /** One utterance of a Taskmaster conversation. */
1245
+ export interface TaskmasterTurn {
1246
+ speaker: string; // upper-cased, so TM-1/2 and TM-3/4 compare equal
1247
+ text: string;
1248
+ }
1249
+
1250
+ /** Normalize ONE element of a Taskmaster data file into its turns, or null when
1251
+ * it carries no usable utterance. Empty/whitespace-only utterances are dropped
1252
+ * (TM-3 has a few); a single implausibly long utterance rejects the whole
1253
+ * conversation as corrupt rather than depositing a dump. */
1254
+ export function toTaskmasterTurns(row: unknown): TaskmasterTurn[] | null {
1255
+ if (!row || typeof row !== "object") return null;
1256
+ const utterances = (row as Record<string, unknown>).utterances;
1257
+ if (!Array.isArray(utterances)) return null;
1258
+ const turns: TaskmasterTurn[] = [];
1259
+ for (const u of utterances) {
1260
+ if (!u || typeof u !== "object") continue;
1261
+ const r = u as Record<string, unknown>;
1262
+ const text = typeof r.text === "string" ? r.text.trim() : "";
1263
+ if (!text) continue;
1264
+ if (text.length > MAX_TASKMASTER_TURN_CHARS) return null;
1265
+ turns.push({
1266
+ speaker: String(r.speaker ?? "").trim().toUpperCase(),
1267
+ text,
1268
+ });
1269
+ }
1270
+ return turns.length ? turns : null;
1271
+ }
1272
+
1273
+ /** Collapse consecutive same-speaker turns into one, joining with a space, and
1274
+ * return the bare texts in order. A turn with no speaker never merges with its
1275
+ * neighbour: an unlabelled row is of unknown origin, and joining two of them
1276
+ * would invent a contribution that may span two speakers. */
1277
+ export function mergeTaskmasterTurns(turns: TaskmasterTurn[]): string[] {
1278
+ const out: string[] = [];
1279
+ let prev = "";
1280
+ for (const t of turns) {
1281
+ if (out.length > 0 && t.speaker !== "" && t.speaker === prev) {
1282
+ out[out.length - 1] += " " + t.text;
1283
+ } else {
1284
+ out.push(t.text);
1285
+ }
1286
+ prev = t.speaker;
1287
+ }
1288
+ return out;
1289
+ }
1290
+
1291
+ /** Translate ONE Taskmaster conversation into SEMA training items: the
1292
+ * cumulative walk over its merged turns. Returns [] for a conversation below
1293
+ * TASKMASTER_MIN_TURNS, so callers can simply skip empties. */
1294
+ export function taskmasterConversationToItems(
1295
+ turns: TaskmasterTurn[],
1296
+ ): TrainingItem[] {
1297
+ const texts = mergeTaskmasterTurns(turns);
1298
+ if (texts.length < TASKMASTER_MIN_TURNS) return [];
1299
+ return refineItems(accumulate(texts));
1300
+ }
1301
+
1302
+ // ═══════════════════════════════════════════════════════════════════════
1303
+ // §6e″ 2WikiMultihopQA parsing — `evidences` TRIPLES → SEMA facts
1304
+ //
1305
+ // This is the only stage whose purpose is COMPOSITION: answering a question
1306
+ // whose answer no single deposited fact contains. Sema composes by grounding
1307
+ // hop 1, then pivoting on the longest unconsumed learnt context that the
1308
+ // grounded answer CONTAINS (`reason`/`pivotStep`), so the pivot target must
1309
+ // itself be a deposited context. Each triple therefore deposits TWO facts:
1310
+ //
1311
+ // "<subject> <relation>" → "The <relation> of <subject> is <object>."
1312
+ // "<subject>" → "The <relation> of <subject> is <object>."
1313
+ //
1314
+ // The second is the PIVOT FACT. Without it the bare entity naming hop 2's
1315
+ // subject is not a learnt context, so the chain is structurally unreachable no
1316
+ // matter what the rest of the pipeline does.
1317
+ //
1318
+ // MEASURED on 200 real chained dev rows, depositing triples only and asking the
1319
+ // dataset's own composed questions (D = 1024, seed 7):
1320
+ //
1321
+ // relation fact only 240 deposits 5/120 ( 4%) pivotStep 0
1322
+ // relation + pivot fact 800 deposits 44/200 (22%) pivotStep 31
1323
+ //
1324
+ // A 5x improvement, and the only variant where the second hop fires at all.
1325
+ //
1326
+ // REJECTED ALTERNATIVE, so it is not re-tried blind: depositing the pivot fact
1327
+ // only for subjects that also appear as an OBJECT within the same row's
1328
+ // evidences (a row-local "something can pivot into this" test) cut deposits 25%
1329
+ // (800 → 600) but cost composition — 41/200 (20.5%) with pivotStep down to 21,
1330
+ // because real chains also run BETWEEN rows. Composition is this stage's entire
1331
+ // justification, so the deposits are worth keeping.
1332
+ //
1333
+ // The residual ~78% is a KNOWN, previously-recorded limitation and not a defect
1334
+ // in this adapter: the climb elects a topic rather than a relation, so a
1335
+ // question phrased "When did X's father die?" does not align with the Wikidata
1336
+ // property label "date of death". Failure is dominated by hop 2 never firing,
1337
+ // not by a wrong hop 2. Answer-shape breakdown at N = 120: entity answers
1338
+ // 23/106, date answers 2/14 — dates are worse, but not the cliff an earlier
1339
+ // note suggested, which is why no object-shape filter is applied here.
1340
+ // ═══════════════════════════════════════════════════════════════════════
1341
+
1342
+ /** One (subject, relation, object) triple from a 2Wiki `evidences` cell. */
1343
+ export interface WikiTriple {
1344
+ subject: string;
1345
+ relation: string;
1346
+ object: string;
1347
+ }
1348
+
1349
+ /** Normalize a 2Wiki row into its evidence triples, or null when it carries
1350
+ * none usable. `evidences` is a JSON STRING holding an array of 3-element
1351
+ * arrays; a row whose cell is absent, unparseable, or empty yields null.
1352
+ * Individual malformed or oversized triples are dropped without discarding the
1353
+ * row — one bad triple should not cost the others. */
1354
+ export function toWikiTriples(row: unknown): WikiTriple[] | null {
1355
+ if (!row || typeof row !== "object") return null;
1356
+ const cell = (row as Record<string, unknown>).evidences;
1357
+ let parsed: unknown = cell;
1358
+ if (typeof cell === "string") {
1359
+ try {
1360
+ parsed = JSON.parse(cell);
1361
+ } catch {
1362
+ return null;
1363
+ }
1364
+ }
1365
+ if (!Array.isArray(parsed)) return null;
1366
+ const out: WikiTriple[] = [];
1367
+ for (const e of parsed) {
1368
+ if (!Array.isArray(e) || e.length < 3) continue;
1369
+ const subject = typeof e[0] === "string" ? e[0].trim() : "";
1370
+ const relation = typeof e[1] === "string" ? e[1].trim() : "";
1371
+ const object = typeof e[2] === "string" ? e[2].trim() : "";
1372
+ if (!subject || !relation || !object) continue;
1373
+ if (
1374
+ subject.length > MAX_WIKI2_FIELD_CHARS ||
1375
+ relation.length > MAX_WIKI2_FIELD_CHARS ||
1376
+ object.length > MAX_WIKI2_FIELD_CHARS
1377
+ ) continue;
1378
+ out.push({ subject, relation, object });
1379
+ }
1380
+ return out.length ? out : null;
1381
+ }
1382
+
1383
+ /** Render ONE triple as the prose fact Sema stores. Kept separate so the two
1384
+ * deposits below are guaranteed to share a byte-identical continuation: the
1385
+ * pivot fact only works if it leads to the SAME node the relation fact does. */
1386
+ export function wikiTripleSentence(t: WikiTriple): string {
1387
+ return `The ${t.relation} of ${t.subject} is ${t.object}.`;
1388
+ }
1389
+
1390
+ /** Translate a row's triples into SEMA items: per triple, the relation fact and
1391
+ * the bare-subject PIVOT fact (see the section note above). refineItems drops
1392
+ * the duplicates this produces when a row states the same triple twice. */
1393
+ export function wikiTriplesToItems(triples: WikiTriple[]): TrainingItem[] {
1394
+ const items: TrainingItem[] = [];
1395
+ for (const t of triples) {
1396
+ const fact = wikiTripleSentence(t);
1397
+ items.push({ context: `${t.subject} ${t.relation}`, continuation: fact });
1398
+ items.push({ context: t.subject, continuation: fact });
1399
+ }
1400
+ return refineItems(items);
1401
+ }
1402
+
1403
+ // ═══════════════════════════════════════════════════════════════════════
1404
+ // §6e‴ SODA parsing — a social dialogue row → SEMA items
1405
+ //
1406
+ // Each row carries `dialogue` (an array of turn strings) and `speakers` (the
1407
+ // speaker name per turn). The deposit is the cumulative walk over speaker-merged
1408
+ // turns, identical in shape to Taskmaster and oasst2 — turns are short (mean
1409
+ // 87 B) and dialogues average 7.3 turns, so the accumulated context stays well
1410
+ // inside the healthy range.
1411
+ //
1412
+ // `narrative`, `literal` and the ATOMIC-style `head`/`relation`/`tail` columns
1413
+ // are NOT deposited: they are the generation scaffolding SODA was distilled
1414
+ // from, they restate the dialogue in the third person, and depositing both a
1415
+ // dialogue and its paraphrased summary gives one meaning two shapes — which is
1416
+ // measured to SUPPRESS composition rather than help it.
1417
+ // ═══════════════════════════════════════════════════════════════════════
1418
+
1419
+ /** Normalize a SODA row into its turns, or null when it carries no usable
1420
+ * dialogue. Speakers are optional (they only drive merging); an implausibly
1421
+ * long turn rejects the dialogue as corrupt. */
1422
+ export function toSodaTurns(row: unknown): TaskmasterTurn[] | null {
1423
+ if (!row || typeof row !== "object") return null;
1424
+ const r = row as Record<string, unknown>;
1425
+ const dialogue = r.dialogue;
1426
+ if (!Array.isArray(dialogue)) return null;
1427
+ const speakers = Array.isArray(r.speakers) ? r.speakers : [];
1428
+ const turns: TaskmasterTurn[] = [];
1429
+ for (let i = 0; i < dialogue.length; i++) {
1430
+ const text = typeof dialogue[i] === "string"
1431
+ ? (dialogue[i] as string).trim()
1432
+ : "";
1433
+ if (!text) continue;
1434
+ if (text.length > MAX_SODA_TURN_CHARS) return null;
1435
+ turns.push({
1436
+ speaker: String(speakers[i] ?? "").trim().toUpperCase(),
1437
+ text,
1438
+ });
1439
+ }
1440
+ return turns.length ? turns : null;
1441
+ }
1442
+
1443
+ /** Translate ONE SODA dialogue into SEMA items: the cumulative walk over its
1444
+ * speaker-merged turns. Shares `mergeTaskmasterTurns` because the rule is the
1445
+ * same one — consecutive turns by one speaker are one contribution. */
1446
+ export function sodaDialogueToItems(turns: TaskmasterTurn[]): TrainingItem[] {
1447
+ const texts = mergeTaskmasterTurns(turns);
1448
+ if (texts.length < 2) return []; // not an exchange
1449
+ return refineItems(accumulate(texts));
1450
+ }
1451
+
1452
+ // ═══════════════════════════════════════════════════════════════════════
1453
+ // §6e⁗ MASSIVE parsing — one short utterance → ONE SEMA experience
1454
+ //
1455
+ // See the constants note for why this deposits a bare experience and not a
1456
+ // relation: the two relational shapes this corpus appears to offer are both
1457
+ // false (same-intent rows are not paraphrases; same-id rows across locales are
1458
+ // translations, SmolSent's worst-scoring relation).
1459
+ // ═══════════════════════════════════════════════════════════════════════
1460
+
1461
+ /** Translate ONE MASSIVE row into SEMA items: its bare utterance, as an
1462
+ * experience. `annot_utt` (slot-annotated) is deliberately not used — its
1463
+ * "[date : ...]" markup is not prose. Returns [] for an unusable row. */
1464
+ export function massiveRowToItems(row: unknown): TrainingItem[] {
1465
+ if (!row || typeof row !== "object") return [];
1466
+ const utt = (row as Record<string, unknown>).utt;
1467
+ const text = typeof utt === "string" ? utt.trim() : "";
1468
+ if (!text || text.length > MAX_MASSIVE_UTT_CHARS) return [];
1469
+ return refineItems([text]);
1470
+ }
1471
+
929
1472
  // ═══════════════════════════════════════════════════════════════════════
930
1473
  // §6f General-Knowledge parsing — a {Question, Answer} row → SEMA fact
931
1474
  //
@@ -1142,6 +1685,77 @@ async function listSmolSentFiles(): Promise<string[]> {
1142
1685
  return paths.filter((p) => want.has(basename(p).replace(/\.jsonl$/i, "")));
1143
1686
  }
1144
1687
 
1688
+ /** List the Taskmaster data files to train, in TASKMASTER_SETS order. Returns
1689
+ * repo-relative paths, e.g. "TM-3-2020/data/data_00.json".
1690
+ *
1691
+ * TM-2/3/4 keep their dialogue files under `<set>/data`, so everything there is
1692
+ * fair game. TM-1 has no `data` directory: its two dialogue files sit at the
1693
+ * set root NEXT TO `ontology.json` (a slot schema) and `sample.json` (a small
1694
+ * excerpt of self-dialogs). Neither is an array of conversations, and training
1695
+ * the excerpt would deposit a subset of TM-1 twice, so TM-1 is filtered to the
1696
+ * `*-dialogs.json` pair (self-dialogs, woz-dialogs). */
1697
+ async function listTaskmasterFiles(): Promise<
1698
+ Array<{ set: string; path: string }>
1699
+ > {
1700
+ const out: Array<{ set: string; path: string }> = [];
1701
+ for (const set of TASKMASTER_SETS) {
1702
+ const rootOnly = /^TM-1\b/i.test(set);
1703
+ const dir = rootOnly ? set : `${set}/data`;
1704
+ const body = await getJson(
1705
+ `https://api.github.com/repos/${TASKMASTER_REPO}/contents/${dir}`,
1706
+ `GET Taskmaster ${dir}`,
1707
+ );
1708
+ const names: string[] = Array.isArray(body)
1709
+ ? body
1710
+ .filter((e: any) => e?.type === "file" && /\.json$/i.test(e?.name))
1711
+ .map((e: any) => String(e.name))
1712
+ : [];
1713
+ names.sort();
1714
+ for (const name of names) {
1715
+ if (rootOnly && !/-dialogs\.json$/i.test(name)) continue;
1716
+ out.push({ set, path: `${dir}/${name}` });
1717
+ }
1718
+ }
1719
+ return out;
1720
+ }
1721
+
1722
+ /** List a dataset's Parquet shards on Hugging Face's auto-converted
1723
+ * `refs/convert/parquet` branch, restricted to `config` and to `splits`.
1724
+ *
1725
+ * Shared by 2Wiki, SODA and MASSIVE. The converted branch is used rather than
1726
+ * `main` because a dataset's own Parquet may be written as ONE giant row-group
1727
+ * (SODA's is 1,191,582 rows), and a column chunk is per-group, so reading any
1728
+ * part of it materialises all of it. The converted branch is uniformly
1729
+ * 10,000-row groups. See test/79-parquet-batching.test.mjs.
1730
+ *
1731
+ * Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
1732
+ * path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
1733
+ * whose "/" must not be. */
1734
+ async function listConvertedParquet(
1735
+ dataset: string,
1736
+ config: string,
1737
+ splits: string[],
1738
+ label: string,
1739
+ ): Promise<string[]> {
1740
+ const body = await getJson(
1741
+ `https://huggingface.co/api/datasets/${dataset}` +
1742
+ `/tree/refs%2Fconvert%2Fparquet/${config}?recursive=true`,
1743
+ `GET ${label} tree`,
1744
+ );
1745
+ const paths: string[] = Array.isArray(body)
1746
+ ? body
1747
+ .filter((e: any) => e?.type === "file" && /\.parquet$/i.test(e?.path))
1748
+ .map((e: any) => String(e.path))
1749
+ : [];
1750
+ paths.sort();
1751
+ const want = new Set(splits);
1752
+ return paths.filter((p) => {
1753
+ const parts = p.split("/");
1754
+ // The tree is rooted at `config`, so the split is the second-to-last part.
1755
+ return want.has(parts[parts.length - 2] ?? "");
1756
+ });
1757
+ }
1758
+
1145
1759
  /** Stream a plain-JSONL file from disk, deposit each parsed row via `toItems`.
1146
1760
  * Lines are split without buffering the whole file; an oversize/malformed line
1147
1761
  * is counted skipped and the stream continues. Shared by SmolSent (and any
@@ -1227,16 +1841,59 @@ async function processJsonl(
1227
1841
  }
1228
1842
  }
1229
1843
 
1230
- /** Read a downloaded Parquet file row-group by row-group with hyparquet (+Snappy
1844
+ /** How many rows to materialise in one read from a row-group of `rgRows` rows
1845
+ * occupying `groupBytes` uncompressed bytes, under a `budgetBytes` target.
1846
+ *
1847
+ * The group's own footer statistics give the mean row width, so the batch
1848
+ * follows the CORPUS's row size rather than the writer's layout: wide rows
1849
+ * (SODA carries a whole dialogue per row) batch smaller than narrow ones at
1850
+ * the same memory cost. Never exceeds the group — a batch is a subdivision of
1851
+ * a group, never a span across two, because `parquetReadObjects` is given an
1852
+ * absolute row range and column chunks are per-group. Never returns 0, or the
1853
+ * read loop could not advance.
1854
+ *
1855
+ * A writer that omits `total_byte_size` yields `groupBytes <= 0`; the batch is
1856
+ * then the whole group, which is exactly the behaviour this replaced. That
1857
+ * fallback is safe for every file we read today (all three report it) and
1858
+ * degrades to the old memory profile rather than to a wrong result. */
1859
+ export function parquetBatchRows(
1860
+ rgRows: number,
1861
+ groupBytes: number,
1862
+ budgetBytes: number,
1863
+ ): number {
1864
+ if (!(rgRows > 0)) return 0; // empty group — the caller skips it
1865
+ if (!(groupBytes > 0) || !Number.isFinite(groupBytes)) return rgRows;
1866
+ const perRow = groupBytes / rgRows;
1867
+ const fit = Math.floor(budgetBytes / perRow);
1868
+ return Math.min(rgRows, Math.max(1, fit));
1869
+ }
1870
+
1871
+ /** Read a downloaded Parquet file in bounded row batches with hyparquet (+Snappy
1231
1872
  * from hyparquet-compressors) over a web-standard Blob byte source, depositing
1232
- * each row via `toItems`. Only one row-group is materialised at a time, so a
1233
- * multi-hundred-MB file never loads whole into memory. */
1873
+ * each row via `toItems`. At most `PARQUET_BATCH_BYTES` of source rows are
1874
+ * materialised at a time, so neither a multi-hundred-MB file nor a file written
1875
+ * as ONE giant row-group loads whole into memory.
1876
+ *
1877
+ * Batching also makes a single-group file INTERRUPTIBLE: the abort check runs
1878
+ * per batch, where before a 1.19M-row group could not be cancelled at all. */
1234
1879
  async function processParquet(
1235
1880
  filePath: string,
1236
1881
  toItems: (row: unknown) => TrainingItem[] | null,
1237
1882
  ci: CachedIngest,
1238
1883
  onExample: (contentBytes: number) => Promise<boolean>,
1239
1884
  sample: (it: TrainingItem) => void,
1885
+ // Optional stage-level stop, checked per row and before each batch is
1886
+ // decoded. A stage BUDGET must stop the read rather than reject rows: left to
1887
+ // reject, a budgeted stage still DECODES every remaining row-group — 143,346
1888
+ // rows of one 86.7 MB SODA shard — and reports them as "unusable" when
1889
+ // nothing was wrong with them, which is a lie in the run log.
1890
+ //
1891
+ // Measured honestly: on that shard the wall time did NOT improve (2m 35s ->
1892
+ // 2m 37s), because a budgeted run is dominated by depositing the rows it DID
1893
+ // take, not by scanning past the ones it did not. The win here is a truthful
1894
+ // log and the CPU/allocation of ~143k skipped row decodes, not elapsed time.
1895
+ // A larger shard past a small budget is where the decode cost would show.
1896
+ shouldStop?: () => boolean,
1240
1897
  ): Promise<FileResult> {
1241
1898
  const blob = await openAsBlob(filePath);
1242
1899
  const file = {
@@ -1248,28 +1905,39 @@ async function processParquet(
1248
1905
  let examples = 0, skipped = 0;
1249
1906
  let rowStart = 0;
1250
1907
  for (const rg of meta.row_groups) {
1251
- if (shutdown.signal.aborted) return { examples, stopped: true, skipped };
1252
1908
  const rgRows = Number(rg.num_rows);
1253
- const rowEnd = rowStart + rgRows;
1254
- // Materialise exactly one row-group, then deposit its rows.
1255
- const rows = await parquetReadObjects({
1256
- file,
1257
- compressors,
1258
- rowStart,
1259
- rowEnd,
1260
- });
1261
- rowStart = rowEnd;
1262
- for (const row of rows) {
1263
- const items = toItems(row);
1264
- if (!items || items.length === 0) {
1265
- skipped++;
1266
- continue;
1909
+ const rgEnd = rowStart + rgRows;
1910
+ const batchRows = parquetBatchRows(
1911
+ rgRows,
1912
+ Number(rg.total_byte_size ?? 0),
1913
+ PARQUET_BATCH_BYTES,
1914
+ );
1915
+ if (batchRows <= 0) continue; // empty group
1916
+ // Materialise one bounded batch at a time, then deposit its rows.
1917
+ while (rowStart < rgEnd) {
1918
+ if (shutdown.signal.aborted) return { examples, stopped: true, skipped };
1919
+ if (shouldStop?.()) return { examples, stopped: true, skipped };
1920
+ const rowEnd = Math.min(rowStart + batchRows, rgEnd);
1921
+ const rows = await parquetReadObjects({
1922
+ file,
1923
+ compressors,
1924
+ rowStart,
1925
+ rowEnd,
1926
+ });
1927
+ rowStart = rowEnd;
1928
+ for (const row of rows) {
1929
+ if (shouldStop?.()) return { examples, stopped: true, skipped };
1930
+ const items = toItems(row);
1931
+ if (!items || items.length === 0) {
1932
+ skipped++;
1933
+ continue;
1934
+ }
1935
+ const ok = await ingestItems(ci, items, async (contentBytes) => {
1936
+ examples++;
1937
+ return onExample(contentBytes);
1938
+ }, sample);
1939
+ if (!ok) return { examples, stopped: true, skipped };
1267
1940
  }
1268
- const ok = await ingestItems(ci, items, async (contentBytes) => {
1269
- examples++;
1270
- return onExample(contentBytes);
1271
- }, sample);
1272
- if (!ok) return { examples, stopped: true, skipped };
1273
1941
  }
1274
1942
  }
1275
1943
  return { examples, stopped: false, skipped };
@@ -1683,7 +2351,10 @@ async function main(): Promise<void> {
1683
2351
  process.exit(1);
1684
2352
  }
1685
2353
 
1686
- await store.setMeta("train.dataset", "SmolSent+Aya+oasst2");
2354
+ await store.setMeta(
2355
+ "train.dataset",
2356
+ "SmolSent+Aya+oasst2+Taskmaster+2Wiki+SODA+MASSIVE",
2357
+ );
1687
2358
  await store.setMeta("train.D", String(D));
1688
2359
  await store.setMeta("train.seed", String(SEED));
1689
2360
  await store.setMeta("train.createdAt", new Date().toISOString());
@@ -2581,6 +3252,478 @@ async function main(): Promise<void> {
2581
3252
  const r = toGenKnowRow(row);
2582
3253
  return r ? genKnowRowToItems(r) : null;
2583
3254
  };
3255
+ // ── §10d′ Taskmaster 1–4 stage (task-oriented dialogue; runs AFTER oasst2) ──
3256
+ //
3257
+ // Lists the repo's data files, then for each: download, parse the JSON array,
3258
+ // deposit one cumulative walk per conversation. Per-FILE resume ids, like
3259
+ // SmolSent — an interrupted file is re-read from the top on resume, and
3260
+ // re-deposition is idempotent. LOCAL_PATH/taskmaster/ may hold pre-downloaded
3261
+ // *.json (a subdirectory, because these share the .json extension with the
3262
+ // General-Knowledge source and must not be confused with it).
3263
+ const tmToItems = (row: unknown): TrainingItem[] | null => {
3264
+ const turns = toTaskmasterTurns(row);
3265
+ if (!turns) return null;
3266
+ const items = taskmasterConversationToItems(turns); // [] when too short
3267
+ return items.length ? items : null;
3268
+ };
3269
+
3270
+ const trainTaskmaster = async (): Promise<void> => {
3271
+ if (!TASKMASTER) return;
3272
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) return;
3273
+
3274
+ let files: Array<
3275
+ { id: string; name: string; local?: string; url?: string }
3276
+ >;
3277
+ if (LOCAL_PATH) {
3278
+ const dir = join(LOCAL_PATH, "taskmaster");
3279
+ let names: string[] = [];
3280
+ try {
3281
+ names = readdirSync(dir).filter((f: string) => /\.json$/i.test(f))
3282
+ .sort();
3283
+ } catch { /* no local taskmaster dir */ }
3284
+ if (names.length === 0) {
3285
+ progress.log(
3286
+ ` ${DIM}· no Taskmaster *.json in ${dir} — skipping${R}`,
3287
+ );
3288
+ return;
3289
+ }
3290
+ files = names.map((f: string) => ({
3291
+ id: `taskmaster::${f}`,
3292
+ name: f,
3293
+ local: join(dir, f),
3294
+ }));
3295
+ } else {
3296
+ let listed: Array<{ set: string; path: string }>;
3297
+ try {
3298
+ listed = await listTaskmasterFiles();
3299
+ } catch (e) {
3300
+ if (stopRequested || (e as Error)?.name === "AbortError") return;
3301
+ progress.log(
3302
+ ` ${RED}✗${R} Taskmaster file listing failed: ${
3303
+ (e as Error).message
3304
+ }`,
3305
+ );
3306
+ return;
3307
+ }
3308
+ files = listed.map((f) => ({
3309
+ id: `taskmaster::${f.path}`,
3310
+ name: `${f.set}/${basename(f.path)}`,
3311
+ url: `${TASKMASTER_RAW}/${f.path}`,
3312
+ }));
3313
+ }
3314
+ if (files.length === 0) {
3315
+ progress.log(` ${DIM}· no Taskmaster files found — skipping${R}`);
3316
+ return;
3317
+ }
3318
+
3319
+ const p = await loadProgress(store);
3320
+ const done = new Set(p.completedFiles);
3321
+ const remaining = files.filter((f) => !done.has(f.id));
3322
+ if (remaining.length === 0) {
3323
+ progress.log(` ${DIM}· Taskmaster already trained — skipping${R}`);
3324
+ return;
3325
+ }
3326
+ state.fileTotal = files.length;
3327
+ progress.log(
3328
+ ` ${GRN}✓${R} Taskmaster: ${remaining.length}/${files.length} dialogue file(s) to train`,
3329
+ );
3330
+
3331
+ let idx = 0;
3332
+ for (const f of files) {
3333
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) break;
3334
+ idx++;
3335
+ if (done.has(f.id)) continue;
3336
+
3337
+ let path = f.local ?? "";
3338
+ let downloaded = false;
3339
+ if (!path) {
3340
+ const got = await acquire(
3341
+ f.url!,
3342
+ f.id.replace(/[^A-Za-z0-9._-]+/g, "_"),
3343
+ `Taskmaster ${f.name}`,
3344
+ );
3345
+ if (!got) {
3346
+ if (stopRequested) break;
3347
+ continue; // a single failed file never aborts the stage
3348
+ }
3349
+ path = got;
3350
+ downloaded = true;
3351
+ }
3352
+
3353
+ try {
3354
+ totalCorpusBytes += statSync(path).size;
3355
+ } catch { /* best effort */ }
3356
+
3357
+ state.activity = "process";
3358
+ state.fileIndex = idx;
3359
+ state.filePath = `Taskmaster ${f.name}`;
3360
+ state.fileExamples = 0;
3361
+ tick(true);
3362
+ const p0 = Date.now();
3363
+ let res: FileResult;
3364
+ try {
3365
+ res = await processJsonArray(path, tmToItems, ci, onDeposit, sample);
3366
+ } catch (e) {
3367
+ if (stopRequested || (e as Error)?.name === "AbortError") break;
3368
+ progress.log(
3369
+ ` ${RED}✗${R} Taskmaster ${f.name} parse failed: ${
3370
+ (e as Error).message
3371
+ }`,
3372
+ );
3373
+ if (downloaded) {
3374
+ try {
3375
+ unlinkSync(path);
3376
+ } catch { /* best effort */ }
3377
+ }
3378
+ continue;
3379
+ }
3380
+ langTally["taskmaster"] = (langTally["taskmaster"] ?? 0) + res.examples;
3381
+ progress.log(
3382
+ ` ${GRN}✓${R} ${f.name} ${DIM}[task dialogue]${R} → ${
3383
+ int(res.examples)
3384
+ } facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
3385
+ (res.skipped
3386
+ ? ` ${YEL}· ${
3387
+ int(res.skipped)
3388
+ } unusable conversation(s) skipped${R}`
3389
+ : "") +
3390
+ (res.stopped ? ` ${YEL}(stopped early)${R}` : ""),
3391
+ );
3392
+
3393
+ if (!res.stopped) {
3394
+ try {
3395
+ totalBytesProcessed += statSync(path).size;
3396
+ } catch { /* best effort */ }
3397
+ if (downloaded) {
3398
+ try {
3399
+ unlinkSync(path);
3400
+ } catch { /* best effort */ }
3401
+ }
3402
+ done.add(f.id);
3403
+ p.completedFiles.push(f.id);
3404
+ }
3405
+ try {
3406
+ await saveProgress(store, {
3407
+ completedFiles: p.completedFiles,
3408
+ depositCount,
3409
+ trainedContentBytes,
3410
+ totalBytesProcessed,
3411
+ totalCorpusBytes,
3412
+ });
3413
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
3414
+ } catch { /* best effort — finish() will retry */ }
3415
+ if (res.stopped) break; // cap/signal — leave file un-completed for resume
3416
+ }
3417
+ };
3418
+
3419
+ // ── §10d″ 2WikiMultihopQA stage (composition; runs AFTER Taskmaster) ──
3420
+ //
3421
+ // Only the `evidences` column is ever touched — see §6e″ for why `context`
3422
+ // and `question`/`answer` are not.
3423
+ const wiki2ToItems = (row: unknown): TrainingItem[] | null => {
3424
+ const triples = toWikiTriples(row);
3425
+ if (!triples) return null;
3426
+ const items = wikiTriplesToItems(triples);
3427
+ return items.length ? items : null;
3428
+ };
3429
+
3430
+ const trainWiki2 = (): Promise<void> =>
3431
+ runConvertedParquetStage({
3432
+ enabled: WIKI2,
3433
+ label: "2Wiki",
3434
+ tally: "2wiki",
3435
+ kind: "relation triples",
3436
+ dataset: WIKI2_DATASET,
3437
+ config: "default",
3438
+ splits: WIKI2_SPLITS,
3439
+ localDir: "2wiki",
3440
+ maxRows: WIKI2_MAX_ROWS,
3441
+ toItems: wiki2ToItems,
3442
+ });
3443
+
3444
+ // ── §10d‴ Converted-Parquet stage runner (2Wiki, SODA, MASSIVE) ──
3445
+ //
3446
+ // These three differ only in their name, their row adapter and their row
3447
+ // budget, so they share one runner instead of three copies of the per-shard
3448
+ // loop. Resume is per SHARD, as everywhere else.
3449
+ //
3450
+ // The BUDGET is applied here rather than inside an adapter because it is a
3451
+ // curriculum decision about corpus MIX, not a property of a row: SODA's train
3452
+ // split would otherwise contribute ~8M episodes against the 662,221 deposits
3453
+ // of the whole current corpus. A budgeted stage never marks its remaining
3454
+ // shards complete, so raising the budget later resumes rather than restarts.
3455
+ const runConvertedParquetStage = async (opts: {
3456
+ enabled: boolean;
3457
+ label: string; // human name, e.g. "SODA"
3458
+ tally: string; // langTally key
3459
+ kind: string; // dim tag in the log line, e.g. "social dialogue"
3460
+ dataset: string;
3461
+ config: string;
3462
+ splits: string[];
3463
+ localDir: string; // subdirectory of LOCAL_PATH
3464
+ maxRows: number; // 0 = no budget
3465
+ toItems: (row: unknown) => TrainingItem[] | null;
3466
+ }): Promise<void> => {
3467
+ if (!opts.enabled) return;
3468
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) return;
3469
+
3470
+ let files: Array<
3471
+ { id: string; name: string; local?: string; url?: string }
3472
+ >;
3473
+ if (LOCAL_PATH) {
3474
+ const dir = join(LOCAL_PATH, opts.localDir);
3475
+ let names: string[] = [];
3476
+ try {
3477
+ names = readdirSync(dir).filter((f: string) => /\.parquet$/i.test(f))
3478
+ .sort();
3479
+ } catch { /* no local dir for this stage */ }
3480
+ if (names.length === 0) {
3481
+ progress.log(
3482
+ ` ${DIM}· no ${opts.label} *.parquet in ${dir} — skipping${R}`,
3483
+ );
3484
+ return;
3485
+ }
3486
+ files = names.map((f: string) => ({
3487
+ id: `${opts.tally}::${f}`,
3488
+ name: f,
3489
+ local: join(dir, f),
3490
+ }));
3491
+ } else {
3492
+ let paths: string[];
3493
+ try {
3494
+ paths = await listConvertedParquet(
3495
+ opts.dataset,
3496
+ opts.config,
3497
+ opts.splits,
3498
+ opts.label,
3499
+ );
3500
+ } catch (e) {
3501
+ if (stopRequested || (e as Error)?.name === "AbortError") return;
3502
+ progress.log(
3503
+ ` ${RED}✗${R} ${opts.label} file listing failed: ${
3504
+ (e as Error).message
3505
+ }`,
3506
+ );
3507
+ return;
3508
+ }
3509
+ files = paths.map((path) => ({
3510
+ id: `${opts.tally}::${path}`,
3511
+ name: path,
3512
+ url: `https://huggingface.co/datasets/${opts.dataset}` +
3513
+ `/resolve/refs%2Fconvert%2Fparquet/${path}`,
3514
+ }));
3515
+ }
3516
+ if (files.length === 0) {
3517
+ progress.log(` ${DIM}· no ${opts.label} files found — skipping${R}`);
3518
+ return;
3519
+ }
3520
+
3521
+ const p = await loadProgress(store);
3522
+ const done = new Set(p.completedFiles);
3523
+ // A budget-limited stage never marks its later shards complete — that is
3524
+ // what lets a raised budget resume instead of restarting. But it also means
3525
+ // "every shard complete" is NOT how such a stage finishes, so without a
3526
+ // marker of its own a satisfied budget would re-read and re-deposit its
3527
+ // rows on every subsequent run: harmless to the store (deposition is
3528
+ // idempotent) but it repeats the work and double-counts langTally.
3529
+ //
3530
+ // The marker carries the budget it was satisfied AT, so raising the budget
3531
+ // still resumes: a bigger budget does not match the marker and the stage
3532
+ // runs again, re-reading rows it already holds (idempotent) and adding the
3533
+ // new ones.
3534
+ const budgetMark = opts.maxRows > 0
3535
+ ? `${opts.tally}::budget=${opts.maxRows}`
3536
+ : "";
3537
+ if (budgetMark && done.has(budgetMark)) {
3538
+ progress.log(
3539
+ ` ${DIM}· ${opts.label} budget of ${
3540
+ int(opts.maxRows)
3541
+ } row(s) already met — skipping${R}`,
3542
+ );
3543
+ return;
3544
+ }
3545
+ const remaining = files.filter((f) => !done.has(f.id));
3546
+ if (remaining.length === 0) {
3547
+ progress.log(` ${DIM}· ${opts.label} already trained — skipping${R}`);
3548
+ return;
3549
+ }
3550
+ state.fileTotal = files.length;
3551
+ progress.log(
3552
+ ` ${GRN}✓${R} ${opts.label}: ${remaining.length}/${files.length} shard(s) to train` +
3553
+ (opts.maxRows > 0
3554
+ ? ` ${DIM}(budget ${int(opts.maxRows)} rows)${R}`
3555
+ : ""),
3556
+ );
3557
+
3558
+ // The budget spans the whole stage, not one shard, so it is counted here.
3559
+ let rowsTaken = 0;
3560
+ const spent = () => opts.maxRows > 0 && rowsTaken >= opts.maxRows;
3561
+ const budgeted = (row: unknown): TrainingItem[] | null => {
3562
+ const items = opts.toItems(row);
3563
+ if (!items || items.length === 0) return null;
3564
+ rowsTaken++;
3565
+ return items;
3566
+ };
3567
+
3568
+ let idx = 0;
3569
+ for (const f of files) {
3570
+ if (trainedContentBytes >= MAX_BYTES || stopRequested) break;
3571
+ if (opts.maxRows > 0 && rowsTaken >= opts.maxRows) break;
3572
+ idx++;
3573
+ if (done.has(f.id)) continue;
3574
+
3575
+ let path = f.local ?? "";
3576
+ let downloaded = false;
3577
+ if (!path) {
3578
+ const got = await acquire(
3579
+ f.url!,
3580
+ f.id.replace(/[^A-Za-z0-9._-]+/g, "_"),
3581
+ `${opts.label} ${f.name}`,
3582
+ );
3583
+ if (!got) {
3584
+ if (stopRequested) break;
3585
+ continue; // a single failed shard never aborts the stage
3586
+ }
3587
+ path = got;
3588
+ downloaded = true;
3589
+ }
3590
+
3591
+ try {
3592
+ totalCorpusBytes += statSync(path).size;
3593
+ } catch { /* best effort */ }
3594
+
3595
+ state.activity = "process";
3596
+ state.fileIndex = idx;
3597
+ state.filePath = `${opts.label} ${f.name}`;
3598
+ state.fileExamples = 0;
3599
+ tick(true);
3600
+ const p0 = Date.now();
3601
+ const before = rowsTaken;
3602
+ let res: FileResult;
3603
+ try {
3604
+ res = await processParquet(
3605
+ path,
3606
+ budgeted,
3607
+ ci,
3608
+ onDeposit,
3609
+ sample,
3610
+ spent,
3611
+ );
3612
+ } catch (e) {
3613
+ if (stopRequested || (e as Error)?.name === "AbortError") break;
3614
+ progress.log(
3615
+ ` ${RED}✗${R} ${opts.label} ${f.name} parse failed: ${
3616
+ (e as Error).message
3617
+ }`,
3618
+ );
3619
+ if (downloaded) {
3620
+ try {
3621
+ unlinkSync(path);
3622
+ } catch { /* best effort */ }
3623
+ }
3624
+ continue;
3625
+ }
3626
+ // A shard cut short by the BUDGET is not "done" — leave it resumable so a
3627
+ // later run with a bigger budget continues instead of starting over.
3628
+ const hitBudget = spent();
3629
+ langTally[opts.tally] = (langTally[opts.tally] ?? 0) + res.examples;
3630
+ progress.log(
3631
+ ` ${GRN}✓${R} ${f.name} ${DIM}[${opts.kind}]${R} → ${
3632
+ int(res.examples)
3633
+ } facts ${DIM}from ${int(rowsTaken - before)} row(s) in ${
3634
+ dur((Date.now() - p0) / 1000)
3635
+ }${R}` +
3636
+ (res.skipped
3637
+ ? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
3638
+ : "") +
3639
+ (hitBudget
3640
+ ? ` ${YEL}(budget reached)${R}`
3641
+ : res.stopped
3642
+ ? ` ${YEL}(stopped early)${R}`
3643
+ : ""),
3644
+ );
3645
+
3646
+ if (!res.stopped && !hitBudget) {
3647
+ try {
3648
+ totalBytesProcessed += statSync(path).size;
3649
+ } catch { /* best effort */ }
3650
+ if (downloaded) {
3651
+ try {
3652
+ unlinkSync(path);
3653
+ } catch { /* best effort */ }
3654
+ }
3655
+ done.add(f.id);
3656
+ p.completedFiles.push(f.id);
3657
+ }
3658
+ try {
3659
+ await saveProgress(store, {
3660
+ completedFiles: p.completedFiles,
3661
+ depositCount,
3662
+ trainedContentBytes,
3663
+ totalBytesProcessed,
3664
+ totalCorpusBytes,
3665
+ });
3666
+ await store.setMeta("train.langTally", JSON.stringify(langTally));
3667
+ } catch { /* best effort — finish() will retry */ }
3668
+ // A budget stop is not a cap/signal stop: the stage is finished, so fall
3669
+ // out of the loop rather than treating it as an interruption.
3670
+ if (res.stopped && !hitBudget) break;
3671
+ }
3672
+
3673
+ // Record a satisfied budget so the next run skips this stage instead of
3674
+ // re-reading it. Only when the budget was actually reached: a stage that
3675
+ // ran out of shards first is complete by the normal per-shard rule, and a
3676
+ // stage cut short by MAX_MB or Ctrl+C must stay resumable.
3677
+ if (budgetMark && spent() && !stopRequested && !done.has(budgetMark)) {
3678
+ p.completedFiles.push(budgetMark);
3679
+ try {
3680
+ await saveProgress(store, {
3681
+ completedFiles: p.completedFiles,
3682
+ depositCount,
3683
+ trainedContentBytes,
3684
+ totalBytesProcessed,
3685
+ totalCorpusBytes,
3686
+ });
3687
+ } catch { /* best effort — finish() will retry */ }
3688
+ }
3689
+ };
3690
+
3691
+ const trainSoda = (): Promise<void> =>
3692
+ runConvertedParquetStage({
3693
+ enabled: SODA,
3694
+ label: "SODA",
3695
+ tally: "soda",
3696
+ kind: "social dialogue",
3697
+ dataset: SODA_DATASET,
3698
+ config: "default",
3699
+ splits: SODA_SPLITS,
3700
+ localDir: "soda",
3701
+ maxRows: SODA_MAX_DIALOGS,
3702
+ toItems: (row) => {
3703
+ const turns = toSodaTurns(row);
3704
+ if (!turns) return null;
3705
+ const items = sodaDialogueToItems(turns);
3706
+ return items.length ? items : null;
3707
+ },
3708
+ });
3709
+
3710
+ const trainMassive = (): Promise<void> =>
3711
+ runConvertedParquetStage({
3712
+ enabled: MASSIVE,
3713
+ label: "MASSIVE",
3714
+ tally: "massive",
3715
+ kind: "short intents",
3716
+ dataset: MASSIVE_DATASET,
3717
+ config: MASSIVE_CONFIG,
3718
+ splits: MASSIVE_SPLITS,
3719
+ localDir: "massive",
3720
+ maxRows: MASSIVE_MAX_ROWS,
3721
+ toItems: (row) => {
3722
+ const items = massiveRowToItems(row);
3723
+ return items.length ? items : null;
3724
+ },
3725
+ });
3726
+
2584
3727
  const trainGenKnow = async (): Promise<void> => {
2585
3728
  if (!GENKNOW) return;
2586
3729
  if (trainedContentBytes >= MAX_BYTES || stopRequested) return;
@@ -2714,6 +3857,10 @@ async function main(): Promise<void> {
2714
3857
  if (!stopRequested) await trainSmolSent();
2715
3858
  if (!stopRequested) await trainAya();
2716
3859
  if (!stopRequested) await trainOasst();
3860
+ if (!stopRequested) await trainTaskmaster();
3861
+ if (!stopRequested) await trainWiki2();
3862
+ if (!stopRequested) await trainSoda();
3863
+ if (!stopRequested) await trainMassive();
2717
3864
  if (!stopRequested) await trainGenKnow();
2718
3865
 
2719
3866
  await finish(stopRequested ? stopReason : "done");