@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.
- package/.github/workflows/release.yml +80 -0
- package/AGENTS.md +73 -13
- 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/meter.d.ts +1 -4
- package/dist/src/meter.js +0 -3
- package/dist/src/mind/attention.js +22 -20
- package/dist/src/mind/graph-search.d.ts +43 -9
- package/dist/src/mind/graph-search.js +82 -15
- package/dist/src/mind/junction.d.ts +13 -0
- package/dist/src/mind/junction.js +13 -0
- package/dist/src/mind/mechanisms/cover.js +23 -2
- package/dist/src/mind/mechanisms/prefix-completion.js +13 -11
- package/dist/src/mind/mechanisms/recall.js +8 -4
- package/dist/src/mind/mind.d.ts +1 -1
- package/dist/src/mind/mind.js +1 -1
- package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
- package/dist/src/mind/pipeline-mechanism.js +13 -36
- package/dist/src/mind/pipeline.d.ts +23 -0
- package/dist/src/mind/pipeline.js +51 -3
- package/dist/src/mind/recognition.d.ts +6 -1
- package/dist/src/mind/recognition.js +11 -6
- package/dist/src/mind/resonance.js +48 -13
- package/dist/src/store.js +22 -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 +8 -5
- package/src/meter.ts +1 -4
- package/src/mind/attention.ts +22 -19
- package/src/mind/graph-search.ts +93 -16
- package/src/mind/junction.ts +13 -0
- package/src/mind/mechanisms/cover.ts +23 -4
- package/src/mind/mechanisms/prefix-completion.ts +13 -11
- package/src/mind/mechanisms/recall.ts +8 -4
- package/src/mind/mind.ts +1 -1
- package/src/mind/pipeline-mechanism.ts +13 -42
- package/src/mind/pipeline.ts +87 -3
- package/src/mind/recognition.ts +19 -6
- package/src/mind/resonance.ts +79 -50
- package/src/store.ts +21 -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/test/89-completion-recursion.test.mjs +230 -0
- package/test/90-connector-read-cap.test.mjs +130 -0
- package/test/91-branch-bytes-cache.test.mjs +152 -0
- package/test/93-regime-prediction.test.mjs +148 -0
- package/test/94-cross-region-budget.test.mjs +67 -0
- package/test/95-wide-resonance-removed.test.mjs +109 -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
|
@@ -1,3220 +0,0 @@
|
|
|
1
|
-
//This file uses the Google SMOL dataset, made available under the CC BY 4.0 license.
|
|
2
|
-
//This file uses Aya and oasst2 datasets, made available under the apache-2.0 license.
|
|
3
|
-
//
|
|
4
|
-
//A trained Sema store retains its training text VERBATIM, so distributing a
|
|
5
|
-
//store distributes these corpora and every upstream licence applies to it in
|
|
6
|
-
//full. Read DATASETS.md before adding a corpus here or publishing a store:
|
|
7
|
-
//it carries the per-corpus attribution a distributed store is required to
|
|
8
|
-
//travel with, and the two rules a candidate corpus must pass (no NonCommercial
|
|
9
|
-
//term, no ShareAlike term — checked against what the corpus was BUILT FROM,
|
|
10
|
-
//not merely against the repository's licence tag).
|
|
11
|
-
//This file is a more appropriate training example for Sema.
|
|
12
|
-
//Sema does not learn through repetition;
|
|
13
|
-
//it does not require a massive database.
|
|
14
|
-
//It needs fundamental datasets that teach basic cognitive concepts such as conversation, logic, relationships, behaviors and feelings.
|
|
15
|
-
//The focus is on covering fundamental patterns, not repetition.
|
|
16
|
-
//Tip: ontology-based adapted training datasets could be an interesting path.
|
|
17
|
-
// train_base.ts — streaming trainer for the SmolSent + Aya + oasst2 +
|
|
18
|
-
// General-Knowledge base.
|
|
19
|
-
//
|
|
20
|
-
// Training IS deposition: every source datum is translated into SEMA facts (or,
|
|
21
|
-
// for genuine dialogue, accumulated-context episodes), then stored in one pass.
|
|
22
|
-
// There are no gradients or epochs, and there is no LLM in the loop — the only
|
|
23
|
-
// "model" is the SEMA store itself. The ingestion structures, filtering,
|
|
24
|
-
// checkpointing, cache, and resume model are unchanged from the original LLM-
|
|
25
|
-
// base trainer; only corpus discovery and the row adapters are source-specific.
|
|
26
|
-
//
|
|
27
|
-
// Every source here is commercially licensable (cc-by-4.0 / apache-2.0).
|
|
28
|
-
//
|
|
29
|
-
// The curriculum runs in eight stages, into ONE store:
|
|
30
|
-
// 1. SmolSent (google/smol) — sentence-level TRANSLATION pairs across 100+
|
|
31
|
-
// low-resource languages; see §6c. Each pair is "two names for one meaning"
|
|
32
|
-
// → a foreign→English translation FACT, so every language's rendering of a
|
|
33
|
-
// meaning converges on ONE English node — the cross-language concept SEMA
|
|
34
|
-
// fuses (cf. test/05-concepts.test.mjs). The reverse binding is NOT
|
|
35
|
-
// deposited by default; see SMOLSENT_DIRECTIONS for why.
|
|
36
|
-
// 2. Aya Dataset — ~204k human prompt→completion pairs, 70+ languages; see §6d
|
|
37
|
-
// → one (question → answer) FACT each.
|
|
38
|
-
// 3. oasst2 — MULTI-TURN human↔assistant conversation trees; see §6e → the
|
|
39
|
-
// accumulated-context walk (single-turn trees are skipped, by design).
|
|
40
|
-
// 4. Taskmaster 1–4 (google-research-datasets) — task-oriented DIALOGUE, the
|
|
41
|
-
// best-scoring corpora on the fold-unit recurrence benchmark that predicts
|
|
42
|
-
// halo health; see §6e′ → the accumulated-context walk, over turns merged
|
|
43
|
-
// per speaker.
|
|
44
|
-
// 5. 2WikiMultihopQA — the `evidences` (subject, relation, object) TRIPLES,
|
|
45
|
-
// the one stage aimed at COMPOSITION; see §6e″ → a relation fact plus a
|
|
46
|
-
// bare-subject PIVOT fact each. Its Wikipedia passages and its composed
|
|
47
|
-
// questions are deliberately NOT read.
|
|
48
|
-
// 6. SODA — social/commonsense DIALOGUE; see §6e‴ → the accumulated-context
|
|
49
|
-
// walk. Budgeted: its train split alone would otherwise contribute ~8M
|
|
50
|
-
// episodes against 662k for the whole current corpus.
|
|
51
|
-
// 7. MASSIVE — short intent utterances in 51 locales; see §6e⁗ → ONE bare
|
|
52
|
-
// experience each. Contributes recurring fold units, nothing relational.
|
|
53
|
-
// DISABLED BY DEFAULT — edge-less content was measured to manufacture
|
|
54
|
-
// answers where the store should stay silent.
|
|
55
|
-
// 8. General-Knowledge (MuskumPillerum) — ~37.6k {Question, Answer} pairs; see
|
|
56
|
-
// §6f → one (question → answer) FACT each. DISABLED BY DEFAULT on licence
|
|
57
|
-
// grounds; see DATASETS.md §3.2.
|
|
58
|
-
// Each stage runs only after the previous one finishes, and is recorded in the
|
|
59
|
-
// same completed-files set, so a single store resumes the whole curriculum.
|
|
60
|
-
//
|
|
61
|
-
// Every source is DOWNLOADED as a file and streamed from disk (never paged
|
|
62
|
-
// row-by-row over an HTTP API — that was slow and rate-limited): SmolSent as
|
|
63
|
-
// per-pair JSONL, oasst2 as a gzipped JSONL, Taskmaster and General-Knowledge as
|
|
64
|
-
// JSON arrays,
|
|
65
|
-
// and Aya as Snappy-Parquet read row-group by row-group with hyparquet (the one
|
|
66
|
-
// case the web platform can't decode alone). Resume is per-file: a fully-
|
|
67
|
-
// consumed file is marked complete; an interrupted one re-reads from the top
|
|
68
|
-
// (re-deposition is idempotent). LOCAL_PATH may hold pre-downloaded files.
|
|
69
|
-
//
|
|
70
|
-
// REPRESENTATION POLICY (one datum → one form; no replication):
|
|
71
|
-
// • FACTS are the default. A datum that is a RELATION (translation pair,
|
|
72
|
-
// question → answer) is emitted as a (context → continuation) edge SEMA
|
|
73
|
-
// points at and, by example across the corpus, generalizes from (cf.
|
|
74
|
-
// example/demo.ts). SmolSent emits two facts (both directions); Aya one.
|
|
75
|
-
// • EXPERIENCES (bare statements) are used only when a fact is NOT possible —
|
|
76
|
-
// content with no natural relational split. (No current stage needs this;
|
|
77
|
-
// it stays available for plain-text corpora.)
|
|
78
|
-
// • CUMULATIVE CONTINUOUS CONTEXT is used only when truly necessary — genuine
|
|
79
|
-
// MULTI-TURN dialogue, where a turn follows from the whole conversation so
|
|
80
|
-
// far. Only oasst2 (§6e) uses it; the fact stages do NOT synthesize a multi-
|
|
81
|
-
// turn walk, which would just replicate the facts (repetition SEMA avoids).
|
|
82
|
-
//
|
|
83
|
-
// The store IS the model: memories, training metadata, and the config snapshot
|
|
84
|
-
// all live in {DB_PATH}.sqlite, so a run resumes from the store alone.
|
|
85
|
-
//
|
|
86
|
-
// Built on web standards. All I/O except the durable disk cache uses platform
|
|
87
|
-
// primitives — fetch, WHATWG ReadableStream/WritableStream/TransformStream,
|
|
88
|
-
// DecompressionStream ("gzip" for the oasst2 file), TextDecoderStream, Blob,
|
|
89
|
-
// AbortController. The sole third-party code is hyparquet (+ its Snappy codec),
|
|
90
|
-
// used only to read Aya's Parquet over a web-standard Blob byte source. Node's
|
|
91
|
-
// stdlib is touched only for the filesystem (the cache), which the web platform
|
|
92
|
-
// does not expose. Consistency guarantees:
|
|
93
|
-
// • Resume from the store alone — completed stage-units, example count,
|
|
94
|
-
// learned-content bytes, and processed-byte total are persisted in
|
|
95
|
-
// {DB_PATH}.sqlite and reloaded. API stages persist a page offset; the
|
|
96
|
-
// oasst2 download is atomic (see below).
|
|
97
|
-
// • Atomic cache — a download streams to "<file>.part", is fsync'd, then
|
|
98
|
-
// renamed into place; a file at its final path is, by construction,
|
|
99
|
-
// complete, so an interrupted download can never be mistaken for a cached
|
|
100
|
-
// one.
|
|
101
|
-
// • Bounded cache — a download blocks under the MAX_CACHE_GB ceiling and the
|
|
102
|
-
// fully-processed file is deleted immediately.
|
|
103
|
-
// • Interruptible — Ctrl+C (SIGINT/SIGTERM) aborts in-flight network at once,
|
|
104
|
-
// stops at the next item boundary, writes a final checkpoint, and exits; an
|
|
105
|
-
// un-finished stage-unit is NOT marked complete, so resume re-reads it (re-
|
|
106
|
-
// deposition is idempotent). A second Ctrl+C, or a 60s watchdog, force-exits.
|
|
107
|
-
//
|
|
108
|
-
// Run:
|
|
109
|
-
// npx tsc && node dist/example/train_base.js
|
|
110
|
-
// MAX_MB=500 node dist/example/train_base.js
|
|
111
|
-
// CHECKPOINT_MB=250 node dist/example/train_base.js
|
|
112
|
-
// SMOLSENT_PAIRS=ha_en,zu_en node dist/example/train_base.js # a subset of pairs
|
|
113
|
-
// SMOLSENT_DIRECTIONS=both node dist/example/train_base.js # also English->foreign
|
|
114
|
-
// SMOLSENT=0 node dist/example/train_base.js # skip SmolSent stage
|
|
115
|
-
// AYA=0 node dist/example/train_base.js # skip Aya stage
|
|
116
|
-
// AYA_SPLIT=test node dist/example/train_base.js # small Aya slice
|
|
117
|
-
// OASST=0 node dist/example/train_base.js # skip oasst2 stage
|
|
118
|
-
// OASST_MIN_TURNS=6 node dist/example/train_base.js # deeper multi-turn only
|
|
119
|
-
// GENKNOW=1 node dist/example/train_base.js # General-Knowledge (see DATASETS.md §3.2)
|
|
120
|
-
// PARQUET_BATCH_MB=8 node dist/example/train_base.js # smaller Parquet reads on a tight host
|
|
121
|
-
// TASKMASTER=0 node dist/example/train_base.js # skip Taskmaster stage
|
|
122
|
-
// TASKMASTER_SETS=TM-3-2020 node dist/example/train_base.js # one Taskmaster set
|
|
123
|
-
// WIKI2=0 node dist/example/train_base.js # skip 2Wiki triples stage
|
|
124
|
-
// SODA=0 node dist/example/train_base.js # skip the SODA stage
|
|
125
|
-
// MASSIVE=1 node dist/example/train_base.js # enable MASSIVE (off by default)
|
|
126
|
-
// SODA_MAX_DIALOGS=0 node dist/example/train_base.js # lift the SODA budget
|
|
127
|
-
// WIKI2_MAX_ROWS=50000 node dist/example/train_base.js # budget the 2Wiki stage
|
|
128
|
-
// LOCAL_PATH=./base node dist/example/train_base.js # offline: *.jsonl/.parquet/.jsonl.gz/.json
|
|
129
|
-
// DB_PATH=./data/sema node dist/example/train_base.js
|
|
130
|
-
import { CachedIngest, Mind, SQliteStore } from "../src/index.js";
|
|
131
|
-
// One Node module — node:fs — and nothing else. Everything else (HTTP, byte
|
|
132
|
-
// streams, (de)compression, text decoding, cancellation) is a web standard:
|
|
133
|
-
// fetch, WHATWG ReadableStream/WritableStream/TransformStream,
|
|
134
|
-
// DecompressionStream, TextDecoderStream, Blob, AbortController. Reading a file
|
|
135
|
-
// goes through openAsBlob, which returns a web Blob (`.stream()` → web streams);
|
|
136
|
-
// writing a file is the single capability the web platform does not expose, so
|
|
137
|
-
// the download sink uses the synchronous fs descriptor calls below. The durable
|
|
138
|
-
// disk cache is therefore the sole, irreducible Node dependency.
|
|
139
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, openAsBlob, openSync, readdirSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
140
|
-
import { basename, join } from "node:path";
|
|
141
|
-
// The ONLY third-party dependencies, and only for the one source that ships
|
|
142
|
-
// exclusively as Snappy-compressed Parquet (Aya): hyparquet is a pure-JS,
|
|
143
|
-
// dependency-free Parquet reader driven over a web-standard Blob byte source;
|
|
144
|
-
// hyparquet-compressors supplies the Snappy codec. Every other source is plain
|
|
145
|
-
// JSONL / JSON / gzip and needs no library.
|
|
146
|
-
import { parquetMetadataAsync, parquetReadObjects } from "hyparquet";
|
|
147
|
-
import { compressors } from "hyparquet-compressors";
|
|
148
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
149
|
-
// §1 Configuration (all from the environment)
|
|
150
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
151
|
-
const env = (k, d) => process.env[k] ?? d;
|
|
152
|
-
// ── google/smol · SmolSent (the first training stage) ──
|
|
153
|
-
// SmolSent is Google's sentence-level translation set: ~863 human sentence pairs
|
|
154
|
-
// per language pair across 100+ low-resource languages, cc-by-4.0 (commercial-
|
|
155
|
-
// friendly). Each row is {sl, tl, src, trg, …} — a source sentence and its
|
|
156
|
-
// translation. A pair is "two names for one meaning", which is exactly the
|
|
157
|
-
// cross-language concept SEMA fuses (see test/05-concepts.test.mjs), so each row
|
|
158
|
-
// becomes FACTS that bind the two phrasings as one concept at recall time.
|
|
159
|
-
//
|
|
160
|
-
// The corpus ships as one plain JSONL file PER language pair under smolsent/ in
|
|
161
|
-
// the HF repo (e.g. smolsent/ha_en.jsonl). We DOWNLOAD each file and stream its
|
|
162
|
-
// lines — far faster and free of the rate-limiting that per-row API paging hit.
|
|
163
|
-
// The file list is discovered from the HF repo tree. SMOLSENT=0 disables the
|
|
164
|
-
// stage; SMOLSENT_PAIRS (comma-separated basenames without .jsonl, e.g.
|
|
165
|
-
// "ha_en,zu_en") restricts to a chosen subset.
|
|
166
|
-
const SMOLSENT = env("SMOLSENT", "1") !== "0";
|
|
167
|
-
const SMOLSENT_DATASET = env("SMOLSENT_DATASET", "google/smol");
|
|
168
|
-
const SMOLSENT_PAIRS = (process.env.SMOLSENT_PAIRS ?? "")
|
|
169
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
170
|
-
// The resume id PREFIX for the SmolSent stage; one completed-files entry per
|
|
171
|
-
// file (e.g. "smolsent::ha_en.jsonl").
|
|
172
|
-
const SMOLSENT_ID = "smolsent";
|
|
173
|
-
// Which direction(s) of a translation pair to deposit. Was effectively "both",
|
|
174
|
-
// and that is now the default NO longer, for a reason measured rather than
|
|
175
|
-
// assumed.
|
|
176
|
-
//
|
|
177
|
-
// SmolSent's English side is a SHARED POOL translated into every language: row
|
|
178
|
-
// id 0 of smolsent/ha_en.jsonl, zu_en.jsonl and am_en.jsonl all carry the SAME
|
|
179
|
-
// `trg` ("It allows me to work by following my vibes and ..."). The two
|
|
180
|
-
// directions are therefore not symmetric at all:
|
|
181
|
-
//
|
|
182
|
-
// src2trg (foreign -> English) many distinct contexts -> ONE shared
|
|
183
|
-
// continuation. Every language's rendering of
|
|
184
|
-
// a meaning converges on the same English
|
|
185
|
-
// node — the cross-language concept fusion
|
|
186
|
-
// this stage exists for.
|
|
187
|
-
// trg2src (English -> foreign) ONE context -> 100+ DIFFERENT continuations,
|
|
188
|
-
// one per language file. The same English
|
|
189
|
-
// sentence is deposited over and over with a
|
|
190
|
-
// different answer each time.
|
|
191
|
-
//
|
|
192
|
-
// So dropping trg2src is not merely a corpus-size economy (it halves the
|
|
193
|
-
// largest stage, which was 60.9% of all examples in the last trained store); it
|
|
194
|
-
// removes a genuine ambiguity pathology. Set SMOLSENT_DIRECTIONS=both to
|
|
195
|
-
// restore the old behaviour, or trg2src for English->foreign only.
|
|
196
|
-
//
|
|
197
|
-
// WHAT THE CUT DOES NOT DO, measured on a three-pair store: asking the English
|
|
198
|
-
// sentence still ANSWERS with a foreign rendering, because the engine can reach
|
|
199
|
-
// a shared continuation's predecessors on its own. What is removed is the
|
|
200
|
-
// DEPOSITED forward ambiguity — one context carrying ~100 competing
|
|
201
|
-
// continuations — not every reverse association.
|
|
202
|
-
const SMOLSENT_DIRECTIONS = env("SMOLSENT_DIRECTIONS", "src2trg")
|
|
203
|
-
.trim().toLowerCase();
|
|
204
|
-
const SMOLSENT_SRC2TRG = SMOLSENT_DIRECTIONS !== "trg2src";
|
|
205
|
-
const SMOLSENT_TRG2SRC = SMOLSENT_DIRECTIONS === "trg2src" ||
|
|
206
|
-
SMOLSENT_DIRECTIONS === "both";
|
|
207
|
-
// A SmolSent side longer than this is skipped (a sentence pair is short; a huge
|
|
208
|
-
// value is corruption, not a sentence).
|
|
209
|
-
const MAX_SMOLSENT_CHARS = Math.max(2_000, Math.floor(Number(env("MAX_SMOLSENT_KB", "16")) * 1000) || 16_000);
|
|
210
|
-
const DB_PATH = env("DB_PATH", "sema"); // → {DB_PATH}.sqlite
|
|
211
|
-
const D = Number(env("D", "1024"));
|
|
212
|
-
const SEED = Number(env("SEED", "7"));
|
|
213
|
-
// Checkpoint cadence is measured in LEARNED CONTENT, not deposits: a snapshot
|
|
214
|
-
// every CHECKPOINT_MB megabytes of trained UTF-8 content (decimal MB, matching
|
|
215
|
-
// the bytes() helper). A floor of 1 MB: a zero/NaN value must not make every
|
|
216
|
-
// deposit checkpoint, nor silently disable checkpointing. The tail (a run that
|
|
217
|
-
// learns less than one interval, or the remainder past the last interval) is
|
|
218
|
-
// always saved by finish() at exit — a complete point.
|
|
219
|
-
const CHECKPOINT_BYTES = Math.max(1_000_000, Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000);
|
|
220
|
-
// Target size of ONE materialised Parquet read, in uncompressed source bytes.
|
|
221
|
-
// A row-GROUP is a layout choice made by whoever wrote the file, not a memory
|
|
222
|
-
// budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
|
|
223
|
-
// group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
|
|
224
|
-
// (666 MB). Reading "exactly one row-group" is therefore safe for the first and
|
|
225
|
-
// fatal for the others, so reads are sized in BYTES instead — see
|
|
226
|
-
// `parquetBatchRows`. Materialised JS objects cost several times their source
|
|
227
|
-
// bytes, hence a default well under available memory.
|
|
228
|
-
const PARQUET_BATCH_BYTES = Math.max(1_000_000, Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000);
|
|
229
|
-
const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir of *.zip
|
|
230
|
-
const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
|
|
231
|
-
const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
|
|
232
|
-
const PROGRESS_MS = Number(env("PROGRESS_MS", "250")); // panel refresh cadence
|
|
233
|
-
// Index maintenance at checkpoints: compact (remove garbage), repair (fill
|
|
234
|
-
// gaps), then refresh the canonical-form index (equivalence-class resolution —
|
|
235
|
-
// src/canon.ts). All three are idempotent batch operations (the canon build is
|
|
236
|
-
// additionally incremental via the store's `canon.upto` cursor);
|
|
237
|
-
// INDEX_MAINTENANCE=0 disables.
|
|
238
|
-
const INDEX_MAINTENANCE = env("INDEX_MAINTENANCE", "1") !== "0";
|
|
239
|
-
const DOWNLOAD_TRIES = 5;
|
|
240
|
-
// In-progress downloads are written to a sibling "<dest>.part" and atomically
|
|
241
|
-
// renamed into place only after the bytes are fully flushed to disk. The cache
|
|
242
|
-
// invariant is therefore absolute: a file at its final path is, by definition,
|
|
243
|
-
// complete. Partial transfers (a crash, a kill, a dropped socket) leave only a
|
|
244
|
-
// .part file, which is swept at startup and never fed to the parser.
|
|
245
|
-
const PART_SUFFIX = ".part";
|
|
246
|
-
// A single process-wide abort signal. SIGINT/SIGTERM aborts it, which cancels
|
|
247
|
-
// every in-flight fetch immediately (instead of waiting out a slow socket), so
|
|
248
|
-
// Ctrl+C is responsive even mid-download. The deposit loop also polls it to
|
|
249
|
-
// stop cleanly at the next item boundary, leaving the store consistent.
|
|
250
|
-
const shutdown = new AbortController();
|
|
251
|
-
// The checkpoint recall is a best-effort diagnostic — it must NEVER stall
|
|
252
|
-
// training. We bound it so a slow/large store cannot freeze the deposit loop.
|
|
253
|
-
const INFER_TIMEOUT_MS = Number(env("INFER_TIMEOUT_MS", "15000"));
|
|
254
|
-
// A module-level hook so the low-level fetch retries can surface a rate-limit
|
|
255
|
-
// WAIT into the live progress log (set once main()'s panel exists). Without it a
|
|
256
|
-
// long 429 back-off would look like a silent hang. Throttled so a storm of 429s
|
|
257
|
-
// logs at most one "waiting" notice every few seconds.
|
|
258
|
-
let onThrottleWait = null;
|
|
259
|
-
let lastThrottleLog = 0;
|
|
260
|
-
// Optional ceiling on how much LEARNED CONTENT to train, in megabytes (decimal,
|
|
261
|
-
// like CHECKPOINT_MB). Default Infinity = unbounded. The cap is checked against
|
|
262
|
-
// trainedContentBytes after each deposit, so a run stops at the first item that
|
|
263
|
-
// carries the running total to/past the ceiling (that item is still counted).
|
|
264
|
-
const MAX_MB = Number(env("MAX_MB", "Infinity"));
|
|
265
|
-
if (isNaN(MAX_MB) || MAX_MB < 0) {
|
|
266
|
-
process.stderr.write(`fatal: MAX_MB must be a non-negative number or "Infinity"\n`);
|
|
267
|
-
process.exit(1);
|
|
268
|
-
}
|
|
269
|
-
const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
|
|
270
|
-
// ── CohereLabs/aya_dataset (the second training stage, after SmolSent) ──
|
|
271
|
-
// The Aya Dataset is ~204k HUMAN-annotated prompt→completion pairs across 70+
|
|
272
|
-
// languages, each a clean (inputs → targets) fact in a named language. It ships
|
|
273
|
-
// ONLY as Snappy-compressed Parquet (no JSONL/CSV). We DOWNLOAD the one train
|
|
274
|
-
// Parquet file and read it row-group by row-group with `hyparquet` (a pure-JS,
|
|
275
|
-
// dependency-free Parquet reader) + `hyparquet-compressors` (Snappy) over a
|
|
276
|
-
// web-standard Blob byte source — no whole-file-in-memory load. AYA=0 disables
|
|
277
|
-
// the stage; AYA_URL overrides the Parquet source.
|
|
278
|
-
const AYA = env("AYA", "1") !== "0";
|
|
279
|
-
const AYA_URL = env("AYA_URL", "https://huggingface.co/datasets/CohereLabs/aya_dataset/resolve/main/data/train-00000-of-00001.parquet");
|
|
280
|
-
// The resume id of the Aya stage, kept in the same completed-files set as the
|
|
281
|
-
// other stages, so one store records the whole curriculum.
|
|
282
|
-
const AYA_ID = "aya::dataset";
|
|
283
|
-
// A single Aya field this many chars or longer is skipped: inputs/targets range
|
|
284
|
-
// up to ~3.3M chars, and a multi-MB "pair" is documentation/dump noise, not a
|
|
285
|
-
// cognitive example.
|
|
286
|
-
const MAX_AYA_FIELD_CHARS = Math.max(10_000, Math.floor(Number(env("MAX_AYA_FIELD_KB", "256")) * 1000) || 256_000);
|
|
287
|
-
// ── OpenAssistant/oasst2 (the fourth training stage, after Aya) ──
|
|
288
|
-
// oasst2 is a corpus of human↔assistant conversation TREES. Its richest, most
|
|
289
|
-
// stream-friendly artifact is "<date>_oasst2_ready.trees.jsonl.gz": one JSON
|
|
290
|
-
// conversation tree PER LINE, gzip-compressed (a web standard — Decompression
|
|
291
|
-
// Stream("gzip")). Each tree is {message_tree_id, prompt:{role,text,replies:[…]}}
|
|
292
|
-
// where `replies` nests recursively and a prompt can have several ranked
|
|
293
|
-
// assistant replies (rank 0 = best). We follow the best-ranked, non-deleted
|
|
294
|
-
// reply at each step to get ONE linear, strictly-alternating conversation per
|
|
295
|
-
// tree, then keep only the MULTI-TURN ones (≥ OASST_MIN_TURNS messages, i.e. at
|
|
296
|
-
// least two full user→assistant exchanges) — single Q→A trees are skipped, by
|
|
297
|
-
// design. OASST=0 disables the stage; OASST_URL overrides the source.
|
|
298
|
-
const OASST = env("OASST", "1") !== "0";
|
|
299
|
-
const OASST_URL = env("OASST_URL", "https://huggingface.co/datasets/OpenAssistant/oasst2/resolve/main/2023-11-05_oasst2_ready.trees.jsonl.gz");
|
|
300
|
-
// The resume id of the oasst2 stage, in the same completed-files set as the
|
|
301
|
-
// other stages, so one store records the whole curriculum.
|
|
302
|
-
const OASST_ID = "oasst2::trees";
|
|
303
|
-
// Multi-turn threshold: a conversation must have at least this many turns to be
|
|
304
|
-
// trained (4 = user→assistant→user→assistant, the smallest real multi-turn).
|
|
305
|
-
const OASST_MIN_TURNS = Math.max(2, Math.floor(Number(env("OASST_MIN_TURNS", "4"))) || 4);
|
|
306
|
-
// Skip a tree whose decoded JSON line exceeds this (a pathological record); the
|
|
307
|
-
// real maximum is far smaller, so this only guards against corruption.
|
|
308
|
-
const MAX_OASST_LINE_CHARS = Math.max(100_000, Math.floor(Number(env("MAX_OASST_LINE_MB", "8")) * 1_000_000) || 8_000_000);
|
|
309
|
-
// ── google-research-datasets/Taskmaster 1–4 (the dialogue stages) ──
|
|
310
|
-
// Four corpora of task-oriented dialogue, one shape between them: each file is a
|
|
311
|
-
// JSON ARRAY of conversations and each conversation carries
|
|
312
|
-
// `utterances: [{speaker, text, …}]`. TM-1 ships two files directly under its
|
|
313
|
-
// directory (self-dialogs, woz-dialogs); TM-2/3/4 ship theirs under `<set>/data`.
|
|
314
|
-
// They are the best-scoring corpora on the fold-unit recurrence benchmark that
|
|
315
|
-
// selects for halo health (TM-3 85.1%, TM-4 78.8%, TM-2 68.7%, TM-1 51.8%,
|
|
316
|
-
// against 23.2% for the incumbent SmolSent), and they are genuinely multi-turn
|
|
317
|
-
// where the incumbent multi-turn stage is not (TM-3 median 20 turns of ~43 B,
|
|
318
|
-
// against oasst2's median turn of 529 B).
|
|
319
|
-
//
|
|
320
|
-
// Served from GitHub raw, not Hugging Face: the HF mirrors are loading-script
|
|
321
|
-
// repos with no data files, and the official copies carry the CC BY 4.0 notice.
|
|
322
|
-
const TASKMASTER = env("TASKMASTER", "1") !== "0";
|
|
323
|
-
// Which sets to train, in order. Each is a directory in the Taskmaster repo.
|
|
324
|
-
const TASKMASTER_SETS = env("TASKMASTER_SETS", "TM-1-2019,TM-2-2020,TM-3-2020,TM-4-2024").split(",").map((s) => s.trim()).filter(Boolean);
|
|
325
|
-
const TASKMASTER_REPO = env("TASKMASTER_REPO", "google-research-datasets/Taskmaster");
|
|
326
|
-
const TASKMASTER_RAW = `https://raw.githubusercontent.com/${TASKMASTER_REPO}/master`;
|
|
327
|
-
// A conversation must have at least this many turns AFTER same-speaker merging.
|
|
328
|
-
// The default of 2 keeps every real exchange: unlike oasst2 — where a lone Q→A
|
|
329
|
-
// tree merely replicates the Aya stage's shape and is dropped — a two-turn
|
|
330
|
-
// task-oriented exchange is still task-oriented dialogue, and TM-4's dialogues
|
|
331
|
-
// are short by design (median 3.7 turns), so a higher bar would discard most of
|
|
332
|
-
// that set.
|
|
333
|
-
const TASKMASTER_MIN_TURNS = Math.max(2, Math.floor(Number(env("TASKMASTER_MIN_TURNS", "2"))) || 2);
|
|
334
|
-
// Skip a conversation carrying an implausibly long utterance (corruption). The
|
|
335
|
-
// measured maximum across TM-1/2/3/4 is 1,897 bytes, so this only guards.
|
|
336
|
-
const MAX_TASKMASTER_TURN_CHARS = Math.max(1_000, Math.floor(Number(env("MAX_TASKMASTER_TURN_KB", "32")) * 1000) || 32_000);
|
|
337
|
-
// ── 2WikiMultihopQA — the `evidences` TRIPLES only (the composition stage) ──
|
|
338
|
-
// Each row carries `evidences`: a JSON string of (subject, relation, object)
|
|
339
|
-
// triples that CHAIN — one triple's object is the next's subject. 72.5% of rows
|
|
340
|
-
// carry such a chain (measured over 4,000 rows), and those triples are the only
|
|
341
|
-
// representation measured to make Sema compose a two-hop answer at all.
|
|
342
|
-
//
|
|
343
|
-
// TWO COLUMNS ARE DELIBERATELY NOT READ, one for licence reasons and one for
|
|
344
|
-
// capability reasons:
|
|
345
|
-
// • `context` holds Wikipedia PROSE. The repo is Apache-2.0 but Wikipedia text
|
|
346
|
-
// is CC BY-SA, and a Sema store keeps text verbatim, so ingesting the
|
|
347
|
-
// passages would attach ShareAlike to every distributed store. The triples
|
|
348
|
-
// originate in Wikidata (CC0). See DATASETS.md §3.2/§4.
|
|
349
|
-
// • `question`/`answer` are the composed multi-hop QUESTION. Depositing those
|
|
350
|
-
// teaches the answer to that exact question and nothing else — it memorises
|
|
351
|
-
// rather than composes. They are used to EVALUATE this adapter, never as
|
|
352
|
-
// training input.
|
|
353
|
-
//
|
|
354
|
-
// Read from Hugging Face's auto-converted `refs/convert/parquet` branch, not
|
|
355
|
-
// from main: the main-branch train.parquet is written as ONE 167,454-row
|
|
356
|
-
// group (666 MB uncompressed) and a Parquet column chunk is per-group, so any
|
|
357
|
-
// read of it materialises the whole file. The converted branch uses uniform
|
|
358
|
-
// 10,000-row groups. See test/79-parquet-batching.test.mjs.
|
|
359
|
-
const WIKI2 = env("WIKI2", "1") !== "0";
|
|
360
|
-
const WIKI2_DATASET = env("WIKI2_DATASET", "xanhho/2WikiMultihopQA");
|
|
361
|
-
// Splits to train, in order. Only `train` by default: `validation`/`test` are
|
|
362
|
-
// the dataset's held-out sets and are what an honest evaluation of this
|
|
363
|
-
// adapter's composition rate has to be measured on.
|
|
364
|
-
const WIKI2_SPLITS = env("WIKI2_SPLITS", "train")
|
|
365
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
366
|
-
// Reject a triple with an implausibly long field (corruption); real subjects and
|
|
367
|
-
// objects are entity names, and relations are Wikidata property labels.
|
|
368
|
-
// 0 = every row. The train split holds 167,454 rows at ~4.95 deposits each
|
|
369
|
-
// (~830k facts), so this is the knob that keeps 2Wiki proportionate to the rest
|
|
370
|
-
// of the curriculum in the same way SODA_MAX_DIALOGS does.
|
|
371
|
-
const WIKI2_MAX_ROWS = Math.max(0, Math.floor(Number(env("WIKI2_MAX_ROWS", "0"))) || 0);
|
|
372
|
-
const MAX_WIKI2_FIELD_CHARS = Math.max(100, Math.floor(Number(env("MAX_WIKI2_FIELD_KB", "2")) * 1000) || 2_000);
|
|
373
|
-
// ── allenai/soda (social dialogue) and AmazonScience/massive (short intents) ──
|
|
374
|
-
// Both are read from Hugging Face's auto-converted `refs/convert/parquet`
|
|
375
|
-
// branch. For SODA that is mandatory, not cosmetic: its main-branch
|
|
376
|
-
// train.parquet is ONE 1,191,582-row group (1.19 GB uncompressed), and a
|
|
377
|
-
// Parquet column chunk is per-group, so any read of it materialises the whole
|
|
378
|
-
// file — measured at 100% of a 689 MB file and 2 GB of heap for a 500-row read.
|
|
379
|
-
// The converted branch uses uniform 10,000-row groups.
|
|
380
|
-
//
|
|
381
|
-
// BOTH STAGES ARE BUDGETED, and that is a curriculum decision rather than an
|
|
382
|
-
// algorithmic cap. SODA's train split holds 1,191,582 dialogues which the
|
|
383
|
-
// cumulative walk would turn into ~8 MILLION episodes — against the 662,221
|
|
384
|
-
// deposits of the entire current corpus. Trained whole it would not join the
|
|
385
|
-
// mix, it would BE the mix, and corpus size is the quantity every scale problem
|
|
386
|
-
// in this engine is measured against. The default takes the first
|
|
387
|
-
// SODA_MAX_DIALOGS of them; set it to 0 to lift the budget.
|
|
388
|
-
const SODA = env("SODA", "1") !== "0";
|
|
389
|
-
const SODA_DATASET = env("SODA_DATASET", "allenai/soda");
|
|
390
|
-
const SODA_SPLITS = env("SODA_SPLITS", "train")
|
|
391
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
392
|
-
// ~6.3 episodes per dialogue, so this budgets ~750k episodes — comparable to
|
|
393
|
-
// the Taskmaster stage and to Aya, which is the intended balance. 0 = no budget.
|
|
394
|
-
const SODA_MAX_DIALOGS = Math.max(0, Math.floor(Number(env("SODA_MAX_DIALOGS", "120000"))) || 0);
|
|
395
|
-
const MAX_SODA_TURN_CHARS = Math.max(1_000, Math.floor(Number(env("MAX_SODA_TURN_KB", "32")) * 1000) || 32_000);
|
|
396
|
-
// MASSIVE deposits BARE UTTERANCES — an experience, not an episode — and that
|
|
397
|
-
// is the only shape its data supports. Two richer shapes were considered and
|
|
398
|
-
// rejected on evidence:
|
|
399
|
-
// • Same-intent pairs as paraphrases. 49.1% of consecutive rows share
|
|
400
|
-
// (locale, intent), but they are NOT meaning-equivalent: intent 48 in mn-MN
|
|
401
|
-
// runs "wake me at nine on the fifth" next to "set an alarm two hours from
|
|
402
|
-
// now". Depositing that pair as an episode teaches a continuation that does
|
|
403
|
-
// not exist.
|
|
404
|
-
// • Same-id rows across locales. Those ARE translations of one another —
|
|
405
|
-
// which is exactly SmolSent's relation, and SmolSent scores worst of every
|
|
406
|
-
// corpus measured on fold-unit recurrence (23.2%) because cross-lingual
|
|
407
|
-
// pairs share no units.
|
|
408
|
-
// So the stage contributes recurring fold units and lexical coverage (65.1%
|
|
409
|
-
// recurring unit mass, median 29 B) and nothing relational. `annot_utt` carries
|
|
410
|
-
// slot markup ("[date : tavdahad] ...") and is never read.
|
|
411
|
-
// DISABLED BY DEFAULT, on evidence gathered after the stage was written. A bare
|
|
412
|
-
// experience deposits content with NO EDGE, and that cuts both ways. Measured on
|
|
413
|
-
// a three-pair dialogue store with and without six MASSIVE-style utterances:
|
|
414
|
-
//
|
|
415
|
-
// "set an alarm" without: "Sure, what size would you like?" (wrong)
|
|
416
|
-
// with: "set an alarm for seven" (better)
|
|
417
|
-
// "play music" without: "" (correct silence)
|
|
418
|
-
// with: "Yes, sweetened or unsweetened?" (wrong)
|
|
419
|
-
//
|
|
420
|
-
// So it displaces some wrong answers and manufactures others, INCLUDING turning
|
|
421
|
-
// a correct silence into a wrong answer — and honest silence is a stated
|
|
422
|
-
// property of this engine (AGENTS §2.13). On the mixed-curriculum store the
|
|
423
|
-
// same shape produced the fragment "nus" for "wake me up at nine am".
|
|
424
|
-
//
|
|
425
|
-
// That evidence is four probes on toy stores and is NOT conclusive; it is,
|
|
426
|
-
// however, the only evidence there is, and it points the wrong way. The stage
|
|
427
|
-
// stays implemented and one env var away. Turn it on (MASSIVE=1) once there is
|
|
428
|
-
// a real measurement showing the recurring fold units it contributes (72.3% of
|
|
429
|
-
// deposited unit mass) buy more than the spurious answers cost.
|
|
430
|
-
const MASSIVE = env("MASSIVE", "0") !== "0";
|
|
431
|
-
const MASSIVE_DATASET = env("MASSIVE_DATASET", "AmazonScience/massive");
|
|
432
|
-
// "all" is the config covering every locale in one set of shards.
|
|
433
|
-
const MASSIVE_CONFIG = env("MASSIVE_CONFIG", "all");
|
|
434
|
-
const MASSIVE_SPLITS = env("MASSIVE_SPLITS", "train")
|
|
435
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
436
|
-
// 0 = every row (587,214 in `all`/train, ~17 MB of content).
|
|
437
|
-
const MASSIVE_MAX_ROWS = Math.max(0, Math.floor(Number(env("MASSIVE_MAX_ROWS", "0"))) || 0);
|
|
438
|
-
const MAX_MASSIVE_UTT_CHARS = Math.max(100, Math.floor(Number(env("MAX_MASSIVE_UTT_KB", "2")) * 1000) || 2_000);
|
|
439
|
-
// ── MuskumPillerum/General-Knowledge (the fourth training stage, after oasst2) ──
|
|
440
|
-
// A ~37.6k-row general-knowledge Q&A set: each row is a single {Question, Answer}
|
|
441
|
-
// pair. A row is a pure RELATION (question → answer), so it becomes exactly ONE
|
|
442
|
-
// FACT, identical in shape to the Aya stage. It ships as a single JSON array
|
|
443
|
-
// file (output.json); we DOWNLOAD it and stream the array. GENKNOW_URL overrides
|
|
444
|
-
// the source.
|
|
445
|
-
//
|
|
446
|
-
// DISABLED BY DEFAULT ON LICENCE GROUNDS (2026-08-13). The HF repo carries NO
|
|
447
|
-
// licence tag and no licence in its card — an earlier header in this file
|
|
448
|
-
// claimed MIT without support — and its own dataset card states it "contains a
|
|
449
|
-
// subset of the alpaca dataset". Alpaca is CC BY-NC 4.0: NonCommercial, which
|
|
450
|
-
// conflicts with Sema's commercial licence. Because a Sema store retains its
|
|
451
|
-
// training text VERBATIM, an unlicensed corpus inside it makes the whole
|
|
452
|
-
// artifact undistributable. See DATASETS.md §3.2. GENKNOW=1 re-enables the
|
|
453
|
-
// stage for local, non-distributed experiments only.
|
|
454
|
-
const GENKNOW = env("GENKNOW", "0") !== "0";
|
|
455
|
-
const GENKNOW_URL = env("GENKNOW_URL", "https://huggingface.co/datasets/MuskumPillerum/General-Knowledge/resolve/main/output.json");
|
|
456
|
-
// The resume id of the General-Knowledge stage, in the same completed-files set
|
|
457
|
-
// as the other stages, so one store records the whole curriculum.
|
|
458
|
-
const GENKNOW_ID = "genknow::qa";
|
|
459
|
-
// A Question/Answer longer than this is skipped (answers run to a few hundred
|
|
460
|
-
// chars; this only guards against a corrupt/runaway field).
|
|
461
|
-
const MAX_GENKNOW_CHARS = Math.max(4_000, Math.floor(Number(env("MAX_GENKNOW_KB", "64")) * 1000) || 64_000);
|
|
462
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
463
|
-
// §2 Terminal + formatting helpers
|
|
464
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
465
|
-
const CSI = "\x1b[";
|
|
466
|
-
const B = `${CSI}1m`, DIM = `${CSI}2m`, R = `${CSI}0m`;
|
|
467
|
-
const GREY = `${CSI}90m`, CYAN = `${CSI}36m`, GRN = `${CSI}32m`;
|
|
468
|
-
const YEL = `${CSI}33m`, RED = `${CSI}31m`;
|
|
469
|
-
const HIDE = `${CSI}?25l`, SHOW = `${CSI}?25h`;
|
|
470
|
-
/** Sleep `ms`, but wake early if the shutdown signal fires — so a long back-off
|
|
471
|
-
* (e.g. a rate-limit wait) never swallows Ctrl+C. Resolves either way. */
|
|
472
|
-
const waitMs = (ms) => new Promise((resolve) => {
|
|
473
|
-
if (shutdown.signal.aborted)
|
|
474
|
-
return resolve();
|
|
475
|
-
// NOTE: the timer is deliberately NOT unref'd — an unref'd timer does not
|
|
476
|
-
// keep the event loop alive, so a pending wait (e.g. the pace between page
|
|
477
|
-
// requests, or a rate-limit back-off) would let Node exit early and the run
|
|
478
|
-
// would "do nothing and close". The listener lets a shutdown wake it early.
|
|
479
|
-
const t = setTimeout(done, ms);
|
|
480
|
-
function done() {
|
|
481
|
-
clearTimeout(t);
|
|
482
|
-
shutdown.signal.removeEventListener("abort", done);
|
|
483
|
-
resolve();
|
|
484
|
-
}
|
|
485
|
-
shutdown.signal.addEventListener("abort", done, { once: true });
|
|
486
|
-
});
|
|
487
|
-
/** Resolve `p`, but reject with a TimeoutError if it takes longer than `ms`.
|
|
488
|
-
* The underlying promise is left to settle on its own (we just stop waiting),
|
|
489
|
-
* so a slow black-box call can never wedge the caller. */
|
|
490
|
-
function withTimeout(p, ms, label = "operation") {
|
|
491
|
-
return new Promise((resolve, reject) => {
|
|
492
|
-
const t = setTimeout(() => {
|
|
493
|
-
const e = new Error(`${label} timed out after ${ms}ms`);
|
|
494
|
-
e.name = "TimeoutError";
|
|
495
|
-
reject(e);
|
|
496
|
-
}, ms);
|
|
497
|
-
if (typeof t.unref === "function")
|
|
498
|
-
t.unref();
|
|
499
|
-
p.then((v) => {
|
|
500
|
-
clearTimeout(t);
|
|
501
|
-
resolve(v);
|
|
502
|
-
}, (e) => {
|
|
503
|
-
clearTimeout(t);
|
|
504
|
-
reject(e);
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
/** Human-readable duration from seconds. */
|
|
509
|
-
function dur(seconds) {
|
|
510
|
-
if (!isFinite(seconds) || seconds < 0)
|
|
511
|
-
return "--";
|
|
512
|
-
const h = Math.floor(seconds / 3600);
|
|
513
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
514
|
-
const s = Math.floor(seconds % 60);
|
|
515
|
-
if (h > 0)
|
|
516
|
-
return `${h}h ${m}m ${s}s`;
|
|
517
|
-
if (m > 0)
|
|
518
|
-
return `${m}m ${s}s`;
|
|
519
|
-
return `${s}s`;
|
|
520
|
-
}
|
|
521
|
-
/** Human-readable byte size. */
|
|
522
|
-
function bytes(n) {
|
|
523
|
-
if (!isFinite(n) || n < 0)
|
|
524
|
-
return "--";
|
|
525
|
-
if (n < 1024)
|
|
526
|
-
return `${n} B`;
|
|
527
|
-
if (n < 1e6)
|
|
528
|
-
return `${(n / 1024).toFixed(1)} KB`;
|
|
529
|
-
if (n < 1e9)
|
|
530
|
-
return `${(n / 1e6).toFixed(1)} MB`;
|
|
531
|
-
return `${(n / 1e9).toFixed(2)} GB`;
|
|
532
|
-
}
|
|
533
|
-
/** Short count: 1234567 → "1.23M". */
|
|
534
|
-
function num(n) {
|
|
535
|
-
if (n >= 1e9)
|
|
536
|
-
return `${(n / 1e9).toFixed(2)}B`;
|
|
537
|
-
if (n >= 1e6)
|
|
538
|
-
return `${(n / 1e6).toFixed(2)}M`;
|
|
539
|
-
if (n >= 1e3)
|
|
540
|
-
return `${(n / 1e3).toFixed(1)}K`;
|
|
541
|
-
return String(n);
|
|
542
|
-
}
|
|
543
|
-
const int = (n) => Math.round(n).toLocaleString("en-US");
|
|
544
|
-
const clamp01 = (f) => Math.max(0, Math.min(1, f));
|
|
545
|
-
const pct = (f) => `${(clamp01(f) * 100).toFixed(1)}%`;
|
|
546
|
-
/** A progress bar of width `w` filled to fraction `frac`. */
|
|
547
|
-
function bar(w, frac) {
|
|
548
|
-
const filled = Math.round(clamp01(frac) * w);
|
|
549
|
-
return `${GRN}${"█".repeat(filled)}${GREY}${"░".repeat(w - filled)}${R}`;
|
|
550
|
-
}
|
|
551
|
-
/** Collapse whitespace and clip to `max` chars with an ellipsis. */
|
|
552
|
-
function clip(text, max) {
|
|
553
|
-
const t = text.replace(/\s+/g, " ").trim();
|
|
554
|
-
if (max < 1)
|
|
555
|
-
return "";
|
|
556
|
-
return t.length <= max ? t : t.slice(0, max - 1) + "…";
|
|
557
|
-
}
|
|
558
|
-
/** Retry `fn` with exponential backoff.
|
|
559
|
-
*
|
|
560
|
-
* Three error classes:
|
|
561
|
-
* • `.fatal` / AbortError → rethrown immediately (never retried).
|
|
562
|
-
* • `.throttle` (429/503) → the server is rate-limiting/overloaded. We are
|
|
563
|
-
* NOT failing — we WAIT (honouring Retry-After, else capped exponential
|
|
564
|
-
* back-off with jitter) and retry WITHOUT consuming an attempt, so a
|
|
565
|
-
* throttled request holds on until it succeeds rather than being dropped.
|
|
566
|
-
* Only a shutdown breaks this loop.
|
|
567
|
-
* • anything else → a genuine transient error, retried up to `tries`
|
|
568
|
-
* with exponential back-off before giving up.
|
|
569
|
-
*
|
|
570
|
-
* `onFail` is called after each non-throttle failed attempt; `onThrottle` after
|
|
571
|
-
* each throttle wait (for a "waiting…" notice). */
|
|
572
|
-
async function retry(label, fn, tries, onFail, onThrottle) {
|
|
573
|
-
let wait = 1000, last = "", throttleWait = 1000, throttleHits = 0;
|
|
574
|
-
for (let attempt = 1; attempt <= tries;) {
|
|
575
|
-
if (shutdown.signal.aborted) {
|
|
576
|
-
const e = new Error("aborted");
|
|
577
|
-
e.fatal = true;
|
|
578
|
-
throw e;
|
|
579
|
-
}
|
|
580
|
-
try {
|
|
581
|
-
return await fn();
|
|
582
|
-
}
|
|
583
|
-
catch (e) {
|
|
584
|
-
const err = e;
|
|
585
|
-
if (err.name === "AbortError" || err.fatal)
|
|
586
|
-
throw err;
|
|
587
|
-
// Rate-limited / overloaded: wait it out. Does NOT advance `attempt`, so a
|
|
588
|
-
// busy server can never exhaust the retry budget and drop the request.
|
|
589
|
-
if (err.throttle && !shutdown.signal.aborted) {
|
|
590
|
-
throttleHits++;
|
|
591
|
-
// Honour Retry-After when the server sent one; else exponential back-off
|
|
592
|
-
// with jitter, capped, so a fleet of requests does not resynchronise.
|
|
593
|
-
const base = err.retryAfterMs && err.retryAfterMs > 0
|
|
594
|
-
? err.retryAfterMs
|
|
595
|
-
: throttleWait;
|
|
596
|
-
const ms = Math.min(base, 60_000) +
|
|
597
|
-
Math.floor(base * 0.25 * Math.random());
|
|
598
|
-
onThrottle?.(ms);
|
|
599
|
-
await waitMs(ms);
|
|
600
|
-
throttleWait = Math.min(throttleWait * 2, 60_000);
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
last = err.message;
|
|
604
|
-
onFail?.(attempt, err);
|
|
605
|
-
attempt++;
|
|
606
|
-
if (attempt <= tries) {
|
|
607
|
-
await waitMs(wait);
|
|
608
|
-
wait = Math.min(wait * 2, 30_000);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
throw new Error(`${label} failed after ${tries} attempts: ${last}`);
|
|
613
|
-
}
|
|
614
|
-
/** Classify a non-OK HTTP response into an {@link HttpError} for {@link retry}:
|
|
615
|
-
* • 429 / 503 → THROTTLE (rate-limited / overloaded): retried indefinitely,
|
|
616
|
-
* honouring a Retry-After header (seconds or an HTTP-date) when present.
|
|
617
|
-
* • other 5xx → transient: retried up to the caller's attempt budget.
|
|
618
|
-
* • other 4xx → FATAL: a real client error (404, 401, …) — not retried.
|
|
619
|
-
* Never throttles forever silently: the wait is interruptible by shutdown. */
|
|
620
|
-
function httpError(res) {
|
|
621
|
-
const err = new Error(`HTTP ${res.status}`);
|
|
622
|
-
if (res.status === 429 || res.status === 503) {
|
|
623
|
-
err.throttle = true;
|
|
624
|
-
const ra = res.headers.get("retry-after");
|
|
625
|
-
if (ra) {
|
|
626
|
-
const secs = Number(ra);
|
|
627
|
-
if (Number.isFinite(secs))
|
|
628
|
-
err.retryAfterMs = Math.max(0, secs * 1000);
|
|
629
|
-
else {
|
|
630
|
-
const when = Date.parse(ra);
|
|
631
|
-
if (Number.isFinite(when)) {
|
|
632
|
-
err.retryAfterMs = Math.max(0, when - Date.now());
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
else if (res.status < 500) {
|
|
638
|
-
err.fatal = true; // genuine client error — do not retry
|
|
639
|
-
} // other 5xx: neither fatal nor throttle → ordinary bounded retry
|
|
640
|
-
return err;
|
|
641
|
-
}
|
|
642
|
-
/** GET a URL and parse JSON, with the shared retry policy: rate-limits (429/503)
|
|
643
|
-
* WAIT indefinitely (surfaced to the progress log via onThrottleWait, throttled
|
|
644
|
-
* to one notice every few seconds), other 4xx is fatal, other 5xx retried up to
|
|
645
|
-
* DOWNLOAD_TRIES. Used by every datasets-server API stage so all share the same
|
|
646
|
-
* never-drop-on-throttle behaviour. */
|
|
647
|
-
async function getJson(url, label) {
|
|
648
|
-
return retry(label, async () => {
|
|
649
|
-
const res = await fetch(url, { signal: shutdown.signal });
|
|
650
|
-
if (res.ok)
|
|
651
|
-
return res.json();
|
|
652
|
-
throw httpError(res);
|
|
653
|
-
}, DOWNLOAD_TRIES, undefined, (ms) => {
|
|
654
|
-
const now = Date.now();
|
|
655
|
-
if (onThrottleWait && now - lastThrottleLog > 3000) {
|
|
656
|
-
lastThrottleLog = now;
|
|
657
|
-
onThrottleWait(ms, label);
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
662
|
-
// §3 Cache + download helpers (a downloaded file is bounded by MAX_CACHE_GB)
|
|
663
|
-
//
|
|
664
|
-
// SmolSent and Aya are paged from the datasets-server JSON API (no file to
|
|
665
|
-
// download); oasst2 downloads ONE gzipped file. So only the generic download
|
|
666
|
-
// helpers below survive — there is no per-language ZIP discovery or prefetch.
|
|
667
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
668
|
-
/** A cheap HEAD to learn a download's size (for the cache ceiling and a real
|
|
669
|
-
* ETA). Rate-limits wait; other 4xx is fatal; total failure → 0. */
|
|
670
|
-
/** Advertised transfer size of `url`, used only to reserve cache room. Like any
|
|
671
|
-
* `content-length` this is the ON-THE-WIRE size, so for a content-coded source
|
|
672
|
-
* (GitHub raw gzips JSON ~14x) it UNDER-estimates the file that lands on disk.
|
|
673
|
-
* That is tolerable here because the cache ceiling is a budget, not a
|
|
674
|
-
* correctness property — a run may overshoot MAX_CACHE_GB by the compression
|
|
675
|
-
* ratio of one in-flight file, and each file is deleted as soon as it is
|
|
676
|
-
* consumed. It must NOT be reused as an integrity check; see downloadFile. */
|
|
677
|
-
async function headSize(url) {
|
|
678
|
-
return retry(`HEAD ${url}`, async () => {
|
|
679
|
-
const res = await fetch(url, { method: "HEAD", signal: shutdown.signal });
|
|
680
|
-
if (res.ok)
|
|
681
|
-
return Number(res.headers.get("content-length")) || 0;
|
|
682
|
-
throw httpError(res);
|
|
683
|
-
}, 4);
|
|
684
|
-
}
|
|
685
|
-
function cacheSize() {
|
|
686
|
-
if (!existsSync(CACHE_DIR))
|
|
687
|
-
return 0;
|
|
688
|
-
let total = 0;
|
|
689
|
-
for (const name of readdirSync(CACHE_DIR)) {
|
|
690
|
-
try {
|
|
691
|
-
total += statSync(join(CACHE_DIR, name)).size;
|
|
692
|
-
}
|
|
693
|
-
catch { /* raced with a delete */ }
|
|
694
|
-
}
|
|
695
|
-
return total;
|
|
696
|
-
}
|
|
697
|
-
/** Block until there is room for a file of `fileBytes` under the ceiling.
|
|
698
|
-
* A single file larger than the whole ceiling can never "fit", so we let it
|
|
699
|
-
* through (it is deleted right after processing) rather than wait forever. */
|
|
700
|
-
async function ensureCacheRoom(fileBytes, warn) {
|
|
701
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
702
|
-
if (fileBytes >= MAX_CACHE_BYTES)
|
|
703
|
-
return;
|
|
704
|
-
let warned = false;
|
|
705
|
-
// Stop waiting the moment a shutdown is requested — the abort signal unblocks
|
|
706
|
-
// a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
|
|
707
|
-
while (!shutdown.signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES) {
|
|
708
|
-
if (!warned) {
|
|
709
|
-
warn?.(`${YEL}⚠${R} cache at ${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling — waiting for room…`);
|
|
710
|
-
warned = true;
|
|
711
|
-
}
|
|
712
|
-
await waitMs(5_000);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
716
|
-
// §5 Download (streamed to disk, with retry + cleanup on failure)
|
|
717
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
718
|
-
async function downloadFile(url, destPath, tries = DOWNLOAD_TRIES, onFail, onProgress) {
|
|
719
|
-
const partPath = destPath + PART_SUFFIX;
|
|
720
|
-
await retry(`download ${basename(destPath)}`, async () => {
|
|
721
|
-
// Abort promptly on shutdown rather than waiting out a slow socket.
|
|
722
|
-
if (shutdown.signal.aborted) {
|
|
723
|
-
const e = new Error("aborted");
|
|
724
|
-
e.fatal = true;
|
|
725
|
-
throw e;
|
|
726
|
-
}
|
|
727
|
-
const res = await fetch(url, { signal: shutdown.signal });
|
|
728
|
-
if (!res.ok)
|
|
729
|
-
throw httpError(res);
|
|
730
|
-
if (!res.body)
|
|
731
|
-
throw new Error("empty response body");
|
|
732
|
-
// `content-length` describes the bytes ON THE WIRE. When the server
|
|
733
|
-
// applied a content-coding, fetch hands us the DECODED body, so the
|
|
734
|
-
// header no longer describes what gets written to disk and the integrity
|
|
735
|
-
// guard below must not use it. Measured: raw.githubusercontent.com sends
|
|
736
|
-
// `content-encoding: gzip` with content-length 110,928 for a file that
|
|
737
|
-
// decodes to 1,607,931 bytes — a size check against that rejects every
|
|
738
|
-
// healthy download. (The bug stayed latent because Hugging Face sends
|
|
739
|
-
// `content-encoding: br` and NO content-length, leaving total = 0, which
|
|
740
|
-
// already disables the guard.)
|
|
741
|
-
const encoding = (res.headers.get("content-encoding") ?? "").trim()
|
|
742
|
-
.toLowerCase();
|
|
743
|
-
const decoded = encoding !== "" && encoding !== "identity";
|
|
744
|
-
const total = decoded
|
|
745
|
-
? 0
|
|
746
|
-
: Number(res.headers.get("content-length")) || 0;
|
|
747
|
-
let done = 0;
|
|
748
|
-
// Stream straight to a ".part" sibling using pure WHATWG streams. A
|
|
749
|
-
// TransformStream meters progress; pipeTo into a WritableStream gives REAL
|
|
750
|
-
// backpressure natively — the sink's write() returns a promise the
|
|
751
|
-
// readable side awaits, so a fast server can never outrun the disk (no
|
|
752
|
-
// whole-file heap buffering). The sink wraps a single raw fs descriptor
|
|
753
|
-
// (the one capability the web platform lacks); writing to disk is the only
|
|
754
|
-
// Node operation in the whole pipeline. The final, valid file only ever
|
|
755
|
-
// appears via the atomic rename below, so a crash mid-transfer can never
|
|
756
|
-
// leave a truncated file at the real path.
|
|
757
|
-
const meter = new TransformStream({
|
|
758
|
-
transform(chunk, controller) {
|
|
759
|
-
done += chunk.length;
|
|
760
|
-
onProgress?.(done, total);
|
|
761
|
-
controller.enqueue(chunk);
|
|
762
|
-
},
|
|
763
|
-
});
|
|
764
|
-
const fd = openSync(partPath, "w");
|
|
765
|
-
let closed = false;
|
|
766
|
-
const closeFd = () => {
|
|
767
|
-
if (closed)
|
|
768
|
-
return;
|
|
769
|
-
closed = true;
|
|
770
|
-
try {
|
|
771
|
-
closeSync(fd);
|
|
772
|
-
}
|
|
773
|
-
catch { /* already closed */ }
|
|
774
|
-
};
|
|
775
|
-
const sink = new WritableStream({
|
|
776
|
-
write(chunk) {
|
|
777
|
-
// writeSync drains the whole chunk before returning, so the readable
|
|
778
|
-
// side is paused for exactly as long as the disk needs — backpressure.
|
|
779
|
-
let off = 0;
|
|
780
|
-
while (off < chunk.length) {
|
|
781
|
-
off += writeSync(fd, chunk, off, chunk.length - off);
|
|
782
|
-
}
|
|
783
|
-
},
|
|
784
|
-
close() {
|
|
785
|
-
fsyncSync(fd); // durable bytes before the rename promotes them
|
|
786
|
-
closeFd();
|
|
787
|
-
},
|
|
788
|
-
abort() {
|
|
789
|
-
closeFd();
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
try {
|
|
793
|
-
await res.body.pipeThrough(meter).pipeTo(sink, {
|
|
794
|
-
signal: shutdown.signal,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
catch (e) {
|
|
798
|
-
// pipeTo's abort() ran the sink's abort() (closing the descriptor); if
|
|
799
|
-
// it didn't (a non-abort throw), make sure the descriptor is not leaked.
|
|
800
|
-
closeFd();
|
|
801
|
-
try {
|
|
802
|
-
unlinkSync(partPath);
|
|
803
|
-
}
|
|
804
|
-
catch { /* best effort */ }
|
|
805
|
-
throw e;
|
|
806
|
-
}
|
|
807
|
-
// Optional integrity guard: when the server advertised a size FOR THE
|
|
808
|
-
// BYTES WE WRITE (see the content-encoding note above — `total` is 0 for
|
|
809
|
-
// a decoded body, which disables this), a complete file must match it. A
|
|
810
|
-
// short read (silent truncation) is retried rather than promoted, so the
|
|
811
|
-
// parser never sees a partial file.
|
|
812
|
-
try {
|
|
813
|
-
const got = statSync(partPath).size;
|
|
814
|
-
if (total > 0 && got !== total) {
|
|
815
|
-
try {
|
|
816
|
-
unlinkSync(partPath);
|
|
817
|
-
}
|
|
818
|
-
catch { /* best effort */ }
|
|
819
|
-
throw new Error(`size mismatch: got ${got}, expected ${total}`);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
catch (e) {
|
|
823
|
-
if (e instanceof Error && e.message.startsWith("size mismatch")) {
|
|
824
|
-
throw e;
|
|
825
|
-
}
|
|
826
|
-
// statSync failure is non-fatal here; the rename below will surface it.
|
|
827
|
-
}
|
|
828
|
-
// Atomic publish: rename is atomic within a filesystem, so the final path
|
|
829
|
-
// flips from "absent" to "complete" in one step — never an in-between.
|
|
830
|
-
renameSync(partPath, destPath);
|
|
831
|
-
}, tries, onFail);
|
|
832
|
-
}
|
|
833
|
-
const isEpisode = (it) => typeof it !== "string";
|
|
834
|
-
/** Build the accumulated-context episodes of a turn sequence: each successive
|
|
835
|
-
* turn is the continuation of ALL the turns before it joined together. This is
|
|
836
|
-
* the same cumulative-context shape a multi-turn conversation deposits, so the
|
|
837
|
-
* store learns to continue a growing context.
|
|
838
|
-
*
|
|
839
|
-
* The "\n" below is a CORPUS choice, not a protocol. oasst2 turns are
|
|
840
|
-
* paragraphs, and reading them back with the newlines kept is how this corpus
|
|
841
|
-
* reads naturally; a different corpus may join with nothing, and
|
|
842
|
-
* test/13-conversation.test.mjs does exactly that. Neither has to match the
|
|
843
|
-
* other, because Sema never scans content for turn boundaries — those are
|
|
844
|
-
* offsets the Conversation API carries beside the bytes (see Mind.addTurn's
|
|
845
|
-
* "ON SEPARATORS" note). The newline here is simply part of the text this
|
|
846
|
-
* store learnt, so anything replaying this corpus feeds it back as part of
|
|
847
|
-
* the turn: `addTurn(conv, "\n" + turnText)`. It is not a convention the
|
|
848
|
-
* engine, the API, or the tests have to agree on. */
|
|
849
|
-
function accumulate(turns) {
|
|
850
|
-
const out = [];
|
|
851
|
-
for (let i = 1; i < turns.length; i++) {
|
|
852
|
-
out.push({ context: turns.slice(0, i).join("\n"), continuation: turns[i] });
|
|
853
|
-
}
|
|
854
|
-
return out;
|
|
855
|
-
}
|
|
856
|
-
/** Dedup + trim a concept's items: drop empty/degenerate pairs and exact
|
|
857
|
-
* repeats so a concept never deposits the same form twice. */
|
|
858
|
-
export function refineItems(items) {
|
|
859
|
-
const out = [];
|
|
860
|
-
const seen = new Set();
|
|
861
|
-
for (const it of items) {
|
|
862
|
-
if (!isEpisode(it)) {
|
|
863
|
-
const exp = it.trim();
|
|
864
|
-
const key = "E:" + exp;
|
|
865
|
-
if (exp && !seen.has(key)) {
|
|
866
|
-
seen.add(key);
|
|
867
|
-
out.push(exp);
|
|
868
|
-
}
|
|
869
|
-
continue;
|
|
870
|
-
}
|
|
871
|
-
const ctx = it.context.trim();
|
|
872
|
-
const cont = it.continuation.trim();
|
|
873
|
-
if (!ctx || !cont || ctx === cont)
|
|
874
|
-
continue;
|
|
875
|
-
const key = "P:" + ctx + "\u0000" + cont;
|
|
876
|
-
if (seen.has(key))
|
|
877
|
-
continue;
|
|
878
|
-
seen.add(key);
|
|
879
|
-
out.push({ context: ctx, continuation: cont });
|
|
880
|
-
}
|
|
881
|
-
return out;
|
|
882
|
-
}
|
|
883
|
-
/** Normalize a raw datasets-server row into a SmolSentRow, or null when it lacks
|
|
884
|
-
* both sides or a side is implausibly large (a dump, not a sentence). */
|
|
885
|
-
export function toSmolSentRow(row) {
|
|
886
|
-
if (!row || typeof row !== "object")
|
|
887
|
-
return null;
|
|
888
|
-
const r = row;
|
|
889
|
-
const src = typeof r.src === "string" ? r.src.trim() : "";
|
|
890
|
-
// `trg` is a single string in smolsent; tolerate a list form defensively.
|
|
891
|
-
const trgRaw = Array.isArray(r.trgs) ? r.trgs[0] : r.trg;
|
|
892
|
-
const trg = typeof trgRaw === "string" ? trgRaw.trim() : "";
|
|
893
|
-
if (!src || !trg)
|
|
894
|
-
return null;
|
|
895
|
-
if (src.length > MAX_SMOLSENT_CHARS || trg.length > MAX_SMOLSENT_CHARS)
|
|
896
|
-
return null;
|
|
897
|
-
const sl = typeof r.sl === "string" ? r.sl.trim() : "";
|
|
898
|
-
const tl = typeof r.tl === "string" ? r.tl.trim() : "";
|
|
899
|
-
return { src, trg, sl, tl };
|
|
900
|
-
}
|
|
901
|
-
/** Translate ONE SmolSent pair into SEMA facts. The two sentences are one
|
|
902
|
-
* meaning in two languages, but the two BINDINGS are not equally sound —
|
|
903
|
-
* SmolSent's English side is a shared pool translated into every language, so
|
|
904
|
-
* `trg -> src` gives one English context a different answer in every language
|
|
905
|
-
* file. See SMOLSENT_DIRECTIONS. refineItems drops the degenerate case where
|
|
906
|
-
* src === trg. */
|
|
907
|
-
export function smolSentRowToItems(row) {
|
|
908
|
-
const { src, trg } = row;
|
|
909
|
-
const items = [];
|
|
910
|
-
if (SMOLSENT_SRC2TRG)
|
|
911
|
-
items.push({ context: src, continuation: trg });
|
|
912
|
-
if (SMOLSENT_TRG2SRC)
|
|
913
|
-
items.push({ context: trg, continuation: src });
|
|
914
|
-
return refineItems(items);
|
|
915
|
-
}
|
|
916
|
-
/** Normalize a raw datasets-server row object into an AyaRow, or null when it
|
|
917
|
-
* lacks a usable prompt/answer or a field is implausibly large (a dump, not a
|
|
918
|
-
* cognitive example). Trims surrounding whitespace; keeps inner text verbatim
|
|
919
|
-
* (human prose, possibly multi-paragraph). */
|
|
920
|
-
export function toAyaRow(row) {
|
|
921
|
-
if (!row || typeof row !== "object")
|
|
922
|
-
return null;
|
|
923
|
-
const r = row;
|
|
924
|
-
const inputs = typeof r.inputs === "string" ? r.inputs.trim() : "";
|
|
925
|
-
const targets = typeof r.targets === "string" ? r.targets.trim() : "";
|
|
926
|
-
if (!inputs || !targets)
|
|
927
|
-
return null;
|
|
928
|
-
if (inputs.length > MAX_AYA_FIELD_CHARS || targets.length > MAX_AYA_FIELD_CHARS)
|
|
929
|
-
return null;
|
|
930
|
-
const language = typeof r.language === "string" ? r.language.trim() : "";
|
|
931
|
-
return { inputs, targets, language };
|
|
932
|
-
}
|
|
933
|
-
/** Translate ONE Aya row into SEMA training items. A row is a single human
|
|
934
|
-
* (question → answer) exchange — exactly one FACT, the (inputs → targets) edge.
|
|
935
|
-
* No standalone-answer experience and no one-exchange "cumulative" walk: a lone
|
|
936
|
-
* Q→A is not multi-turn, and both would only replicate the same edge. */
|
|
937
|
-
export function ayaRowToItems(row) {
|
|
938
|
-
const { inputs, targets } = row;
|
|
939
|
-
return refineItems([{ context: inputs, continuation: targets }]);
|
|
940
|
-
}
|
|
941
|
-
/** Collapse a conversation tree to ONE linear path: at each node, descend into
|
|
942
|
-
* its best-ranked, non-deleted reply (rank 0 preferred; unranked sorts last).
|
|
943
|
-
* Returns the ordered turns (already strictly alternating in this corpus). */
|
|
944
|
-
export function bestOasstPath(root) {
|
|
945
|
-
const turns = [];
|
|
946
|
-
let node = root;
|
|
947
|
-
while (node) {
|
|
948
|
-
const text = typeof node.text === "string" ? node.text.trim() : "";
|
|
949
|
-
if (text)
|
|
950
|
-
turns.push({ role: String(node.role ?? "?"), text });
|
|
951
|
-
const live = (node.replies ?? []).filter((r) => r && !r.deleted && typeof r.text === "string" && r.text.trim() !== "");
|
|
952
|
-
if (live.length === 0)
|
|
953
|
-
break;
|
|
954
|
-
live.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
|
|
955
|
-
node = live[0];
|
|
956
|
-
}
|
|
957
|
-
return turns;
|
|
958
|
-
}
|
|
959
|
-
/** Translate ONE multi-turn oasst2 conversation into SEMA training items.
|
|
960
|
-
*
|
|
961
|
-
* This is the ONE stage where cumulative continuous context is truly necessary:
|
|
962
|
-
* the data is a real multi-turn dialogue, and what must be learned is how each
|
|
963
|
-
* turn follows from the WHOLE conversation so far — not from the previous turn
|
|
964
|
-
* alone. The conversation is emitted ONLY as the accumulated walk; standalone
|
|
965
|
-
* turn experiences and local adjacent-pair facts are NOT emitted (they are
|
|
966
|
-
* subsumed by it and would merely replicate the content).
|
|
967
|
-
*
|
|
968
|
-
* The walk is the pattern proven in test/13-conversation.test.mjs
|
|
969
|
-
* ("teachConversation"): each turn is the continuation of all prior turns,
|
|
970
|
-
* with BARE turn text — NO "User:/Assistant:" labels. The SHAPE is identical
|
|
971
|
-
* (cumulative context → next turn); the join string is not, and does not need
|
|
972
|
-
* to be — that file joins with nothing and this corpus joins with "\n" (see
|
|
973
|
-
* `accumulate`). Saying "byte-for-byte", as this comment used to, invites the
|
|
974
|
-
* reading that the two must agree on a separator. They must not agree,
|
|
975
|
-
* because there is nothing to agree about: turn boundaries are offsets, and
|
|
976
|
-
* the join string is just corpus text. Roles already
|
|
977
|
-
* alternate by position in an oasst2 best-path (the root is a prompter), so a
|
|
978
|
-
* label adds nothing the position does not, while a clean continuation matches
|
|
979
|
-
* the test's recall (predictNext queries bare prior turns) and lets a turn share
|
|
980
|
-
* its gist with the same text elsewhere (e.g. an Aya question stored bare).
|
|
981
|
-
*
|
|
982
|
-
* Returns [] for a conversation below the multi-turn threshold, so callers can
|
|
983
|
-
* simply skip empties. */
|
|
984
|
-
export function oasstConversationToItems(turns) {
|
|
985
|
-
if (turns.length < OASST_MIN_TURNS)
|
|
986
|
-
return []; // not multi-turn — skip
|
|
987
|
-
return refineItems(accumulate(turns.map((t) => t.text)));
|
|
988
|
-
}
|
|
989
|
-
/** Normalize ONE element of a Taskmaster data file into its turns, or null when
|
|
990
|
-
* it carries no usable utterance. Empty/whitespace-only utterances are dropped
|
|
991
|
-
* (TM-3 has a few); a single implausibly long utterance rejects the whole
|
|
992
|
-
* conversation as corrupt rather than depositing a dump. */
|
|
993
|
-
export function toTaskmasterTurns(row) {
|
|
994
|
-
if (!row || typeof row !== "object")
|
|
995
|
-
return null;
|
|
996
|
-
const utterances = row.utterances;
|
|
997
|
-
if (!Array.isArray(utterances))
|
|
998
|
-
return null;
|
|
999
|
-
const turns = [];
|
|
1000
|
-
for (const u of utterances) {
|
|
1001
|
-
if (!u || typeof u !== "object")
|
|
1002
|
-
continue;
|
|
1003
|
-
const r = u;
|
|
1004
|
-
const text = typeof r.text === "string" ? r.text.trim() : "";
|
|
1005
|
-
if (!text)
|
|
1006
|
-
continue;
|
|
1007
|
-
if (text.length > MAX_TASKMASTER_TURN_CHARS)
|
|
1008
|
-
return null;
|
|
1009
|
-
turns.push({
|
|
1010
|
-
speaker: String(r.speaker ?? "").trim().toUpperCase(),
|
|
1011
|
-
text,
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
return turns.length ? turns : null;
|
|
1015
|
-
}
|
|
1016
|
-
/** Collapse consecutive same-speaker turns into one, joining with a space, and
|
|
1017
|
-
* return the bare texts in order. A turn with no speaker never merges with its
|
|
1018
|
-
* neighbour: an unlabelled row is of unknown origin, and joining two of them
|
|
1019
|
-
* would invent a contribution that may span two speakers. */
|
|
1020
|
-
export function mergeTaskmasterTurns(turns) {
|
|
1021
|
-
const out = [];
|
|
1022
|
-
let prev = "";
|
|
1023
|
-
for (const t of turns) {
|
|
1024
|
-
if (out.length > 0 && t.speaker !== "" && t.speaker === prev) {
|
|
1025
|
-
out[out.length - 1] += " " + t.text;
|
|
1026
|
-
}
|
|
1027
|
-
else {
|
|
1028
|
-
out.push(t.text);
|
|
1029
|
-
}
|
|
1030
|
-
prev = t.speaker;
|
|
1031
|
-
}
|
|
1032
|
-
return out;
|
|
1033
|
-
}
|
|
1034
|
-
/** Translate ONE Taskmaster conversation into SEMA training items: the
|
|
1035
|
-
* cumulative walk over its merged turns. Returns [] for a conversation below
|
|
1036
|
-
* TASKMASTER_MIN_TURNS, so callers can simply skip empties. */
|
|
1037
|
-
export function taskmasterConversationToItems(turns) {
|
|
1038
|
-
const texts = mergeTaskmasterTurns(turns);
|
|
1039
|
-
if (texts.length < TASKMASTER_MIN_TURNS)
|
|
1040
|
-
return [];
|
|
1041
|
-
return refineItems(accumulate(texts));
|
|
1042
|
-
}
|
|
1043
|
-
/** Normalize a 2Wiki row into its evidence triples, or null when it carries
|
|
1044
|
-
* none usable. `evidences` is a JSON STRING holding an array of 3-element
|
|
1045
|
-
* arrays; a row whose cell is absent, unparseable, or empty yields null.
|
|
1046
|
-
* Individual malformed or oversized triples are dropped without discarding the
|
|
1047
|
-
* row — one bad triple should not cost the others. */
|
|
1048
|
-
export function toWikiTriples(row) {
|
|
1049
|
-
if (!row || typeof row !== "object")
|
|
1050
|
-
return null;
|
|
1051
|
-
const cell = row.evidences;
|
|
1052
|
-
let parsed = cell;
|
|
1053
|
-
if (typeof cell === "string") {
|
|
1054
|
-
try {
|
|
1055
|
-
parsed = JSON.parse(cell);
|
|
1056
|
-
}
|
|
1057
|
-
catch {
|
|
1058
|
-
return null;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
if (!Array.isArray(parsed))
|
|
1062
|
-
return null;
|
|
1063
|
-
const out = [];
|
|
1064
|
-
for (const e of parsed) {
|
|
1065
|
-
if (!Array.isArray(e) || e.length < 3)
|
|
1066
|
-
continue;
|
|
1067
|
-
const subject = typeof e[0] === "string" ? e[0].trim() : "";
|
|
1068
|
-
const relation = typeof e[1] === "string" ? e[1].trim() : "";
|
|
1069
|
-
const object = typeof e[2] === "string" ? e[2].trim() : "";
|
|
1070
|
-
if (!subject || !relation || !object)
|
|
1071
|
-
continue;
|
|
1072
|
-
if (subject.length > MAX_WIKI2_FIELD_CHARS ||
|
|
1073
|
-
relation.length > MAX_WIKI2_FIELD_CHARS ||
|
|
1074
|
-
object.length > MAX_WIKI2_FIELD_CHARS)
|
|
1075
|
-
continue;
|
|
1076
|
-
out.push({ subject, relation, object });
|
|
1077
|
-
}
|
|
1078
|
-
return out.length ? out : null;
|
|
1079
|
-
}
|
|
1080
|
-
/** Render ONE triple as the prose fact Sema stores. Kept separate so the two
|
|
1081
|
-
* deposits below are guaranteed to share a byte-identical continuation: the
|
|
1082
|
-
* pivot fact only works if it leads to the SAME node the relation fact does. */
|
|
1083
|
-
export function wikiTripleSentence(t) {
|
|
1084
|
-
return `The ${t.relation} of ${t.subject} is ${t.object}.`;
|
|
1085
|
-
}
|
|
1086
|
-
/** Translate a row's triples into SEMA items: per triple, the relation fact and
|
|
1087
|
-
* the bare-subject PIVOT fact (see the section note above). refineItems drops
|
|
1088
|
-
* the duplicates this produces when a row states the same triple twice. */
|
|
1089
|
-
export function wikiTriplesToItems(triples) {
|
|
1090
|
-
const items = [];
|
|
1091
|
-
for (const t of triples) {
|
|
1092
|
-
const fact = wikiTripleSentence(t);
|
|
1093
|
-
items.push({ context: `${t.subject} ${t.relation}`, continuation: fact });
|
|
1094
|
-
items.push({ context: t.subject, continuation: fact });
|
|
1095
|
-
}
|
|
1096
|
-
return refineItems(items);
|
|
1097
|
-
}
|
|
1098
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1099
|
-
// §6e‴ SODA parsing — a social dialogue row → SEMA items
|
|
1100
|
-
//
|
|
1101
|
-
// Each row carries `dialogue` (an array of turn strings) and `speakers` (the
|
|
1102
|
-
// speaker name per turn). The deposit is the cumulative walk over speaker-merged
|
|
1103
|
-
// turns, identical in shape to Taskmaster and oasst2 — turns are short (mean
|
|
1104
|
-
// 87 B) and dialogues average 7.3 turns, so the accumulated context stays well
|
|
1105
|
-
// inside the healthy range.
|
|
1106
|
-
//
|
|
1107
|
-
// `narrative`, `literal` and the ATOMIC-style `head`/`relation`/`tail` columns
|
|
1108
|
-
// are NOT deposited: they are the generation scaffolding SODA was distilled
|
|
1109
|
-
// from, they restate the dialogue in the third person, and depositing both a
|
|
1110
|
-
// dialogue and its paraphrased summary gives one meaning two shapes — which is
|
|
1111
|
-
// measured to SUPPRESS composition rather than help it.
|
|
1112
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1113
|
-
/** Normalize a SODA row into its turns, or null when it carries no usable
|
|
1114
|
-
* dialogue. Speakers are optional (they only drive merging); an implausibly
|
|
1115
|
-
* long turn rejects the dialogue as corrupt. */
|
|
1116
|
-
export function toSodaTurns(row) {
|
|
1117
|
-
if (!row || typeof row !== "object")
|
|
1118
|
-
return null;
|
|
1119
|
-
const r = row;
|
|
1120
|
-
const dialogue = r.dialogue;
|
|
1121
|
-
if (!Array.isArray(dialogue))
|
|
1122
|
-
return null;
|
|
1123
|
-
const speakers = Array.isArray(r.speakers) ? r.speakers : [];
|
|
1124
|
-
const turns = [];
|
|
1125
|
-
for (let i = 0; i < dialogue.length; i++) {
|
|
1126
|
-
const text = typeof dialogue[i] === "string"
|
|
1127
|
-
? dialogue[i].trim()
|
|
1128
|
-
: "";
|
|
1129
|
-
if (!text)
|
|
1130
|
-
continue;
|
|
1131
|
-
if (text.length > MAX_SODA_TURN_CHARS)
|
|
1132
|
-
return null;
|
|
1133
|
-
turns.push({
|
|
1134
|
-
speaker: String(speakers[i] ?? "").trim().toUpperCase(),
|
|
1135
|
-
text,
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
return turns.length ? turns : null;
|
|
1139
|
-
}
|
|
1140
|
-
/** Translate ONE SODA dialogue into SEMA items: the cumulative walk over its
|
|
1141
|
-
* speaker-merged turns. Shares `mergeTaskmasterTurns` because the rule is the
|
|
1142
|
-
* same one — consecutive turns by one speaker are one contribution. */
|
|
1143
|
-
export function sodaDialogueToItems(turns) {
|
|
1144
|
-
const texts = mergeTaskmasterTurns(turns);
|
|
1145
|
-
if (texts.length < 2)
|
|
1146
|
-
return []; // not an exchange
|
|
1147
|
-
return refineItems(accumulate(texts));
|
|
1148
|
-
}
|
|
1149
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1150
|
-
// §6e⁗ MASSIVE parsing — one short utterance → ONE SEMA experience
|
|
1151
|
-
//
|
|
1152
|
-
// See the constants note for why this deposits a bare experience and not a
|
|
1153
|
-
// relation: the two relational shapes this corpus appears to offer are both
|
|
1154
|
-
// false (same-intent rows are not paraphrases; same-id rows across locales are
|
|
1155
|
-
// translations, SmolSent's worst-scoring relation).
|
|
1156
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1157
|
-
/** Translate ONE MASSIVE row into SEMA items: its bare utterance, as an
|
|
1158
|
-
* experience. `annot_utt` (slot-annotated) is deliberately not used — its
|
|
1159
|
-
* "[date : ...]" markup is not prose. Returns [] for an unusable row. */
|
|
1160
|
-
export function massiveRowToItems(row) {
|
|
1161
|
-
if (!row || typeof row !== "object")
|
|
1162
|
-
return [];
|
|
1163
|
-
const utt = row.utt;
|
|
1164
|
-
const text = typeof utt === "string" ? utt.trim() : "";
|
|
1165
|
-
if (!text || text.length > MAX_MASSIVE_UTT_CHARS)
|
|
1166
|
-
return [];
|
|
1167
|
-
return refineItems([text]);
|
|
1168
|
-
}
|
|
1169
|
-
/** Turn a source value into clean prose: decode the literal "\n"/"\t"/"\r"
|
|
1170
|
-
* two-character escapes the source JSON left in the text, collapse the runs of
|
|
1171
|
-
* whitespace that creates, and trim. */
|
|
1172
|
-
function unescapePlain(s) {
|
|
1173
|
-
return s
|
|
1174
|
-
.replace(/\\r\\n|\\n|\\r/g, "\n")
|
|
1175
|
-
.replace(/\\t/g, " ")
|
|
1176
|
-
.replace(/[ \t]+/g, " ")
|
|
1177
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
1178
|
-
.trim();
|
|
1179
|
-
}
|
|
1180
|
-
/** Normalize a raw datasets-server row into a GenKnowRow, or null when it lacks
|
|
1181
|
-
* a usable question/answer or a side is implausibly large (corruption). */
|
|
1182
|
-
export function toGenKnowRow(row) {
|
|
1183
|
-
if (!row || typeof row !== "object")
|
|
1184
|
-
return null;
|
|
1185
|
-
const r = row;
|
|
1186
|
-
const question = typeof r.Question === "string"
|
|
1187
|
-
? unescapePlain(r.Question)
|
|
1188
|
-
: "";
|
|
1189
|
-
const answer = typeof r.Answer === "string" ? unescapePlain(r.Answer) : "";
|
|
1190
|
-
if (!question || !answer)
|
|
1191
|
-
return null;
|
|
1192
|
-
if (question.length > MAX_GENKNOW_CHARS || answer.length > MAX_GENKNOW_CHARS)
|
|
1193
|
-
return null;
|
|
1194
|
-
return { question, answer };
|
|
1195
|
-
}
|
|
1196
|
-
/** Translate ONE General-Knowledge row into SEMA items: exactly one
|
|
1197
|
-
* (question → answer) FACT. refineItems drops a degenerate question === answer. */
|
|
1198
|
-
export function genKnowRowToItems(row) {
|
|
1199
|
-
return refineItems([{ context: row.question, continuation: row.answer }]);
|
|
1200
|
-
}
|
|
1201
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1202
|
-
// §7 Ingestion
|
|
1203
|
-
//
|
|
1204
|
-
// Each item is deposited directly: an experience via ingest(text), an episode
|
|
1205
|
-
// via ingest(context, continuation). After each, the per-example callback
|
|
1206
|
-
// receives the item's UTF-8 content size — the quantity the scaling suite
|
|
1207
|
-
// (14-scaling.test.mjs) reports as a constant KB/s — then gates the global
|
|
1208
|
-
// example count and checkpointing (returns false to stop). `sample` feeds the
|
|
1209
|
-
// reservoir used for the periodic recall box.
|
|
1210
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1211
|
-
const ENC = new TextEncoder();
|
|
1212
|
-
/** Content size of a training item in UTF-8 bytes — the same quantity the
|
|
1213
|
-
* scaling suite (14-scaling.test.mjs) measures as KB/s: for an episode the
|
|
1214
|
-
* context plus the continuation, for a bare experience its own text. */
|
|
1215
|
-
const itemBytes = (it) => isEpisode(it)
|
|
1216
|
-
? ENC.encode(it.context).length + ENC.encode(it.continuation).length
|
|
1217
|
-
: ENC.encode(it).length;
|
|
1218
|
-
async function ingestItems(ci, items, onItem, sample) {
|
|
1219
|
-
for (const it of items) {
|
|
1220
|
-
if (isEpisode(it))
|
|
1221
|
-
await ci.ingest(it.context, it.continuation);
|
|
1222
|
-
else
|
|
1223
|
-
await ci.ingest(it);
|
|
1224
|
-
sample?.(it);
|
|
1225
|
-
if (!(await onItem(itemBytes(it))))
|
|
1226
|
-
return false; // stop requested
|
|
1227
|
-
}
|
|
1228
|
-
return true;
|
|
1229
|
-
}
|
|
1230
|
-
// ── §7a′ oasst2 — stream the gzipped JSONL of trees and deposit multi-turn ──
|
|
1231
|
-
//
|
|
1232
|
-
// The file is gzipped JSONL: one conversation tree per line. We inflate with the
|
|
1233
|
-
// web-standard DecompressionStream("gzip"), split on newlines without buffering
|
|
1234
|
-
// the whole file or an unbounded line, parse each tree, collapse it to its best
|
|
1235
|
-
// linear path, and deposit only the multi-turn ones. Robust by construction: a
|
|
1236
|
-
// line that fails to parse (or is oversize) is counted skipped and the stream
|
|
1237
|
-
// continues; a cap/signal stops cleanly at a conversation boundary.
|
|
1238
|
-
async function processOasst(filePath, ci, onExample, sample) {
|
|
1239
|
-
const blob = await openAsBlob(filePath);
|
|
1240
|
-
const reader = blob.stream()
|
|
1241
|
-
.pipeThrough(new DecompressionStream("gzip"))
|
|
1242
|
-
.pipeThrough(new TextDecoderStream())
|
|
1243
|
-
.getReader();
|
|
1244
|
-
let examples = 0;
|
|
1245
|
-
let skipped = 0; // malformed/oversize lines
|
|
1246
|
-
let multi = 0; // multi-turn conversations deposited
|
|
1247
|
-
let leftover = "";
|
|
1248
|
-
let droppingLine = false;
|
|
1249
|
-
const processLine = async (line) => {
|
|
1250
|
-
if (!line.trim())
|
|
1251
|
-
return true;
|
|
1252
|
-
let tree;
|
|
1253
|
-
try {
|
|
1254
|
-
tree = JSON.parse(line);
|
|
1255
|
-
}
|
|
1256
|
-
catch {
|
|
1257
|
-
skipped++;
|
|
1258
|
-
return true;
|
|
1259
|
-
}
|
|
1260
|
-
if (!tree.prompt)
|
|
1261
|
-
return true;
|
|
1262
|
-
const turns = bestOasstPath(tree.prompt);
|
|
1263
|
-
const items = oasstConversationToItems(turns); // [] when not multi-turn
|
|
1264
|
-
if (items.length === 0)
|
|
1265
|
-
return true; // single-turn / empty — skipped
|
|
1266
|
-
multi++;
|
|
1267
|
-
return ingestItems(ci, items, async (contentBytes) => {
|
|
1268
|
-
examples++;
|
|
1269
|
-
return onExample(contentBytes);
|
|
1270
|
-
}, sample);
|
|
1271
|
-
};
|
|
1272
|
-
try {
|
|
1273
|
-
while (true) {
|
|
1274
|
-
const { done, value } = await reader.read();
|
|
1275
|
-
if (done)
|
|
1276
|
-
break;
|
|
1277
|
-
let chunk = value;
|
|
1278
|
-
for (;;) {
|
|
1279
|
-
const nl = chunk.indexOf("\n");
|
|
1280
|
-
if (nl < 0) {
|
|
1281
|
-
if (!droppingLine) {
|
|
1282
|
-
if (leftover.length + chunk.length > MAX_OASST_LINE_CHARS) {
|
|
1283
|
-
leftover = "";
|
|
1284
|
-
droppingLine = true;
|
|
1285
|
-
skipped++;
|
|
1286
|
-
}
|
|
1287
|
-
else
|
|
1288
|
-
leftover += chunk;
|
|
1289
|
-
}
|
|
1290
|
-
break;
|
|
1291
|
-
}
|
|
1292
|
-
const part = chunk.slice(0, nl);
|
|
1293
|
-
chunk = chunk.slice(nl + 1);
|
|
1294
|
-
if (droppingLine) {
|
|
1295
|
-
droppingLine = false;
|
|
1296
|
-
leftover = "";
|
|
1297
|
-
continue;
|
|
1298
|
-
}
|
|
1299
|
-
if (leftover.length + part.length > MAX_OASST_LINE_CHARS) {
|
|
1300
|
-
leftover = "";
|
|
1301
|
-
skipped++;
|
|
1302
|
-
continue;
|
|
1303
|
-
}
|
|
1304
|
-
const line = leftover + part;
|
|
1305
|
-
leftover = "";
|
|
1306
|
-
if (!(await processLine(line))) {
|
|
1307
|
-
return { examples, stopped: true, skipped, multi };
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
if (!droppingLine && leftover.trim()) {
|
|
1312
|
-
if (!(await processLine(leftover))) {
|
|
1313
|
-
return { examples, stopped: true, skipped, multi };
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
return { examples, stopped: false, skipped, multi };
|
|
1317
|
-
}
|
|
1318
|
-
finally {
|
|
1319
|
-
try {
|
|
1320
|
-
reader.releaseLock();
|
|
1321
|
-
}
|
|
1322
|
-
catch { /* best effort */ }
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
/** Discover the SmolSent per-pair JSONL files from the HF repo tree, restricted
|
|
1326
|
-
* to SMOLSENT_PAIRS (basenames without .jsonl) when set. Each entry is the
|
|
1327
|
-
* repo-relative path, e.g. "smolsent/ha_en.jsonl". */
|
|
1328
|
-
async function listSmolSentFiles() {
|
|
1329
|
-
// The dataset id ("owner/name") is a PATH here, so its "/" must not be
|
|
1330
|
-
// percent-encoded. `recursive=true` returns every file under smolsent/.
|
|
1331
|
-
const url = `https://huggingface.co/api/datasets/${SMOLSENT_DATASET}` +
|
|
1332
|
-
`/tree/main/smolsent?recursive=true`;
|
|
1333
|
-
const body = await getJson(url, `GET smol tree`);
|
|
1334
|
-
const paths = Array.isArray(body)
|
|
1335
|
-
? body
|
|
1336
|
-
.filter((e) => e?.type === "file" && /\.jsonl$/i.test(e?.path))
|
|
1337
|
-
.map((e) => String(e.path))
|
|
1338
|
-
: [];
|
|
1339
|
-
paths.sort();
|
|
1340
|
-
if (!SMOLSENT_PAIRS.length)
|
|
1341
|
-
return paths;
|
|
1342
|
-
const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
|
|
1343
|
-
return paths.filter((p) => want.has(basename(p).replace(/\.jsonl$/i, "")));
|
|
1344
|
-
}
|
|
1345
|
-
/** List the Taskmaster data files to train, in TASKMASTER_SETS order. Returns
|
|
1346
|
-
* repo-relative paths, e.g. "TM-3-2020/data/data_00.json".
|
|
1347
|
-
*
|
|
1348
|
-
* TM-2/3/4 keep their dialogue files under `<set>/data`, so everything there is
|
|
1349
|
-
* fair game. TM-1 has no `data` directory: its two dialogue files sit at the
|
|
1350
|
-
* set root NEXT TO `ontology.json` (a slot schema) and `sample.json` (a small
|
|
1351
|
-
* excerpt of self-dialogs). Neither is an array of conversations, and training
|
|
1352
|
-
* the excerpt would deposit a subset of TM-1 twice, so TM-1 is filtered to the
|
|
1353
|
-
* `*-dialogs.json` pair (self-dialogs, woz-dialogs). */
|
|
1354
|
-
async function listTaskmasterFiles() {
|
|
1355
|
-
const out = [];
|
|
1356
|
-
for (const set of TASKMASTER_SETS) {
|
|
1357
|
-
const rootOnly = /^TM-1\b/i.test(set);
|
|
1358
|
-
const dir = rootOnly ? set : `${set}/data`;
|
|
1359
|
-
const body = await getJson(`https://api.github.com/repos/${TASKMASTER_REPO}/contents/${dir}`, `GET Taskmaster ${dir}`);
|
|
1360
|
-
const names = Array.isArray(body)
|
|
1361
|
-
? body
|
|
1362
|
-
.filter((e) => e?.type === "file" && /\.json$/i.test(e?.name))
|
|
1363
|
-
.map((e) => String(e.name))
|
|
1364
|
-
: [];
|
|
1365
|
-
names.sort();
|
|
1366
|
-
for (const name of names) {
|
|
1367
|
-
if (rootOnly && !/-dialogs\.json$/i.test(name))
|
|
1368
|
-
continue;
|
|
1369
|
-
out.push({ set, path: `${dir}/${name}` });
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
return out;
|
|
1373
|
-
}
|
|
1374
|
-
/** List a dataset's Parquet shards on Hugging Face's auto-converted
|
|
1375
|
-
* `refs/convert/parquet` branch, restricted to `config` and to `splits`.
|
|
1376
|
-
*
|
|
1377
|
-
* Shared by 2Wiki, SODA and MASSIVE. The converted branch is used rather than
|
|
1378
|
-
* `main` because a dataset's own Parquet may be written as ONE giant row-group
|
|
1379
|
-
* (SODA's is 1,191,582 rows), and a column chunk is per-group, so reading any
|
|
1380
|
-
* part of it materialises all of it. The converted branch is uniformly
|
|
1381
|
-
* 10,000-row groups. See test/79-parquet-batching.test.mjs.
|
|
1382
|
-
*
|
|
1383
|
-
* Paths look like "<config>/<split>/0000.parquet". The BRANCH name is a single
|
|
1384
|
-
* path SEGMENT here, so its "/" is percent-encoded — unlike a dataset id,
|
|
1385
|
-
* whose "/" must not be. */
|
|
1386
|
-
async function listConvertedParquet(dataset, config, splits, label) {
|
|
1387
|
-
const body = await getJson(`https://huggingface.co/api/datasets/${dataset}` +
|
|
1388
|
-
`/tree/refs%2Fconvert%2Fparquet/${config}?recursive=true`, `GET ${label} tree`);
|
|
1389
|
-
const paths = Array.isArray(body)
|
|
1390
|
-
? body
|
|
1391
|
-
.filter((e) => e?.type === "file" && /\.parquet$/i.test(e?.path))
|
|
1392
|
-
.map((e) => String(e.path))
|
|
1393
|
-
: [];
|
|
1394
|
-
paths.sort();
|
|
1395
|
-
const want = new Set(splits);
|
|
1396
|
-
return paths.filter((p) => {
|
|
1397
|
-
const parts = p.split("/");
|
|
1398
|
-
// The tree is rooted at `config`, so the split is the second-to-last part.
|
|
1399
|
-
return want.has(parts[parts.length - 2] ?? "");
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
/** Stream a plain-JSONL file from disk, deposit each parsed row via `toItems`.
|
|
1403
|
-
* Lines are split without buffering the whole file; an oversize/malformed line
|
|
1404
|
-
* is counted skipped and the stream continues. Shared by SmolSent (and any
|
|
1405
|
-
* future JSONL source). */
|
|
1406
|
-
async function processJsonl(filePath, toItems, ci, onExample, sample, maxLineChars) {
|
|
1407
|
-
const blob = await openAsBlob(filePath);
|
|
1408
|
-
const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
|
|
1409
|
-
let examples = 0, skipped = 0, leftover = "", dropping = false;
|
|
1410
|
-
const processLine = async (line) => {
|
|
1411
|
-
if (!line.trim())
|
|
1412
|
-
return true;
|
|
1413
|
-
let row;
|
|
1414
|
-
try {
|
|
1415
|
-
row = JSON.parse(line);
|
|
1416
|
-
}
|
|
1417
|
-
catch {
|
|
1418
|
-
skipped++;
|
|
1419
|
-
return true;
|
|
1420
|
-
}
|
|
1421
|
-
const items = toItems(row);
|
|
1422
|
-
if (!items || items.length === 0) {
|
|
1423
|
-
skipped++;
|
|
1424
|
-
return true;
|
|
1425
|
-
}
|
|
1426
|
-
return ingestItems(ci, items, async (contentBytes) => {
|
|
1427
|
-
examples++;
|
|
1428
|
-
return onExample(contentBytes);
|
|
1429
|
-
}, sample);
|
|
1430
|
-
};
|
|
1431
|
-
try {
|
|
1432
|
-
while (true) {
|
|
1433
|
-
const { done, value } = await reader.read();
|
|
1434
|
-
if (done)
|
|
1435
|
-
break;
|
|
1436
|
-
let chunk = value;
|
|
1437
|
-
for (;;) {
|
|
1438
|
-
const nl = chunk.indexOf("\n");
|
|
1439
|
-
if (nl < 0) {
|
|
1440
|
-
if (!dropping) {
|
|
1441
|
-
if (leftover.length + chunk.length > maxLineChars) {
|
|
1442
|
-
leftover = "";
|
|
1443
|
-
dropping = true;
|
|
1444
|
-
skipped++;
|
|
1445
|
-
}
|
|
1446
|
-
else
|
|
1447
|
-
leftover += chunk;
|
|
1448
|
-
}
|
|
1449
|
-
break;
|
|
1450
|
-
}
|
|
1451
|
-
const part = chunk.slice(0, nl);
|
|
1452
|
-
chunk = chunk.slice(nl + 1);
|
|
1453
|
-
if (dropping) {
|
|
1454
|
-
dropping = false;
|
|
1455
|
-
leftover = "";
|
|
1456
|
-
continue;
|
|
1457
|
-
}
|
|
1458
|
-
if (leftover.length + part.length > maxLineChars) {
|
|
1459
|
-
leftover = "";
|
|
1460
|
-
skipped++;
|
|
1461
|
-
continue;
|
|
1462
|
-
}
|
|
1463
|
-
const line = leftover + part;
|
|
1464
|
-
leftover = "";
|
|
1465
|
-
if (!(await processLine(line))) {
|
|
1466
|
-
return { examples, stopped: true, skipped };
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
if (!dropping && leftover.trim()) {
|
|
1471
|
-
if (!(await processLine(leftover))) {
|
|
1472
|
-
return { examples, stopped: true, skipped };
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
return { examples, stopped: false, skipped };
|
|
1476
|
-
}
|
|
1477
|
-
finally {
|
|
1478
|
-
try {
|
|
1479
|
-
reader.releaseLock();
|
|
1480
|
-
}
|
|
1481
|
-
catch { /* best effort */ }
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
/** How many rows to materialise in one read from a row-group of `rgRows` rows
|
|
1485
|
-
* occupying `groupBytes` uncompressed bytes, under a `budgetBytes` target.
|
|
1486
|
-
*
|
|
1487
|
-
* The group's own footer statistics give the mean row width, so the batch
|
|
1488
|
-
* follows the CORPUS's row size rather than the writer's layout: wide rows
|
|
1489
|
-
* (SODA carries a whole dialogue per row) batch smaller than narrow ones at
|
|
1490
|
-
* the same memory cost. Never exceeds the group — a batch is a subdivision of
|
|
1491
|
-
* a group, never a span across two, because `parquetReadObjects` is given an
|
|
1492
|
-
* absolute row range and column chunks are per-group. Never returns 0, or the
|
|
1493
|
-
* read loop could not advance.
|
|
1494
|
-
*
|
|
1495
|
-
* A writer that omits `total_byte_size` yields `groupBytes <= 0`; the batch is
|
|
1496
|
-
* then the whole group, which is exactly the behaviour this replaced. That
|
|
1497
|
-
* fallback is safe for every file we read today (all three report it) and
|
|
1498
|
-
* degrades to the old memory profile rather than to a wrong result. */
|
|
1499
|
-
export function parquetBatchRows(rgRows, groupBytes, budgetBytes) {
|
|
1500
|
-
if (!(rgRows > 0))
|
|
1501
|
-
return 0; // empty group — the caller skips it
|
|
1502
|
-
if (!(groupBytes > 0) || !Number.isFinite(groupBytes))
|
|
1503
|
-
return rgRows;
|
|
1504
|
-
const perRow = groupBytes / rgRows;
|
|
1505
|
-
const fit = Math.floor(budgetBytes / perRow);
|
|
1506
|
-
return Math.min(rgRows, Math.max(1, fit));
|
|
1507
|
-
}
|
|
1508
|
-
/** Read a downloaded Parquet file in bounded row batches with hyparquet (+Snappy
|
|
1509
|
-
* from hyparquet-compressors) over a web-standard Blob byte source, depositing
|
|
1510
|
-
* each row via `toItems`. At most `PARQUET_BATCH_BYTES` of source rows are
|
|
1511
|
-
* materialised at a time, so neither a multi-hundred-MB file nor a file written
|
|
1512
|
-
* as ONE giant row-group loads whole into memory.
|
|
1513
|
-
*
|
|
1514
|
-
* Batching also makes a single-group file INTERRUPTIBLE: the abort check runs
|
|
1515
|
-
* per batch, where before a 1.19M-row group could not be cancelled at all. */
|
|
1516
|
-
async function processParquet(filePath, toItems, ci, onExample, sample,
|
|
1517
|
-
// Optional stage-level stop, checked per row and before each batch is
|
|
1518
|
-
// decoded. A stage BUDGET must stop the read rather than reject rows: left to
|
|
1519
|
-
// reject, a budgeted stage still DECODES every remaining row-group — 143,346
|
|
1520
|
-
// rows of one 86.7 MB SODA shard — and reports them as "unusable" when
|
|
1521
|
-
// nothing was wrong with them, which is a lie in the run log.
|
|
1522
|
-
//
|
|
1523
|
-
// Measured honestly: on that shard the wall time did NOT improve (2m 35s ->
|
|
1524
|
-
// 2m 37s), because a budgeted run is dominated by depositing the rows it DID
|
|
1525
|
-
// take, not by scanning past the ones it did not. The win here is a truthful
|
|
1526
|
-
// log and the CPU/allocation of ~143k skipped row decodes, not elapsed time.
|
|
1527
|
-
// A larger shard past a small budget is where the decode cost would show.
|
|
1528
|
-
shouldStop) {
|
|
1529
|
-
const blob = await openAsBlob(filePath);
|
|
1530
|
-
const file = {
|
|
1531
|
-
byteLength: blob.size,
|
|
1532
|
-
slice: async (start, end) => await blob.slice(start, end ?? blob.size).arrayBuffer(),
|
|
1533
|
-
};
|
|
1534
|
-
const meta = await parquetMetadataAsync(file);
|
|
1535
|
-
let examples = 0, skipped = 0;
|
|
1536
|
-
let rowStart = 0;
|
|
1537
|
-
for (const rg of meta.row_groups) {
|
|
1538
|
-
const rgRows = Number(rg.num_rows);
|
|
1539
|
-
const rgEnd = rowStart + rgRows;
|
|
1540
|
-
const batchRows = parquetBatchRows(rgRows, Number(rg.total_byte_size ?? 0), PARQUET_BATCH_BYTES);
|
|
1541
|
-
if (batchRows <= 0)
|
|
1542
|
-
continue; // empty group
|
|
1543
|
-
// Materialise one bounded batch at a time, then deposit its rows.
|
|
1544
|
-
while (rowStart < rgEnd) {
|
|
1545
|
-
if (shutdown.signal.aborted)
|
|
1546
|
-
return { examples, stopped: true, skipped };
|
|
1547
|
-
if (shouldStop?.())
|
|
1548
|
-
return { examples, stopped: true, skipped };
|
|
1549
|
-
const rowEnd = Math.min(rowStart + batchRows, rgEnd);
|
|
1550
|
-
const rows = await parquetReadObjects({
|
|
1551
|
-
file,
|
|
1552
|
-
compressors,
|
|
1553
|
-
rowStart,
|
|
1554
|
-
rowEnd,
|
|
1555
|
-
});
|
|
1556
|
-
rowStart = rowEnd;
|
|
1557
|
-
for (const row of rows) {
|
|
1558
|
-
if (shouldStop?.())
|
|
1559
|
-
return { examples, stopped: true, skipped };
|
|
1560
|
-
const items = toItems(row);
|
|
1561
|
-
if (!items || items.length === 0) {
|
|
1562
|
-
skipped++;
|
|
1563
|
-
continue;
|
|
1564
|
-
}
|
|
1565
|
-
const ok = await ingestItems(ci, items, async (contentBytes) => {
|
|
1566
|
-
examples++;
|
|
1567
|
-
return onExample(contentBytes);
|
|
1568
|
-
}, sample);
|
|
1569
|
-
if (!ok)
|
|
1570
|
-
return { examples, stopped: true, skipped };
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
return { examples, stopped: false, skipped };
|
|
1575
|
-
}
|
|
1576
|
-
/** Read a downloaded JSON-array file (General-Knowledge output.json) and deposit
|
|
1577
|
-
* each element via `toItems`. The array is small enough (~16 MB) to parse whole;
|
|
1578
|
-
* a huge file would be rejected by the cache ceiling long before this. */
|
|
1579
|
-
async function processJsonArray(filePath, toItems, ci, onExample, sample) {
|
|
1580
|
-
const blob = await openAsBlob(filePath);
|
|
1581
|
-
let arr;
|
|
1582
|
-
try {
|
|
1583
|
-
arr = JSON.parse(await blob.text());
|
|
1584
|
-
}
|
|
1585
|
-
catch (e) {
|
|
1586
|
-
throw new Error(`invalid JSON: ${e.message}`);
|
|
1587
|
-
}
|
|
1588
|
-
const rows = Array.isArray(arr) ? arr : [];
|
|
1589
|
-
let examples = 0, skipped = 0;
|
|
1590
|
-
for (const row of rows) {
|
|
1591
|
-
if (shutdown.signal.aborted)
|
|
1592
|
-
return { examples, stopped: true, skipped };
|
|
1593
|
-
const items = toItems(row);
|
|
1594
|
-
if (!items || items.length === 0) {
|
|
1595
|
-
skipped++;
|
|
1596
|
-
continue;
|
|
1597
|
-
}
|
|
1598
|
-
const ok = await ingestItems(ci, items, async (contentBytes) => {
|
|
1599
|
-
examples++;
|
|
1600
|
-
return onExample(contentBytes);
|
|
1601
|
-
}, sample);
|
|
1602
|
-
if (!ok)
|
|
1603
|
-
return { examples, stopped: true, skipped };
|
|
1604
|
-
}
|
|
1605
|
-
return { examples, stopped: false, skipped };
|
|
1606
|
-
}
|
|
1607
|
-
/** A prompt/expected pair to display for an item. */
|
|
1608
|
-
function promptOf(it) {
|
|
1609
|
-
return isEpisode(it)
|
|
1610
|
-
? { prompt: it.context, expected: it.continuation, kind: "episode" }
|
|
1611
|
-
: { prompt: it.slice(0, 200), expected: null, kind: "experience" };
|
|
1612
|
-
}
|
|
1613
|
-
/** A coarse, honest similarity between an expected continuation and SEMA's
|
|
1614
|
-
* recall. Both are normalized (lowercased, whitespace-collapsed) and compared
|
|
1615
|
-
* by the longest shared leading run plus token overlap, so the verdict is a
|
|
1616
|
-
* heuristic signal of recall quality rather than a brittle fixed-prefix test. */
|
|
1617
|
-
function recallSimilarity(expected, response) {
|
|
1618
|
-
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1619
|
-
const a = norm(expected), b = norm(response);
|
|
1620
|
-
if (!a || !b)
|
|
1621
|
-
return 0;
|
|
1622
|
-
let lead = 0;
|
|
1623
|
-
const lim = Math.min(a.length, b.length);
|
|
1624
|
-
while (lead < lim && a[lead] === b[lead])
|
|
1625
|
-
lead++;
|
|
1626
|
-
const leadFrac = lead / Math.max(1, Math.min(a.length, b.length));
|
|
1627
|
-
const ta = new Set(a.split(" ")), tb = new Set(b.split(" "));
|
|
1628
|
-
let inter = 0;
|
|
1629
|
-
for (const w of ta)
|
|
1630
|
-
if (tb.has(w))
|
|
1631
|
-
inter++;
|
|
1632
|
-
const jac = inter / Math.max(1, ta.size + tb.size - inter);
|
|
1633
|
-
return Math.max(leadFrac, jac);
|
|
1634
|
-
}
|
|
1635
|
-
/** A framed recall sample. Pinned in the panel on a TTY (so the most recent
|
|
1636
|
-
* example is always on screen) and logged once per checkpoint when piped. */
|
|
1637
|
-
function renderInferenceBox(prompt, expected, response, kind, checkpointN) {
|
|
1638
|
-
const W = 68;
|
|
1639
|
-
const hr = `${DIM}${"─".repeat(W)}${R}`;
|
|
1640
|
-
const title = kind === "episode"
|
|
1641
|
-
? "latest recall"
|
|
1642
|
-
: "latest recall (experience)";
|
|
1643
|
-
const head = `${title} · checkpoint #${checkpointN} `;
|
|
1644
|
-
const shown = response.trim() ? response : "(empty)";
|
|
1645
|
-
const lines = [
|
|
1646
|
-
`${B}╭─ ${head}${"─".repeat(Math.max(0, W - 2 - head.length))}╮${R}`,
|
|
1647
|
-
`${B}│${R} ${hr}`,
|
|
1648
|
-
`${B}│${R} ${CYAN}${B}Context:${R} ${clip(prompt, W - 13)}`,
|
|
1649
|
-
];
|
|
1650
|
-
if (expected) {
|
|
1651
|
-
lines.push(`${B}│${R} ${YEL}${B}Expected:${R} ${clip(expected, W - 13)}`);
|
|
1652
|
-
}
|
|
1653
|
-
lines.push(`${B}│${R} ${GRN}${B}SEMA:${R} ${clip(shown, W - 13)}`);
|
|
1654
|
-
lines.push(`${B}│${R} ${hr}`);
|
|
1655
|
-
let verdict;
|
|
1656
|
-
if (expected) {
|
|
1657
|
-
const sim = recallSimilarity(expected, response);
|
|
1658
|
-
const pctStr = `${Math.round(sim * 100)}%`;
|
|
1659
|
-
verdict = sim >= 0.6
|
|
1660
|
-
? `${GRN}✓${R} recall close to expected ${DIM}(~${pctStr} overlap)${R}`
|
|
1661
|
-
: sim >= 0.25
|
|
1662
|
-
? `${YEL}△${R} partial recall ${DIM}(~${pctStr} overlap)${R}`
|
|
1663
|
-
: `${RED}✗${R} recall diverges ${DIM}(~${pctStr} overlap)${R}`;
|
|
1664
|
-
}
|
|
1665
|
-
else {
|
|
1666
|
-
verdict = `${DIM}·${R} plain experience — no expected answer`;
|
|
1667
|
-
}
|
|
1668
|
-
lines.push(`${B}│${R} ${verdict}`);
|
|
1669
|
-
lines.push(`${B}╰${"─".repeat(W)}╯${R}`);
|
|
1670
|
-
return lines.join("\n");
|
|
1671
|
-
}
|
|
1672
|
-
function renderPanel(s) {
|
|
1673
|
-
const targetKnown = isFinite(s.target);
|
|
1674
|
-
// Primary progress: by learned-content bytes when a MAX_MB target is set,
|
|
1675
|
-
// else by how far we are through the corpus on disk (bytes) — so the default
|
|
1676
|
-
// unbounded run still shows a real fraction and a real ETA.
|
|
1677
|
-
const frac = targetKnown
|
|
1678
|
-
? (s.target > 0 ? s.trainedBytes / s.target : 0)
|
|
1679
|
-
: (s.bytesTotal > 0 ? s.bytesDone / s.bytesTotal : 0);
|
|
1680
|
-
const etaStr = (() => {
|
|
1681
|
-
if (targetKnown) {
|
|
1682
|
-
return s.trainedRate > 0
|
|
1683
|
-
? dur((s.target - s.trainedBytes) / s.trainedRate)
|
|
1684
|
-
: "∞";
|
|
1685
|
-
}
|
|
1686
|
-
if (s.bytesTotal > 0 && s.bytesRate > 0) {
|
|
1687
|
-
return dur((s.bytesTotal - s.bytesDone) / s.bytesRate);
|
|
1688
|
-
}
|
|
1689
|
-
return "∞";
|
|
1690
|
-
})();
|
|
1691
|
-
const fileFrac = s.fileTotal > 0 ? s.fileIndex / s.fileTotal : 0;
|
|
1692
|
-
let actIcon = `${DIM}·${R}`, actText = "waiting…";
|
|
1693
|
-
if (s.activity === "download") {
|
|
1694
|
-
actIcon = `${CYAN}⬇${R}`;
|
|
1695
|
-
const name = s.filePath;
|
|
1696
|
-
const total = s.dlTotal > 0 ? s.dlTotal : s.fileSize;
|
|
1697
|
-
if (total > 0 && s.dlDone > 0) {
|
|
1698
|
-
const dlFrac = clamp01(s.dlDone / total);
|
|
1699
|
-
actText =
|
|
1700
|
-
`downloading ${name} ${bar(18, dlFrac)} ${B}${pct(dlFrac)}${R}` +
|
|
1701
|
-
` ${DIM}${bytes(s.dlDone)}/${bytes(total)}${R}`;
|
|
1702
|
-
if (s.dlSpeed > 0)
|
|
1703
|
-
actText += ` ${DIM}@ ${bytes(s.dlSpeed)}/s${R}`;
|
|
1704
|
-
}
|
|
1705
|
-
else {
|
|
1706
|
-
actText = total > 0
|
|
1707
|
-
? `downloading ${name} · ${bytes(total)}…`
|
|
1708
|
-
: `downloading ${name}…`;
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
else if (s.activity === "process") {
|
|
1712
|
-
actIcon = `${GRN}✓${R}`;
|
|
1713
|
-
actText = `processing ${s.filePath} · ${int(s.fileExamples)} examples so far`;
|
|
1714
|
-
}
|
|
1715
|
-
const targetStr = targetKnown ? bytes(s.target) : "∞";
|
|
1716
|
-
const headExamples = targetKnown
|
|
1717
|
-
? `${CYAN}${bytes(s.trainedBytes)}${R} / ${targetStr} learned ${DIM}·${R} ${int(s.exampleCount)} examples`
|
|
1718
|
-
: `${CYAN}${int(s.exampleCount)}${R} examples`;
|
|
1719
|
-
const corpusInfo = s.bytesTotal > 0
|
|
1720
|
-
? `${B}📦${R} ${bytes(s.bytesDone)}/${bytes(s.bytesTotal)} (${pct(s.bytesDone / s.bytesTotal)})`
|
|
1721
|
-
: `${B}📦${R} ${bytes(s.bytesDone)} processed`;
|
|
1722
|
-
const fileInfo = s.fileTotal > 0
|
|
1723
|
-
? `${B}🌐${R} ${s.fileIndex}/${s.fileTotal} (${pct(fileFrac)})`
|
|
1724
|
-
: `${B}🌐${R} ${s.fileIndex} languages`;
|
|
1725
|
-
const panel = [
|
|
1726
|
-
`${B}╭${R}${B} sema train${R} ${DIM}·${R} SmolSent+Aya+oasst2 ${DIM}·${R} ` +
|
|
1727
|
-
`D=${D} ${DIM}·${R} seed=${SEED} ${DIM}·${R} ` +
|
|
1728
|
-
`store=${basename(DB_PATH)}.sqlite\n${B}╰${R} target=${CYAN}${targetStr}${R} ` +
|
|
1729
|
-
`learned ${DIM}·${R} checkpoint every ${bytes(CHECKPOINT_BYTES)}`,
|
|
1730
|
-
`\n${bar(40, frac)} ${B}${pct(frac)}${R} ${headExamples}`,
|
|
1731
|
-
`\n${B}⚡${R} ${bytes(s.trainedRate)}/s learned ${B}🧠${R} ${bytes(s.trainedBytes)} content ${B}⏱${R} ${dur(s.elapsedS)} elapsed ${B}🕐${R} ${etaStr} ETA`,
|
|
1732
|
-
`${fileInfo} ${corpusInfo} ${B}🗄${R} ${num(s.storeEntries)} entries ` +
|
|
1733
|
-
`${B}💾${R} cache ${bytes(s.cacheBytes)}`,
|
|
1734
|
-
`\n${actIcon} ${actText}`,
|
|
1735
|
-
].join("");
|
|
1736
|
-
return s.lastSample ? `${panel}\n${s.lastSample}` : panel;
|
|
1737
|
-
}
|
|
1738
|
-
/** A live panel pinned to the bottom of stderr. On a TTY it redraws in place,
|
|
1739
|
-
* clearing only its own lines; logs are flushed into the scrollback above it.
|
|
1740
|
-
* Off a TTY (piped/CI) the panel is suppressed and a plain status line is
|
|
1741
|
-
* emitted occasionally, so logs stay clean and parseable. */
|
|
1742
|
-
class Progress {
|
|
1743
|
-
lines = 0; // height of the panel currently on screen
|
|
1744
|
-
lastPaint = 0;
|
|
1745
|
-
lastStatus = 0;
|
|
1746
|
-
last = null;
|
|
1747
|
-
tty = process.stderr.isTTY === true;
|
|
1748
|
-
/** True when attached to an interactive terminal (panel is live). */
|
|
1749
|
-
get interactive() {
|
|
1750
|
-
return this.tty;
|
|
1751
|
-
}
|
|
1752
|
-
/** Cursor sequence that returns to the top of the panel and clears it. */
|
|
1753
|
-
clearPanel() {
|
|
1754
|
-
if (this.lines <= 0)
|
|
1755
|
-
return "";
|
|
1756
|
-
const up = this.lines - 1; // cursor is on the panel's last line
|
|
1757
|
-
return (up > 0 ? `${CSI}${up}F` : "\r") + `${CSI}0J`;
|
|
1758
|
-
}
|
|
1759
|
-
render(s, force = false) {
|
|
1760
|
-
this.last = s;
|
|
1761
|
-
const now = Date.now();
|
|
1762
|
-
if (!force && now - this.lastPaint < PROGRESS_MS)
|
|
1763
|
-
return;
|
|
1764
|
-
this.lastPaint = now;
|
|
1765
|
-
if (!this.tty) {
|
|
1766
|
-
if (force || now - this.lastStatus >= 10_000) {
|
|
1767
|
-
this.lastStatus = now;
|
|
1768
|
-
const targetKnown = isFinite(s.target);
|
|
1769
|
-
const where = s.bytesTotal > 0
|
|
1770
|
-
? ` ${pct(s.bytesDone / s.bytesTotal)} of corpus`
|
|
1771
|
-
: "";
|
|
1772
|
-
process.stderr.write(`[sema] ${bytes(s.trainedBytes)}${targetKnown ? "/" + bytes(s.target) : ""} learned · ${int(s.exampleCount)} examples · ` +
|
|
1773
|
-
`${bytes(s.trainedRate)}/s · lang ${s.fileIndex}/${s.fileTotal}${where} · ` +
|
|
1774
|
-
`${num(s.storeEntries)} entries\n`);
|
|
1775
|
-
}
|
|
1776
|
-
return;
|
|
1777
|
-
}
|
|
1778
|
-
const text = renderPanel(s);
|
|
1779
|
-
process.stderr.write(`${this.clearPanel()}${HIDE}${text}`);
|
|
1780
|
-
this.lines = text.split("\n").length;
|
|
1781
|
-
}
|
|
1782
|
-
/** Emit a line (or block) into the scrollback above the panel; the panel is
|
|
1783
|
-
* redrawn immediately beneath it so it never disappears between frames. */
|
|
1784
|
-
log(msg) {
|
|
1785
|
-
if (!this.tty) {
|
|
1786
|
-
process.stderr.write(`${msg}\n`);
|
|
1787
|
-
return;
|
|
1788
|
-
}
|
|
1789
|
-
let out = `${this.clearPanel()}${msg}\n`;
|
|
1790
|
-
this.lines = 0;
|
|
1791
|
-
if (this.last) {
|
|
1792
|
-
const text = renderPanel(this.last);
|
|
1793
|
-
out += `${HIDE}${text}`;
|
|
1794
|
-
this.lines = text.split("\n").length;
|
|
1795
|
-
}
|
|
1796
|
-
process.stderr.write(out);
|
|
1797
|
-
}
|
|
1798
|
-
dispose() {
|
|
1799
|
-
if (this.tty)
|
|
1800
|
-
process.stderr.write(`${SHOW}\n`);
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1804
|
-
// §9 Progress persistence (inside the store — resume from the store alone)
|
|
1805
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1806
|
-
const META_COMPLETED = "train.completedFiles";
|
|
1807
|
-
const META_DEPOSITS = "train.depositCount";
|
|
1808
|
-
const META_TRAINED_BYTES = "train.trainedContentBytes";
|
|
1809
|
-
const META_BYTES = "train.totalBytesProcessed";
|
|
1810
|
-
const META_CORPUS_BYTES = "train.totalCorpusBytes";
|
|
1811
|
-
async function loadProgress(store) {
|
|
1812
|
-
try {
|
|
1813
|
-
const raw = await store.getMeta(META_COMPLETED);
|
|
1814
|
-
const deps = await store.getMeta(META_DEPOSITS);
|
|
1815
|
-
const b = await store.getMeta(META_BYTES);
|
|
1816
|
-
if (raw !== null && deps !== null && b !== null) {
|
|
1817
|
-
const completedFiles = JSON.parse(raw);
|
|
1818
|
-
if (Array.isArray(completedFiles)) {
|
|
1819
|
-
const trained = await store.getMeta(META_TRAINED_BYTES);
|
|
1820
|
-
const corpus = await store.getMeta(META_CORPUS_BYTES);
|
|
1821
|
-
return {
|
|
1822
|
-
completedFiles,
|
|
1823
|
-
depositCount: Number(deps) || 0,
|
|
1824
|
-
trainedContentBytes: Number(trained) || 0,
|
|
1825
|
-
totalBytesProcessed: Number(b) || 0,
|
|
1826
|
-
totalCorpusBytes: Number(corpus) || 0,
|
|
1827
|
-
};
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
catch { /* corrupt/missing — start fresh */ }
|
|
1832
|
-
return {
|
|
1833
|
-
completedFiles: [],
|
|
1834
|
-
depositCount: 0,
|
|
1835
|
-
trainedContentBytes: 0,
|
|
1836
|
-
totalBytesProcessed: 0,
|
|
1837
|
-
totalCorpusBytes: 0,
|
|
1838
|
-
};
|
|
1839
|
-
}
|
|
1840
|
-
async function saveProgress(store, p) {
|
|
1841
|
-
await store.setMeta(META_COMPLETED, JSON.stringify(p.completedFiles));
|
|
1842
|
-
await store.setMeta(META_DEPOSITS, String(p.depositCount));
|
|
1843
|
-
await store.setMeta(META_TRAINED_BYTES, String(p.trainedContentBytes));
|
|
1844
|
-
await store.setMeta(META_BYTES, String(p.totalBytesProcessed));
|
|
1845
|
-
await store.setMeta(META_CORPUS_BYTES, String(p.totalCorpusBytes));
|
|
1846
|
-
await store.setMeta("train.updatedAt", new Date().toISOString());
|
|
1847
|
-
store.commit();
|
|
1848
|
-
}
|
|
1849
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1850
|
-
// §10 Main
|
|
1851
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
1852
|
-
async function main() {
|
|
1853
|
-
// The vector indices' memory knob (MiB) — each index's SQLite page cache.
|
|
1854
|
-
// The IVF index routes inserts through a RAM-resident pivot table and
|
|
1855
|
-
// appends to chunk blobs, so this cache mostly serves query-time cluster
|
|
1856
|
-
// scans; 256 MiB comfortably covers the probed working set of a trained
|
|
1857
|
-
// store. Override with VECTOR_CACHE_MB (64 is the library default).
|
|
1858
|
-
const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "256")));
|
|
1859
|
-
// Page cache for the MAIN DAG database (node/kid/edge/contain tables).
|
|
1860
|
-
// Training issues millions of content-addressed point probes per session
|
|
1861
|
-
// against a GB-scale file; the library default (64 MiB) is sized for a
|
|
1862
|
-
// small machine — a training box affords more. Override with
|
|
1863
|
-
// SQLITE_CACHE_MB.
|
|
1864
|
-
const SQLITE_CACHE_MB = Math.max(0, Number(env("SQLITE_CACHE_MB", "256")));
|
|
1865
|
-
const store = new SQliteStore({
|
|
1866
|
-
path: DB_PATH,
|
|
1867
|
-
D,
|
|
1868
|
-
vectorCacheMb: VECTOR_CACHE_MB,
|
|
1869
|
-
sqliteCacheMb: SQLITE_CACHE_MB,
|
|
1870
|
-
});
|
|
1871
|
-
// The store IS the model: memories, progress, and metadata all persist in
|
|
1872
|
-
// it, so a resumed run just reopens the same store and continues. Guard
|
|
1873
|
-
// against a changed D/SEED by comparing against what a previous run recorded.
|
|
1874
|
-
const mind = new Mind({ seed: SEED, store });
|
|
1875
|
-
// Pre-fill the vector indices' RAM caches with sequential scans (bounded by
|
|
1876
|
-
// VECTOR_CACHE_MB). A resumed run over a large store otherwise spends its
|
|
1877
|
-
// first minutes warming those caches through random point reads — the
|
|
1878
|
-
// ingest hot path is cache-miss bound until then. Seconds, once, up front.
|
|
1879
|
-
if (VECTOR_CACHE_MB > 0) {
|
|
1880
|
-
const t = Date.now();
|
|
1881
|
-
const warmed = await store.warmVectorCaches();
|
|
1882
|
-
if (warmed > 0) {
|
|
1883
|
-
process.stderr.write(` warmed vector caches: ${num(warmed)} rows in ${dur((Date.now() - t) / 1000)}\n`);
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
const ci = new CachedIngest(mind);
|
|
1887
|
-
const prevD = await store.getMeta("train.D");
|
|
1888
|
-
const prevSeed = await store.getMeta("train.seed");
|
|
1889
|
-
if ((prevD && Number(prevD) !== D) || (prevSeed && Number(prevSeed) !== SEED)) {
|
|
1890
|
-
process.stderr.write(`fatal: D/SEED changed (store has D=${prevD} seed=${prevSeed}, ` +
|
|
1891
|
-
`requested D=${D} seed=${SEED}). Delete ${DB_PATH}.sqlite ` +
|
|
1892
|
-
`to start fresh.\n`);
|
|
1893
|
-
process.exit(1);
|
|
1894
|
-
}
|
|
1895
|
-
await store.setMeta("train.dataset", "SmolSent+Aya+oasst2+Taskmaster+2Wiki+SODA+MASSIVE");
|
|
1896
|
-
await store.setMeta("train.D", String(D));
|
|
1897
|
-
await store.setMeta("train.seed", String(SEED));
|
|
1898
|
-
await store.setMeta("train.createdAt", new Date().toISOString());
|
|
1899
|
-
// ── counters & sampling ──
|
|
1900
|
-
let depositCount = 0;
|
|
1901
|
-
let trainedContentBytes = 0;
|
|
1902
|
-
let bytesSinceCkpt = 0;
|
|
1903
|
-
let checkpointNum = 0;
|
|
1904
|
-
let totalBytesProcessed = 0;
|
|
1905
|
-
let totalCorpusBytes = 0;
|
|
1906
|
-
const langTally = {};
|
|
1907
|
-
const t0 = Date.now();
|
|
1908
|
-
// Reservoir sample: one uniformly-random item from the current window, shown
|
|
1909
|
-
// in the recall box at each checkpoint.
|
|
1910
|
-
let sampleItem = null;
|
|
1911
|
-
let seenInWindow = 0;
|
|
1912
|
-
const sample = (it) => {
|
|
1913
|
-
seenInWindow++;
|
|
1914
|
-
if (Math.random() < 1 / seenInWindow)
|
|
1915
|
-
sampleItem = it;
|
|
1916
|
-
};
|
|
1917
|
-
// ── progress panel ──
|
|
1918
|
-
const progress = new Progress();
|
|
1919
|
-
// Surface rate-limit waits from the low-level fetch retries into the live log,
|
|
1920
|
-
// so a 429 back-off reads as "waiting", never a silent hang or a dropped file.
|
|
1921
|
-
onThrottleWait = (ms, label) => {
|
|
1922
|
-
progress.log(` ${YEL}⏳${R} rate-limited (${label}); waiting ${(ms / 1000).toFixed(1)}s and retrying — not skipping`);
|
|
1923
|
-
};
|
|
1924
|
-
const state = {
|
|
1925
|
-
exampleCount: 0,
|
|
1926
|
-
target: MAX_BYTES,
|
|
1927
|
-
elapsedS: 0,
|
|
1928
|
-
trainedBytes: 0,
|
|
1929
|
-
trainedRate: 0,
|
|
1930
|
-
bytesDone: 0,
|
|
1931
|
-
bytesTotal: 0,
|
|
1932
|
-
bytesRate: 0,
|
|
1933
|
-
fileIndex: 0,
|
|
1934
|
-
fileTotal: 0,
|
|
1935
|
-
filePath: "",
|
|
1936
|
-
fileSize: 0,
|
|
1937
|
-
fileExamples: 0,
|
|
1938
|
-
activity: "idle",
|
|
1939
|
-
dlSpeed: 0,
|
|
1940
|
-
dlDone: 0,
|
|
1941
|
-
dlTotal: 0,
|
|
1942
|
-
storeEntries: 0,
|
|
1943
|
-
cacheBytes: 0,
|
|
1944
|
-
lastSample: null,
|
|
1945
|
-
};
|
|
1946
|
-
// store.size() is async; refresh it on a slow cadence so the hot loop and
|
|
1947
|
-
// the repaint never block on a query.
|
|
1948
|
-
let cachedEntries = 0;
|
|
1949
|
-
let sizeInFlight = false;
|
|
1950
|
-
const refreshSize = () => {
|
|
1951
|
-
if (sizeInFlight)
|
|
1952
|
-
return;
|
|
1953
|
-
sizeInFlight = true;
|
|
1954
|
-
void mind.store.size()
|
|
1955
|
-
.then((n) => (cachedEntries = n))
|
|
1956
|
-
.catch(() => undefined)
|
|
1957
|
-
.finally(() => (sizeInFlight = false));
|
|
1958
|
-
};
|
|
1959
|
-
// Cache size changes only at download/delete boundaries — recompute it
|
|
1960
|
-
// lazily rather than statting the dir on every deposit.
|
|
1961
|
-
let cachedCacheBytes = 0;
|
|
1962
|
-
let lastCacheUpdate = 0;
|
|
1963
|
-
// Live download progress for the panel.
|
|
1964
|
-
let dlSlot = null;
|
|
1965
|
-
// Rolling throughput: a short EMA over wall-clock windows, so the headline
|
|
1966
|
-
// figures reflect CURRENT speed rather than a lifetime average diluted by the
|
|
1967
|
-
// listing and download phases (which train nothing).
|
|
1968
|
-
let rateT = t0;
|
|
1969
|
-
let rateTrained = 0;
|
|
1970
|
-
let rateBytes = 0;
|
|
1971
|
-
const syncState = () => {
|
|
1972
|
-
const now = Date.now();
|
|
1973
|
-
state.exampleCount = depositCount;
|
|
1974
|
-
state.trainedBytes = trainedContentBytes;
|
|
1975
|
-
state.elapsedS = (now - t0) / 1000;
|
|
1976
|
-
state.storeEntries = cachedEntries;
|
|
1977
|
-
state.bytesDone = totalBytesProcessed;
|
|
1978
|
-
state.bytesTotal = totalCorpusBytes;
|
|
1979
|
-
if (dlSlot && state.activity === "download") {
|
|
1980
|
-
state.dlDone = dlSlot.done;
|
|
1981
|
-
state.dlTotal = dlSlot.total;
|
|
1982
|
-
const ds = (now - dlSlot.t0) / 1000;
|
|
1983
|
-
state.dlSpeed = ds > 0.2 ? dlSlot.done / ds : 0;
|
|
1984
|
-
}
|
|
1985
|
-
else {
|
|
1986
|
-
state.dlDone = 0;
|
|
1987
|
-
state.dlTotal = 0;
|
|
1988
|
-
}
|
|
1989
|
-
const dt = (now - rateT) / 1000;
|
|
1990
|
-
if (dt >= 0.5) {
|
|
1991
|
-
const instTrained = (trainedContentBytes - rateTrained) / dt;
|
|
1992
|
-
const instByte = (totalBytesProcessed - rateBytes) / dt;
|
|
1993
|
-
const a = 0.3; // EMA weight on the newest sample
|
|
1994
|
-
state.trainedRate = state.trainedRate === 0
|
|
1995
|
-
? instTrained
|
|
1996
|
-
: state.trainedRate * (1 - a) + instTrained * a;
|
|
1997
|
-
state.bytesRate = state.bytesRate === 0
|
|
1998
|
-
? instByte
|
|
1999
|
-
: state.bytesRate * (1 - a) + instByte * a;
|
|
2000
|
-
rateT = now;
|
|
2001
|
-
rateTrained = trainedContentBytes;
|
|
2002
|
-
rateBytes = totalBytesProcessed;
|
|
2003
|
-
}
|
|
2004
|
-
if (now - lastCacheUpdate > 2000) {
|
|
2005
|
-
cachedCacheBytes = cacheSize();
|
|
2006
|
-
lastCacheUpdate = now;
|
|
2007
|
-
}
|
|
2008
|
-
state.cacheBytes = cachedCacheBytes;
|
|
2009
|
-
};
|
|
2010
|
-
const tick = (force = false) => {
|
|
2011
|
-
syncState();
|
|
2012
|
-
progress.render(state, force);
|
|
2013
|
-
};
|
|
2014
|
-
const paintTimer = setInterval(() => {
|
|
2015
|
-
refreshSize();
|
|
2016
|
-
tick(false);
|
|
2017
|
-
}, PROGRESS_MS);
|
|
2018
|
-
if (typeof paintTimer.unref === "function")
|
|
2019
|
-
paintTimer.unref();
|
|
2020
|
-
// ── keep-alive: the process must never exit on its own mid-training ──
|
|
2021
|
-
// The CPU-bound processing phase (perceive + intern + the batched vector-index
|
|
2022
|
-
// writes) hands control back to the event loop between batches via the store's
|
|
2023
|
-
// yieldToEventLoop(), which parks on an UNREF'd setImmediate so the library
|
|
2024
|
-
// never holds a process open by itself. node:sqlite is synchronous and the
|
|
2025
|
-
// vector index is in-memory, so the store's awaits resolve as microtasks with
|
|
2026
|
-
// no I/O handle, and the paint timer above is unref'd too. That leaves a window
|
|
2027
|
-
// — a batch flush that fires while we're processing an in-memory chunk, not
|
|
2028
|
-
// awaiting a disk read — in which the ONLY pending work is that unref'd
|
|
2029
|
-
// setImmediate and NOTHING is ref'd. Node's rule is to exit when only unref'd
|
|
2030
|
-
// handles remain, WITHOUT running them: the yield's continuation never fires,
|
|
2031
|
-
// main() is abandoned, and the process exits 0 silently mid-file — no error for
|
|
2032
|
-
// the fault-tolerance to catch. This one ref'd (NOT unref'd) timer guarantees a
|
|
2033
|
-
// live handle for the whole run, so the loop can never drain from under a
|
|
2034
|
-
// pending yield. Every real exit is an explicit process.exit() (finish(), the
|
|
2035
|
-
// shutdown watchdog, the second-signal path, the fatal catch), so keeping this
|
|
2036
|
-
// handle alive never delays a genuine shutdown; finish() clears it before that
|
|
2037
|
-
// final exit for tidiness. Same lesson as waitMs above (deliberately un-unref'd).
|
|
2038
|
-
const keepAlive = setInterval(() => { }, 1 << 30);
|
|
2039
|
-
const checkpoint = () => mind.save();
|
|
2040
|
-
/** Run index maintenance: compact (remove garbage), repair (fill gaps),
|
|
2041
|
-
* then refresh the canonical-form index (see below). All three are
|
|
2042
|
-
* idempotent — running twice produces the same result as once.
|
|
2043
|
-
* Compaction frees index space first; repair then adds back every
|
|
2044
|
-
* edge/halo-bearing node whose gist was evicted from the pending cache
|
|
2045
|
-
* before it reached the content index, completing the coverage that
|
|
2046
|
-
* incremental promotion alone cannot guarantee.
|
|
2047
|
-
*
|
|
2048
|
-
* repair runs with minParents = 0, NOT the library default of 2. The
|
|
2049
|
-
* default repairs only structural BRIDGES (≥2 parents), but this
|
|
2050
|
-
* trainer's fact deposits also leave answer-side DEPOSIT ROOTS with 0
|
|
2051
|
-
* structural parents ("The capital of France is Paris." as the dst of a
|
|
2052
|
-
* Q→A edge is a root of its own tree, contained in nothing). Those are
|
|
2053
|
-
* resonance targets recall depends on — a trained store shipped without
|
|
2054
|
-
* them cannot ground statement-shaped queries against its own answers
|
|
2055
|
-
* (observed: 33 such roots missing after a full curriculum, including
|
|
2056
|
-
* high-traffic conversation replies). minParents = 0 admits every
|
|
2057
|
-
* edge/halo bearer; the candidate set is still corpus-of-experiences-
|
|
2058
|
-
* sized, so the pass stays cheap.
|
|
2059
|
-
*
|
|
2060
|
-
* Logs the number of entries removed/added so a run that silently degrades
|
|
2061
|
-
* (growing compaction count, or repair never recovering anything) is
|
|
2062
|
-
* visible in the training log. */
|
|
2063
|
-
const runIndexMaintenance = async () => {
|
|
2064
|
-
if (!INDEX_MAINTENANCE)
|
|
2065
|
-
return;
|
|
2066
|
-
try {
|
|
2067
|
-
const removed = await mind.store.compactContentIndex();
|
|
2068
|
-
if (removed > 0) {
|
|
2069
|
-
progress.log(` ${DIM}index compact: removed ${int(removed)} isolated entries${R}`);
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
catch (err) {
|
|
2073
|
-
progress.log(` ${YEL}⚠ index compact failed${R}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2074
|
-
}
|
|
2075
|
-
try {
|
|
2076
|
-
const added = await mind.repairContentIndex(0);
|
|
2077
|
-
if (added > 0) {
|
|
2078
|
-
progress.log(` ${GRN}index repair: added ${int(added)} missing resonance targets${R}`);
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
catch (err) {
|
|
2082
|
-
progress.log(` ${YEL}⚠ index repair failed${R}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2083
|
-
}
|
|
2084
|
-
// Canonical-form index (src/canon.ts): lets resolution find stored forms
|
|
2085
|
-
// across surface variation (case, width, whitespace). Incremental and
|
|
2086
|
-
// idempotent by construction — the `canon.upto` meta cursor scans only
|
|
2087
|
-
// nodes newer than the last pass, and the (h, id) primary key ignores
|
|
2088
|
-
// re-inserted rows — so it composes with the resume model exactly like
|
|
2089
|
-
// compact/repair: every checkpoint (and finish) leaves the index
|
|
2090
|
-
// covering all content trained so far.
|
|
2091
|
-
try {
|
|
2092
|
-
const added = await mind.buildCanonIndex();
|
|
2093
|
-
if (added > 0) {
|
|
2094
|
-
progress.log(` ${GRN}canon index: added ${int(added)} canonical-form entries${R}`);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
catch (err) {
|
|
2098
|
-
progress.log(` ${YEL}⚠ canon index build failed${R}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2099
|
-
}
|
|
2100
|
-
};
|
|
2101
|
-
// The checkpoint recall is a best-effort diagnostic. It is time-bounded so a
|
|
2102
|
-
// slow/large store can never freeze the deposit loop, and guarded so a still
|
|
2103
|
-
// running recall is never stacked on top of another.
|
|
2104
|
-
let inferBusy = false;
|
|
2105
|
-
const runRecall = async (item, n) => {
|
|
2106
|
-
if (inferBusy)
|
|
2107
|
-
return;
|
|
2108
|
-
inferBusy = true;
|
|
2109
|
-
try {
|
|
2110
|
-
const info = promptOf(item);
|
|
2111
|
-
const r = await withTimeout(mind.respond(info.prompt), INFER_TIMEOUT_MS, "recall");
|
|
2112
|
-
const resp = new TextDecoder().decode(r.bytes).replace(/\u0000+/g, "");
|
|
2113
|
-
const box = renderInferenceBox(info.prompt, info.expected, resp, info.kind, n);
|
|
2114
|
-
state.lastSample = box;
|
|
2115
|
-
if (!progress.interactive)
|
|
2116
|
-
progress.log(box);
|
|
2117
|
-
tick(true);
|
|
2118
|
-
}
|
|
2119
|
-
catch (err) {
|
|
2120
|
-
progress.log(` ${DIM}· checkpoint #${n} recall skipped: ${err instanceof Error ? err.message : String(err)}${R}`);
|
|
2121
|
-
}
|
|
2122
|
-
finally {
|
|
2123
|
-
inferBusy = false;
|
|
2124
|
-
}
|
|
2125
|
-
};
|
|
2126
|
-
// ── graceful shutdown (always leaves the store consistent) ──
|
|
2127
|
-
let stopRequested = false;
|
|
2128
|
-
let stopReason = "interrupted";
|
|
2129
|
-
let finishing = false;
|
|
2130
|
-
const finish = async (why) => {
|
|
2131
|
-
if (finishing)
|
|
2132
|
-
return;
|
|
2133
|
-
finishing = true;
|
|
2134
|
-
shutdown.abort(); // unblock any straggling fetch/pipeTo
|
|
2135
|
-
tick(true);
|
|
2136
|
-
await store.setMeta("train.completedAt", new Date().toISOString());
|
|
2137
|
-
await store.setMeta("train.totalDeposits", String(depositCount));
|
|
2138
|
-
await store.setMeta("train.totalTrainedBytes", String(trainedContentBytes));
|
|
2139
|
-
await store.setMeta("train.totalBytes", String(totalBytesProcessed));
|
|
2140
|
-
await store.setMeta("train.totalCorpusBytes", String(totalCorpusBytes));
|
|
2141
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
2142
|
-
try {
|
|
2143
|
-
await runIndexMaintenance();
|
|
2144
|
-
await checkpoint();
|
|
2145
|
-
}
|
|
2146
|
-
catch (err) {
|
|
2147
|
-
process.stderr.write(`\n ${YEL}⚠ final checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2148
|
-
}
|
|
2149
|
-
clearInterval(paintTimer);
|
|
2150
|
-
clearInterval(keepAlive);
|
|
2151
|
-
progress.dispose();
|
|
2152
|
-
const elapsedS = (Date.now() - t0) / 1000;
|
|
2153
|
-
const elapsed = dur(elapsedS);
|
|
2154
|
-
const avgRate = elapsedS > 0 ? trainedContentBytes / elapsedS : 0;
|
|
2155
|
-
const tally = Object.entries(langTally)
|
|
2156
|
-
.sort((a, b) => b[1] - a[1])
|
|
2157
|
-
.map(([k, v]) => `${k}:${int(v)}`)
|
|
2158
|
-
.join(", ");
|
|
2159
|
-
let entries = depositCount;
|
|
2160
|
-
try {
|
|
2161
|
-
entries = await mind.store.size();
|
|
2162
|
-
}
|
|
2163
|
-
catch { /* best effort */ }
|
|
2164
|
-
console.log(`\n${GRN}✓${R} ${why}. ${basename(DB_PATH)}.sqlite: ` +
|
|
2165
|
-
`${int(entries)} entries, ${int(depositCount)} examples, ` +
|
|
2166
|
-
`${bytes(trainedContentBytes)} content learned ` +
|
|
2167
|
-
`${DIM}(${bytes(avgRate)}/s avg)${R}, ` +
|
|
2168
|
-
`${bytes(totalBytesProcessed)} corpus processed, ${elapsed} elapsed.` +
|
|
2169
|
-
(tally ? `\n ${DIM}per language:${R} ${tally}` : ""));
|
|
2170
|
-
try {
|
|
2171
|
-
await store.close();
|
|
2172
|
-
}
|
|
2173
|
-
catch { /* best effort */ }
|
|
2174
|
-
process.exit(0);
|
|
2175
|
-
};
|
|
2176
|
-
const requestStop = (reason) => {
|
|
2177
|
-
if (stopRequested) {
|
|
2178
|
-
process.stderr.write(`\n${YEL}⚠ second signal — exiting now${R}\n`);
|
|
2179
|
-
process.stderr.write(SHOW);
|
|
2180
|
-
process.exit(130);
|
|
2181
|
-
}
|
|
2182
|
-
stopRequested = true;
|
|
2183
|
-
stopReason = reason;
|
|
2184
|
-
shutdown.abort();
|
|
2185
|
-
progress.log(` ${YEL}⏸${R} ${reason} — finishing current item, saving…`);
|
|
2186
|
-
const watchdog = setTimeout(() => {
|
|
2187
|
-
process.stderr.write(`\n${YEL}⚠ shutdown watchdog fired — forcing exit${R}\n`);
|
|
2188
|
-
process.stderr.write(SHOW);
|
|
2189
|
-
process.exit(130);
|
|
2190
|
-
}, 60_000);
|
|
2191
|
-
if (typeof watchdog.unref === "function")
|
|
2192
|
-
watchdog.unref();
|
|
2193
|
-
};
|
|
2194
|
-
process.on("SIGINT", () => requestStop("interrupted"));
|
|
2195
|
-
process.on("SIGTERM", () => requestStop("terminated"));
|
|
2196
|
-
// ── fail-safe: a dropped connection must never kill a long run ──
|
|
2197
|
-
process.on("unhandledRejection", (reason) => {
|
|
2198
|
-
progress.log(` ${YEL}⚠ unhandled rejection${R}: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
2199
|
-
});
|
|
2200
|
-
process.on("uncaughtException", (err) => {
|
|
2201
|
-
const code = err?.code ?? err?.cause?.code;
|
|
2202
|
-
if (err.message === "terminated" || code === "UND_ERR_SOCKET") {
|
|
2203
|
-
progress.log(` ${YEL}⚠ connection error (ignored)${R}: ${err.message}`);
|
|
2204
|
-
return;
|
|
2205
|
-
}
|
|
2206
|
-
process.stderr.write(`\n${RED}uncaught exception${R}: ${err.message}\n${err.stack ?? ""}\n`);
|
|
2207
|
-
try {
|
|
2208
|
-
void store.setMeta("train.crashedAt", new Date().toISOString());
|
|
2209
|
-
void store.setMeta("train.crashError", err.message);
|
|
2210
|
-
void store.setMeta("train.totalDeposits", String(depositCount));
|
|
2211
|
-
}
|
|
2212
|
-
catch { /* best effort */ }
|
|
2213
|
-
process.exit(1);
|
|
2214
|
-
});
|
|
2215
|
-
// ── per-example callback: gates MAX_MB, drives checkpoints + samples ──
|
|
2216
|
-
const onDeposit = async (contentBytes) => {
|
|
2217
|
-
depositCount++;
|
|
2218
|
-
trainedContentBytes += contentBytes;
|
|
2219
|
-
bytesSinceCkpt += contentBytes;
|
|
2220
|
-
state.fileExamples++;
|
|
2221
|
-
if (bytesSinceCkpt >= CHECKPOINT_BYTES) {
|
|
2222
|
-
bytesSinceCkpt %= CHECKPOINT_BYTES;
|
|
2223
|
-
const n = ++checkpointNum;
|
|
2224
|
-
const item = sampleItem;
|
|
2225
|
-
sampleItem = null;
|
|
2226
|
-
seenInWindow = 0;
|
|
2227
|
-
if (item)
|
|
2228
|
-
await runRecall(item, n);
|
|
2229
|
-
try {
|
|
2230
|
-
await runIndexMaintenance();
|
|
2231
|
-
await checkpoint();
|
|
2232
|
-
}
|
|
2233
|
-
catch (err) {
|
|
2234
|
-
progress.log(` ${YEL}⚠ checkpoint failed${R}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2235
|
-
}
|
|
2236
|
-
tick(true);
|
|
2237
|
-
}
|
|
2238
|
-
else {
|
|
2239
|
-
tick();
|
|
2240
|
-
}
|
|
2241
|
-
// Stop AFTER the deposit is counted/displayed so the final item is never
|
|
2242
|
-
// lost from the totals. A pending signal stops at this same boundary, so a
|
|
2243
|
-
// clean shutdown and a MAX_MB cap unwind through identical, tested code.
|
|
2244
|
-
return !stopRequested && trainedContentBytes < MAX_BYTES;
|
|
2245
|
-
};
|
|
2246
|
-
const cacheWarn = (m) => progress.log(` ${m}`);
|
|
2247
|
-
/** Acquire a source file: reuse a cached copy, else download `url` into the
|
|
2248
|
-
* cache under `destName` (atomic, retried, rate-limit-tolerant, shows live
|
|
2249
|
-
* byte progress). Returns the local path, or null on a non-abort failure
|
|
2250
|
-
* (logged). `label` names the file in the panel/log. */
|
|
2251
|
-
const acquire = async (url, destName, label) => {
|
|
2252
|
-
const dest = join(CACHE_DIR, destName);
|
|
2253
|
-
if (existsSync(dest)) {
|
|
2254
|
-
progress.log(` ${GRN}✓${R} ${label} ${DIM}(cached)${R}`);
|
|
2255
|
-
return dest;
|
|
2256
|
-
}
|
|
2257
|
-
let size = 0;
|
|
2258
|
-
try {
|
|
2259
|
-
size = await headSize(url);
|
|
2260
|
-
}
|
|
2261
|
-
catch { /* unknown — proceed without a cache-room reservation */ }
|
|
2262
|
-
state.activity = "download";
|
|
2263
|
-
state.filePath = label;
|
|
2264
|
-
state.fileSize = size;
|
|
2265
|
-
const slot = { done: 0, total: size, t0: Date.now() };
|
|
2266
|
-
dlSlot = slot;
|
|
2267
|
-
tick(true);
|
|
2268
|
-
try {
|
|
2269
|
-
await ensureCacheRoom(size, cacheWarn);
|
|
2270
|
-
slot.t0 = Date.now();
|
|
2271
|
-
await downloadFile(url, dest, DOWNLOAD_TRIES, (n, e) => progress.log(` ${YEL}⚠${R} ${label} download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`), (done, total) => {
|
|
2272
|
-
slot.done = done;
|
|
2273
|
-
if (total > 0)
|
|
2274
|
-
slot.total = total;
|
|
2275
|
-
});
|
|
2276
|
-
}
|
|
2277
|
-
catch (e) {
|
|
2278
|
-
dlSlot = null;
|
|
2279
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2280
|
-
return null;
|
|
2281
|
-
progress.log(` ${RED}✗${R} ${label} download failed: ${e.message}`);
|
|
2282
|
-
try {
|
|
2283
|
-
unlinkSync(dest);
|
|
2284
|
-
}
|
|
2285
|
-
catch { /* best effort */ }
|
|
2286
|
-
return null;
|
|
2287
|
-
}
|
|
2288
|
-
dlSlot = null;
|
|
2289
|
-
const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
|
|
2290
|
-
const sz = statSync(dest).size;
|
|
2291
|
-
progress.log(` ${CYAN}⬇${R} ${label} ${bytes(sz)} ${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`);
|
|
2292
|
-
return dest;
|
|
2293
|
-
};
|
|
2294
|
-
// ── §10a SmolSent stage (the FIRST stage) ──
|
|
2295
|
-
//
|
|
2296
|
-
// Downloads each SmolSent per-pair JSONL file and streams its lines. Resume is
|
|
2297
|
-
// per-file: a fully-consumed file is recorded in completedFiles
|
|
2298
|
-
// ("smolsent::<name>"); an interrupted file is re-streamed from the top on
|
|
2299
|
-
// resume (re-deposition is idempotent). LOCAL_PATH may hold pre-downloaded
|
|
2300
|
-
// smolsent *.jsonl files.
|
|
2301
|
-
const smolToItems = (row) => {
|
|
2302
|
-
const r = toSmolSentRow(row);
|
|
2303
|
-
return r ? smolSentRowToItems(r) : null;
|
|
2304
|
-
};
|
|
2305
|
-
const trainSmolSent = async () => {
|
|
2306
|
-
if (!SMOLSENT)
|
|
2307
|
-
return;
|
|
2308
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2309
|
-
return;
|
|
2310
|
-
// Work-list: local *.jsonl in LOCAL_PATH, else the repo's smolsent/ files.
|
|
2311
|
-
let files;
|
|
2312
|
-
if (LOCAL_PATH) {
|
|
2313
|
-
files = readdirSync(LOCAL_PATH)
|
|
2314
|
-
.filter((f) => /\.jsonl$/i.test(f))
|
|
2315
|
-
.sort()
|
|
2316
|
-
.map((f) => ({
|
|
2317
|
-
id: `${SMOLSENT_ID}::${f}`,
|
|
2318
|
-
name: f,
|
|
2319
|
-
local: join(LOCAL_PATH, f),
|
|
2320
|
-
}));
|
|
2321
|
-
if (SMOLSENT_PAIRS.length) {
|
|
2322
|
-
const want = new Set(SMOLSENT_PAIRS.map((p) => p.replace(/\.jsonl$/i, "")));
|
|
2323
|
-
files = files.filter((f) => want.has(f.name.replace(/\.jsonl$/i, "")));
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
else {
|
|
2327
|
-
let paths;
|
|
2328
|
-
try {
|
|
2329
|
-
paths = await listSmolSentFiles();
|
|
2330
|
-
}
|
|
2331
|
-
catch (e) {
|
|
2332
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2333
|
-
return;
|
|
2334
|
-
progress.log(` ${RED}✗${R} SmolSent file listing failed: ${e.message}`);
|
|
2335
|
-
return;
|
|
2336
|
-
}
|
|
2337
|
-
files = paths.map((path) => ({
|
|
2338
|
-
id: `${SMOLSENT_ID}::${basename(path)}`,
|
|
2339
|
-
name: basename(path),
|
|
2340
|
-
// owner/name and the file path are URL PATH segments — do not encode "/".
|
|
2341
|
-
url: `https://huggingface.co/datasets/${SMOLSENT_DATASET}/resolve/main/${path}`,
|
|
2342
|
-
}));
|
|
2343
|
-
}
|
|
2344
|
-
if (files.length === 0) {
|
|
2345
|
-
progress.log(` ${DIM}· no SmolSent files found — skipping${R}`);
|
|
2346
|
-
return;
|
|
2347
|
-
}
|
|
2348
|
-
const p = await loadProgress(store);
|
|
2349
|
-
const done = new Set(p.completedFiles);
|
|
2350
|
-
const remaining = files.filter((f) => !done.has(f.id));
|
|
2351
|
-
if (remaining.length === 0) {
|
|
2352
|
-
progress.log(` ${DIM}· SmolSent already trained — skipping${R}`);
|
|
2353
|
-
return;
|
|
2354
|
-
}
|
|
2355
|
-
state.fileTotal = files.length;
|
|
2356
|
-
progress.log(` ${GRN}✓${R} SmolSent: ${remaining.length}/${files.length} translation file(s) to train`);
|
|
2357
|
-
let idx = 0;
|
|
2358
|
-
for (const f of files) {
|
|
2359
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2360
|
-
break;
|
|
2361
|
-
idx++;
|
|
2362
|
-
if (done.has(f.id))
|
|
2363
|
-
continue;
|
|
2364
|
-
// Acquire (download or reuse), then stream the JSONL.
|
|
2365
|
-
let path = f.local ?? "";
|
|
2366
|
-
let downloaded = false;
|
|
2367
|
-
if (!path) {
|
|
2368
|
-
const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `SmolSent ${f.name}`);
|
|
2369
|
-
if (!got) {
|
|
2370
|
-
if (stopRequested)
|
|
2371
|
-
break;
|
|
2372
|
-
continue; // a single failed file never aborts the stage
|
|
2373
|
-
}
|
|
2374
|
-
path = got;
|
|
2375
|
-
downloaded = true;
|
|
2376
|
-
}
|
|
2377
|
-
// Accumulate known corpus bytes so the progress bar shows a
|
|
2378
|
-
// meaningful ETA — grows as each file's size is discovered.
|
|
2379
|
-
try {
|
|
2380
|
-
totalCorpusBytes += statSync(path).size;
|
|
2381
|
-
}
|
|
2382
|
-
catch { /* best effort */ }
|
|
2383
|
-
state.activity = "process";
|
|
2384
|
-
state.fileIndex = idx;
|
|
2385
|
-
state.filePath = `SmolSent ${f.name}`;
|
|
2386
|
-
state.fileExamples = 0;
|
|
2387
|
-
tick(true);
|
|
2388
|
-
const p0 = Date.now();
|
|
2389
|
-
let res;
|
|
2390
|
-
try {
|
|
2391
|
-
res = await processJsonl(path, smolToItems, ci, onDeposit, sample, MAX_SMOLSENT_CHARS * 4);
|
|
2392
|
-
}
|
|
2393
|
-
catch (e) {
|
|
2394
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2395
|
-
break;
|
|
2396
|
-
progress.log(` ${RED}✗${R} SmolSent ${f.name} parse failed: ${e.message}`);
|
|
2397
|
-
if (downloaded) {
|
|
2398
|
-
try {
|
|
2399
|
-
unlinkSync(path);
|
|
2400
|
-
}
|
|
2401
|
-
catch { /* best effort */ }
|
|
2402
|
-
}
|
|
2403
|
-
continue;
|
|
2404
|
-
}
|
|
2405
|
-
langTally["smolsent"] = (langTally["smolsent"] ?? 0) + res.examples;
|
|
2406
|
-
progress.log(` ${GRN}✓${R} ${f.name.replace(/\.jsonl$/i, "")} ${DIM}[translation]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
2407
|
-
(res.skipped
|
|
2408
|
-
? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
|
|
2409
|
-
: "") +
|
|
2410
|
-
(res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
|
|
2411
|
-
if (!res.stopped) {
|
|
2412
|
-
try {
|
|
2413
|
-
totalBytesProcessed += statSync(path).size;
|
|
2414
|
-
}
|
|
2415
|
-
catch { /* best effort */ }
|
|
2416
|
-
if (downloaded) {
|
|
2417
|
-
try {
|
|
2418
|
-
unlinkSync(path);
|
|
2419
|
-
}
|
|
2420
|
-
catch { /* best effort */ }
|
|
2421
|
-
}
|
|
2422
|
-
done.add(f.id);
|
|
2423
|
-
p.completedFiles.push(f.id);
|
|
2424
|
-
}
|
|
2425
|
-
try {
|
|
2426
|
-
await saveProgress(store, {
|
|
2427
|
-
completedFiles: p.completedFiles,
|
|
2428
|
-
depositCount,
|
|
2429
|
-
trainedContentBytes,
|
|
2430
|
-
totalBytesProcessed,
|
|
2431
|
-
totalCorpusBytes,
|
|
2432
|
-
});
|
|
2433
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
2434
|
-
}
|
|
2435
|
-
catch { /* best effort — finish() will retry */ }
|
|
2436
|
-
if (res.stopped)
|
|
2437
|
-
break; // cap/signal — leave file un-completed for resume
|
|
2438
|
-
}
|
|
2439
|
-
};
|
|
2440
|
-
// ── §10b Aya Dataset stage (runs AFTER SmolSent) ──
|
|
2441
|
-
//
|
|
2442
|
-
// Downloads the one train Parquet file and reads it row-group by row-group
|
|
2443
|
-
// (hyparquet + Snappy) — one (inputs → targets) fact per row. Marked complete
|
|
2444
|
-
// only when fully consumed; an interrupted run re-reads from the top on resume
|
|
2445
|
-
// (re-deposition is idempotent). LOCAL_PATH may hold a pre-downloaded *.parquet.
|
|
2446
|
-
const ayaToItems = (row) => {
|
|
2447
|
-
const r = toAyaRow(row);
|
|
2448
|
-
return r ? ayaRowToItems(r) : null;
|
|
2449
|
-
};
|
|
2450
|
-
const trainAya = async () => {
|
|
2451
|
-
if (!AYA)
|
|
2452
|
-
return;
|
|
2453
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2454
|
-
return;
|
|
2455
|
-
const p = await loadProgress(store);
|
|
2456
|
-
if (p.completedFiles.includes(AYA_ID)) {
|
|
2457
|
-
progress.log(` ${DIM}· Aya Dataset already trained — skipping${R}`);
|
|
2458
|
-
return;
|
|
2459
|
-
}
|
|
2460
|
-
let path = "", downloaded = false;
|
|
2461
|
-
if (LOCAL_PATH) {
|
|
2462
|
-
const hit = readdirSync(LOCAL_PATH).find((f) => /aya.*\.parquet$/i.test(f) || /\.parquet$/i.test(f));
|
|
2463
|
-
if (!hit) {
|
|
2464
|
-
progress.log(` ${DIM}· no Aya *.parquet in ${LOCAL_PATH} — skipping${R}`);
|
|
2465
|
-
return;
|
|
2466
|
-
}
|
|
2467
|
-
path = join(LOCAL_PATH, hit);
|
|
2468
|
-
}
|
|
2469
|
-
else {
|
|
2470
|
-
const got = await acquire(AYA_URL, "aya_train.parquet", "Aya Dataset");
|
|
2471
|
-
if (!got)
|
|
2472
|
-
return;
|
|
2473
|
-
path = got;
|
|
2474
|
-
downloaded = true;
|
|
2475
|
-
}
|
|
2476
|
-
try {
|
|
2477
|
-
totalCorpusBytes += statSync(path).size;
|
|
2478
|
-
}
|
|
2479
|
-
catch { /* best effort */ }
|
|
2480
|
-
state.fileTotal = 1;
|
|
2481
|
-
state.fileIndex = 1;
|
|
2482
|
-
state.activity = "process";
|
|
2483
|
-
state.filePath = "Aya Dataset";
|
|
2484
|
-
state.fileExamples = 0;
|
|
2485
|
-
tick(true);
|
|
2486
|
-
const p0 = Date.now();
|
|
2487
|
-
let res;
|
|
2488
|
-
try {
|
|
2489
|
-
res = await processParquet(path, ayaToItems, ci, onDeposit, sample);
|
|
2490
|
-
}
|
|
2491
|
-
catch (e) {
|
|
2492
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2493
|
-
return;
|
|
2494
|
-
progress.log(` ${RED}✗${R} Aya processing failed: ${e.message}`);
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
langTally["aya"] = (langTally["aya"] ?? 0) + res.examples;
|
|
2498
|
-
progress.log(` ${GRN}✓${R} Aya Dataset ${DIM}[multilingual chat]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
2499
|
-
(res.skipped
|
|
2500
|
-
? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
|
|
2501
|
-
: "") +
|
|
2502
|
-
(res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
|
|
2503
|
-
if (!res.stopped) {
|
|
2504
|
-
try {
|
|
2505
|
-
totalBytesProcessed += statSync(path).size;
|
|
2506
|
-
}
|
|
2507
|
-
catch { /* best effort */ }
|
|
2508
|
-
if (downloaded) {
|
|
2509
|
-
try {
|
|
2510
|
-
unlinkSync(path);
|
|
2511
|
-
}
|
|
2512
|
-
catch { /* best effort */ }
|
|
2513
|
-
}
|
|
2514
|
-
p.completedFiles.push(AYA_ID);
|
|
2515
|
-
}
|
|
2516
|
-
try {
|
|
2517
|
-
await saveProgress(store, {
|
|
2518
|
-
completedFiles: p.completedFiles,
|
|
2519
|
-
depositCount,
|
|
2520
|
-
trainedContentBytes,
|
|
2521
|
-
totalBytesProcessed,
|
|
2522
|
-
totalCorpusBytes,
|
|
2523
|
-
});
|
|
2524
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
2525
|
-
}
|
|
2526
|
-
catch { /* best effort — finish() will retry */ }
|
|
2527
|
-
};
|
|
2528
|
-
// ── §10c oasst2 stage (multi-turn conversations; runs AFTER Aya; both modes) ──
|
|
2529
|
-
//
|
|
2530
|
-
// Resolves the source (a local *trees*.jsonl.gz in
|
|
2531
|
-
// LOCAL_PATH, else the gzip downloaded to the cache), streams it, and marks
|
|
2532
|
-
// OASST_ID complete only when fully consumed (an interrupted run re-streams
|
|
2533
|
-
// from the top; re-deposition is idempotent). Only multi-turn conversations
|
|
2534
|
-
// are deposited — single Q→A trees are skipped inside processOasst.
|
|
2535
|
-
const trainOasst = async () => {
|
|
2536
|
-
if (!OASST)
|
|
2537
|
-
return;
|
|
2538
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2539
|
-
return;
|
|
2540
|
-
const p = await loadProgress(store);
|
|
2541
|
-
if (p.completedFiles.includes(OASST_ID)) {
|
|
2542
|
-
progress.log(` ${DIM}· oasst2 already trained — skipping${R}`);
|
|
2543
|
-
return;
|
|
2544
|
-
}
|
|
2545
|
-
let gzPath = "";
|
|
2546
|
-
let downloaded = false;
|
|
2547
|
-
if (LOCAL_PATH) {
|
|
2548
|
-
const hit = readdirSync(LOCAL_PATH).find((f) => /oasst.*trees.*\.jsonl\.gz$/i.test(f) || /oasst.*\.jsonl\.gz$/i.test(f));
|
|
2549
|
-
if (!hit) {
|
|
2550
|
-
progress.log(` ${DIM}· no oasst2 *trees*.jsonl.gz in ${LOCAL_PATH} — skipping${R}`);
|
|
2551
|
-
return;
|
|
2552
|
-
}
|
|
2553
|
-
gzPath = join(LOCAL_PATH, hit);
|
|
2554
|
-
}
|
|
2555
|
-
else {
|
|
2556
|
-
const dest = join(CACHE_DIR, "oasst2_ready.trees.jsonl.gz");
|
|
2557
|
-
if (existsSync(dest)) {
|
|
2558
|
-
gzPath = dest; // reuse a copy left by a previous interrupted run
|
|
2559
|
-
progress.log(` ${GRN}✓${R} oasst2 trees ${DIM}(cached)${R}`);
|
|
2560
|
-
}
|
|
2561
|
-
else {
|
|
2562
|
-
let size = 0;
|
|
2563
|
-
try {
|
|
2564
|
-
size = await headSize(OASST_URL);
|
|
2565
|
-
}
|
|
2566
|
-
catch { /* unknown — proceed without a cache-room reservation */ }
|
|
2567
|
-
state.activity = "download";
|
|
2568
|
-
state.filePath = "oasst2 trees";
|
|
2569
|
-
state.fileSize = size;
|
|
2570
|
-
const slot = { done: 0, total: size, t0: Date.now() };
|
|
2571
|
-
dlSlot = slot;
|
|
2572
|
-
tick(true);
|
|
2573
|
-
try {
|
|
2574
|
-
await ensureCacheRoom(size, cacheWarn);
|
|
2575
|
-
slot.t0 = Date.now();
|
|
2576
|
-
await downloadFile(OASST_URL, dest, DOWNLOAD_TRIES, (n, e) => progress.log(` ${YEL}⚠${R} oasst2 download attempt ${n}/${DOWNLOAD_TRIES}: ${e.message}`), (done, total) => {
|
|
2577
|
-
slot.done = done;
|
|
2578
|
-
if (total > 0)
|
|
2579
|
-
slot.total = total;
|
|
2580
|
-
});
|
|
2581
|
-
}
|
|
2582
|
-
catch (e) {
|
|
2583
|
-
dlSlot = null;
|
|
2584
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2585
|
-
return;
|
|
2586
|
-
progress.log(` ${RED}✗${R} oasst2 download failed: ${e.message}`);
|
|
2587
|
-
try {
|
|
2588
|
-
unlinkSync(dest);
|
|
2589
|
-
}
|
|
2590
|
-
catch { /* best effort */ }
|
|
2591
|
-
return;
|
|
2592
|
-
}
|
|
2593
|
-
dlSlot = null;
|
|
2594
|
-
const dlS = Math.max(0.001, (Date.now() - slot.t0) / 1000);
|
|
2595
|
-
const sz = statSync(dest).size;
|
|
2596
|
-
progress.log(` ${CYAN}⬇${R} oasst2 trees ${bytes(sz)} ` +
|
|
2597
|
-
`${DIM}${dur(dlS)} @ ${bytes(sz / dlS)}/s${R}`);
|
|
2598
|
-
gzPath = dest;
|
|
2599
|
-
downloaded = true;
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
try {
|
|
2603
|
-
totalCorpusBytes += statSync(gzPath).size;
|
|
2604
|
-
}
|
|
2605
|
-
catch { /* best effort */ }
|
|
2606
|
-
state.fileTotal = 1;
|
|
2607
|
-
state.fileIndex = 1;
|
|
2608
|
-
// Stream the trees.
|
|
2609
|
-
state.activity = "process";
|
|
2610
|
-
state.filePath = "oasst2 (multi-turn)";
|
|
2611
|
-
state.fileExamples = 0;
|
|
2612
|
-
tick(true);
|
|
2613
|
-
const p0 = Date.now();
|
|
2614
|
-
let result;
|
|
2615
|
-
try {
|
|
2616
|
-
result = await processOasst(gzPath, ci, onDeposit, sample);
|
|
2617
|
-
}
|
|
2618
|
-
catch (e) {
|
|
2619
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2620
|
-
return;
|
|
2621
|
-
progress.log(` ${RED}✗${R} oasst2 processing failed: ${e.message}`);
|
|
2622
|
-
return;
|
|
2623
|
-
}
|
|
2624
|
-
const { examples, stopped, skipped, multi } = result;
|
|
2625
|
-
langTally["oasst2"] = (langTally["oasst2"] ?? 0) + examples;
|
|
2626
|
-
progress.log(` ${GRN}✓${R} oasst2 ${DIM}[multi-turn chat]${R} → ${int(examples)} examples from ${int(multi)} conversation(s) ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
2627
|
-
(skipped
|
|
2628
|
-
? ` ${YEL}· ${int(skipped)} malformed line(s) skipped${R}`
|
|
2629
|
-
: "") +
|
|
2630
|
-
(stopped ? ` ${YEL}(stopped early)${R}` : ""));
|
|
2631
|
-
// Only mark complete (and reclaim the cache) when fully consumed.
|
|
2632
|
-
if (!stopped) {
|
|
2633
|
-
try {
|
|
2634
|
-
totalBytesProcessed += statSync(gzPath).size;
|
|
2635
|
-
}
|
|
2636
|
-
catch { /* best effort */ }
|
|
2637
|
-
if (downloaded) {
|
|
2638
|
-
try {
|
|
2639
|
-
unlinkSync(gzPath);
|
|
2640
|
-
}
|
|
2641
|
-
catch { /* best effort */ }
|
|
2642
|
-
}
|
|
2643
|
-
p.completedFiles.push(OASST_ID);
|
|
2644
|
-
}
|
|
2645
|
-
try {
|
|
2646
|
-
await saveProgress(store, {
|
|
2647
|
-
completedFiles: p.completedFiles,
|
|
2648
|
-
depositCount,
|
|
2649
|
-
trainedContentBytes,
|
|
2650
|
-
totalBytesProcessed,
|
|
2651
|
-
totalCorpusBytes,
|
|
2652
|
-
});
|
|
2653
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
2654
|
-
}
|
|
2655
|
-
catch { /* best effort — finish() will retry */ }
|
|
2656
|
-
};
|
|
2657
|
-
// ── §10d General-Knowledge stage (runs AFTER oasst2) ──
|
|
2658
|
-
//
|
|
2659
|
-
// Downloads the single JSON-array file (output.json) and deposits each
|
|
2660
|
-
// {Question, Answer} as one fact. Marked complete only when fully consumed.
|
|
2661
|
-
// LOCAL_PATH may hold a pre-downloaded *.json.
|
|
2662
|
-
const genToItems = (row) => {
|
|
2663
|
-
const r = toGenKnowRow(row);
|
|
2664
|
-
return r ? genKnowRowToItems(r) : null;
|
|
2665
|
-
};
|
|
2666
|
-
// ── §10d′ Taskmaster 1–4 stage (task-oriented dialogue; runs AFTER oasst2) ──
|
|
2667
|
-
//
|
|
2668
|
-
// Lists the repo's data files, then for each: download, parse the JSON array,
|
|
2669
|
-
// deposit one cumulative walk per conversation. Per-FILE resume ids, like
|
|
2670
|
-
// SmolSent — an interrupted file is re-read from the top on resume, and
|
|
2671
|
-
// re-deposition is idempotent. LOCAL_PATH/taskmaster/ may hold pre-downloaded
|
|
2672
|
-
// *.json (a subdirectory, because these share the .json extension with the
|
|
2673
|
-
// General-Knowledge source and must not be confused with it).
|
|
2674
|
-
const tmToItems = (row) => {
|
|
2675
|
-
const turns = toTaskmasterTurns(row);
|
|
2676
|
-
if (!turns)
|
|
2677
|
-
return null;
|
|
2678
|
-
const items = taskmasterConversationToItems(turns); // [] when too short
|
|
2679
|
-
return items.length ? items : null;
|
|
2680
|
-
};
|
|
2681
|
-
const trainTaskmaster = async () => {
|
|
2682
|
-
if (!TASKMASTER)
|
|
2683
|
-
return;
|
|
2684
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2685
|
-
return;
|
|
2686
|
-
let files;
|
|
2687
|
-
if (LOCAL_PATH) {
|
|
2688
|
-
const dir = join(LOCAL_PATH, "taskmaster");
|
|
2689
|
-
let names = [];
|
|
2690
|
-
try {
|
|
2691
|
-
names = readdirSync(dir).filter((f) => /\.json$/i.test(f))
|
|
2692
|
-
.sort();
|
|
2693
|
-
}
|
|
2694
|
-
catch { /* no local taskmaster dir */ }
|
|
2695
|
-
if (names.length === 0) {
|
|
2696
|
-
progress.log(` ${DIM}· no Taskmaster *.json in ${dir} — skipping${R}`);
|
|
2697
|
-
return;
|
|
2698
|
-
}
|
|
2699
|
-
files = names.map((f) => ({
|
|
2700
|
-
id: `taskmaster::${f}`,
|
|
2701
|
-
name: f,
|
|
2702
|
-
local: join(dir, f),
|
|
2703
|
-
}));
|
|
2704
|
-
}
|
|
2705
|
-
else {
|
|
2706
|
-
let listed;
|
|
2707
|
-
try {
|
|
2708
|
-
listed = await listTaskmasterFiles();
|
|
2709
|
-
}
|
|
2710
|
-
catch (e) {
|
|
2711
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2712
|
-
return;
|
|
2713
|
-
progress.log(` ${RED}✗${R} Taskmaster file listing failed: ${e.message}`);
|
|
2714
|
-
return;
|
|
2715
|
-
}
|
|
2716
|
-
files = listed.map((f) => ({
|
|
2717
|
-
id: `taskmaster::${f.path}`,
|
|
2718
|
-
name: `${f.set}/${basename(f.path)}`,
|
|
2719
|
-
url: `${TASKMASTER_RAW}/${f.path}`,
|
|
2720
|
-
}));
|
|
2721
|
-
}
|
|
2722
|
-
if (files.length === 0) {
|
|
2723
|
-
progress.log(` ${DIM}· no Taskmaster files found — skipping${R}`);
|
|
2724
|
-
return;
|
|
2725
|
-
}
|
|
2726
|
-
const p = await loadProgress(store);
|
|
2727
|
-
const done = new Set(p.completedFiles);
|
|
2728
|
-
const remaining = files.filter((f) => !done.has(f.id));
|
|
2729
|
-
if (remaining.length === 0) {
|
|
2730
|
-
progress.log(` ${DIM}· Taskmaster already trained — skipping${R}`);
|
|
2731
|
-
return;
|
|
2732
|
-
}
|
|
2733
|
-
state.fileTotal = files.length;
|
|
2734
|
-
progress.log(` ${GRN}✓${R} Taskmaster: ${remaining.length}/${files.length} dialogue file(s) to train`);
|
|
2735
|
-
let idx = 0;
|
|
2736
|
-
for (const f of files) {
|
|
2737
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2738
|
-
break;
|
|
2739
|
-
idx++;
|
|
2740
|
-
if (done.has(f.id))
|
|
2741
|
-
continue;
|
|
2742
|
-
let path = f.local ?? "";
|
|
2743
|
-
let downloaded = false;
|
|
2744
|
-
if (!path) {
|
|
2745
|
-
const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `Taskmaster ${f.name}`);
|
|
2746
|
-
if (!got) {
|
|
2747
|
-
if (stopRequested)
|
|
2748
|
-
break;
|
|
2749
|
-
continue; // a single failed file never aborts the stage
|
|
2750
|
-
}
|
|
2751
|
-
path = got;
|
|
2752
|
-
downloaded = true;
|
|
2753
|
-
}
|
|
2754
|
-
try {
|
|
2755
|
-
totalCorpusBytes += statSync(path).size;
|
|
2756
|
-
}
|
|
2757
|
-
catch { /* best effort */ }
|
|
2758
|
-
state.activity = "process";
|
|
2759
|
-
state.fileIndex = idx;
|
|
2760
|
-
state.filePath = `Taskmaster ${f.name}`;
|
|
2761
|
-
state.fileExamples = 0;
|
|
2762
|
-
tick(true);
|
|
2763
|
-
const p0 = Date.now();
|
|
2764
|
-
let res;
|
|
2765
|
-
try {
|
|
2766
|
-
res = await processJsonArray(path, tmToItems, ci, onDeposit, sample);
|
|
2767
|
-
}
|
|
2768
|
-
catch (e) {
|
|
2769
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2770
|
-
break;
|
|
2771
|
-
progress.log(` ${RED}✗${R} Taskmaster ${f.name} parse failed: ${e.message}`);
|
|
2772
|
-
if (downloaded) {
|
|
2773
|
-
try {
|
|
2774
|
-
unlinkSync(path);
|
|
2775
|
-
}
|
|
2776
|
-
catch { /* best effort */ }
|
|
2777
|
-
}
|
|
2778
|
-
continue;
|
|
2779
|
-
}
|
|
2780
|
-
langTally["taskmaster"] = (langTally["taskmaster"] ?? 0) + res.examples;
|
|
2781
|
-
progress.log(` ${GRN}✓${R} ${f.name} ${DIM}[task dialogue]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
2782
|
-
(res.skipped
|
|
2783
|
-
? ` ${YEL}· ${int(res.skipped)} unusable conversation(s) skipped${R}`
|
|
2784
|
-
: "") +
|
|
2785
|
-
(res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
|
|
2786
|
-
if (!res.stopped) {
|
|
2787
|
-
try {
|
|
2788
|
-
totalBytesProcessed += statSync(path).size;
|
|
2789
|
-
}
|
|
2790
|
-
catch { /* best effort */ }
|
|
2791
|
-
if (downloaded) {
|
|
2792
|
-
try {
|
|
2793
|
-
unlinkSync(path);
|
|
2794
|
-
}
|
|
2795
|
-
catch { /* best effort */ }
|
|
2796
|
-
}
|
|
2797
|
-
done.add(f.id);
|
|
2798
|
-
p.completedFiles.push(f.id);
|
|
2799
|
-
}
|
|
2800
|
-
try {
|
|
2801
|
-
await saveProgress(store, {
|
|
2802
|
-
completedFiles: p.completedFiles,
|
|
2803
|
-
depositCount,
|
|
2804
|
-
trainedContentBytes,
|
|
2805
|
-
totalBytesProcessed,
|
|
2806
|
-
totalCorpusBytes,
|
|
2807
|
-
});
|
|
2808
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
2809
|
-
}
|
|
2810
|
-
catch { /* best effort — finish() will retry */ }
|
|
2811
|
-
if (res.stopped)
|
|
2812
|
-
break; // cap/signal — leave file un-completed for resume
|
|
2813
|
-
}
|
|
2814
|
-
};
|
|
2815
|
-
// ── §10d″ 2WikiMultihopQA stage (composition; runs AFTER Taskmaster) ──
|
|
2816
|
-
//
|
|
2817
|
-
// Only the `evidences` column is ever touched — see §6e″ for why `context`
|
|
2818
|
-
// and `question`/`answer` are not.
|
|
2819
|
-
const wiki2ToItems = (row) => {
|
|
2820
|
-
const triples = toWikiTriples(row);
|
|
2821
|
-
if (!triples)
|
|
2822
|
-
return null;
|
|
2823
|
-
const items = wikiTriplesToItems(triples);
|
|
2824
|
-
return items.length ? items : null;
|
|
2825
|
-
};
|
|
2826
|
-
const trainWiki2 = () => runConvertedParquetStage({
|
|
2827
|
-
enabled: WIKI2,
|
|
2828
|
-
label: "2Wiki",
|
|
2829
|
-
tally: "2wiki",
|
|
2830
|
-
kind: "relation triples",
|
|
2831
|
-
dataset: WIKI2_DATASET,
|
|
2832
|
-
config: "default",
|
|
2833
|
-
splits: WIKI2_SPLITS,
|
|
2834
|
-
localDir: "2wiki",
|
|
2835
|
-
maxRows: WIKI2_MAX_ROWS,
|
|
2836
|
-
toItems: wiki2ToItems,
|
|
2837
|
-
});
|
|
2838
|
-
// ── §10d‴ Converted-Parquet stage runner (2Wiki, SODA, MASSIVE) ──
|
|
2839
|
-
//
|
|
2840
|
-
// These three differ only in their name, their row adapter and their row
|
|
2841
|
-
// budget, so they share one runner instead of three copies of the per-shard
|
|
2842
|
-
// loop. Resume is per SHARD, as everywhere else.
|
|
2843
|
-
//
|
|
2844
|
-
// The BUDGET is applied here rather than inside an adapter because it is a
|
|
2845
|
-
// curriculum decision about corpus MIX, not a property of a row: SODA's train
|
|
2846
|
-
// split would otherwise contribute ~8M episodes against the 662,221 deposits
|
|
2847
|
-
// of the whole current corpus. A budgeted stage never marks its remaining
|
|
2848
|
-
// shards complete, so raising the budget later resumes rather than restarts.
|
|
2849
|
-
const runConvertedParquetStage = async (opts) => {
|
|
2850
|
-
if (!opts.enabled)
|
|
2851
|
-
return;
|
|
2852
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2853
|
-
return;
|
|
2854
|
-
let files;
|
|
2855
|
-
if (LOCAL_PATH) {
|
|
2856
|
-
const dir = join(LOCAL_PATH, opts.localDir);
|
|
2857
|
-
let names = [];
|
|
2858
|
-
try {
|
|
2859
|
-
names = readdirSync(dir).filter((f) => /\.parquet$/i.test(f))
|
|
2860
|
-
.sort();
|
|
2861
|
-
}
|
|
2862
|
-
catch { /* no local dir for this stage */ }
|
|
2863
|
-
if (names.length === 0) {
|
|
2864
|
-
progress.log(` ${DIM}· no ${opts.label} *.parquet in ${dir} — skipping${R}`);
|
|
2865
|
-
return;
|
|
2866
|
-
}
|
|
2867
|
-
files = names.map((f) => ({
|
|
2868
|
-
id: `${opts.tally}::${f}`,
|
|
2869
|
-
name: f,
|
|
2870
|
-
local: join(dir, f),
|
|
2871
|
-
}));
|
|
2872
|
-
}
|
|
2873
|
-
else {
|
|
2874
|
-
let paths;
|
|
2875
|
-
try {
|
|
2876
|
-
paths = await listConvertedParquet(opts.dataset, opts.config, opts.splits, opts.label);
|
|
2877
|
-
}
|
|
2878
|
-
catch (e) {
|
|
2879
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2880
|
-
return;
|
|
2881
|
-
progress.log(` ${RED}✗${R} ${opts.label} file listing failed: ${e.message}`);
|
|
2882
|
-
return;
|
|
2883
|
-
}
|
|
2884
|
-
files = paths.map((path) => ({
|
|
2885
|
-
id: `${opts.tally}::${path}`,
|
|
2886
|
-
name: path,
|
|
2887
|
-
url: `https://huggingface.co/datasets/${opts.dataset}` +
|
|
2888
|
-
`/resolve/refs%2Fconvert%2Fparquet/${path}`,
|
|
2889
|
-
}));
|
|
2890
|
-
}
|
|
2891
|
-
if (files.length === 0) {
|
|
2892
|
-
progress.log(` ${DIM}· no ${opts.label} files found — skipping${R}`);
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
const p = await loadProgress(store);
|
|
2896
|
-
const done = new Set(p.completedFiles);
|
|
2897
|
-
// A budget-limited stage never marks its later shards complete — that is
|
|
2898
|
-
// what lets a raised budget resume instead of restarting. But it also means
|
|
2899
|
-
// "every shard complete" is NOT how such a stage finishes, so without a
|
|
2900
|
-
// marker of its own a satisfied budget would re-read and re-deposit its
|
|
2901
|
-
// rows on every subsequent run: harmless to the store (deposition is
|
|
2902
|
-
// idempotent) but it repeats the work and double-counts langTally.
|
|
2903
|
-
//
|
|
2904
|
-
// The marker carries the budget it was satisfied AT, so raising the budget
|
|
2905
|
-
// still resumes: a bigger budget does not match the marker and the stage
|
|
2906
|
-
// runs again, re-reading rows it already holds (idempotent) and adding the
|
|
2907
|
-
// new ones.
|
|
2908
|
-
const budgetMark = opts.maxRows > 0
|
|
2909
|
-
? `${opts.tally}::budget=${opts.maxRows}`
|
|
2910
|
-
: "";
|
|
2911
|
-
if (budgetMark && done.has(budgetMark)) {
|
|
2912
|
-
progress.log(` ${DIM}· ${opts.label} budget of ${int(opts.maxRows)} row(s) already met — skipping${R}`);
|
|
2913
|
-
return;
|
|
2914
|
-
}
|
|
2915
|
-
const remaining = files.filter((f) => !done.has(f.id));
|
|
2916
|
-
if (remaining.length === 0) {
|
|
2917
|
-
progress.log(` ${DIM}· ${opts.label} already trained — skipping${R}`);
|
|
2918
|
-
return;
|
|
2919
|
-
}
|
|
2920
|
-
state.fileTotal = files.length;
|
|
2921
|
-
progress.log(` ${GRN}✓${R} ${opts.label}: ${remaining.length}/${files.length} shard(s) to train` +
|
|
2922
|
-
(opts.maxRows > 0
|
|
2923
|
-
? ` ${DIM}(budget ${int(opts.maxRows)} rows)${R}`
|
|
2924
|
-
: ""));
|
|
2925
|
-
// The budget spans the whole stage, not one shard, so it is counted here.
|
|
2926
|
-
let rowsTaken = 0;
|
|
2927
|
-
const spent = () => opts.maxRows > 0 && rowsTaken >= opts.maxRows;
|
|
2928
|
-
const budgeted = (row) => {
|
|
2929
|
-
const items = opts.toItems(row);
|
|
2930
|
-
if (!items || items.length === 0)
|
|
2931
|
-
return null;
|
|
2932
|
-
rowsTaken++;
|
|
2933
|
-
return items;
|
|
2934
|
-
};
|
|
2935
|
-
let idx = 0;
|
|
2936
|
-
for (const f of files) {
|
|
2937
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
2938
|
-
break;
|
|
2939
|
-
if (opts.maxRows > 0 && rowsTaken >= opts.maxRows)
|
|
2940
|
-
break;
|
|
2941
|
-
idx++;
|
|
2942
|
-
if (done.has(f.id))
|
|
2943
|
-
continue;
|
|
2944
|
-
let path = f.local ?? "";
|
|
2945
|
-
let downloaded = false;
|
|
2946
|
-
if (!path) {
|
|
2947
|
-
const got = await acquire(f.url, f.id.replace(/[^A-Za-z0-9._-]+/g, "_"), `${opts.label} ${f.name}`);
|
|
2948
|
-
if (!got) {
|
|
2949
|
-
if (stopRequested)
|
|
2950
|
-
break;
|
|
2951
|
-
continue; // a single failed shard never aborts the stage
|
|
2952
|
-
}
|
|
2953
|
-
path = got;
|
|
2954
|
-
downloaded = true;
|
|
2955
|
-
}
|
|
2956
|
-
try {
|
|
2957
|
-
totalCorpusBytes += statSync(path).size;
|
|
2958
|
-
}
|
|
2959
|
-
catch { /* best effort */ }
|
|
2960
|
-
state.activity = "process";
|
|
2961
|
-
state.fileIndex = idx;
|
|
2962
|
-
state.filePath = `${opts.label} ${f.name}`;
|
|
2963
|
-
state.fileExamples = 0;
|
|
2964
|
-
tick(true);
|
|
2965
|
-
const p0 = Date.now();
|
|
2966
|
-
const before = rowsTaken;
|
|
2967
|
-
let res;
|
|
2968
|
-
try {
|
|
2969
|
-
res = await processParquet(path, budgeted, ci, onDeposit, sample, spent);
|
|
2970
|
-
}
|
|
2971
|
-
catch (e) {
|
|
2972
|
-
if (stopRequested || e?.name === "AbortError")
|
|
2973
|
-
break;
|
|
2974
|
-
progress.log(` ${RED}✗${R} ${opts.label} ${f.name} parse failed: ${e.message}`);
|
|
2975
|
-
if (downloaded) {
|
|
2976
|
-
try {
|
|
2977
|
-
unlinkSync(path);
|
|
2978
|
-
}
|
|
2979
|
-
catch { /* best effort */ }
|
|
2980
|
-
}
|
|
2981
|
-
continue;
|
|
2982
|
-
}
|
|
2983
|
-
// A shard cut short by the BUDGET is not "done" — leave it resumable so a
|
|
2984
|
-
// later run with a bigger budget continues instead of starting over.
|
|
2985
|
-
const hitBudget = spent();
|
|
2986
|
-
langTally[opts.tally] = (langTally[opts.tally] ?? 0) + res.examples;
|
|
2987
|
-
progress.log(` ${GRN}✓${R} ${f.name} ${DIM}[${opts.kind}]${R} → ${int(res.examples)} facts ${DIM}from ${int(rowsTaken - before)} row(s) in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
2988
|
-
(res.skipped
|
|
2989
|
-
? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
|
|
2990
|
-
: "") +
|
|
2991
|
-
(hitBudget
|
|
2992
|
-
? ` ${YEL}(budget reached)${R}`
|
|
2993
|
-
: res.stopped
|
|
2994
|
-
? ` ${YEL}(stopped early)${R}`
|
|
2995
|
-
: ""));
|
|
2996
|
-
if (!res.stopped && !hitBudget) {
|
|
2997
|
-
try {
|
|
2998
|
-
totalBytesProcessed += statSync(path).size;
|
|
2999
|
-
}
|
|
3000
|
-
catch { /* best effort */ }
|
|
3001
|
-
if (downloaded) {
|
|
3002
|
-
try {
|
|
3003
|
-
unlinkSync(path);
|
|
3004
|
-
}
|
|
3005
|
-
catch { /* best effort */ }
|
|
3006
|
-
}
|
|
3007
|
-
done.add(f.id);
|
|
3008
|
-
p.completedFiles.push(f.id);
|
|
3009
|
-
}
|
|
3010
|
-
try {
|
|
3011
|
-
await saveProgress(store, {
|
|
3012
|
-
completedFiles: p.completedFiles,
|
|
3013
|
-
depositCount,
|
|
3014
|
-
trainedContentBytes,
|
|
3015
|
-
totalBytesProcessed,
|
|
3016
|
-
totalCorpusBytes,
|
|
3017
|
-
});
|
|
3018
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
3019
|
-
}
|
|
3020
|
-
catch { /* best effort — finish() will retry */ }
|
|
3021
|
-
// A budget stop is not a cap/signal stop: the stage is finished, so fall
|
|
3022
|
-
// out of the loop rather than treating it as an interruption.
|
|
3023
|
-
if (res.stopped && !hitBudget)
|
|
3024
|
-
break;
|
|
3025
|
-
}
|
|
3026
|
-
// Record a satisfied budget so the next run skips this stage instead of
|
|
3027
|
-
// re-reading it. Only when the budget was actually reached: a stage that
|
|
3028
|
-
// ran out of shards first is complete by the normal per-shard rule, and a
|
|
3029
|
-
// stage cut short by MAX_MB or Ctrl+C must stay resumable.
|
|
3030
|
-
if (budgetMark && spent() && !stopRequested && !done.has(budgetMark)) {
|
|
3031
|
-
p.completedFiles.push(budgetMark);
|
|
3032
|
-
try {
|
|
3033
|
-
await saveProgress(store, {
|
|
3034
|
-
completedFiles: p.completedFiles,
|
|
3035
|
-
depositCount,
|
|
3036
|
-
trainedContentBytes,
|
|
3037
|
-
totalBytesProcessed,
|
|
3038
|
-
totalCorpusBytes,
|
|
3039
|
-
});
|
|
3040
|
-
}
|
|
3041
|
-
catch { /* best effort — finish() will retry */ }
|
|
3042
|
-
}
|
|
3043
|
-
};
|
|
3044
|
-
const trainSoda = () => runConvertedParquetStage({
|
|
3045
|
-
enabled: SODA,
|
|
3046
|
-
label: "SODA",
|
|
3047
|
-
tally: "soda",
|
|
3048
|
-
kind: "social dialogue",
|
|
3049
|
-
dataset: SODA_DATASET,
|
|
3050
|
-
config: "default",
|
|
3051
|
-
splits: SODA_SPLITS,
|
|
3052
|
-
localDir: "soda",
|
|
3053
|
-
maxRows: SODA_MAX_DIALOGS,
|
|
3054
|
-
toItems: (row) => {
|
|
3055
|
-
const turns = toSodaTurns(row);
|
|
3056
|
-
if (!turns)
|
|
3057
|
-
return null;
|
|
3058
|
-
const items = sodaDialogueToItems(turns);
|
|
3059
|
-
return items.length ? items : null;
|
|
3060
|
-
},
|
|
3061
|
-
});
|
|
3062
|
-
const trainMassive = () => runConvertedParquetStage({
|
|
3063
|
-
enabled: MASSIVE,
|
|
3064
|
-
label: "MASSIVE",
|
|
3065
|
-
tally: "massive",
|
|
3066
|
-
kind: "short intents",
|
|
3067
|
-
dataset: MASSIVE_DATASET,
|
|
3068
|
-
config: MASSIVE_CONFIG,
|
|
3069
|
-
splits: MASSIVE_SPLITS,
|
|
3070
|
-
localDir: "massive",
|
|
3071
|
-
maxRows: MASSIVE_MAX_ROWS,
|
|
3072
|
-
toItems: (row) => {
|
|
3073
|
-
const items = massiveRowToItems(row);
|
|
3074
|
-
return items.length ? items : null;
|
|
3075
|
-
},
|
|
3076
|
-
});
|
|
3077
|
-
const trainGenKnow = async () => {
|
|
3078
|
-
if (!GENKNOW)
|
|
3079
|
-
return;
|
|
3080
|
-
if (trainedContentBytes >= MAX_BYTES || stopRequested)
|
|
3081
|
-
return;
|
|
3082
|
-
const p = await loadProgress(store);
|
|
3083
|
-
if (p.completedFiles.includes(GENKNOW_ID)) {
|
|
3084
|
-
progress.log(` ${DIM}· General-Knowledge already trained — skipping${R}`);
|
|
3085
|
-
return;
|
|
3086
|
-
}
|
|
3087
|
-
let path = "", downloaded = false;
|
|
3088
|
-
if (LOCAL_PATH) {
|
|
3089
|
-
const hit = readdirSync(LOCAL_PATH).find((f) => /general.*knowledge.*\.json$/i.test(f) || /output\.json$/i.test(f));
|
|
3090
|
-
if (!hit) {
|
|
3091
|
-
progress.log(` ${DIM}· no General-Knowledge *.json in ${LOCAL_PATH} — skipping${R}`);
|
|
3092
|
-
return;
|
|
3093
|
-
}
|
|
3094
|
-
path = join(LOCAL_PATH, hit);
|
|
3095
|
-
}
|
|
3096
|
-
else {
|
|
3097
|
-
const got = await acquire(GENKNOW_URL, "general_knowledge.json", "General-Knowledge");
|
|
3098
|
-
if (!got)
|
|
3099
|
-
return;
|
|
3100
|
-
path = got;
|
|
3101
|
-
downloaded = true;
|
|
3102
|
-
}
|
|
3103
|
-
try {
|
|
3104
|
-
totalCorpusBytes += statSync(path).size;
|
|
3105
|
-
}
|
|
3106
|
-
catch { /* best effort */ }
|
|
3107
|
-
state.fileTotal = 1;
|
|
3108
|
-
state.fileIndex = 1;
|
|
3109
|
-
state.activity = "process";
|
|
3110
|
-
state.filePath = "General-Knowledge";
|
|
3111
|
-
state.fileExamples = 0;
|
|
3112
|
-
tick(true);
|
|
3113
|
-
const p0 = Date.now();
|
|
3114
|
-
let res;
|
|
3115
|
-
try {
|
|
3116
|
-
res = await processJsonArray(path, genToItems, ci, onDeposit, sample);
|
|
3117
|
-
}
|
|
3118
|
-
catch (e) {
|
|
3119
|
-
if (stopRequested || e?.name === "AbortError")
|
|
3120
|
-
return;
|
|
3121
|
-
progress.log(` ${RED}✗${R} General-Knowledge processing failed: ${e.message}`);
|
|
3122
|
-
return;
|
|
3123
|
-
}
|
|
3124
|
-
langTally["genknow"] = (langTally["genknow"] ?? 0) + res.examples;
|
|
3125
|
-
progress.log(` ${GRN}✓${R} General-Knowledge ${DIM}[Q&A facts]${R} → ${int(res.examples)} facts ${DIM}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
3126
|
-
(res.skipped
|
|
3127
|
-
? ` ${YEL}· ${int(res.skipped)} unusable row(s) skipped${R}`
|
|
3128
|
-
: "") +
|
|
3129
|
-
(res.stopped ? ` ${YEL}(stopped early)${R}` : ""));
|
|
3130
|
-
if (!res.stopped) {
|
|
3131
|
-
try {
|
|
3132
|
-
totalBytesProcessed += statSync(path).size;
|
|
3133
|
-
}
|
|
3134
|
-
catch { /* best effort */ }
|
|
3135
|
-
if (downloaded) {
|
|
3136
|
-
try {
|
|
3137
|
-
unlinkSync(path);
|
|
3138
|
-
}
|
|
3139
|
-
catch { /* best effort */ }
|
|
3140
|
-
}
|
|
3141
|
-
p.completedFiles.push(GENKNOW_ID);
|
|
3142
|
-
}
|
|
3143
|
-
try {
|
|
3144
|
-
await saveProgress(store, {
|
|
3145
|
-
completedFiles: p.completedFiles,
|
|
3146
|
-
depositCount,
|
|
3147
|
-
trainedContentBytes,
|
|
3148
|
-
totalBytesProcessed,
|
|
3149
|
-
totalCorpusBytes,
|
|
3150
|
-
});
|
|
3151
|
-
await store.setMeta("train.langTally", JSON.stringify(langTally));
|
|
3152
|
-
}
|
|
3153
|
-
catch { /* best effort — finish() will retry */ }
|
|
3154
|
-
};
|
|
3155
|
-
// ── §10 Train the curriculum (resume-aware; one store records all stages) ──
|
|
3156
|
-
//
|
|
3157
|
-
// Every source is paged from an HTTP API (SmolSent, Aya) or a single
|
|
3158
|
-
// downloaded file (oasst2) — there is no per-file ZIP loop. Each stage closure
|
|
3159
|
-
// reads the authoritative completed-set from the store, skips itself when
|
|
3160
|
-
// already done, and persists its own progress, so the whole curriculum resumes
|
|
3161
|
-
// from the store alone. LOCAL_PATH lets oasst2 read a local *.jsonl.gz.
|
|
3162
|
-
tick(true);
|
|
3163
|
-
// ── resume — restore counters and the per-source tally from the store ──
|
|
3164
|
-
const prog = await loadProgress(store);
|
|
3165
|
-
depositCount = prog.depositCount;
|
|
3166
|
-
trainedContentBytes = prog.trainedContentBytes;
|
|
3167
|
-
totalBytesProcessed = prog.totalBytesProcessed;
|
|
3168
|
-
totalCorpusBytes = prog.totalCorpusBytes;
|
|
3169
|
-
rateTrained = trainedContentBytes;
|
|
3170
|
-
rateBytes = totalBytesProcessed;
|
|
3171
|
-
try {
|
|
3172
|
-
const t = await store.getMeta("train.langTally");
|
|
3173
|
-
if (t) {
|
|
3174
|
-
const parsed = JSON.parse(t);
|
|
3175
|
-
if (parsed && typeof parsed === "object") {
|
|
3176
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
3177
|
-
langTally[k] = Number(v) || 0;
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
}
|
|
3182
|
-
catch { /* fresh tally */ }
|
|
3183
|
-
if (prog.completedFiles.length > 0) {
|
|
3184
|
-
progress.log(` ${CYAN}↻${R} resuming: ${prog.completedFiles.length} stage-unit(s) done, ` +
|
|
3185
|
-
`${int(depositCount)} examples, ${bytes(trainedContentBytes)} learned`);
|
|
3186
|
-
}
|
|
3187
|
-
// Stage 1 (SmolSent translation facts), 2 (Aya multilingual chat), 3 (oasst2
|
|
3188
|
-
// multi-turn), 4 (General-Knowledge Q&A facts). Each is skipped on a resume
|
|
3189
|
-
// that already finished it.
|
|
3190
|
-
if (!stopRequested)
|
|
3191
|
-
await trainSmolSent();
|
|
3192
|
-
if (!stopRequested)
|
|
3193
|
-
await trainAya();
|
|
3194
|
-
if (!stopRequested)
|
|
3195
|
-
await trainOasst();
|
|
3196
|
-
if (!stopRequested)
|
|
3197
|
-
await trainTaskmaster();
|
|
3198
|
-
if (!stopRequested)
|
|
3199
|
-
await trainWiki2();
|
|
3200
|
-
if (!stopRequested)
|
|
3201
|
-
await trainSoda();
|
|
3202
|
-
if (!stopRequested)
|
|
3203
|
-
await trainMassive();
|
|
3204
|
-
if (!stopRequested)
|
|
3205
|
-
await trainGenKnow();
|
|
3206
|
-
await finish(stopRequested ? stopReason : "done");
|
|
3207
|
-
}
|
|
3208
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3209
|
-
// §11 Entry point — only run when invoked directly, so importing the parser
|
|
3210
|
-
// functions above (e.g. for tests) never starts training.
|
|
3211
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3212
|
-
const isMain = import.meta.url === `file://${process.argv[1]}` ||
|
|
3213
|
-
process.argv[1]?.endsWith("train_base.js");
|
|
3214
|
-
if (isMain) {
|
|
3215
|
-
main().catch((e) => {
|
|
3216
|
-
process.stderr.write(SHOW);
|
|
3217
|
-
console.error(`\n${RED}fatal:${R}`, e);
|
|
3218
|
-
process.exit(1);
|
|
3219
|
-
});
|
|
3220
|
-
}
|