@hviana/sema 0.5.8 → 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 (108) hide show
  1. package/AGENTS.md +43 -4
  2. package/DATASETS.md +160 -0
  3. package/README.md +12 -0
  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/geometry.d.ts +20 -0
  49. package/dist/src/geometry.js +22 -0
  50. package/dist/src/mind/attention.d.ts +6 -0
  51. package/dist/src/mind/attention.js +44 -4
  52. package/dist/src/mind/learning.js +134 -50
  53. package/dist/src/mind/mechanisms/cast.js +45 -1
  54. package/dist/src/mind/mind.d.ts +7 -2
  55. package/dist/src/mind/mind.js +15 -3
  56. package/dist/src/mind/reasoning.js +59 -5
  57. package/dist/src/mind/recognition.js +29 -3
  58. package/dist/src/mind/traverse.d.ts +16 -0
  59. package/dist/src/mind/traverse.js +18 -0
  60. package/dist/src/store-sqlite.d.ts +4 -0
  61. package/dist/src/store-sqlite.js +47 -0
  62. package/dist/src/store.d.ts +7 -0
  63. package/example/train_base/cache.ts +251 -0
  64. package/example/train_base/config.ts +128 -0
  65. package/example/train_base/corpora/aya.ts +106 -0
  66. package/example/train_base/corpora/converted-parquet.ts +64 -0
  67. package/example/train_base/corpora/genknow.ts +114 -0
  68. package/example/train_base/corpora/index.ts +88 -0
  69. package/example/train_base/corpora/massive.ts +111 -0
  70. package/example/train_base/corpora/oasst2.ts +163 -0
  71. package/example/train_base/corpora/smolsent.ts +203 -0
  72. package/example/train_base/corpora/soda.ts +130 -0
  73. package/example/train_base/corpora/taskmaster.ts +217 -0
  74. package/example/train_base/corpora/wiki2.ts +190 -0
  75. package/example/train_base/corpus.ts +150 -0
  76. package/example/train_base/discovery.ts +203 -0
  77. package/example/train_base/http.ts +284 -0
  78. package/example/train_base/items.ts +118 -0
  79. package/example/train_base/main.ts +240 -0
  80. package/example/train_base/progress.ts +149 -0
  81. package/example/train_base/readers.ts +505 -0
  82. package/example/train_base/runtime.ts +894 -0
  83. package/example/train_base/stage.ts +276 -0
  84. package/example/train_base/ui.ts +333 -0
  85. package/jsr.json +1 -1
  86. package/package.json +2 -4
  87. package/src/geometry.ts +23 -0
  88. package/src/mind/attention.ts +54 -1
  89. package/src/mind/learning.ts +137 -43
  90. package/src/mind/mechanisms/cast.ts +48 -1
  91. package/src/mind/mind.ts +13 -2
  92. package/src/mind/reasoning.ts +64 -5
  93. package/src/mind/recognition.ts +29 -3
  94. package/src/mind/traverse.ts +19 -0
  95. package/src/store-sqlite.ts +53 -0
  96. package/src/store.ts +28 -0
  97. package/test/13-conversation.test.mjs +1 -1
  98. package/test/29-counterfactual.test.mjs +43 -6
  99. package/test/77-company-saturation.test.mjs +302 -0
  100. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  101. package/test/84-composed-answer-honesty.test.mjs +137 -0
  102. package/test/85-answered-directly.test.mjs +126 -0
  103. package/test/86-cast-voices-committed.test.mjs +164 -0
  104. package/test/87-codominant-commitment.test.mjs +250 -0
  105. package/test/88-dependency-footprint.test.mjs +99 -0
  106. package/dist/example/train_base.d.ts +0 -93
  107. package/dist/example/train_base.js +0 -2269
  108. package/example/train_base.ts +0 -2735
@@ -0,0 +1,88 @@
1
+ import type { Reader, RowAdapter } from "./readers.js";
2
+ import type { TrainCtx } from "./runtime.js";
3
+ /** One file to train: a shard, a per-language file, or a whole single-file
4
+ * corpus. Exactly one of `url` / `local` is set. */
5
+ export interface Unit {
6
+ /** Resume-id suffix — the corpus id and this form `${id}::${key}`. Part of
7
+ * the store's compatibility surface; see the file header. */
8
+ key: string;
9
+ /** How the run log names this unit once it is read. */
10
+ name: string;
11
+ /** How the live panel names it, and (unless `acquireLabel` overrides) how the
12
+ * download is labelled. Conventionally `${corpus.label} ${name}`. */
13
+ display: string;
14
+ url?: string;
15
+ local?: string;
16
+ /** Size in bytes when the listing said, else 0/absent. Summed BEFORE the
17
+ * stage reads anything, so the corpus progress bar has a denominator that
18
+ * does not grow underneath it. */
19
+ bytes?: number;
20
+ /** Cache filename. Defaults to the resume id with unsafe characters folded. */
21
+ dest?: string;
22
+ /** Download label, when it differs from `display`. */
23
+ acquireLabel?: string;
24
+ }
25
+ /** How one unit's outcome reads in the run log. All optional: the defaults are
26
+ * what every fact-shaped corpus prints. */
27
+ export interface LogStyle {
28
+ /** What one deposit is called. Default "facts". */
29
+ deposits?: string;
30
+ /** When set, the line reports "from N <rows>" — the count of rows that
31
+ * actually produced deposits. */
32
+ rows?: string;
33
+ /** What an unusable record is called. Default "unusable row(s)". */
34
+ bad?: string;
35
+ /** Report only the reader's `skipped` (malformed records), not the rows the
36
+ * adapter declined. For a corpus that DECLINES records by design — oasst2
37
+ * drops every single-turn tree — counting those as damage would be a lie. */
38
+ malformedOnly?: boolean;
39
+ }
40
+ export interface Corpus {
41
+ /** Tally key AND resume-id prefix. Compatibility surface — see the header. */
42
+ id: string;
43
+ /** Human name: the panel, the skip notices, the listing-failure message. */
44
+ label: string;
45
+ /** The dim tag in the log line, e.g. "translation", "social dialogue". */
46
+ kind: string;
47
+ enabled: boolean;
48
+ /** The work-list. Return [] for "nothing found" (the runner says so), or
49
+ * null when the corpus has already logged a more specific reason. */
50
+ discover(ctx: TrainCtx): Promise<Unit[] | null>;
51
+ read: Reader;
52
+ toItems: RowAdapter;
53
+ /** Stage-wide row budget; 0/absent = unbounded. See the budget notes in
54
+ * stage.ts. */
55
+ maxRows?: number;
56
+ /** Noun for the "N/M ___ to train" announcement. Absent ⇒ no announcement,
57
+ * which is what a single-unit corpus has always done. */
58
+ unitNoun?: string;
59
+ /** Keep a file that came from the CACHE after a complete read. Only oasst2
60
+ * does this: every other corpus deletes whatever acquire() handed it. */
61
+ keepCached?: boolean;
62
+ log?: LogStyle;
63
+ }
64
+ /** The string a store records once this unit is finished. ONE rule, no
65
+ * exceptions — see the file header for why there used to be two. */
66
+ export declare const unitIdOf: (corpus: Corpus, unit: Unit) => string;
67
+ /** Local files live under `LOCAL_PATH/<sub>`, or directly in LOCAL_PATH when
68
+ * `sub` is empty. Kept here because the layout is a user-facing convention:
69
+ * the corpora that share an extension (.json, .parquet) are kept apart by a
70
+ * subdirectory so a local run cannot feed one corpus's files to another. */
71
+ export declare const localDir: (sub: string) => string;
72
+ /** A single-unit corpus: one fixed URL, or one local file matched by pattern.
73
+ * Factored out because the three corpora that are ONE file resolve it the same
74
+ * way. `key` is what the store records this corpus under — `aya::dataset` is
75
+ * `key: "dataset"` — so it is required rather than defaulted: a resume id is
76
+ * the one thing here that must never be guessed. */
77
+ export declare function singleUnit(opts: {
78
+ key: string;
79
+ label: string;
80
+ display: string;
81
+ url: string;
82
+ dest: string;
83
+ acquireLabel?: string;
84
+ /** Patterns tried, in order, against LOCAL_PATH. */
85
+ localMatch: RegExp[];
86
+ /** How the "no local copy" notice describes what it looked for. */
87
+ localWhat: string;
88
+ }): (ctx: TrainCtx) => Promise<Unit[] | null>;
@@ -0,0 +1,65 @@
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
+ import { LOCAL_PATH } from "./config.js";
25
+ import { localFind } from "./discovery.js";
26
+ import { DIM, R } from "./ui.js";
27
+ import { join } from "node:path";
28
+ /** The string a store records once this unit is finished. ONE rule, no
29
+ * exceptions — see the file header for why there used to be two. */
30
+ export const unitIdOf = (corpus, unit) => `${corpus.id}::${unit.key}`;
31
+ /** Local files live under `LOCAL_PATH/<sub>`, or directly in LOCAL_PATH when
32
+ * `sub` is empty. Kept here because the layout is a user-facing convention:
33
+ * the corpora that share an extension (.json, .parquet) are kept apart by a
34
+ * subdirectory so a local run cannot feed one corpus's files to another. */
35
+ export const localDir = (sub) => sub ? join(LOCAL_PATH, sub) : LOCAL_PATH;
36
+ /** A single-unit corpus: one fixed URL, or one local file matched by pattern.
37
+ * Factored out because the three corpora that are ONE file resolve it the same
38
+ * way. `key` is what the store records this corpus under — `aya::dataset` is
39
+ * `key: "dataset"` — so it is required rather than defaulted: a resume id is
40
+ * the one thing here that must never be guessed. */
41
+ export function singleUnit(opts) {
42
+ return async (ctx) => {
43
+ if (LOCAL_PATH) {
44
+ const hit = localFind(LOCAL_PATH, ...opts.localMatch);
45
+ if (!hit) {
46
+ ctx.progress.log(` ${DIM}· no ${opts.localWhat} in ${LOCAL_PATH} — skipping${R}`);
47
+ return null;
48
+ }
49
+ return [{
50
+ key: opts.key,
51
+ name: opts.label,
52
+ display: opts.display,
53
+ local: join(LOCAL_PATH, hit),
54
+ }];
55
+ }
56
+ return [{
57
+ key: opts.key,
58
+ name: opts.label,
59
+ display: opts.display,
60
+ url: opts.url,
61
+ dest: opts.dest,
62
+ acquireLabel: opts.acquireLabel,
63
+ }];
64
+ };
65
+ }
@@ -0,0 +1,48 @@
1
+ import { type HttpOptions } from "./http.js";
2
+ /** A note a listing needs to make about ITSELF — an accepted-but-unexpected
3
+ * split name, say. Distinct from a failure: the run continues, but silently
4
+ * continuing would hide why fewer units appeared than expected. */
5
+ export type Note = (msg: string) => void;
6
+ /** One listed file: where it is, and how big it is.
7
+ *
8
+ * The SIZE is carried because the panel is otherwise dishonest. Corpus
9
+ * progress used to be measured against a total that GREW as each file was
10
+ * opened, so the bar ran to 100% at the end of every file and then fell back
11
+ * when the next one was added — 100%, 50%, 100%, 66%… Both listing APIs
12
+ * already return the size, so the denominator can simply be known before the
13
+ * first byte is read. */
14
+ export interface Listed {
15
+ path: string;
16
+ /** Bytes, or 0 when the source did not say. */
17
+ size: number;
18
+ }
19
+ /** Files under `path` in a Hugging Face dataset repo's main branch, filtered to
20
+ * an extension. Returns repo-relative paths (e.g. "smolsent/ha_en.jsonl"),
21
+ * sorted, so a run's unit order is stable across machines. */
22
+ export declare function hfTree(dataset: string, path: string, ext: RegExp, label: string, opts: HttpOptions): Promise<Listed[]>;
23
+ /** A dataset's Parquet shards on Hugging Face's auto-converted
24
+ * `refs/convert/parquet` branch, restricted to `config` and to `splits`.
25
+ *
26
+ * The converted branch is used rather than `main` because a dataset's own
27
+ * Parquet may be written as ONE giant row-group (SODA's is 1,191,582 rows),
28
+ * and a column chunk is per-group, so reading any part of it materialises all
29
+ * of it. The converted branch is uniformly 10,000-row groups.
30
+ *
31
+ * Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
32
+ * path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
33
+ * whose "/" must not be. */
34
+ export declare function hfConvertedParquet(dataset: string, config: string, splits: string[], label: string, opts: HttpOptions, note?: Note): Promise<Listed[]>;
35
+ /** File NAMES (not paths) in one directory of a GitHub repo, filtered to an
36
+ * extension and sorted. Used for corpora served from GitHub raw rather than
37
+ * Hugging Face — where the HF mirrors are loading-script repos with no data
38
+ * files, the official GitHub copy is the one carrying the licence notice. */
39
+ export declare function githubContents(repo: string, dir: string, ext: RegExp, label: string, opts: HttpOptions): Promise<Listed[]>;
40
+ /** Every file in a local directory matching `ext`, sorted. A missing directory
41
+ * is an empty list, not an error: LOCAL_PATH is an offline convenience and a
42
+ * stage with no local copy simply reports that and moves on. */
43
+ export declare function localFiles(dir: string, ext: RegExp): Listed[];
44
+ /** The FIRST file in a local directory matching any of `exts`, in directory
45
+ * order (deliberately NOT sorted — this mirrors the single-file stages, which
46
+ * take whichever copy the filesystem hands back first). Null when none match
47
+ * or the directory is absent. */
48
+ export declare function localFind(dir: string, ...exts: RegExp[]): string | null;
@@ -0,0 +1,143 @@
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
+ import { getJson, getJsonPaged } from "./http.js";
13
+ import { readdirSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ const sizeOf = (e) => {
16
+ const n = Number(e?.size ?? e?.lfs?.size ?? 0);
17
+ return Number.isFinite(n) && n > 0 ? n : 0;
18
+ };
19
+ /** Files under `path` in a Hugging Face dataset repo's main branch, filtered to
20
+ * an extension. Returns repo-relative paths (e.g. "smolsent/ha_en.jsonl"),
21
+ * sorted, so a run's unit order is stable across machines. */
22
+ export async function hfTree(dataset, path, ext, label, opts) {
23
+ // The dataset id is a PATH here, so its "/" must not be percent-encoded.
24
+ // `recursive=true` returns every file under `path`, 1,000 at a time — hence
25
+ // the PAGED fetch: a truncated work-list would train part of a corpus and
26
+ // then call it finished.
27
+ const url = `https://huggingface.co/api/datasets/${dataset}` +
28
+ `/tree/main/${path}?recursive=true`;
29
+ const body = await getJsonPaged(url, label, opts);
30
+ const out = body
31
+ .filter((e) => e?.type === "file" && ext.test(e?.path))
32
+ .map((e) => ({ path: String(e.path), size: sizeOf(e) }));
33
+ out.sort((a, b) => a.path.localeCompare(b.path));
34
+ return out;
35
+ }
36
+ /** A dataset's Parquet shards on Hugging Face's auto-converted
37
+ * `refs/convert/parquet` branch, restricted to `config` and to `splits`.
38
+ *
39
+ * The converted branch is used rather than `main` because a dataset's own
40
+ * Parquet may be written as ONE giant row-group (SODA's is 1,191,582 rows),
41
+ * and a column chunk is per-group, so reading any part of it materialises all
42
+ * of it. The converted branch is uniformly 10,000-row groups.
43
+ *
44
+ * Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
45
+ * path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
46
+ * whose "/" must not be. */
47
+ export async function hfConvertedParquet(dataset, config, splits, label, opts, note) {
48
+ const body = await getJsonPaged(`https://huggingface.co/api/datasets/${dataset}` +
49
+ `/tree/refs%2Fconvert%2Fparquet/${config}?recursive=true`, `GET ${label} tree`, opts);
50
+ const paths = body
51
+ .filter((e) => e?.type === "file" && /\.parquet$/i.test(e?.path))
52
+ .map((e) => ({ path: String(e.path), size: sizeOf(e) }));
53
+ paths.sort((a, b) => a.path.localeCompare(b.path));
54
+ // The tree is rooted at `config`, so the split is the second-to-last part.
55
+ const splitOf = (p) => p.split("/").slice(-2)[0] ?? "";
56
+ const present = new Set(paths.map((p) => splitOf(p.path)));
57
+ // MATCH THE SPLIT AGAINST WHAT THE BRANCH ACTUALLY CARRIES. An exact name is
58
+ // not guaranteed: the converter renames a split it could not finish, and
59
+ // shards a very large one. Observed on real datasets today —
60
+ // allenai/c4 → partial-train, partial-validation
61
+ // HuggingFaceFW/fineweb → train-part0
62
+ // — neither of which equals "train". The old exact-match filter returned []
63
+ // for both, and an empty work-list is reported as "no files found — skipping",
64
+ // which reads like a normal outcome rather than a corpus being dropped whole.
65
+ const wanted = new Set();
66
+ for (const want of splits) {
67
+ const pick = present.has(want)
68
+ ? want
69
+ : [...present].find((s) => s === `partial-${want}`) ??
70
+ [...present].find((s) => s.startsWith(`${want}-part`));
71
+ if (!pick) {
72
+ // Loud, not empty: a requested split that simply is not there is a
73
+ // configuration error, and silence would hide the whole corpus.
74
+ throw new Error(`${label}: split "${want}" is not on the converted branch — it ` +
75
+ `carries ${[...present].join(", ") || "no parquet at all"}`);
76
+ }
77
+ if (pick !== want) {
78
+ note?.(`${label}: split "${want}" is published as "${pick}"` +
79
+ (pick.startsWith("partial-")
80
+ ? " — Hugging Face has only PARTIALLY converted this dataset, so " +
81
+ "the shards below are not the whole split"
82
+ : ""));
83
+ }
84
+ wanted.add(pick);
85
+ }
86
+ return paths.filter((p) => wanted.has(splitOf(p.path)));
87
+ }
88
+ /** File NAMES (not paths) in one directory of a GitHub repo, filtered to an
89
+ * extension and sorted. Used for corpora served from GitHub raw rather than
90
+ * Hugging Face — where the HF mirrors are loading-script repos with no data
91
+ * files, the official GitHub copy is the one carrying the licence notice. */
92
+ export async function githubContents(repo, dir, ext, label, opts) {
93
+ const body = await getJson(`https://api.github.com/repos/${repo}/contents/${dir}`, label, opts);
94
+ const entries = Array.isArray(body) ? body : [];
95
+ // The contents API returns at most 1,000 entries for a directory and does NOT
96
+ // paginate them — it simply stops, with no Link header and no error. A
97
+ // directory at that boundary is therefore indistinguishable from a truncated
98
+ // one, so the only honest response is to refuse rather than train part of it
99
+ // and record the part as the whole.
100
+ if (entries.length >= 1000) {
101
+ throw new Error(`${label}: GitHub returned ${entries.length} entries, the point at ` +
102
+ `which the contents API truncates without saying so — this listing ` +
103
+ `cannot be trusted to be complete`);
104
+ }
105
+ const names = entries
106
+ .filter((e) => e?.type === "file" && ext.test(e?.name))
107
+ .map((e) => ({ path: String(e.name), size: sizeOf(e) }));
108
+ names.sort((a, b) => a.path.localeCompare(b.path));
109
+ return names;
110
+ }
111
+ /** Every file in a local directory matching `ext`, sorted. A missing directory
112
+ * is an empty list, not an error: LOCAL_PATH is an offline convenience and a
113
+ * stage with no local copy simply reports that and moves on. */
114
+ export function localFiles(dir, ext) {
115
+ try {
116
+ return readdirSync(dir)
117
+ .filter((f) => ext.test(f))
118
+ .sort()
119
+ .map((f) => {
120
+ let size = 0;
121
+ try {
122
+ size = statSync(join(dir, f)).size;
123
+ }
124
+ catch { /* unreadable — the read will report it */ }
125
+ return { path: f, size };
126
+ });
127
+ }
128
+ catch {
129
+ return []; // no such directory
130
+ }
131
+ }
132
+ /** The FIRST file in a local directory matching any of `exts`, in directory
133
+ * order (deliberately NOT sorted — this mirrors the single-file stages, which
134
+ * take whichever copy the filesystem hands back first). Null when none match
135
+ * or the directory is absent. */
136
+ export function localFind(dir, ...exts) {
137
+ try {
138
+ return readdirSync(dir).find((f) => exts.some((re) => re.test(f))) ?? null;
139
+ }
140
+ catch {
141
+ return null; // no such directory
142
+ }
143
+ }
@@ -0,0 +1,82 @@
1
+ /** Sleep `ms`, but wake early if `signal` fires — so a long back-off (e.g. a
2
+ * rate-limit wait) never swallows Ctrl+C. Resolves either way. */
3
+ export declare const waitMs: (ms: number, signal: AbortSignal) => Promise<void>;
4
+ /** Resolve `p`, but reject with a TimeoutError if it takes longer than `ms`.
5
+ * The underlying promise is left to settle on its own (we just stop waiting),
6
+ * so a slow black-box call can never wedge the caller. */
7
+ export declare function withTimeout<T>(p: Promise<T>, ms: number, label?: string): Promise<T>;
8
+ /** An HTTP error the caller tagged as transient. `.fatal` skips all retries;
9
+ * `.throttle` (a 429/503 rate-limit or overload) is retried indefinitely and
10
+ * does NOT consume the bounded attempt budget — the server told us to wait, not
11
+ * to give up. `.retryAfterMs` carries a server-suggested delay when present. */
12
+ export type HttpError = Error & {
13
+ fatal?: boolean;
14
+ throttle?: boolean;
15
+ retryAfterMs?: number;
16
+ };
17
+ /** What every network call in this trainer needs from its caller: how to be
18
+ * cancelled, and (optionally) where to report a rate-limit wait. */
19
+ export interface HttpOptions {
20
+ signal: AbortSignal;
21
+ /** Called after each throttle wait, so a 429 back-off reads as "waiting"
22
+ * rather than a silent hang. Omitted ⇒ the wait is silent. */
23
+ onThrottle?: (waitMsAmount: number, label: string) => void;
24
+ }
25
+ /** Wrap a throttle notice so a STORM of 429s logs at most one notice every
26
+ * `minGapMs`. Without the gap a busy server produces a wall of identical
27
+ * "waiting…" lines that pushes the real log out of the scrollback. */
28
+ export declare function throttleNotifier(fn: (waitMsAmount: number, label: string) => void, minGapMs?: number): (waitMsAmount: number, label: string) => void;
29
+ /** Retry `fn` with exponential backoff.
30
+ *
31
+ * Three error classes:
32
+ * • `.fatal` / AbortError → rethrown immediately (never retried).
33
+ * • `.throttle` (429/503) → the server is rate-limiting/overloaded. We are
34
+ * NOT failing — we WAIT (honouring Retry-After, else capped exponential
35
+ * back-off with jitter) and retry WITHOUT consuming an attempt, so a
36
+ * throttled request holds on until it succeeds rather than being dropped.
37
+ * Only a shutdown breaks this loop.
38
+ * • anything else → a genuine transient error, retried up to `tries`
39
+ * with exponential back-off before giving up.
40
+ *
41
+ * `onFail` is called after each non-throttle failed attempt; `onThrottle` after
42
+ * each throttle wait (for a "waiting…" notice). */
43
+ export declare function retry<T>(label: string, fn: () => Promise<T>, tries: number, opts: HttpOptions & {
44
+ onFail?: (attempt: number, err: Error) => void;
45
+ }): Promise<T>;
46
+ /** Classify a non-OK HTTP response into an {@link HttpError} for {@link retry}:
47
+ * • 429 / 503 → THROTTLE (rate-limited / overloaded): retried indefinitely,
48
+ * honouring a Retry-After header (seconds or an HTTP-date) when present.
49
+ * • other 5xx → transient: retried up to the caller's attempt budget.
50
+ * • other 4xx → FATAL: a real client error (404, 401, …) — not retried.
51
+ * Never throttles forever silently: the wait is interruptible by shutdown. */
52
+ export declare function httpError(res: Response): HttpError;
53
+ /** GET a URL and parse JSON, with the shared retry policy: rate-limits (429/503)
54
+ * WAIT indefinitely (surfaced through `opts.onThrottle`), other 4xx is fatal,
55
+ * other 5xx retried up to DOWNLOAD_TRIES. Used by every dataset LISTING call so
56
+ * all share the same never-drop-on-throttle behaviour. */
57
+ export declare function getJson(url: string, label: string, opts: HttpOptions): Promise<any>;
58
+ /** The `rel="next"` URL of an RFC 5988 Link header, or null. */
59
+ export declare function nextLink(header: string | null): string | null;
60
+ /** GET a paginated JSON ARRAY, following `Link: rel="next"` to the end.
61
+ *
62
+ * A LISTING THAT STOPS EARLY IS INVISIBLE, and that is why this exists.
63
+ * Hugging Face caps a tree listing at 1,000 entries and hands back a next
64
+ * link (verified: allenai/c4 returns exactly 1,000 plus a link). A caller that
65
+ * ignores it gets a work-list silently missing everything past the first page,
66
+ * trains it, marks those units complete, and thereafter reports the corpus
67
+ * "already trained". No error at any point. Following the links is the only
68
+ * way the work-list can be trusted to be the whole work-list.
69
+ *
70
+ * `maxPages` is a runaway guard, not a limit anyone should hit; exceeding it
71
+ * throws rather than returning a partial list, for exactly the reason above. */
72
+ export declare function getJsonPaged(url: string, label: string, opts: HttpOptions, maxPages?: number): Promise<unknown[]>;
73
+ /** Advertised transfer size of `url`, used only to reserve cache room. Like any
74
+ * `content-length` this is the ON-THE-WIRE size, so for a content-coded source
75
+ * (GitHub raw gzips JSON ~14x) it UNDER-estimates the file that lands on disk.
76
+ * That is tolerable here because the cache ceiling is a budget, not a
77
+ * correctness property — a run may overshoot MAX_CACHE_GB by the compression
78
+ * ratio of one in-flight file, and each file is deleted as soon as it is
79
+ * consumed. It must NOT be reused as an integrity check; see downloadFile.
80
+ *
81
+ * Rate-limits wait; other 4xx is fatal; total failure → the caller's catch. */
82
+ export declare function headSize(url: string, opts: HttpOptions): Promise<number>;
@@ -0,0 +1,219 @@
1
+ // train_base/http.ts — the shared network policy: wait out throttling, retry
2
+ // what is transient, give up at once on what is not.
3
+ //
4
+ // A leaf module by construction: everything it needs to cancel (the run's
5
+ // AbortSignal) and everything it needs to report (a throttle notice) arrives
6
+ // as a parameter. There is no module-level shutdown handle and no module-level
7
+ // log hook — those were globals precisely because this code used to live in the
8
+ // same file as the run that owned them.
9
+ import { DOWNLOAD_TRIES } from "./config.js";
10
+ /** Sleep `ms`, but wake early if `signal` fires — so a long back-off (e.g. a
11
+ * rate-limit wait) never swallows Ctrl+C. Resolves either way. */
12
+ export const waitMs = (ms, signal) => new Promise((resolve) => {
13
+ if (signal.aborted)
14
+ return resolve();
15
+ // NOTE: the timer is deliberately NOT unref'd — an unref'd timer does not
16
+ // keep the event loop alive, so a pending wait (e.g. the pace between page
17
+ // requests, or a rate-limit back-off) would let Node exit early and the run
18
+ // would "do nothing and close". The listener lets a shutdown wake it early.
19
+ const t = setTimeout(done, ms);
20
+ function done() {
21
+ clearTimeout(t);
22
+ signal.removeEventListener("abort", done);
23
+ resolve();
24
+ }
25
+ signal.addEventListener("abort", done, { once: true });
26
+ });
27
+ /** Resolve `p`, but reject with a TimeoutError if it takes longer than `ms`.
28
+ * The underlying promise is left to settle on its own (we just stop waiting),
29
+ * so a slow black-box call can never wedge the caller. */
30
+ export function withTimeout(p, ms, label = "operation") {
31
+ return new Promise((resolve, reject) => {
32
+ const t = setTimeout(() => {
33
+ const e = new Error(`${label} timed out after ${ms}ms`);
34
+ e.name = "TimeoutError";
35
+ reject(e);
36
+ }, ms);
37
+ if (typeof t.unref === "function")
38
+ t.unref();
39
+ p.then((v) => {
40
+ clearTimeout(t);
41
+ resolve(v);
42
+ }, (e) => {
43
+ clearTimeout(t);
44
+ reject(e);
45
+ });
46
+ });
47
+ }
48
+ /** Wrap a throttle notice so a STORM of 429s logs at most one notice every
49
+ * `minGapMs`. Without the gap a busy server produces a wall of identical
50
+ * "waiting…" lines that pushes the real log out of the scrollback. */
51
+ export function throttleNotifier(fn, minGapMs = 3000) {
52
+ let last = 0;
53
+ return (ms, label) => {
54
+ const now = Date.now();
55
+ if (now - last <= minGapMs)
56
+ return;
57
+ last = now;
58
+ fn(ms, label);
59
+ };
60
+ }
61
+ /** Retry `fn` with exponential backoff.
62
+ *
63
+ * Three error classes:
64
+ * • `.fatal` / AbortError → rethrown immediately (never retried).
65
+ * • `.throttle` (429/503) → the server is rate-limiting/overloaded. We are
66
+ * NOT failing — we WAIT (honouring Retry-After, else capped exponential
67
+ * back-off with jitter) and retry WITHOUT consuming an attempt, so a
68
+ * throttled request holds on until it succeeds rather than being dropped.
69
+ * Only a shutdown breaks this loop.
70
+ * • anything else → a genuine transient error, retried up to `tries`
71
+ * with exponential back-off before giving up.
72
+ *
73
+ * `onFail` is called after each non-throttle failed attempt; `onThrottle` after
74
+ * each throttle wait (for a "waiting…" notice). */
75
+ export async function retry(label, fn, tries, opts) {
76
+ const { signal, onFail, onThrottle } = opts;
77
+ let wait = 1000, last = "", throttleWait = 1000;
78
+ for (let attempt = 1; attempt <= tries;) {
79
+ if (signal.aborted) {
80
+ const e = new Error("aborted");
81
+ e.fatal = true;
82
+ throw e;
83
+ }
84
+ try {
85
+ return await fn();
86
+ }
87
+ catch (e) {
88
+ const err = e;
89
+ if (err.name === "AbortError" || err.fatal)
90
+ throw err;
91
+ // Rate-limited / overloaded: wait it out. Does NOT advance `attempt`, so a
92
+ // busy server can never exhaust the retry budget and drop the request.
93
+ if (err.throttle && !signal.aborted) {
94
+ // Honour Retry-After when the server sent one; else exponential back-off
95
+ // with jitter, capped, so a fleet of requests does not resynchronise.
96
+ const base = err.retryAfterMs && err.retryAfterMs > 0
97
+ ? err.retryAfterMs
98
+ : throttleWait;
99
+ const ms = Math.min(base, 60_000) +
100
+ Math.floor(base * 0.25 * Math.random());
101
+ onThrottle?.(ms, label);
102
+ await waitMs(ms, signal);
103
+ throttleWait = Math.min(throttleWait * 2, 60_000);
104
+ continue;
105
+ }
106
+ last = err.message;
107
+ onFail?.(attempt, err);
108
+ attempt++;
109
+ if (attempt <= tries) {
110
+ await waitMs(wait, signal);
111
+ wait = Math.min(wait * 2, 30_000);
112
+ }
113
+ }
114
+ }
115
+ throw new Error(`${label} failed after ${tries} attempts: ${last}`);
116
+ }
117
+ /** Classify a non-OK HTTP response into an {@link HttpError} for {@link retry}:
118
+ * • 429 / 503 → THROTTLE (rate-limited / overloaded): retried indefinitely,
119
+ * honouring a Retry-After header (seconds or an HTTP-date) when present.
120
+ * • other 5xx → transient: retried up to the caller's attempt budget.
121
+ * • other 4xx → FATAL: a real client error (404, 401, …) — not retried.
122
+ * Never throttles forever silently: the wait is interruptible by shutdown. */
123
+ export function httpError(res) {
124
+ const err = new Error(`HTTP ${res.status}`);
125
+ if (res.status === 429 || res.status === 503) {
126
+ err.throttle = true;
127
+ const ra = res.headers.get("retry-after");
128
+ if (ra) {
129
+ const secs = Number(ra);
130
+ if (Number.isFinite(secs))
131
+ err.retryAfterMs = Math.max(0, secs * 1000);
132
+ else {
133
+ const when = Date.parse(ra);
134
+ if (Number.isFinite(when)) {
135
+ err.retryAfterMs = Math.max(0, when - Date.now());
136
+ }
137
+ }
138
+ }
139
+ }
140
+ else if (res.status < 500) {
141
+ err.fatal = true; // genuine client error — do not retry
142
+ } // other 5xx: neither fatal nor throttle → ordinary bounded retry
143
+ return err;
144
+ }
145
+ /** GET a URL and parse JSON, with the shared retry policy: rate-limits (429/503)
146
+ * WAIT indefinitely (surfaced through `opts.onThrottle`), other 4xx is fatal,
147
+ * other 5xx retried up to DOWNLOAD_TRIES. Used by every dataset LISTING call so
148
+ * all share the same never-drop-on-throttle behaviour. */
149
+ export async function getJson(url, label, opts) {
150
+ return retry(label, async () => {
151
+ const res = await fetch(url, { signal: opts.signal });
152
+ if (res.ok)
153
+ return res.json();
154
+ throw httpError(res);
155
+ }, DOWNLOAD_TRIES, opts);
156
+ }
157
+ /** The `rel="next"` URL of an RFC 5988 Link header, or null. */
158
+ export function nextLink(header) {
159
+ if (!header)
160
+ return null;
161
+ for (const part of header.split(",")) {
162
+ const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
163
+ if (m)
164
+ return m[1];
165
+ }
166
+ return null;
167
+ }
168
+ /** GET a paginated JSON ARRAY, following `Link: rel="next"` to the end.
169
+ *
170
+ * A LISTING THAT STOPS EARLY IS INVISIBLE, and that is why this exists.
171
+ * Hugging Face caps a tree listing at 1,000 entries and hands back a next
172
+ * link (verified: allenai/c4 returns exactly 1,000 plus a link). A caller that
173
+ * ignores it gets a work-list silently missing everything past the first page,
174
+ * trains it, marks those units complete, and thereafter reports the corpus
175
+ * "already trained". No error at any point. Following the links is the only
176
+ * way the work-list can be trusted to be the whole work-list.
177
+ *
178
+ * `maxPages` is a runaway guard, not a limit anyone should hit; exceeding it
179
+ * throws rather than returning a partial list, for exactly the reason above. */
180
+ export async function getJsonPaged(url, label, opts, maxPages = 500) {
181
+ const out = [];
182
+ let next = url;
183
+ let pages = 0;
184
+ while (next !== null) {
185
+ const at = next;
186
+ const { body, link } = await retry(label, async () => {
187
+ const res = await fetch(at, { signal: opts.signal });
188
+ if (!res.ok)
189
+ throw httpError(res);
190
+ return { body: await res.json(), link: res.headers.get("link") };
191
+ }, DOWNLOAD_TRIES, opts);
192
+ if (!Array.isArray(body))
193
+ break; // not a listing — nothing to page through
194
+ out.push(...body);
195
+ next = nextLink(link);
196
+ if (++pages >= maxPages && next) {
197
+ throw new Error(`${label}: more than ${maxPages} pages of listing — refusing to ` +
198
+ `continue with a work-list that may be incomplete`);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ /** Advertised transfer size of `url`, used only to reserve cache room. Like any
204
+ * `content-length` this is the ON-THE-WIRE size, so for a content-coded source
205
+ * (GitHub raw gzips JSON ~14x) it UNDER-estimates the file that lands on disk.
206
+ * That is tolerable here because the cache ceiling is a budget, not a
207
+ * correctness property — a run may overshoot MAX_CACHE_GB by the compression
208
+ * ratio of one in-flight file, and each file is deleted as soon as it is
209
+ * consumed. It must NOT be reused as an integrity check; see downloadFile.
210
+ *
211
+ * Rate-limits wait; other 4xx is fatal; total failure → the caller's catch. */
212
+ export async function headSize(url, opts) {
213
+ return retry(`HEAD ${url}`, async () => {
214
+ const res = await fetch(url, { method: "HEAD", signal: opts.signal });
215
+ if (res.ok)
216
+ return Number(res.headers.get("content-length")) || 0;
217
+ throw httpError(res);
218
+ }, 4, opts);
219
+ }