@hviana/sema 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/AGENTS.md +20 -4
  2. package/DATASETS.md +12 -11
  3. package/dist/example/train_base/cache.d.ts +35 -0
  4. package/dist/example/train_base/cache.js +211 -0
  5. package/dist/example/train_base/config.d.ts +21 -0
  6. package/dist/example/train_base/config.js +94 -0
  7. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  8. package/dist/example/train_base/corpora/aya.js +76 -0
  9. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  10. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  11. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  12. package/dist/example/train_base/corpora/genknow.js +83 -0
  13. package/dist/example/train_base/corpora/index.d.ts +29 -0
  14. package/dist/example/train_base/corpora/index.js +81 -0
  15. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  16. package/dist/example/train_base/corpora/massive.js +98 -0
  17. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  18. package/dist/example/train_base/corpora/oasst2.js +120 -0
  19. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  20. package/dist/example/train_base/corpora/smolsent.js +156 -0
  21. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  22. package/dist/example/train_base/corpora/soda.js +113 -0
  23. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  24. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  25. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  26. package/dist/example/train_base/corpora/wiki2.js +132 -0
  27. package/dist/example/train_base/corpus.d.ts +88 -0
  28. package/dist/example/train_base/corpus.js +65 -0
  29. package/dist/example/train_base/discovery.d.ts +48 -0
  30. package/dist/example/train_base/discovery.js +143 -0
  31. package/dist/example/train_base/http.d.ts +82 -0
  32. package/dist/example/train_base/http.js +219 -0
  33. package/dist/example/train_base/items.d.ts +46 -0
  34. package/dist/example/train_base/items.js +98 -0
  35. package/dist/example/train_base/main.d.ts +4 -0
  36. package/dist/example/train_base/main.js +207 -0
  37. package/dist/example/train_base/progress.d.ts +34 -0
  38. package/dist/example/train_base/progress.js +114 -0
  39. package/dist/example/train_base/readers.d.ts +125 -0
  40. package/dist/example/train_base/readers.js +391 -0
  41. package/dist/example/train_base/runtime.d.ts +115 -0
  42. package/dist/example/train_base/runtime.js +637 -0
  43. package/dist/example/train_base/stage.d.ts +3 -0
  44. package/dist/example/train_base/stage.js +246 -0
  45. package/dist/example/train_base/ui.d.ts +88 -0
  46. package/dist/example/train_base/ui.js +272 -0
  47. package/dist/src/mind/mind.d.ts +1 -1
  48. package/dist/src/mind/mind.js +1 -1
  49. package/example/train_base/cache.ts +251 -0
  50. package/example/train_base/config.ts +128 -0
  51. package/example/train_base/corpora/aya.ts +106 -0
  52. package/example/train_base/corpora/converted-parquet.ts +64 -0
  53. package/example/train_base/corpora/genknow.ts +114 -0
  54. package/example/train_base/corpora/index.ts +88 -0
  55. package/example/train_base/corpora/massive.ts +111 -0
  56. package/example/train_base/corpora/oasst2.ts +163 -0
  57. package/example/train_base/corpora/smolsent.ts +203 -0
  58. package/example/train_base/corpora/soda.ts +130 -0
  59. package/example/train_base/corpora/taskmaster.ts +217 -0
  60. package/example/train_base/corpora/wiki2.ts +190 -0
  61. package/example/train_base/corpus.ts +150 -0
  62. package/example/train_base/discovery.ts +203 -0
  63. package/example/train_base/http.ts +284 -0
  64. package/example/train_base/items.ts +118 -0
  65. package/example/train_base/main.ts +240 -0
  66. package/example/train_base/progress.ts +149 -0
  67. package/example/train_base/readers.ts +505 -0
  68. package/example/train_base/runtime.ts +894 -0
  69. package/example/train_base/stage.ts +276 -0
  70. package/example/train_base/ui.ts +333 -0
  71. package/jsr.json +1 -1
  72. package/package.json +2 -4
  73. package/src/mind/mind.ts +1 -1
  74. package/test/13-conversation.test.mjs +1 -1
  75. package/test/84-composed-answer-honesty.test.mjs +2 -1
  76. package/test/88-dependency-footprint.test.mjs +99 -0
  77. package/dist/example/train_base.d.ts +0 -163
  78. package/dist/example/train_base.js +0 -3220
  79. package/example/train_base.ts +0 -3882
@@ -0,0 +1,130 @@
1
+ // train_base/corpora/soda.ts — allenai/soda social dialogue
2
+ //
3
+ // Knobs, the row adapter, and the stage descriptor for ONE corpus, together
4
+ // with the evidence that fixed each default. A corpus file owns everything
5
+ // source-specific; the contract it fills is ../corpus.ts and the loop that runs
6
+ // it is ../stage.ts.
7
+
8
+ import { env } from "../config.js";
9
+ import {
10
+ accumulate,
11
+ mergeSpeakerTurns,
12
+ refineItems,
13
+ type TrainingItem,
14
+ } from "../items.js";
15
+ import { parquet } from "../readers.js";
16
+ import type { Corpus } from "../corpus.js";
17
+ import { convertedParquetUnits } from "./converted-parquet.js";
18
+ // SODA turns are the same shape as Taskmaster's, and merge by the same rule.
19
+ import type { TaskmasterTurn } from "./taskmaster.js";
20
+
21
+ // ── allenai/soda (social dialogue) and AmazonScience/massive (short intents) ──
22
+ // Both are read from Hugging Face's auto-converted `refs/convert/parquet`
23
+ // branch. For SODA that is mandatory, not cosmetic: its main-branch
24
+ // train.parquet is ONE 1,191,582-row group (1.19 GB uncompressed), and a
25
+ // Parquet column chunk is per-group, so any read of it materialises the whole
26
+ // file — measured at 100% of a 689 MB file and 2 GB of heap for a 500-row read.
27
+ // The converted branch uses uniform 10,000-row groups.
28
+ //
29
+ // BOTH STAGES ARE BUDGETED, and that is a curriculum decision rather than an
30
+ // algorithmic cap. SODA's train split holds 1,191,582 dialogues which the
31
+ // cumulative walk would turn into ~8 MILLION episodes — against the 662,221
32
+ // deposits of the entire current corpus. Trained whole it would not join the
33
+ // mix, it would BE the mix, and corpus size is the quantity every scale problem
34
+ // in this engine is measured against. The default takes the first
35
+ // SODA_MAX_DIALOGS of them; set it to 0 to lift the budget.
36
+ const SODA = env("SODA", "1") !== "0";
37
+ const SODA_DATASET = env("SODA_DATASET", "allenai/soda");
38
+ const SODA_SPLITS = env("SODA_SPLITS", "train")
39
+ .split(",").map((s) => s.trim()).filter(Boolean);
40
+ // ~6.3 episodes per dialogue, so this budgets ~750k episodes — comparable to
41
+ // the Taskmaster stage and to Aya, which is the intended balance. 0 = no budget.
42
+ const SODA_MAX_DIALOGS = Math.max(
43
+ 0,
44
+ Math.floor(Number(env("SODA_MAX_DIALOGS", "120000"))) || 0,
45
+ );
46
+ const MAX_SODA_TURN_CHARS = Math.max(
47
+ 1_000,
48
+ Math.floor(Number(env("MAX_SODA_TURN_KB", "32")) * 1000) || 32_000,
49
+ );
50
+
51
+ // ═══════════════════════════════════════════════════════════════════════
52
+ // §6e‴ SODA parsing — a social dialogue row → SEMA items
53
+ //
54
+ // Each row carries `dialogue` (an array of turn strings) and `speakers` (the
55
+ // speaker name per turn). The deposit is the cumulative walk over speaker-merged
56
+ // turns, identical in shape to Taskmaster and oasst2 — turns are short (mean
57
+ // 87 B) and dialogues average 7.3 turns, so the accumulated context stays well
58
+ // inside the healthy range.
59
+ //
60
+ // `narrative`, `literal` and the ATOMIC-style `head`/`relation`/`tail` columns
61
+ // are NOT deposited: they are the generation scaffolding SODA was distilled
62
+ // from, they restate the dialogue in the third person, and depositing both a
63
+ // dialogue and its paraphrased summary gives one meaning two shapes — which is
64
+ // measured to SUPPRESS composition rather than help it.
65
+ // ═══════════════════════════════════════════════════════════════════════
66
+
67
+ /** Normalize a SODA row into its turns, or null when it carries no usable
68
+ * dialogue. Speakers are optional (they only drive merging); an implausibly
69
+ * long turn rejects the dialogue as corrupt. */
70
+ export function toSodaTurns(
71
+ row: unknown,
72
+ maxChars = MAX_SODA_TURN_CHARS,
73
+ ): TaskmasterTurn[] | null {
74
+ if (!row || typeof row !== "object") return null;
75
+ const r = row as Record<string, unknown>;
76
+ const dialogue = r.dialogue;
77
+ if (!Array.isArray(dialogue)) return null;
78
+ const speakers = Array.isArray(r.speakers) ? r.speakers : [];
79
+ const turns: TaskmasterTurn[] = [];
80
+ for (let i = 0; i < dialogue.length; i++) {
81
+ const text = typeof dialogue[i] === "string"
82
+ ? (dialogue[i] as string).trim()
83
+ : "";
84
+ if (!text) continue;
85
+ if (text.length > maxChars) return null;
86
+ turns.push({
87
+ speaker: String(speakers[i] ?? "").trim().toUpperCase(),
88
+ text,
89
+ });
90
+ }
91
+ return turns.length ? turns : null;
92
+ }
93
+
94
+ /** Translate ONE SODA dialogue into SEMA items: the cumulative walk over its
95
+ * speaker-merged turns. Shares `mergeSpeakerTurns` because the rule is the
96
+ * same one — consecutive turns by one speaker are one contribution. */
97
+ export function sodaDialogueToItems(turns: TaskmasterTurn[]): TrainingItem[] {
98
+ const texts = mergeSpeakerTurns(turns);
99
+ if (texts.length < 2) return []; // not an exchange
100
+ return refineItems(accumulate(texts));
101
+ }
102
+
103
+ export const soda: Corpus = {
104
+ id: "soda",
105
+ label: "SODA",
106
+ kind: "social dialogue",
107
+ enabled: SODA,
108
+ maxRows: SODA_MAX_DIALOGS,
109
+ // Two of sixteen columns. The `narrative`/`literal`/`head`/`relation`/`tail`
110
+ // scaffolding the note above declines to deposit is now also never decoded:
111
+ // measured on the converted train shard, 449 MB uncompressed across all
112
+ // sixteen against 306 MB for these two (68.1%).
113
+ read: parquet({ columns: ["dialogue", "speakers"] }),
114
+ toItems: (row) => {
115
+ const turns = toSodaTurns(row);
116
+ if (!turns) return null;
117
+ const items = sodaDialogueToItems(turns);
118
+ return items.length ? items : null;
119
+ },
120
+ unitNoun: "shard(s)",
121
+ log: { rows: "row(s)" },
122
+ discover: convertedParquetUnits({
123
+ id: "soda",
124
+ label: "SODA",
125
+ dataset: SODA_DATASET,
126
+ config: "default",
127
+ splits: SODA_SPLITS,
128
+ localSub: "soda",
129
+ }),
130
+ };
@@ -0,0 +1,217 @@
1
+ // train_base/corpora/taskmaster.ts — google-research-datasets/Taskmaster 1–4 dialogue
2
+ //
3
+ // Knobs, the row adapter, and the stage descriptor for ONE corpus, together
4
+ // with the evidence that fixed each default. A corpus file owns everything
5
+ // source-specific; the contract it fills is ../corpus.ts and the loop that runs
6
+ // it is ../stage.ts.
7
+
8
+ import { env, LOCAL_PATH } from "../config.js";
9
+ import {
10
+ accumulate,
11
+ mergeSpeakerTurns,
12
+ refineItems,
13
+ type SpeakerTurn,
14
+ type TrainingItem,
15
+ } from "../items.js";
16
+ import { githubContents, localFiles } from "../discovery.js";
17
+ import { jsonArray } from "../readers.js";
18
+ import { type Corpus, localDir, type Unit } from "../corpus.js";
19
+ import type { TrainCtx } from "../runtime.js";
20
+ import { DIM, R } from "../ui.js";
21
+ import { basename, join } from "node:path";
22
+
23
+ // ── google-research-datasets/Taskmaster 1–4 (the dialogue stages) ──
24
+ // Four corpora of task-oriented dialogue, one shape between them: each file is a
25
+ // JSON ARRAY of conversations and each conversation carries
26
+ // `utterances: [{speaker, text, …}]`. TM-1 ships two files directly under its
27
+ // directory (self-dialogs, woz-dialogs); TM-2/3/4 ship theirs under `<set>/data`.
28
+ // They are the best-scoring corpora on the fold-unit recurrence benchmark that
29
+ // selects for halo health (TM-3 85.1%, TM-4 78.8%, TM-2 68.7%, TM-1 51.8%,
30
+ // against 23.2% for the incumbent SmolSent), and they are genuinely multi-turn
31
+ // where the incumbent multi-turn stage is not (TM-3 median 20 turns of ~43 B,
32
+ // against oasst2's median turn of 529 B).
33
+ //
34
+ // Served from GitHub raw, not Hugging Face: the HF mirrors are loading-script
35
+ // repos with no data files, and the official copies carry the CC BY 4.0 notice.
36
+ const TASKMASTER = env("TASKMASTER", "1") !== "0";
37
+ // Which sets to train, in order. Each is a directory in the Taskmaster repo.
38
+ const TASKMASTER_SETS = env(
39
+ "TASKMASTER_SETS",
40
+ "TM-1-2019,TM-2-2020,TM-3-2020,TM-4-2024",
41
+ ).split(",").map((s) => s.trim()).filter(Boolean);
42
+ const TASKMASTER_REPO = env(
43
+ "TASKMASTER_REPO",
44
+ "google-research-datasets/Taskmaster",
45
+ );
46
+ const TASKMASTER_RAW =
47
+ `https://raw.githubusercontent.com/${TASKMASTER_REPO}/master`;
48
+ // A conversation must have at least this many turns AFTER same-speaker merging.
49
+ // The default of 2 keeps every real exchange: unlike oasst2 — where a lone Q→A
50
+ // tree merely replicates the Aya stage's shape and is dropped — a two-turn
51
+ // task-oriented exchange is still task-oriented dialogue, and TM-4's dialogues
52
+ // are short by design (median 3.7 turns), so a higher bar would discard most of
53
+ // that set.
54
+ const TASKMASTER_MIN_TURNS = Math.max(
55
+ 2,
56
+ Math.floor(Number(env("TASKMASTER_MIN_TURNS", "2"))) || 2,
57
+ );
58
+ // Skip a conversation carrying an implausibly long utterance (corruption). The
59
+ // measured maximum across TM-1/2/3/4 is 1,897 bytes, so this only guards.
60
+ const MAX_TASKMASTER_TURN_CHARS = Math.max(
61
+ 1_000,
62
+ Math.floor(Number(env("MAX_TASKMASTER_TURN_KB", "32")) * 1000) || 32_000,
63
+ );
64
+
65
+ // ═══════════════════════════════════════════════════════════════════════
66
+ // §6e′ Taskmaster 1–4 parsing — a conversation ARRAY ELEMENT → SEMA items
67
+ //
68
+ // One adapter serves all four sets: every Taskmaster conversation, in every
69
+ // set, is `{conversation_id, …, utterances: [{speaker, text, …}]}`.
70
+ //
71
+ // ONLY `utterances[].text` IS READ, and that is a licence-adjacent correctness
72
+ // property, not a stylistic one. TM-3 and TM-4 also carry an `instructions`
73
+ // field holding the crowd-worker's task template — page after page of
74
+ // `{{HIDE movie_1 name.movie No Time To Die}}`, `{{CHECK confirm_natural …}}`
75
+ // and `var_theater_1` placeholders. That is authoring scaffolding, not
76
+ // dialogue, and depositing it would teach the store template noise as prose.
77
+ // Reading only `utterances[].text` excludes it structurally. Verified against
78
+ // the real files: across TM-2 (13,953 turns), TM-3 (24,059) and TM-4 (786),
79
+ // utterance text contains ZERO `var_*` placeholders and ZERO `{{ }}` markers —
80
+ // the scaffolding never leaks out of `instructions`.
81
+ //
82
+ // CONSECUTIVE SAME-SPEAKER TURNS ARE MERGED. Taskmaster splits one speaker's
83
+ // contribution across several indexed utterances ("I can help you with your
84
+ // movie search." / "Where are you located?" are two ASSISTANT rows), which is
85
+ // an artifact of the collection UI. Left unmerged, the cumulative walk deposits
86
+ // a turn boundary in the middle of one speaker's contribution and teaches it as
87
+ // a hand-off. Measured share of turns absorbed by merging: TM-1 17.7%,
88
+ // TM-2 11.9%, TM-3 0.8%, TM-4 0.0% — so this is load-bearing for the older sets
89
+ // and a no-op for the newer ones. Speaker names are compared case-insensitively
90
+ // because TM-1/2 use USER/ASSISTANT and TM-3/4 use user/assistant.
91
+ //
92
+ // The deposit shape is the cumulative walk (§6e's `accumulate`), identical to
93
+ // oasst2: each turn is the continuation of ALL prior turns, bare text, no role
94
+ // labels. It is the right shape here for the same reason and at a far healthier
95
+ // size — merged turns run p50 34–45 B (p90 ~100 B) and the accumulated context
96
+ // p50 301–532 B (p90 ~1.1 KB), against oasst2's median SINGLE turn of 529 B.
97
+ // ═══════════════════════════════════════════════════════════════════════
98
+
99
+ /** One utterance of a Taskmaster conversation — the shared dialogue-turn shape,
100
+ * under the name this corpus's adapters have always used. */
101
+ export type TaskmasterTurn = SpeakerTurn;
102
+
103
+ /** Normalize ONE element of a Taskmaster data file into its turns, or null when
104
+ * it carries no usable utterance. Empty/whitespace-only utterances are dropped
105
+ * (TM-3 has a few); a single implausibly long utterance rejects the whole
106
+ * conversation as corrupt rather than depositing a dump. */
107
+ export function toTaskmasterTurns(
108
+ row: unknown,
109
+ maxChars = MAX_TASKMASTER_TURN_CHARS,
110
+ ): TaskmasterTurn[] | null {
111
+ if (!row || typeof row !== "object") return null;
112
+ const utterances = (row as Record<string, unknown>).utterances;
113
+ if (!Array.isArray(utterances)) return null;
114
+ const turns: TaskmasterTurn[] = [];
115
+ for (const u of utterances) {
116
+ if (!u || typeof u !== "object") continue;
117
+ const r = u as Record<string, unknown>;
118
+ const text = typeof r.text === "string" ? r.text.trim() : "";
119
+ if (!text) continue;
120
+ if (text.length > maxChars) return null;
121
+ turns.push({
122
+ speaker: String(r.speaker ?? "").trim().toUpperCase(),
123
+ text,
124
+ });
125
+ }
126
+ return turns.length ? turns : null;
127
+ }
128
+
129
+ /** Translate ONE Taskmaster conversation into SEMA training items: the
130
+ * cumulative walk over its merged turns. Returns [] for a conversation below
131
+ * TASKMASTER_MIN_TURNS, so callers can simply skip empties. */
132
+ export function taskmasterConversationToItems(
133
+ turns: TaskmasterTurn[],
134
+ minTurns = TASKMASTER_MIN_TURNS,
135
+ ): TrainingItem[] {
136
+ const texts = mergeSpeakerTurns(turns);
137
+ if (texts.length < minTurns) return [];
138
+ return refineItems(accumulate(texts));
139
+ }
140
+
141
+ /** List the Taskmaster data files to train, in TASKMASTER_SETS order. Returns
142
+ * repo-relative paths, e.g. "TM-3-2020/data/data_00.json".
143
+ *
144
+ * TM-2/3/4 keep their dialogue files under `<set>/data`, so everything there is
145
+ * fair game. TM-1 has no `data` directory: its two dialogue files sit at the
146
+ * set root NEXT TO `ontology.json` (a slot schema) and `sample.json` (a small
147
+ * excerpt of self-dialogs). Neither is an array of conversations, and training
148
+ * the excerpt would deposit a subset of TM-1 twice, so TM-1 is filtered to the
149
+ * `*-dialogs.json` pair (self-dialogs, woz-dialogs). */
150
+ async function listFiles(
151
+ ctx: TrainCtx,
152
+ ): Promise<Array<{ set: string; path: string; size: number }>> {
153
+ const out: Array<{ set: string; path: string; size: number }> = [];
154
+ for (const set of TASKMASTER_SETS) {
155
+ const rootOnly = /^TM-1\b/i.test(set);
156
+ const dir = rootOnly ? set : `${set}/data`;
157
+ const names = await githubContents(
158
+ TASKMASTER_REPO,
159
+ dir,
160
+ /\.json$/i,
161
+ `GET Taskmaster ${dir}`,
162
+ ctx.http,
163
+ );
164
+ for (const { path: name, size } of names) {
165
+ if (rootOnly && !/-dialogs\.json$/i.test(name)) continue;
166
+ out.push({ set, path: `${dir}/${name}`, size });
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ const unit = (key: string, name: string): Unit => ({
173
+ key,
174
+ name,
175
+ display: `Taskmaster ${name}`,
176
+ });
177
+
178
+ export const taskmaster: Corpus = {
179
+ id: "taskmaster",
180
+ label: "Taskmaster",
181
+ kind: "task dialogue",
182
+ enabled: TASKMASTER,
183
+ unitNoun: "dialogue file(s)",
184
+ read: jsonArray(),
185
+ toItems: (row) => {
186
+ const turns = toTaskmasterTurns(row);
187
+ if (!turns) return null;
188
+ const items = taskmasterConversationToItems(turns); // [] when too short
189
+ return items.length ? items : null;
190
+ },
191
+ log: { bad: "unusable conversation(s)" },
192
+ async discover(ctx) {
193
+ // LOCAL_PATH/taskmaster/ — a subdirectory, because these share the .json
194
+ // extension with the General-Knowledge source and must not be confused
195
+ // with it.
196
+ if (LOCAL_PATH) {
197
+ const dir = localDir("taskmaster");
198
+ const names = localFiles(dir, /\.json$/i);
199
+ if (names.length === 0) {
200
+ ctx.progress.log(
201
+ ` ${DIM}· no Taskmaster *.json in ${dir} — skipping${R}`,
202
+ );
203
+ return null;
204
+ }
205
+ return names.map((n) => ({
206
+ ...unit(n.path, n.path),
207
+ local: join(dir, n.path),
208
+ bytes: n.size,
209
+ }));
210
+ }
211
+ return (await listFiles(ctx)).map((f) => ({
212
+ ...unit(f.path, `${f.set}/${basename(f.path)}`),
213
+ url: `${TASKMASTER_RAW}/${f.path}`,
214
+ bytes: f.size,
215
+ }));
216
+ },
217
+ };
@@ -0,0 +1,190 @@
1
+ // train_base/corpora/wiki2.ts — 2WikiMultihopQA evidence triples
2
+ //
3
+ // Knobs, the row adapter, and the stage descriptor for ONE corpus, together
4
+ // with the evidence that fixed each default. A corpus file owns everything
5
+ // source-specific; the contract it fills is ../corpus.ts and the loop that runs
6
+ // it is ../stage.ts.
7
+
8
+ import { env } from "../config.js";
9
+ import { refineItems, type TrainingItem } from "../items.js";
10
+ import { parquet } from "../readers.js";
11
+ import type { Corpus } from "../corpus.js";
12
+ import { convertedParquetUnits } from "./converted-parquet.js";
13
+
14
+ // ── 2WikiMultihopQA — the `evidences` TRIPLES only (the composition stage) ──
15
+ // Each row carries `evidences`: a JSON string of (subject, relation, object)
16
+ // triples that CHAIN — one triple's object is the next's subject. 72.5% of rows
17
+ // carry such a chain (measured over 4,000 rows), and those triples are the only
18
+ // representation measured to make Sema compose a two-hop answer at all.
19
+ //
20
+ // TWO COLUMNS ARE DELIBERATELY NOT READ, one for licence reasons and one for
21
+ // capability reasons:
22
+ // • `context` holds Wikipedia PROSE. The repo is Apache-2.0 but Wikipedia text
23
+ // is CC BY-SA, and a Sema store keeps text verbatim, so ingesting the
24
+ // passages would attach ShareAlike to every distributed store. The triples
25
+ // originate in Wikidata (CC0). See DATASETS.md §3.2/§4.
26
+ // • `question`/`answer` are the composed multi-hop QUESTION. Depositing those
27
+ // teaches the answer to that exact question and nothing else — it memorises
28
+ // rather than composes. They are used to EVALUATE this adapter, never as
29
+ // training input.
30
+ //
31
+ // Read from Hugging Face's auto-converted `refs/convert/parquet` branch, not
32
+ // from main: the main-branch train.parquet is written as ONE 167,454-row
33
+ // group (666 MB uncompressed) and a Parquet column chunk is per-group, so any
34
+ // read of it materialises the whole file. The converted branch uses uniform
35
+ // 10,000-row groups, which `parquetBatchRows` then subdivides by BYTES.
36
+ const WIKI2 = env("WIKI2", "1") !== "0";
37
+ const WIKI2_DATASET = env("WIKI2_DATASET", "xanhho/2WikiMultihopQA");
38
+ // Splits to train, in order. Only `train` by default: `validation`/`test` are
39
+ // the dataset's held-out sets and are what an honest evaluation of this
40
+ // adapter's composition rate has to be measured on.
41
+ const WIKI2_SPLITS = env("WIKI2_SPLITS", "train")
42
+ .split(",").map((s) => s.trim()).filter(Boolean);
43
+ // Reject a triple with an implausibly long field (corruption); real subjects and
44
+ // objects are entity names, and relations are Wikidata property labels.
45
+ // 0 = every row. The train split holds 167,454 rows at ~4.95 deposits each
46
+ // (~830k facts), so this is the knob that keeps 2Wiki proportionate to the rest
47
+ // of the curriculum in the same way SODA_MAX_DIALOGS does.
48
+ const WIKI2_MAX_ROWS = Math.max(
49
+ 0,
50
+ Math.floor(Number(env("WIKI2_MAX_ROWS", "0"))) || 0,
51
+ );
52
+ const MAX_WIKI2_FIELD_CHARS = Math.max(
53
+ 100,
54
+ Math.floor(Number(env("MAX_WIKI2_FIELD_KB", "2")) * 1000) || 2_000,
55
+ );
56
+
57
+ // ═══════════════════════════════════════════════════════════════════════
58
+ // §6e″ 2WikiMultihopQA parsing — `evidences` TRIPLES → SEMA facts
59
+ //
60
+ // This is the only stage whose purpose is COMPOSITION: answering a question
61
+ // whose answer no single deposited fact contains. Sema composes by grounding
62
+ // hop 1, then pivoting on the longest unconsumed learnt context that the
63
+ // grounded answer CONTAINS (`reason`/`pivotStep`), so the pivot target must
64
+ // itself be a deposited context. Each triple therefore deposits TWO facts:
65
+ //
66
+ // "<subject> <relation>" → "The <relation> of <subject> is <object>."
67
+ // "<subject>" → "The <relation> of <subject> is <object>."
68
+ //
69
+ // The second is the PIVOT FACT. Without it the bare entity naming hop 2's
70
+ // subject is not a learnt context, so the chain is structurally unreachable no
71
+ // matter what the rest of the pipeline does.
72
+ //
73
+ // MEASURED on 200 real chained dev rows, depositing triples only and asking the
74
+ // dataset's own composed questions (D = 1024, seed 7):
75
+ //
76
+ // relation fact only 240 deposits 5/120 ( 4%) pivotStep 0
77
+ // relation + pivot fact 800 deposits 44/200 (22%) pivotStep 31
78
+ //
79
+ // A 5x improvement, and the only variant where the second hop fires at all.
80
+ //
81
+ // REJECTED ALTERNATIVE, so it is not re-tried blind: depositing the pivot fact
82
+ // only for subjects that also appear as an OBJECT within the same row's
83
+ // evidences (a row-local "something can pivot into this" test) cut deposits 25%
84
+ // (800 → 600) but cost composition — 41/200 (20.5%) with pivotStep down to 21,
85
+ // because real chains also run BETWEEN rows. Composition is this stage's entire
86
+ // justification, so the deposits are worth keeping.
87
+ //
88
+ // The residual ~78% is a KNOWN, previously-recorded limitation and not a defect
89
+ // in this adapter: the climb elects a topic rather than a relation, so a
90
+ // question phrased "When did X's father die?" does not align with the Wikidata
91
+ // property label "date of death". Failure is dominated by hop 2 never firing,
92
+ // not by a wrong hop 2. Answer-shape breakdown at N = 120: entity answers
93
+ // 23/106, date answers 2/14 — dates are worse, but not the cliff an earlier
94
+ // note suggested, which is why no object-shape filter is applied here.
95
+ // ═══════════════════════════════════════════════════════════════════════
96
+
97
+ /** One (subject, relation, object) triple from a 2Wiki `evidences` cell. */
98
+ export interface WikiTriple {
99
+ subject: string;
100
+ relation: string;
101
+ object: string;
102
+ }
103
+
104
+ /** Normalize a 2Wiki row into its evidence triples, or null when it carries
105
+ * none usable. `evidences` is a JSON STRING holding an array of 3-element
106
+ * arrays; a row whose cell is absent, unparseable, or empty yields null.
107
+ * Individual malformed or oversized triples are dropped without discarding the
108
+ * row — one bad triple should not cost the others. */
109
+ export function toWikiTriples(
110
+ row: unknown,
111
+ maxChars = MAX_WIKI2_FIELD_CHARS,
112
+ ): WikiTriple[] | null {
113
+ if (!row || typeof row !== "object") return null;
114
+ const cell = (row as Record<string, unknown>).evidences;
115
+ let parsed: unknown = cell;
116
+ if (typeof cell === "string") {
117
+ try {
118
+ parsed = JSON.parse(cell);
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+ if (!Array.isArray(parsed)) return null;
124
+ const out: WikiTriple[] = [];
125
+ for (const e of parsed) {
126
+ if (!Array.isArray(e) || e.length < 3) continue;
127
+ const subject = typeof e[0] === "string" ? e[0].trim() : "";
128
+ const relation = typeof e[1] === "string" ? e[1].trim() : "";
129
+ const object = typeof e[2] === "string" ? e[2].trim() : "";
130
+ if (!subject || !relation || !object) continue;
131
+ if (
132
+ subject.length > maxChars ||
133
+ relation.length > maxChars ||
134
+ object.length > maxChars
135
+ ) continue;
136
+ out.push({ subject, relation, object });
137
+ }
138
+ return out.length ? out : null;
139
+ }
140
+
141
+ /** Render ONE triple as the prose fact Sema stores. Kept separate so the two
142
+ * deposits below are guaranteed to share a byte-identical continuation: the
143
+ * pivot fact only works if it leads to the SAME node the relation fact does. */
144
+ export function wikiTripleSentence(t: WikiTriple): string {
145
+ return `The ${t.relation} of ${t.subject} is ${t.object}.`;
146
+ }
147
+
148
+ /** Translate a row's triples into SEMA items: per triple, the relation fact and
149
+ * the bare-subject PIVOT fact (see the section note above). refineItems drops
150
+ * the duplicates this produces when a row states the same triple twice. */
151
+ export function wikiTriplesToItems(triples: WikiTriple[]): TrainingItem[] {
152
+ const items: TrainingItem[] = [];
153
+ for (const t of triples) {
154
+ const fact = wikiTripleSentence(t);
155
+ items.push({ context: `${t.subject} ${t.relation}`, continuation: fact });
156
+ items.push({ context: t.subject, continuation: fact });
157
+ }
158
+ return refineItems(items);
159
+ }
160
+
161
+ export const wiki2: Corpus = {
162
+ id: "2wiki",
163
+ label: "2Wiki",
164
+ kind: "relation triples",
165
+ enabled: WIKI2,
166
+ maxRows: WIKI2_MAX_ROWS,
167
+ // Read ONE of the file's seven columns. This is where the "context is not
168
+ // read" rule above stops being a property of the adapter and becomes a
169
+ // property of the read: the Wikipedia prose is never decoded at all. It is
170
+ // also almost the whole file — measured on the converted train shard, the
171
+ // seven columns hold 518 MB uncompressed and `evidences` is 18 MB of it
172
+ // (3.5%), so the projection cuts the decode work by ~28x.
173
+ read: parquet({ columns: ["evidences"] }),
174
+ toItems: (row) => {
175
+ const triples = toWikiTriples(row);
176
+ if (!triples) return null;
177
+ const items = wikiTriplesToItems(triples);
178
+ return items.length ? items : null;
179
+ },
180
+ unitNoun: "shard(s)",
181
+ log: { rows: "row(s)" },
182
+ discover: convertedParquetUnits({
183
+ id: "2wiki",
184
+ label: "2Wiki",
185
+ dataset: WIKI2_DATASET,
186
+ config: "default",
187
+ splits: WIKI2_SPLITS,
188
+ localSub: "2wiki",
189
+ }),
190
+ };