@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.
- package/AGENTS.md +20 -4
- package/DATASETS.md +12 -11
- package/dist/example/train_base/cache.d.ts +35 -0
- package/dist/example/train_base/cache.js +211 -0
- package/dist/example/train_base/config.d.ts +21 -0
- package/dist/example/train_base/config.js +94 -0
- package/dist/example/train_base/corpora/aya.d.ts +19 -0
- package/dist/example/train_base/corpora/aya.js +76 -0
- package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
- package/dist/example/train_base/corpora/converted-parquet.js +44 -0
- package/dist/example/train_base/corpora/genknow.d.ts +14 -0
- package/dist/example/train_base/corpora/genknow.js +83 -0
- package/dist/example/train_base/corpora/index.d.ts +29 -0
- package/dist/example/train_base/corpora/index.js +81 -0
- package/dist/example/train_base/corpora/massive.d.ts +7 -0
- package/dist/example/train_base/corpora/massive.js +98 -0
- package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
- package/dist/example/train_base/corpora/oasst2.js +120 -0
- package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
- package/dist/example/train_base/corpora/smolsent.js +156 -0
- package/dist/example/train_base/corpora/soda.d.ts +12 -0
- package/dist/example/train_base/corpora/soda.js +113 -0
- package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
- package/dist/example/train_base/corpora/taskmaster.js +144 -0
- package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
- package/dist/example/train_base/corpora/wiki2.js +132 -0
- package/dist/example/train_base/corpus.d.ts +88 -0
- package/dist/example/train_base/corpus.js +65 -0
- package/dist/example/train_base/discovery.d.ts +48 -0
- package/dist/example/train_base/discovery.js +143 -0
- package/dist/example/train_base/http.d.ts +82 -0
- package/dist/example/train_base/http.js +219 -0
- package/dist/example/train_base/items.d.ts +46 -0
- package/dist/example/train_base/items.js +98 -0
- package/dist/example/train_base/main.d.ts +4 -0
- package/dist/example/train_base/main.js +207 -0
- package/dist/example/train_base/progress.d.ts +34 -0
- package/dist/example/train_base/progress.js +114 -0
- package/dist/example/train_base/readers.d.ts +125 -0
- package/dist/example/train_base/readers.js +391 -0
- package/dist/example/train_base/runtime.d.ts +115 -0
- package/dist/example/train_base/runtime.js +637 -0
- package/dist/example/train_base/stage.d.ts +3 -0
- package/dist/example/train_base/stage.js +246 -0
- package/dist/example/train_base/ui.d.ts +88 -0
- package/dist/example/train_base/ui.js +272 -0
- package/dist/src/mind/mind.d.ts +1 -1
- package/dist/src/mind/mind.js +1 -1
- package/example/train_base/cache.ts +251 -0
- package/example/train_base/config.ts +128 -0
- package/example/train_base/corpora/aya.ts +106 -0
- package/example/train_base/corpora/converted-parquet.ts +64 -0
- package/example/train_base/corpora/genknow.ts +114 -0
- package/example/train_base/corpora/index.ts +88 -0
- package/example/train_base/corpora/massive.ts +111 -0
- package/example/train_base/corpora/oasst2.ts +163 -0
- package/example/train_base/corpora/smolsent.ts +203 -0
- package/example/train_base/corpora/soda.ts +130 -0
- package/example/train_base/corpora/taskmaster.ts +217 -0
- package/example/train_base/corpora/wiki2.ts +190 -0
- package/example/train_base/corpus.ts +150 -0
- package/example/train_base/discovery.ts +203 -0
- package/example/train_base/http.ts +284 -0
- package/example/train_base/items.ts +118 -0
- package/example/train_base/main.ts +240 -0
- package/example/train_base/progress.ts +149 -0
- package/example/train_base/readers.ts +505 -0
- package/example/train_base/runtime.ts +894 -0
- package/example/train_base/stage.ts +276 -0
- package/example/train_base/ui.ts +333 -0
- package/jsr.json +1 -1
- package/package.json +2 -4
- package/src/mind/mind.ts +1 -1
- package/test/13-conversation.test.mjs +1 -1
- package/test/84-composed-answer-honesty.test.mjs +2 -1
- package/test/88-dependency-footprint.test.mjs +99 -0
- package/dist/example/train_base.d.ts +0 -163
- package/dist/example/train_base.js +0 -3220
- package/example/train_base.ts +0 -3882
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface Episode {
|
|
2
|
+
context: string;
|
|
3
|
+
continuation: string;
|
|
4
|
+
}
|
|
5
|
+
export type TrainingItem = string | Episode;
|
|
6
|
+
export declare const isEpisode: (it: TrainingItem) => it is Episode;
|
|
7
|
+
/** One turn of a dialogue, attributed to a speaker. The speaker is only ever
|
|
8
|
+
* used to decide MERGING (see `mergeSpeakerTurns`); it is never deposited. */
|
|
9
|
+
export interface SpeakerTurn {
|
|
10
|
+
speaker: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
/** Build the accumulated-context episodes of a turn sequence: each successive
|
|
14
|
+
* turn is the continuation of ALL the turns before it joined together. This is
|
|
15
|
+
* the same cumulative-context shape a multi-turn conversation deposits, so the
|
|
16
|
+
* store learns to continue a growing context.
|
|
17
|
+
*
|
|
18
|
+
* The "\n" below is a CORPUS choice, not a protocol. oasst2 turns are
|
|
19
|
+
* paragraphs, and reading them back with the newlines kept is how this corpus
|
|
20
|
+
* reads naturally; a different corpus may join with nothing, and
|
|
21
|
+
* test/13-conversation.test.mjs does exactly that. Neither has to match the
|
|
22
|
+
* other, because Sema never scans content for turn boundaries — those are
|
|
23
|
+
* offsets the Conversation API carries beside the bytes (see Mind.addTurn's
|
|
24
|
+
* "ON SEPARATORS" note). The newline here is simply part of the text this
|
|
25
|
+
* store learnt, so anything replaying this corpus feeds it back as part of
|
|
26
|
+
* the turn: `addTurn(conv, "\n" + turnText)`. It is not a convention the
|
|
27
|
+
* engine, the API, or the tests have to agree on. */
|
|
28
|
+
export declare function accumulate(turns: string[]): Episode[];
|
|
29
|
+
/** Collapse consecutive same-speaker turns into one, joining with a space, and
|
|
30
|
+
* return the bare texts in order. A turn with no speaker never merges with its
|
|
31
|
+
* neighbour: an unlabelled row is of unknown origin, and joining two of them
|
|
32
|
+
* would invent a contribution that may span two speakers.
|
|
33
|
+
*
|
|
34
|
+
* Load-bearing for corpora that split one contribution across several indexed
|
|
35
|
+
* utterances (an artifact of the collection UI). Left unmerged, the cumulative
|
|
36
|
+
* walk deposits a turn boundary in the middle of one speaker's contribution
|
|
37
|
+
* and teaches it as a hand-off. Measured share of turns absorbed by merging:
|
|
38
|
+
* TM-1 17.7%, TM-2 11.9%, TM-3 0.8%, TM-4 0.0%. */
|
|
39
|
+
export declare function mergeSpeakerTurns(turns: SpeakerTurn[]): string[];
|
|
40
|
+
/** Dedup + trim a concept's items: drop empty/degenerate pairs and exact
|
|
41
|
+
* repeats so a concept never deposits the same form twice. */
|
|
42
|
+
export declare function refineItems(items: TrainingItem[]): TrainingItem[];
|
|
43
|
+
/** Content size of a training item in UTF-8 bytes — the same quantity the
|
|
44
|
+
* scaling suite (14-scaling.test.mjs) measures as KB/s: for an episode the
|
|
45
|
+
* context plus the continuation, for a bare experience its own text. */
|
|
46
|
+
export declare const itemBytes: (it: TrainingItem) => number;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// train_base/items.ts — the REPRESENTATION core: what a training item is, and
|
|
2
|
+
// the three shapes a corpus datum may take.
|
|
3
|
+
//
|
|
4
|
+
// REPRESENTATION POLICY (one datum → one form; no replication):
|
|
5
|
+
// • FACTS are the default. A datum that is a RELATION (translation pair,
|
|
6
|
+
// question → answer) is emitted as a (context → continuation) edge SEMA
|
|
7
|
+
// points at and, by example across the corpus, generalizes from (cf.
|
|
8
|
+
// example/demo.ts).
|
|
9
|
+
// • EXPERIENCES (bare statements) are used only when a fact is NOT possible —
|
|
10
|
+
// content with no natural relational split.
|
|
11
|
+
// • CUMULATIVE CONTINUOUS CONTEXT is used only when truly necessary — genuine
|
|
12
|
+
// MULTI-TURN dialogue, where a turn follows from the whole conversation so
|
|
13
|
+
// far. The fact stages do NOT synthesize a multi-turn walk, which would just
|
|
14
|
+
// replicate the facts (repetition SEMA avoids).
|
|
15
|
+
//
|
|
16
|
+
// Nothing here reads the environment or touches I/O: these are the pure
|
|
17
|
+
// functions every corpus adapter is built out of.
|
|
18
|
+
export const isEpisode = (it) => typeof it !== "string";
|
|
19
|
+
/** Build the accumulated-context episodes of a turn sequence: each successive
|
|
20
|
+
* turn is the continuation of ALL the turns before it joined together. This is
|
|
21
|
+
* the same cumulative-context shape a multi-turn conversation deposits, so the
|
|
22
|
+
* store learns to continue a growing context.
|
|
23
|
+
*
|
|
24
|
+
* The "\n" below is a CORPUS choice, not a protocol. oasst2 turns are
|
|
25
|
+
* paragraphs, and reading them back with the newlines kept is how this corpus
|
|
26
|
+
* reads naturally; a different corpus may join with nothing, and
|
|
27
|
+
* test/13-conversation.test.mjs does exactly that. Neither has to match the
|
|
28
|
+
* other, because Sema never scans content for turn boundaries — those are
|
|
29
|
+
* offsets the Conversation API carries beside the bytes (see Mind.addTurn's
|
|
30
|
+
* "ON SEPARATORS" note). The newline here is simply part of the text this
|
|
31
|
+
* store learnt, so anything replaying this corpus feeds it back as part of
|
|
32
|
+
* the turn: `addTurn(conv, "\n" + turnText)`. It is not a convention the
|
|
33
|
+
* engine, the API, or the tests have to agree on. */
|
|
34
|
+
export function accumulate(turns) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 1; i < turns.length; i++) {
|
|
37
|
+
out.push({ context: turns.slice(0, i).join("\n"), continuation: turns[i] });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Collapse consecutive same-speaker turns into one, joining with a space, and
|
|
42
|
+
* return the bare texts in order. A turn with no speaker never merges with its
|
|
43
|
+
* neighbour: an unlabelled row is of unknown origin, and joining two of them
|
|
44
|
+
* would invent a contribution that may span two speakers.
|
|
45
|
+
*
|
|
46
|
+
* Load-bearing for corpora that split one contribution across several indexed
|
|
47
|
+
* utterances (an artifact of the collection UI). Left unmerged, the cumulative
|
|
48
|
+
* walk deposits a turn boundary in the middle of one speaker's contribution
|
|
49
|
+
* and teaches it as a hand-off. Measured share of turns absorbed by merging:
|
|
50
|
+
* TM-1 17.7%, TM-2 11.9%, TM-3 0.8%, TM-4 0.0%. */
|
|
51
|
+
export function mergeSpeakerTurns(turns) {
|
|
52
|
+
const out = [];
|
|
53
|
+
let prev = "";
|
|
54
|
+
for (const t of turns) {
|
|
55
|
+
if (out.length > 0 && t.speaker !== "" && t.speaker === prev) {
|
|
56
|
+
out[out.length - 1] += " " + t.text;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out.push(t.text);
|
|
60
|
+
}
|
|
61
|
+
prev = t.speaker;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Dedup + trim a concept's items: drop empty/degenerate pairs and exact
|
|
66
|
+
* repeats so a concept never deposits the same form twice. */
|
|
67
|
+
export function refineItems(items) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const it of items) {
|
|
71
|
+
if (!isEpisode(it)) {
|
|
72
|
+
const exp = it.trim();
|
|
73
|
+
const key = "E:" + exp;
|
|
74
|
+
if (exp && !seen.has(key)) {
|
|
75
|
+
seen.add(key);
|
|
76
|
+
out.push(exp);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const ctx = it.context.trim();
|
|
81
|
+
const cont = it.continuation.trim();
|
|
82
|
+
if (!ctx || !cont || ctx === cont)
|
|
83
|
+
continue;
|
|
84
|
+
const key = "P:" + ctx + "\u0000" + cont;
|
|
85
|
+
if (seen.has(key))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
out.push({ context: ctx, continuation: cont });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
const ENC = new TextEncoder();
|
|
93
|
+
/** Content size of a training item in UTF-8 bytes — the same quantity the
|
|
94
|
+
* scaling suite (14-scaling.test.mjs) measures as KB/s: for an episode the
|
|
95
|
+
* context plus the continuation, for a bare experience its own text. */
|
|
96
|
+
export const itemBytes = (it) => isEpisode(it)
|
|
97
|
+
? ENC.encode(it.context).length + ENC.encode(it.continuation).length
|
|
98
|
+
: ENC.encode(it).length;
|