@hviana/sema 0.5.9 → 0.7.1

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 (116) hide show
  1. package/.github/workflows/release.yml +80 -0
  2. package/AGENTS.md +73 -13
  3. package/DATASETS.md +12 -11
  4. package/dist/example/train_base/cache.d.ts +35 -0
  5. package/dist/example/train_base/cache.js +211 -0
  6. package/dist/example/train_base/config.d.ts +21 -0
  7. package/dist/example/train_base/config.js +94 -0
  8. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  9. package/dist/example/train_base/corpora/aya.js +76 -0
  10. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  11. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  12. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  13. package/dist/example/train_base/corpora/genknow.js +83 -0
  14. package/dist/example/train_base/corpora/index.d.ts +29 -0
  15. package/dist/example/train_base/corpora/index.js +81 -0
  16. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  17. package/dist/example/train_base/corpora/massive.js +98 -0
  18. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  19. package/dist/example/train_base/corpora/oasst2.js +120 -0
  20. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  21. package/dist/example/train_base/corpora/smolsent.js +156 -0
  22. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  23. package/dist/example/train_base/corpora/soda.js +113 -0
  24. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  25. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  26. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  27. package/dist/example/train_base/corpora/wiki2.js +132 -0
  28. package/dist/example/train_base/corpus.d.ts +88 -0
  29. package/dist/example/train_base/corpus.js +65 -0
  30. package/dist/example/train_base/discovery.d.ts +48 -0
  31. package/dist/example/train_base/discovery.js +143 -0
  32. package/dist/example/train_base/http.d.ts +82 -0
  33. package/dist/example/train_base/http.js +219 -0
  34. package/dist/example/train_base/items.d.ts +46 -0
  35. package/dist/example/train_base/items.js +98 -0
  36. package/dist/example/train_base/main.d.ts +4 -0
  37. package/dist/example/train_base/main.js +207 -0
  38. package/dist/example/train_base/progress.d.ts +34 -0
  39. package/dist/example/train_base/progress.js +114 -0
  40. package/dist/example/train_base/readers.d.ts +125 -0
  41. package/dist/example/train_base/readers.js +391 -0
  42. package/dist/example/train_base/runtime.d.ts +115 -0
  43. package/dist/example/train_base/runtime.js +637 -0
  44. package/dist/example/train_base/stage.d.ts +3 -0
  45. package/dist/example/train_base/stage.js +246 -0
  46. package/dist/example/train_base/ui.d.ts +88 -0
  47. package/dist/example/train_base/ui.js +272 -0
  48. package/dist/src/meter.d.ts +1 -4
  49. package/dist/src/meter.js +0 -3
  50. package/dist/src/mind/attention.js +22 -20
  51. package/dist/src/mind/graph-search.d.ts +43 -9
  52. package/dist/src/mind/graph-search.js +82 -15
  53. package/dist/src/mind/junction.d.ts +13 -0
  54. package/dist/src/mind/junction.js +13 -0
  55. package/dist/src/mind/mechanisms/cover.js +23 -2
  56. package/dist/src/mind/mechanisms/prefix-completion.js +13 -11
  57. package/dist/src/mind/mechanisms/recall.js +8 -4
  58. package/dist/src/mind/mind.d.ts +1 -1
  59. package/dist/src/mind/mind.js +1 -1
  60. package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
  61. package/dist/src/mind/pipeline-mechanism.js +13 -36
  62. package/dist/src/mind/pipeline.d.ts +23 -0
  63. package/dist/src/mind/pipeline.js +51 -3
  64. package/dist/src/mind/recognition.d.ts +6 -1
  65. package/dist/src/mind/recognition.js +11 -6
  66. package/dist/src/mind/resonance.js +48 -13
  67. package/dist/src/store.js +22 -1
  68. package/example/train_base/cache.ts +251 -0
  69. package/example/train_base/config.ts +128 -0
  70. package/example/train_base/corpora/aya.ts +106 -0
  71. package/example/train_base/corpora/converted-parquet.ts +64 -0
  72. package/example/train_base/corpora/genknow.ts +114 -0
  73. package/example/train_base/corpora/index.ts +88 -0
  74. package/example/train_base/corpora/massive.ts +111 -0
  75. package/example/train_base/corpora/oasst2.ts +163 -0
  76. package/example/train_base/corpora/smolsent.ts +203 -0
  77. package/example/train_base/corpora/soda.ts +130 -0
  78. package/example/train_base/corpora/taskmaster.ts +217 -0
  79. package/example/train_base/corpora/wiki2.ts +190 -0
  80. package/example/train_base/corpus.ts +150 -0
  81. package/example/train_base/discovery.ts +203 -0
  82. package/example/train_base/http.ts +284 -0
  83. package/example/train_base/items.ts +118 -0
  84. package/example/train_base/main.ts +240 -0
  85. package/example/train_base/progress.ts +149 -0
  86. package/example/train_base/readers.ts +505 -0
  87. package/example/train_base/runtime.ts +894 -0
  88. package/example/train_base/stage.ts +276 -0
  89. package/example/train_base/ui.ts +333 -0
  90. package/jsr.json +1 -1
  91. package/package.json +8 -5
  92. package/src/meter.ts +1 -4
  93. package/src/mind/attention.ts +22 -19
  94. package/src/mind/graph-search.ts +93 -16
  95. package/src/mind/junction.ts +13 -0
  96. package/src/mind/mechanisms/cover.ts +23 -4
  97. package/src/mind/mechanisms/prefix-completion.ts +13 -11
  98. package/src/mind/mechanisms/recall.ts +8 -4
  99. package/src/mind/mind.ts +1 -1
  100. package/src/mind/pipeline-mechanism.ts +13 -42
  101. package/src/mind/pipeline.ts +87 -3
  102. package/src/mind/recognition.ts +19 -6
  103. package/src/mind/resonance.ts +79 -50
  104. package/src/store.ts +21 -1
  105. package/test/13-conversation.test.mjs +1 -1
  106. package/test/84-composed-answer-honesty.test.mjs +2 -1
  107. package/test/88-dependency-footprint.test.mjs +99 -0
  108. package/test/89-completion-recursion.test.mjs +230 -0
  109. package/test/90-connector-read-cap.test.mjs +130 -0
  110. package/test/91-branch-bytes-cache.test.mjs +152 -0
  111. package/test/93-regime-prediction.test.mjs +148 -0
  112. package/test/94-cross-region-budget.test.mjs +67 -0
  113. package/test/95-wide-resonance-removed.test.mjs +109 -0
  114. package/dist/example/train_base.d.ts +0 -163
  115. package/dist/example/train_base.js +0 -3220
  116. package/example/train_base.ts +0 -3882
@@ -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
+ };
@@ -0,0 +1,150 @@
1
+ // train_base/corpus.ts — WHAT A CORPUS IS: the contract every corpus file
2
+ // declares, and the resume identity derived from it.
3
+ //
4
+ // Separated from the loop that consumes it (stage.ts) so a corpus file never
5
+ // imports the loop. A corpus declares three things the loop does not know —
6
+ // where its work-list comes from, what container its bytes are in, and how a row
7
+ // becomes deposits — and everything here exists to state those three.
8
+ //
9
+ // THE RESUME IDS ARE A COMPATIBILITY SURFACE. A store records the units it has
10
+ // finished as strings, and a store trained by an earlier version must keep
11
+ // resuming, so `unitIdOf` below has to reproduce the ids that store already
12
+ // holds. It does, by one rule with no exceptions: `${corpus.id}::${unit.key}`.
13
+ //
14
+ // That single rule is a recent simplification, and the evidence for it is worth
15
+ // keeping. There used to be a second mechanism — a `Corpus.unitId` field
16
+ // carrying a FIXED id for the three single-unit corpora, which were believed
17
+ // irregular — and with it a trap: those corpora emitted `key: ""` on the
18
+ // assumption that the fixed id would always override, so a new single-unit
19
+ // corpus that forgot the field silently got the id "foo::". Reading the ids out
20
+ // of a real 2.5 GB store settled it: the three are `aya::dataset`,
21
+ // `oasst2::trees` and `genknow::qa`, which decompose EXACTLY into corpus id and
22
+ // key. They were never irregular, so the escape hatch and its trap are gone and
23
+ // one rule covers all 239 recorded ids.
24
+
25
+ import { LOCAL_PATH } from "./config.js";
26
+ import { localFind } from "./discovery.js";
27
+ import type { Reader, RowAdapter } from "./readers.js";
28
+ import type { TrainCtx } from "./runtime.js";
29
+ import { DIM, R } from "./ui.js";
30
+ import { join } from "node:path";
31
+
32
+ /** One file to train: a shard, a per-language file, or a whole single-file
33
+ * corpus. Exactly one of `url` / `local` is set. */
34
+ export interface Unit {
35
+ /** Resume-id suffix — the corpus id and this form `${id}::${key}`. Part of
36
+ * the store's compatibility surface; see the file header. */
37
+ key: string;
38
+ /** How the run log names this unit once it is read. */
39
+ name: string;
40
+ /** How the live panel names it, and (unless `acquireLabel` overrides) how the
41
+ * download is labelled. Conventionally `${corpus.label} ${name}`. */
42
+ display: string;
43
+ url?: string;
44
+ local?: string;
45
+ /** Size in bytes when the listing said, else 0/absent. Summed BEFORE the
46
+ * stage reads anything, so the corpus progress bar has a denominator that
47
+ * does not grow underneath it. */
48
+ bytes?: number;
49
+ /** Cache filename. Defaults to the resume id with unsafe characters folded. */
50
+ dest?: string;
51
+ /** Download label, when it differs from `display`. */
52
+ acquireLabel?: string;
53
+ }
54
+
55
+ /** How one unit's outcome reads in the run log. All optional: the defaults are
56
+ * what every fact-shaped corpus prints. */
57
+ export interface LogStyle {
58
+ /** What one deposit is called. Default "facts". */
59
+ deposits?: string;
60
+ /** When set, the line reports "from N <rows>" — the count of rows that
61
+ * actually produced deposits. */
62
+ rows?: string;
63
+ /** What an unusable record is called. Default "unusable row(s)". */
64
+ bad?: string;
65
+ /** Report only the reader's `skipped` (malformed records), not the rows the
66
+ * adapter declined. For a corpus that DECLINES records by design — oasst2
67
+ * drops every single-turn tree — counting those as damage would be a lie. */
68
+ malformedOnly?: boolean;
69
+ }
70
+
71
+ export interface Corpus {
72
+ /** Tally key AND resume-id prefix. Compatibility surface — see the header. */
73
+ id: string;
74
+ /** Human name: the panel, the skip notices, the listing-failure message. */
75
+ label: string;
76
+ /** The dim tag in the log line, e.g. "translation", "social dialogue". */
77
+ kind: string;
78
+ enabled: boolean;
79
+ /** The work-list. Return [] for "nothing found" (the runner says so), or
80
+ * null when the corpus has already logged a more specific reason. */
81
+ discover(ctx: TrainCtx): Promise<Unit[] | null>;
82
+ read: Reader;
83
+ toItems: RowAdapter;
84
+ /** Stage-wide row budget; 0/absent = unbounded. See the budget notes in
85
+ * stage.ts. */
86
+ maxRows?: number;
87
+ /** Noun for the "N/M ___ to train" announcement. Absent ⇒ no announcement,
88
+ * which is what a single-unit corpus has always done. */
89
+ unitNoun?: string;
90
+ /** Keep a file that came from the CACHE after a complete read. Only oasst2
91
+ * does this: every other corpus deletes whatever acquire() handed it. */
92
+ keepCached?: boolean;
93
+ log?: LogStyle;
94
+ }
95
+
96
+ /** The string a store records once this unit is finished. ONE rule, no
97
+ * exceptions — see the file header for why there used to be two. */
98
+ export const unitIdOf = (corpus: Corpus, unit: Unit): string =>
99
+ `${corpus.id}::${unit.key}`;
100
+
101
+ /** Local files live under `LOCAL_PATH/<sub>`, or directly in LOCAL_PATH when
102
+ * `sub` is empty. Kept here because the layout is a user-facing convention:
103
+ * the corpora that share an extension (.json, .parquet) are kept apart by a
104
+ * subdirectory so a local run cannot feed one corpus's files to another. */
105
+ export const localDir = (sub: string): string =>
106
+ sub ? join(LOCAL_PATH, sub) : LOCAL_PATH;
107
+
108
+ /** A single-unit corpus: one fixed URL, or one local file matched by pattern.
109
+ * Factored out because the three corpora that are ONE file resolve it the same
110
+ * way. `key` is what the store records this corpus under — `aya::dataset` is
111
+ * `key: "dataset"` — so it is required rather than defaulted: a resume id is
112
+ * the one thing here that must never be guessed. */
113
+ export function singleUnit(opts: {
114
+ key: string;
115
+ label: string;
116
+ display: string;
117
+ url: string;
118
+ dest: string;
119
+ acquireLabel?: string;
120
+ /** Patterns tried, in order, against LOCAL_PATH. */
121
+ localMatch: RegExp[];
122
+ /** How the "no local copy" notice describes what it looked for. */
123
+ localWhat: string;
124
+ }): (ctx: TrainCtx) => Promise<Unit[] | null> {
125
+ return async (ctx: TrainCtx) => {
126
+ if (LOCAL_PATH) {
127
+ const hit = localFind(LOCAL_PATH, ...opts.localMatch);
128
+ if (!hit) {
129
+ ctx.progress.log(
130
+ ` ${DIM}· no ${opts.localWhat} in ${LOCAL_PATH} — skipping${R}`,
131
+ );
132
+ return null;
133
+ }
134
+ return [{
135
+ key: opts.key,
136
+ name: opts.label,
137
+ display: opts.display,
138
+ local: join(LOCAL_PATH, hit),
139
+ }];
140
+ }
141
+ return [{
142
+ key: opts.key,
143
+ name: opts.label,
144
+ display: opts.display,
145
+ url: opts.url,
146
+ dest: opts.dest,
147
+ acquireLabel: opts.acquireLabel,
148
+ }];
149
+ };
150
+ }
@@ -0,0 +1,203 @@
1
+ // train_base/discovery.ts — where a stage's WORK-LIST comes from.
2
+ //
3
+ // Four remote strategies and two local ones, each generic over the dataset it
4
+ // is pointed at. What stays with a corpus is its POLICY — which subset of the
5
+ // listing to keep, how to name the resume unit — because that is a curriculum
6
+ // decision, not a protocol one.
7
+ //
8
+ // A note that has bitten this code twice, in both directions: a dataset id
9
+ // ("owner/name") is a PATH here and its "/" must NOT be percent-encoded, while
10
+ // a branch name ("refs/convert/parquet") is a single path SEGMENT and its "/"
11
+ // MUST be.
12
+
13
+ import { getJson, getJsonPaged, type HttpOptions } from "./http.js";
14
+ import { readdirSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** A note a listing needs to make about ITSELF — an accepted-but-unexpected
18
+ * split name, say. Distinct from a failure: the run continues, but silently
19
+ * continuing would hide why fewer units appeared than expected. */
20
+ export type Note = (msg: string) => void;
21
+
22
+ /** One listed file: where it is, and how big it is.
23
+ *
24
+ * The SIZE is carried because the panel is otherwise dishonest. Corpus
25
+ * progress used to be measured against a total that GREW as each file was
26
+ * opened, so the bar ran to 100% at the end of every file and then fell back
27
+ * when the next one was added — 100%, 50%, 100%, 66%… Both listing APIs
28
+ * already return the size, so the denominator can simply be known before the
29
+ * first byte is read. */
30
+ export interface Listed {
31
+ path: string;
32
+ /** Bytes, or 0 when the source did not say. */
33
+ size: number;
34
+ }
35
+
36
+ const sizeOf = (e: any): number => {
37
+ const n = Number(e?.size ?? e?.lfs?.size ?? 0);
38
+ return Number.isFinite(n) && n > 0 ? n : 0;
39
+ };
40
+
41
+ /** Files under `path` in a Hugging Face dataset repo's main branch, filtered to
42
+ * an extension. Returns repo-relative paths (e.g. "smolsent/ha_en.jsonl"),
43
+ * sorted, so a run's unit order is stable across machines. */
44
+ export async function hfTree(
45
+ dataset: string,
46
+ path: string,
47
+ ext: RegExp,
48
+ label: string,
49
+ opts: HttpOptions,
50
+ ): Promise<Listed[]> {
51
+ // The dataset id is a PATH here, so its "/" must not be percent-encoded.
52
+ // `recursive=true` returns every file under `path`, 1,000 at a time — hence
53
+ // the PAGED fetch: a truncated work-list would train part of a corpus and
54
+ // then call it finished.
55
+ const url = `https://huggingface.co/api/datasets/${dataset}` +
56
+ `/tree/main/${path}?recursive=true`;
57
+ const body = await getJsonPaged(url, label, opts);
58
+ const out: Listed[] = body
59
+ .filter((e: any) => e?.type === "file" && ext.test(e?.path))
60
+ .map((e: any) => ({ path: String(e.path), size: sizeOf(e) }));
61
+ out.sort((a, b) => a.path.localeCompare(b.path));
62
+ return out;
63
+ }
64
+
65
+ /** A dataset's Parquet shards on Hugging Face's auto-converted
66
+ * `refs/convert/parquet` branch, restricted to `config` and to `splits`.
67
+ *
68
+ * The converted branch is used rather than `main` because a dataset's own
69
+ * Parquet may be written as ONE giant row-group (SODA's is 1,191,582 rows),
70
+ * and a column chunk is per-group, so reading any part of it materialises all
71
+ * of it. The converted branch is uniformly 10,000-row groups.
72
+ *
73
+ * Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
74
+ * path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
75
+ * whose "/" must not be. */
76
+ export async function hfConvertedParquet(
77
+ dataset: string,
78
+ config: string,
79
+ splits: string[],
80
+ label: string,
81
+ opts: HttpOptions,
82
+ note?: Note,
83
+ ): Promise<Listed[]> {
84
+ const body = await getJsonPaged(
85
+ `https://huggingface.co/api/datasets/${dataset}` +
86
+ `/tree/refs%2Fconvert%2Fparquet/${config}?recursive=true`,
87
+ `GET ${label} tree`,
88
+ opts,
89
+ );
90
+ const paths: Listed[] = body
91
+ .filter((e: any) => e?.type === "file" && /\.parquet$/i.test(e?.path))
92
+ .map((e: any) => ({ path: String(e.path), size: sizeOf(e) }));
93
+ paths.sort((a, b) => a.path.localeCompare(b.path));
94
+
95
+ // The tree is rooted at `config`, so the split is the second-to-last part.
96
+ const splitOf = (p: string) => p.split("/").slice(-2)[0] ?? "";
97
+ const present = new Set(paths.map((p) => splitOf(p.path)));
98
+
99
+ // MATCH THE SPLIT AGAINST WHAT THE BRANCH ACTUALLY CARRIES. An exact name is
100
+ // not guaranteed: the converter renames a split it could not finish, and
101
+ // shards a very large one. Observed on real datasets today —
102
+ // allenai/c4 → partial-train, partial-validation
103
+ // HuggingFaceFW/fineweb → train-part0
104
+ // — neither of which equals "train". The old exact-match filter returned []
105
+ // for both, and an empty work-list is reported as "no files found — skipping",
106
+ // which reads like a normal outcome rather than a corpus being dropped whole.
107
+ const wanted = new Set<string>();
108
+ for (const want of splits) {
109
+ const pick = present.has(want)
110
+ ? want
111
+ : [...present].find((s) => s === `partial-${want}`) ??
112
+ [...present].find((s) => s.startsWith(`${want}-part`));
113
+ if (!pick) {
114
+ // Loud, not empty: a requested split that simply is not there is a
115
+ // configuration error, and silence would hide the whole corpus.
116
+ throw new Error(
117
+ `${label}: split "${want}" is not on the converted branch — it ` +
118
+ `carries ${[...present].join(", ") || "no parquet at all"}`,
119
+ );
120
+ }
121
+ if (pick !== want) {
122
+ note?.(
123
+ `${label}: split "${want}" is published as "${pick}"` +
124
+ (pick.startsWith("partial-")
125
+ ? " — Hugging Face has only PARTIALLY converted this dataset, so " +
126
+ "the shards below are not the whole split"
127
+ : ""),
128
+ );
129
+ }
130
+ wanted.add(pick);
131
+ }
132
+ return paths.filter((p) => wanted.has(splitOf(p.path)));
133
+ }
134
+
135
+ /** File NAMES (not paths) in one directory of a GitHub repo, filtered to an
136
+ * extension and sorted. Used for corpora served from GitHub raw rather than
137
+ * Hugging Face — where the HF mirrors are loading-script repos with no data
138
+ * files, the official GitHub copy is the one carrying the licence notice. */
139
+ export async function githubContents(
140
+ repo: string,
141
+ dir: string,
142
+ ext: RegExp,
143
+ label: string,
144
+ opts: HttpOptions,
145
+ ): Promise<Listed[]> {
146
+ const body = await getJson(
147
+ `https://api.github.com/repos/${repo}/contents/${dir}`,
148
+ label,
149
+ opts,
150
+ );
151
+ const entries: unknown[] = Array.isArray(body) ? body : [];
152
+ // The contents API returns at most 1,000 entries for a directory and does NOT
153
+ // paginate them — it simply stops, with no Link header and no error. A
154
+ // directory at that boundary is therefore indistinguishable from a truncated
155
+ // one, so the only honest response is to refuse rather than train part of it
156
+ // and record the part as the whole.
157
+ if (entries.length >= 1000) {
158
+ throw new Error(
159
+ `${label}: GitHub returned ${entries.length} entries, the point at ` +
160
+ `which the contents API truncates without saying so — this listing ` +
161
+ `cannot be trusted to be complete`,
162
+ );
163
+ }
164
+ const names: Listed[] = entries
165
+ .filter((e: any) => e?.type === "file" && ext.test(e?.name))
166
+ .map((e: any) => ({ path: String(e.name), size: sizeOf(e) }));
167
+ names.sort((a, b) => a.path.localeCompare(b.path));
168
+ return names;
169
+ }
170
+
171
+ /** Every file in a local directory matching `ext`, sorted. A missing directory
172
+ * is an empty list, not an error: LOCAL_PATH is an offline convenience and a
173
+ * stage with no local copy simply reports that and moves on. */
174
+ export function localFiles(dir: string, ext: RegExp): Listed[] {
175
+ try {
176
+ return readdirSync(dir)
177
+ .filter((f: string) => ext.test(f))
178
+ .sort()
179
+ .map((f: string) => {
180
+ let size = 0;
181
+ try {
182
+ size = statSync(join(dir, f)).size;
183
+ } catch { /* unreadable — the read will report it */ }
184
+ return { path: f, size };
185
+ });
186
+ } catch {
187
+ return []; // no such directory
188
+ }
189
+ }
190
+
191
+ /** The FIRST file in a local directory matching any of `exts`, in directory
192
+ * order (deliberately NOT sorted — this mirrors the single-file stages, which
193
+ * take whichever copy the filesystem hands back first). Null when none match
194
+ * or the directory is absent. */
195
+ export function localFind(dir: string, ...exts: RegExp[]): string | null {
196
+ try {
197
+ return readdirSync(dir).find((f: string) =>
198
+ exts.some((re) => re.test(f))
199
+ ) ?? null;
200
+ } catch {
201
+ return null; // no such directory
202
+ }
203
+ }