@hviana/sema 0.5.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -4
- package/DATASETS.md +12 -11
- package/dist/example/train_base/cache.d.ts +35 -0
- package/dist/example/train_base/cache.js +211 -0
- package/dist/example/train_base/config.d.ts +21 -0
- package/dist/example/train_base/config.js +94 -0
- package/dist/example/train_base/corpora/aya.d.ts +19 -0
- package/dist/example/train_base/corpora/aya.js +76 -0
- package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
- package/dist/example/train_base/corpora/converted-parquet.js +44 -0
- package/dist/example/train_base/corpora/genknow.d.ts +14 -0
- package/dist/example/train_base/corpora/genknow.js +83 -0
- package/dist/example/train_base/corpora/index.d.ts +29 -0
- package/dist/example/train_base/corpora/index.js +81 -0
- package/dist/example/train_base/corpora/massive.d.ts +7 -0
- package/dist/example/train_base/corpora/massive.js +98 -0
- package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
- package/dist/example/train_base/corpora/oasst2.js +120 -0
- package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
- package/dist/example/train_base/corpora/smolsent.js +156 -0
- package/dist/example/train_base/corpora/soda.d.ts +12 -0
- package/dist/example/train_base/corpora/soda.js +113 -0
- package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
- package/dist/example/train_base/corpora/taskmaster.js +144 -0
- package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
- package/dist/example/train_base/corpora/wiki2.js +132 -0
- package/dist/example/train_base/corpus.d.ts +88 -0
- package/dist/example/train_base/corpus.js +65 -0
- package/dist/example/train_base/discovery.d.ts +48 -0
- package/dist/example/train_base/discovery.js +143 -0
- package/dist/example/train_base/http.d.ts +82 -0
- package/dist/example/train_base/http.js +219 -0
- package/dist/example/train_base/items.d.ts +46 -0
- package/dist/example/train_base/items.js +98 -0
- package/dist/example/train_base/main.d.ts +4 -0
- package/dist/example/train_base/main.js +207 -0
- package/dist/example/train_base/progress.d.ts +34 -0
- package/dist/example/train_base/progress.js +114 -0
- package/dist/example/train_base/readers.d.ts +125 -0
- package/dist/example/train_base/readers.js +391 -0
- package/dist/example/train_base/runtime.d.ts +115 -0
- package/dist/example/train_base/runtime.js +637 -0
- package/dist/example/train_base/stage.d.ts +3 -0
- package/dist/example/train_base/stage.js +246 -0
- package/dist/example/train_base/ui.d.ts +88 -0
- package/dist/example/train_base/ui.js +272 -0
- package/dist/src/mind/mind.d.ts +1 -1
- package/dist/src/mind/mind.js +1 -1
- package/example/train_base/cache.ts +251 -0
- package/example/train_base/config.ts +128 -0
- package/example/train_base/corpora/aya.ts +106 -0
- package/example/train_base/corpora/converted-parquet.ts +64 -0
- package/example/train_base/corpora/genknow.ts +114 -0
- package/example/train_base/corpora/index.ts +88 -0
- package/example/train_base/corpora/massive.ts +111 -0
- package/example/train_base/corpora/oasst2.ts +163 -0
- package/example/train_base/corpora/smolsent.ts +203 -0
- package/example/train_base/corpora/soda.ts +130 -0
- package/example/train_base/corpora/taskmaster.ts +217 -0
- package/example/train_base/corpora/wiki2.ts +190 -0
- package/example/train_base/corpus.ts +150 -0
- package/example/train_base/discovery.ts +203 -0
- package/example/train_base/http.ts +284 -0
- package/example/train_base/items.ts +118 -0
- package/example/train_base/main.ts +240 -0
- package/example/train_base/progress.ts +149 -0
- package/example/train_base/readers.ts +505 -0
- package/example/train_base/runtime.ts +894 -0
- package/example/train_base/stage.ts +276 -0
- package/example/train_base/ui.ts +333 -0
- package/jsr.json +1 -1
- package/package.json +2 -4
- package/src/mind/mind.ts +1 -1
- package/test/13-conversation.test.mjs +1 -1
- package/test/84-composed-answer-honesty.test.mjs +2 -1
- package/test/88-dependency-footprint.test.mjs +99 -0
- package/dist/example/train_base.d.ts +0 -163
- package/dist/example/train_base.js +0 -3220
- package/example/train_base.ts +0 -3882
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// train_base/stage.ts — ONE loop, run once per corpus.
|
|
2
|
+
//
|
|
3
|
+
// Every stage in this trainer was the same seven steps — resume-check, build a
|
|
4
|
+
// work-list, acquire each unit, read it, tally it, mark it complete, persist —
|
|
5
|
+
// differing only in where the work-list comes from, what container the bytes
|
|
6
|
+
// are in, and how a row becomes deposits. Those three are now DATA (a
|
|
7
|
+
// `Corpus`, declared in corpus.ts), and this file is the loop they are fed to.
|
|
8
|
+
//
|
|
9
|
+
// Nothing but the loop lives here, so no corpus file needs to import it.
|
|
10
|
+
import { MAX_BYTES } from "./config.js";
|
|
11
|
+
import { unitIdOf } from "./corpus.js";
|
|
12
|
+
import { loadProgress } from "./progress.js";
|
|
13
|
+
import { DIM, dur, GRN, int, R, RED, YEL } from "./ui.js";
|
|
14
|
+
import { statSync, unlinkSync } from "node:fs";
|
|
15
|
+
/** Rows a read could not use, as the log has always reported them: one number.
|
|
16
|
+
* The reader keeps malformed records and adapter-declined rows apart (see
|
|
17
|
+
* FileResult) because they mean different things, but only a corpus that
|
|
18
|
+
* declines records BY DESIGN needs the distinction on screen. */
|
|
19
|
+
const unusedRows = (r, style) => style?.malformedOnly ? r.skipped : r.skipped + r.unusable;
|
|
20
|
+
export async function runStage(ctx, corpus) {
|
|
21
|
+
const { progress, state, store, tick } = ctx;
|
|
22
|
+
const c = ctx.counters;
|
|
23
|
+
if (!corpus.enabled)
|
|
24
|
+
return;
|
|
25
|
+
if (c.trainedContentBytes >= MAX_BYTES || ctx.stopRequested)
|
|
26
|
+
return;
|
|
27
|
+
// Say what is actually happening. Discovery is a network call that can wait
|
|
28
|
+
// out a rate limit for minutes, and until it is announced the panel keeps
|
|
29
|
+
// displaying the PREVIOUS stage's file as though it were still processing.
|
|
30
|
+
state.activity = "list";
|
|
31
|
+
state.filePath = corpus.label;
|
|
32
|
+
state.fileExamples = 0;
|
|
33
|
+
// The unit counter belongs to the stage that is running, so it is cleared
|
|
34
|
+
// here rather than left showing the previous stage's totals through this one.
|
|
35
|
+
state.fileIndex = 0;
|
|
36
|
+
state.fileTotal = 0;
|
|
37
|
+
tick(true);
|
|
38
|
+
let units;
|
|
39
|
+
try {
|
|
40
|
+
units = await corpus.discover(ctx);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (ctx.stopRequested || e?.name === "AbortError")
|
|
44
|
+
return;
|
|
45
|
+
progress.log(` ${RED}✗${R} ${corpus.label} file listing failed: ${e.message}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (units === null)
|
|
49
|
+
return; // discover already said why
|
|
50
|
+
if (units.length === 0) {
|
|
51
|
+
progress.log(` ${DIM}· no ${corpus.label} files found — skipping${R}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const idOf = (u) => unitIdOf(corpus, u);
|
|
55
|
+
const maxRows = corpus.maxRows ?? 0;
|
|
56
|
+
const p = await loadProgress(store);
|
|
57
|
+
const done = new Set(p.completedFiles);
|
|
58
|
+
// A budget-limited stage never marks its later shards complete — that is
|
|
59
|
+
// what lets a raised budget resume instead of restarting. But it also means
|
|
60
|
+
// "every shard complete" is NOT how such a stage finishes, so without a
|
|
61
|
+
// marker of its own a satisfied budget would re-read and re-deposit its
|
|
62
|
+
// rows on every subsequent run: harmless to the store (deposition is
|
|
63
|
+
// idempotent) but it repeats the work and double-counts langTally.
|
|
64
|
+
//
|
|
65
|
+
// The marker carries the budget it was satisfied AT, so raising the budget
|
|
66
|
+
// still resumes: a bigger budget does not match the marker and the stage
|
|
67
|
+
// runs again, picking up from the rows it has already taken (below) rather
|
|
68
|
+
// than from zero.
|
|
69
|
+
const budgetMark = maxRows > 0 ? `${corpus.id}::budget=${maxRows}` : "";
|
|
70
|
+
if (budgetMark && done.has(budgetMark)) {
|
|
71
|
+
progress.log(` ${DIM}· ${corpus.label} budget of ${int(maxRows)} row(s) already met — skipping${R}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const remaining = units.filter((u) => !done.has(idOf(u)));
|
|
75
|
+
if (remaining.length === 0) {
|
|
76
|
+
progress.log(` ${DIM}· ${corpus.label} already trained — skipping${R}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
state.fileTotal = units.length;
|
|
80
|
+
state.unitNoun = corpus.unitNoun ?? "unit(s)";
|
|
81
|
+
// Fix the corpus denominator BEFORE reading anything. It used to grow as each
|
|
82
|
+
// file was opened, so the bar ran to 100% at the end of every file and fell
|
|
83
|
+
// back when the next was added — 100%, 50%, 100%, 66%. Both listings report
|
|
84
|
+
// sizes, so the pending work is knowable up front. Units of unknown size
|
|
85
|
+
// (a single-file corpus, whose size arrives with its HEAD) contribute 0 here
|
|
86
|
+
// and are added when they are opened, as before.
|
|
87
|
+
const pendingBytes = units
|
|
88
|
+
.filter((u) => !done.has(idOf(u)))
|
|
89
|
+
.reduce((n, u) => n + (u.bytes ?? 0), 0);
|
|
90
|
+
c.totalCorpusBytes = c.totalBytesProcessed + pendingBytes;
|
|
91
|
+
if (corpus.unitNoun) {
|
|
92
|
+
const taken = c.rowsTaken[corpus.id] ?? 0;
|
|
93
|
+
progress.log(` ${GRN}✓${R} ${corpus.label}: ${remaining.length}/${units.length} ${corpus.unitNoun} to train` +
|
|
94
|
+
(maxRows > 0
|
|
95
|
+
? ` ${DIM}(budget ${int(maxRows)} rows` +
|
|
96
|
+
(taken > 0 ? `, ${int(taken)} already taken` : "") + `)${R}`
|
|
97
|
+
: ""));
|
|
98
|
+
}
|
|
99
|
+
// The budget spans the whole stage AND the whole STORE, not one shard and not
|
|
100
|
+
// one run. It starts from the rows already taken by units this store has
|
|
101
|
+
// finished, so an interrupted run resumes into the same budget instead of
|
|
102
|
+
// being granted a fresh one — without that, every Ctrl+C between two shards
|
|
103
|
+
// let a budgeted corpus deposit up to a full budget more than asked for, and
|
|
104
|
+
// for SODA the budget IS the curriculum balance (see corpora/soda.ts).
|
|
105
|
+
//
|
|
106
|
+
// The persisted figure counts FINISHED units only. Rows read from a unit that
|
|
107
|
+
// was cut short are deliberately not committed, because a resume re-reads
|
|
108
|
+
// that unit from the top and re-deposits exactly those rows (idempotent); had
|
|
109
|
+
// they been committed, the re-read would count them twice and the stage would
|
|
110
|
+
// stop short of its budget.
|
|
111
|
+
let rowsTaken = c.rowsTaken[corpus.id] ?? 0;
|
|
112
|
+
const spent = () => maxRows > 0 && rowsTaken >= maxRows;
|
|
113
|
+
const adapt = maxRows > 0
|
|
114
|
+
? (row) => {
|
|
115
|
+
const items = corpus.toItems(row);
|
|
116
|
+
if (!items || items.length === 0)
|
|
117
|
+
return null;
|
|
118
|
+
rowsTaken++;
|
|
119
|
+
return items;
|
|
120
|
+
}
|
|
121
|
+
: corpus.toItems;
|
|
122
|
+
let idx = 0;
|
|
123
|
+
for (const u of units) {
|
|
124
|
+
if (c.trainedContentBytes >= MAX_BYTES || ctx.stopRequested)
|
|
125
|
+
break;
|
|
126
|
+
if (spent())
|
|
127
|
+
break;
|
|
128
|
+
idx++;
|
|
129
|
+
if (done.has(idOf(u)))
|
|
130
|
+
continue;
|
|
131
|
+
// Acquire (download or reuse), then read.
|
|
132
|
+
let path = u.local ?? "";
|
|
133
|
+
let downloaded = false;
|
|
134
|
+
if (!path) {
|
|
135
|
+
const got = await ctx.acquire(u.url, u.dest ?? idOf(u).replace(/[^A-Za-z0-9._-]+/g, "_"), u.acquireLabel ?? u.display);
|
|
136
|
+
if (!got) {
|
|
137
|
+
if (ctx.stopRequested)
|
|
138
|
+
break;
|
|
139
|
+
continue; // a single failed unit never aborts the stage
|
|
140
|
+
}
|
|
141
|
+
path = got.path;
|
|
142
|
+
// A file that came from the cache is the corpus's to keep or to reclaim;
|
|
143
|
+
// only oasst2 keeps it. See Corpus.keepCached.
|
|
144
|
+
downloaded = corpus.keepCached ? !got.cached : true;
|
|
145
|
+
}
|
|
146
|
+
// Only for a unit the listing could not size (a single-file corpus). One
|
|
147
|
+
// whose size was declared is already in the denominator set above, and
|
|
148
|
+
// adding it again is exactly the drift that made the bar bounce.
|
|
149
|
+
if (!u.bytes) {
|
|
150
|
+
try {
|
|
151
|
+
c.totalCorpusBytes += statSync(path).size;
|
|
152
|
+
}
|
|
153
|
+
catch { /* best effort */ }
|
|
154
|
+
}
|
|
155
|
+
state.activity = "process";
|
|
156
|
+
state.fileIndex = idx;
|
|
157
|
+
state.filePath = u.display;
|
|
158
|
+
state.fileExamples = 0;
|
|
159
|
+
tick(true);
|
|
160
|
+
const p0 = Date.now();
|
|
161
|
+
let res;
|
|
162
|
+
// Pick up inside this unit if the last run stopped inside THIS one. A
|
|
163
|
+
// cursor is never applied to a different unit: the row count means whatever
|
|
164
|
+
// that unit's reader counts, and only there.
|
|
165
|
+
const cur = ctx.resumeCursor;
|
|
166
|
+
const startRow = cur && cur.unitId === idOf(u) ? cur.rows : 0;
|
|
167
|
+
try {
|
|
168
|
+
res = await corpus.read(path, adapt, ctx.readCtx({
|
|
169
|
+
unitId: idOf(u),
|
|
170
|
+
corpusId: corpus.id,
|
|
171
|
+
startRow,
|
|
172
|
+
shouldStop: maxRows > 0 ? spent : undefined,
|
|
173
|
+
// The LIVE count, for the cursor snapshot only. It must not be
|
|
174
|
+
// written into ctx.counters: those are what persist() records, and
|
|
175
|
+
// recording a budget figure for a unit that has not finished would
|
|
176
|
+
// charge the store for rows a resume is about to re-read.
|
|
177
|
+
rowsTakenNow: maxRows > 0 ? () => rowsTaken : undefined,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (ctx.stopRequested || e?.name === "AbortError")
|
|
182
|
+
break;
|
|
183
|
+
progress.log(` ${RED}✗${R} ${u.display} parse failed: ${e.message}`);
|
|
184
|
+
if (downloaded) {
|
|
185
|
+
try {
|
|
186
|
+
unlinkSync(path);
|
|
187
|
+
}
|
|
188
|
+
catch { /* best effort */ }
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// A shard cut short by the BUDGET is not "done" — leave it resumable so a
|
|
193
|
+
// later run with a bigger budget continues instead of starting over.
|
|
194
|
+
const hitBudget = spent();
|
|
195
|
+
// The tally is accrued per deposit by the runtime now (see onDeposit), so
|
|
196
|
+
// that a cursor taken mid-unit carries a true one. Adding res.examples here
|
|
197
|
+
// as well would count this unit twice.
|
|
198
|
+
const style = corpus.log;
|
|
199
|
+
const bad = unusedRows(res, style);
|
|
200
|
+
const badNoun = style?.malformedOnly
|
|
201
|
+
? "malformed line(s)"
|
|
202
|
+
: style?.bad ?? "unusable row(s)";
|
|
203
|
+
const fromRows = style?.rows
|
|
204
|
+
? `from ${int(res.rowsUsed)} ${style.rows} `
|
|
205
|
+
: "";
|
|
206
|
+
progress.log(` ${GRN}✓${R} ${u.name} ${DIM}[${corpus.kind}]${R} → ${int(res.examples)} ${style?.deposits ?? "facts"} ${DIM}${fromRows}in ${dur((Date.now() - p0) / 1000)}${R}` +
|
|
207
|
+
(bad ? ` ${YEL}· ${int(bad)} ${badNoun} skipped${R}` : "") +
|
|
208
|
+
(hitBudget
|
|
209
|
+
? ` ${YEL}(budget reached)${R}`
|
|
210
|
+
: res.stopped
|
|
211
|
+
? ` ${YEL}(stopped early)${R}`
|
|
212
|
+
: ""));
|
|
213
|
+
if (!res.stopped && !hitBudget) {
|
|
214
|
+
try {
|
|
215
|
+
c.totalBytesProcessed += statSync(path).size;
|
|
216
|
+
}
|
|
217
|
+
catch { /* best effort */ }
|
|
218
|
+
if (downloaded) {
|
|
219
|
+
try {
|
|
220
|
+
unlinkSync(path);
|
|
221
|
+
}
|
|
222
|
+
catch { /* best effort */ }
|
|
223
|
+
}
|
|
224
|
+
done.add(idOf(u));
|
|
225
|
+
p.completedFiles.push(idOf(u));
|
|
226
|
+
// Commit this unit's rows against the budget — see the note above: only
|
|
227
|
+
// a FINISHED unit's rows are committed, so a resume never counts a
|
|
228
|
+
// re-read prefix twice.
|
|
229
|
+
if (maxRows > 0)
|
|
230
|
+
c.rowsTaken[corpus.id] = rowsTaken;
|
|
231
|
+
}
|
|
232
|
+
await ctx.persist(p.completedFiles, !res.stopped && !hitBudget);
|
|
233
|
+
// A budget stop is not a cap/signal stop: the stage is finished, so fall
|
|
234
|
+
// out of the loop rather than treating it as an interruption.
|
|
235
|
+
if (res.stopped && !hitBudget)
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
// Record a satisfied budget so the next run skips this stage instead of
|
|
239
|
+
// re-reading it. Only when the budget was actually reached: a stage that
|
|
240
|
+
// ran out of shards first is complete by the normal per-shard rule, and a
|
|
241
|
+
// stage cut short by MAX_MB or Ctrl+C must stay resumable.
|
|
242
|
+
if (budgetMark && spent() && !ctx.stopRequested && !done.has(budgetMark)) {
|
|
243
|
+
p.completedFiles.push(budgetMark);
|
|
244
|
+
await ctx.persist(p.completedFiles);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type TrainingItem } from "./items.js";
|
|
2
|
+
export declare const CSI = "\u001B[";
|
|
3
|
+
export declare const B = "\u001B[1m", DIM = "\u001B[2m", R = "\u001B[0m";
|
|
4
|
+
export declare const GREY = "\u001B[90m", CYAN = "\u001B[36m", GRN = "\u001B[32m";
|
|
5
|
+
export declare const YEL = "\u001B[33m", RED = "\u001B[31m";
|
|
6
|
+
export declare const HIDE = "\u001B[?25l", SHOW = "\u001B[?25h";
|
|
7
|
+
/** Human-readable duration from seconds. */
|
|
8
|
+
export declare function dur(seconds: number): string;
|
|
9
|
+
/** Human-readable byte size. */
|
|
10
|
+
export declare function bytes(n: number): string;
|
|
11
|
+
/** Short count: 1234567 → "1.23M". */
|
|
12
|
+
export declare function num(n: number): string;
|
|
13
|
+
export declare const int: (n: number) => string;
|
|
14
|
+
export declare const clamp01: (f: number) => number;
|
|
15
|
+
export declare const pct: (f: number) => string;
|
|
16
|
+
/** A progress bar of width `w` filled to fraction `frac`. */
|
|
17
|
+
export declare function bar(w: number, frac: number): string;
|
|
18
|
+
/** Collapse whitespace and clip to `max` chars with an ellipsis. */
|
|
19
|
+
export declare function clip(text: string, max: number): string;
|
|
20
|
+
export interface ProgState {
|
|
21
|
+
exampleCount: number;
|
|
22
|
+
target: number;
|
|
23
|
+
elapsedS: number;
|
|
24
|
+
trainedBytes: number;
|
|
25
|
+
trainedRate: number;
|
|
26
|
+
bytesDone: number;
|
|
27
|
+
bytesTotal: number;
|
|
28
|
+
bytesRate: number;
|
|
29
|
+
fileIndex: number;
|
|
30
|
+
fileTotal: number;
|
|
31
|
+
/** What this stage's units ARE ("translation file(s)", "shard(s)"). The panel
|
|
32
|
+
* used to say "languages" for every corpus — true only of SmolSent, and
|
|
33
|
+
* plainly wrong while reading Parquet shards or dialogue files. */
|
|
34
|
+
unitNoun: string;
|
|
35
|
+
filePath: string;
|
|
36
|
+
fileSize: number;
|
|
37
|
+
fileExamples: number;
|
|
38
|
+
/** "list" is the work-list call — an HF/GitHub tree fetch, which can wait
|
|
39
|
+
* minutes behind a rate limit and had no way to say so: the panel simply
|
|
40
|
+
* kept showing the PREVIOUS stage's file as though it were still being
|
|
41
|
+
* processed. */
|
|
42
|
+
activity: "download" | "process" | "list" | "idle";
|
|
43
|
+
dlSpeed: number;
|
|
44
|
+
dlDone: number;
|
|
45
|
+
dlTotal: number;
|
|
46
|
+
storeEntries: number;
|
|
47
|
+
cacheBytes: number;
|
|
48
|
+
lastSample: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** A prompt/expected pair to display for an item. */
|
|
51
|
+
export declare function promptOf(it: TrainingItem): {
|
|
52
|
+
prompt: string;
|
|
53
|
+
expected: string | null;
|
|
54
|
+
kind: "episode" | "experience";
|
|
55
|
+
};
|
|
56
|
+
/** A coarse, honest similarity between an expected continuation and SEMA's
|
|
57
|
+
* recall. Both are normalized (lowercased, whitespace-collapsed) and compared
|
|
58
|
+
* by the longest shared leading run plus token overlap, so the verdict is a
|
|
59
|
+
* heuristic signal of recall quality rather than a brittle fixed-prefix test. */
|
|
60
|
+
export declare function recallSimilarity(expected: string, response: string): number;
|
|
61
|
+
/** A framed recall sample. Pinned in the panel on a TTY (so the most recent
|
|
62
|
+
* example is always on screen) and logged once per checkpoint when piped. */
|
|
63
|
+
export declare function renderInferenceBox(prompt: string, expected: string | null, response: string, kind: "episode" | "experience", checkpointN: number): string;
|
|
64
|
+
/** Render the whole panel. `title` names the curriculum being trained; the run
|
|
65
|
+
* supplies it, so the panel never has to know which corpora exist. */
|
|
66
|
+
export declare function renderPanel(s: ProgState, title: string): string;
|
|
67
|
+
/** A live panel pinned to the bottom of stderr. On a TTY it redraws in place,
|
|
68
|
+
* clearing only its own lines; logs are flushed into the scrollback above it.
|
|
69
|
+
* Off a TTY (piped/CI) the panel is suppressed and a plain status line is
|
|
70
|
+
* emitted occasionally, so logs stay clean and parseable. */
|
|
71
|
+
export declare class Progress {
|
|
72
|
+
private readonly title;
|
|
73
|
+
private lines;
|
|
74
|
+
private lastPaint;
|
|
75
|
+
private lastStatus;
|
|
76
|
+
private last;
|
|
77
|
+
private readonly tty;
|
|
78
|
+
constructor(title: string);
|
|
79
|
+
/** True when attached to an interactive terminal (panel is live). */
|
|
80
|
+
get interactive(): boolean;
|
|
81
|
+
/** Cursor sequence that returns to the top of the panel and clears it. */
|
|
82
|
+
private clearPanel;
|
|
83
|
+
render(s: ProgState, force?: boolean): void;
|
|
84
|
+
/** Emit a line (or block) into the scrollback above the panel; the panel is
|
|
85
|
+
* redrawn immediately beneath it so it never disappears between frames. */
|
|
86
|
+
log(msg: string): void;
|
|
87
|
+
dispose(): void;
|
|
88
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// train_base/ui.ts — everything the run puts on the screen: the formatters, the
|
|
2
|
+
// live panel pinned to the bottom of stderr, and the checkpoint recall box.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here decides anything. It renders a ProgState the run owns, and the
|
|
5
|
+
// run never reads a value back out of it — so the whole display can be swapped
|
|
6
|
+
// (or silenced) without touching a line of training logic.
|
|
7
|
+
import { CHECKPOINT_BYTES, D, DB_PATH, PROGRESS_MS, SEED } from "./config.js";
|
|
8
|
+
import { isEpisode } from "./items.js";
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
// ── colours ──
|
|
11
|
+
export const CSI = "\x1b[";
|
|
12
|
+
export const B = `${CSI}1m`, DIM = `${CSI}2m`, R = `${CSI}0m`;
|
|
13
|
+
export const GREY = `${CSI}90m`, CYAN = `${CSI}36m`, GRN = `${CSI}32m`;
|
|
14
|
+
export const YEL = `${CSI}33m`, RED = `${CSI}31m`;
|
|
15
|
+
export const HIDE = `${CSI}?25l`, SHOW = `${CSI}?25h`;
|
|
16
|
+
// ── formatters ──
|
|
17
|
+
/** Human-readable duration from seconds. */
|
|
18
|
+
export function dur(seconds) {
|
|
19
|
+
if (!isFinite(seconds) || seconds < 0)
|
|
20
|
+
return "--";
|
|
21
|
+
const h = Math.floor(seconds / 3600);
|
|
22
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
23
|
+
const s = Math.floor(seconds % 60);
|
|
24
|
+
if (h > 0)
|
|
25
|
+
return `${h}h ${m}m ${s}s`;
|
|
26
|
+
if (m > 0)
|
|
27
|
+
return `${m}m ${s}s`;
|
|
28
|
+
return `${s}s`;
|
|
29
|
+
}
|
|
30
|
+
/** Human-readable byte size. */
|
|
31
|
+
export function bytes(n) {
|
|
32
|
+
if (!isFinite(n) || n < 0)
|
|
33
|
+
return "--";
|
|
34
|
+
if (n < 1024)
|
|
35
|
+
return `${n} B`;
|
|
36
|
+
if (n < 1e6)
|
|
37
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
38
|
+
if (n < 1e9)
|
|
39
|
+
return `${(n / 1e6).toFixed(1)} MB`;
|
|
40
|
+
return `${(n / 1e9).toFixed(2)} GB`;
|
|
41
|
+
}
|
|
42
|
+
/** Short count: 1234567 → "1.23M". */
|
|
43
|
+
export function num(n) {
|
|
44
|
+
if (n >= 1e9)
|
|
45
|
+
return `${(n / 1e9).toFixed(2)}B`;
|
|
46
|
+
if (n >= 1e6)
|
|
47
|
+
return `${(n / 1e6).toFixed(2)}M`;
|
|
48
|
+
if (n >= 1e3)
|
|
49
|
+
return `${(n / 1e3).toFixed(1)}K`;
|
|
50
|
+
return String(n);
|
|
51
|
+
}
|
|
52
|
+
export const int = (n) => Math.round(n).toLocaleString("en-US");
|
|
53
|
+
export const clamp01 = (f) => Math.max(0, Math.min(1, f));
|
|
54
|
+
export const pct = (f) => `${(clamp01(f) * 100).toFixed(1)}%`;
|
|
55
|
+
/** A progress bar of width `w` filled to fraction `frac`. */
|
|
56
|
+
export function bar(w, frac) {
|
|
57
|
+
const filled = Math.round(clamp01(frac) * w);
|
|
58
|
+
return `${GRN}${"█".repeat(filled)}${GREY}${"░".repeat(w - filled)}${R}`;
|
|
59
|
+
}
|
|
60
|
+
/** Collapse whitespace and clip to `max` chars with an ellipsis. */
|
|
61
|
+
export function clip(text, max) {
|
|
62
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
63
|
+
if (max < 1)
|
|
64
|
+
return "";
|
|
65
|
+
return t.length <= max ? t : t.slice(0, max - 1) + "…";
|
|
66
|
+
}
|
|
67
|
+
/** A prompt/expected pair to display for an item. */
|
|
68
|
+
export function promptOf(it) {
|
|
69
|
+
return isEpisode(it)
|
|
70
|
+
? { prompt: it.context, expected: it.continuation, kind: "episode" }
|
|
71
|
+
: { prompt: it.slice(0, 200), expected: null, kind: "experience" };
|
|
72
|
+
}
|
|
73
|
+
/** A coarse, honest similarity between an expected continuation and SEMA's
|
|
74
|
+
* recall. Both are normalized (lowercased, whitespace-collapsed) and compared
|
|
75
|
+
* by the longest shared leading run plus token overlap, so the verdict is a
|
|
76
|
+
* heuristic signal of recall quality rather than a brittle fixed-prefix test. */
|
|
77
|
+
export function recallSimilarity(expected, response) {
|
|
78
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
79
|
+
const a = norm(expected), b = norm(response);
|
|
80
|
+
if (!a || !b)
|
|
81
|
+
return 0;
|
|
82
|
+
let lead = 0;
|
|
83
|
+
const lim = Math.min(a.length, b.length);
|
|
84
|
+
while (lead < lim && a[lead] === b[lead])
|
|
85
|
+
lead++;
|
|
86
|
+
const leadFrac = lead / Math.max(1, Math.min(a.length, b.length));
|
|
87
|
+
const ta = new Set(a.split(" ")), tb = new Set(b.split(" "));
|
|
88
|
+
let inter = 0;
|
|
89
|
+
for (const w of ta)
|
|
90
|
+
if (tb.has(w))
|
|
91
|
+
inter++;
|
|
92
|
+
const jac = inter / Math.max(1, ta.size + tb.size - inter);
|
|
93
|
+
return Math.max(leadFrac, jac);
|
|
94
|
+
}
|
|
95
|
+
/** A framed recall sample. Pinned in the panel on a TTY (so the most recent
|
|
96
|
+
* example is always on screen) and logged once per checkpoint when piped. */
|
|
97
|
+
export function renderInferenceBox(prompt, expected, response, kind, checkpointN) {
|
|
98
|
+
const W = 68;
|
|
99
|
+
const hr = `${DIM}${"─".repeat(W)}${R}`;
|
|
100
|
+
const title = kind === "episode"
|
|
101
|
+
? "latest recall"
|
|
102
|
+
: "latest recall (experience)";
|
|
103
|
+
const head = `${title} · checkpoint #${checkpointN} `;
|
|
104
|
+
const shown = response.trim() ? response : "(empty)";
|
|
105
|
+
const lines = [
|
|
106
|
+
`${B}╭─ ${head}${"─".repeat(Math.max(0, W - 2 - head.length))}╮${R}`,
|
|
107
|
+
`${B}│${R} ${hr}`,
|
|
108
|
+
`${B}│${R} ${CYAN}${B}Context:${R} ${clip(prompt, W - 13)}`,
|
|
109
|
+
];
|
|
110
|
+
if (expected) {
|
|
111
|
+
lines.push(`${B}│${R} ${YEL}${B}Expected:${R} ${clip(expected, W - 13)}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push(`${B}│${R} ${GRN}${B}SEMA:${R} ${clip(shown, W - 13)}`);
|
|
114
|
+
lines.push(`${B}│${R} ${hr}`);
|
|
115
|
+
let verdict;
|
|
116
|
+
if (expected) {
|
|
117
|
+
const sim = recallSimilarity(expected, response);
|
|
118
|
+
const pctStr = `${Math.round(sim * 100)}%`;
|
|
119
|
+
verdict = sim >= 0.6
|
|
120
|
+
? `${GRN}✓${R} recall close to expected ${DIM}(~${pctStr} overlap)${R}`
|
|
121
|
+
: sim >= 0.25
|
|
122
|
+
? `${YEL}△${R} partial recall ${DIM}(~${pctStr} overlap)${R}`
|
|
123
|
+
: `${RED}✗${R} recall diverges ${DIM}(~${pctStr} overlap)${R}`;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
verdict = `${DIM}·${R} plain experience — no expected answer`;
|
|
127
|
+
}
|
|
128
|
+
lines.push(`${B}│${R} ${verdict}`);
|
|
129
|
+
lines.push(`${B}╰${"─".repeat(W)}╯${R}`);
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
/** Render the whole panel. `title` names the curriculum being trained; the run
|
|
133
|
+
* supplies it, so the panel never has to know which corpora exist. */
|
|
134
|
+
export function renderPanel(s, title) {
|
|
135
|
+
const targetKnown = isFinite(s.target);
|
|
136
|
+
// Primary progress: by learned-content bytes when a MAX_MB target is set,
|
|
137
|
+
// else by how far we are through the corpus on disk (bytes) — so the default
|
|
138
|
+
// unbounded run still shows a real fraction and a real ETA.
|
|
139
|
+
const frac = targetKnown
|
|
140
|
+
? (s.target > 0 ? s.trainedBytes / s.target : 0)
|
|
141
|
+
: (s.bytesTotal > 0 ? s.bytesDone / s.bytesTotal : 0);
|
|
142
|
+
const etaStr = (() => {
|
|
143
|
+
if (targetKnown) {
|
|
144
|
+
return s.trainedRate > 0
|
|
145
|
+
? dur((s.target - s.trainedBytes) / s.trainedRate)
|
|
146
|
+
: "∞";
|
|
147
|
+
}
|
|
148
|
+
if (s.bytesTotal > 0 && s.bytesRate > 0) {
|
|
149
|
+
return dur((s.bytesTotal - s.bytesDone) / s.bytesRate);
|
|
150
|
+
}
|
|
151
|
+
return "∞";
|
|
152
|
+
})();
|
|
153
|
+
const fileFrac = s.fileTotal > 0 ? s.fileIndex / s.fileTotal : 0;
|
|
154
|
+
let actIcon = `${DIM}·${R}`, actText = "waiting…";
|
|
155
|
+
if (s.activity === "download") {
|
|
156
|
+
actIcon = `${CYAN}⬇${R}`;
|
|
157
|
+
const name = s.filePath;
|
|
158
|
+
const total = s.dlTotal > 0 ? s.dlTotal : s.fileSize;
|
|
159
|
+
if (total > 0 && s.dlDone > 0) {
|
|
160
|
+
const dlFrac = clamp01(s.dlDone / total);
|
|
161
|
+
actText =
|
|
162
|
+
`downloading ${name} ${bar(18, dlFrac)} ${B}${pct(dlFrac)}${R}` +
|
|
163
|
+
` ${DIM}${bytes(s.dlDone)}/${bytes(total)}${R}`;
|
|
164
|
+
if (s.dlSpeed > 0)
|
|
165
|
+
actText += ` ${DIM}@ ${bytes(s.dlSpeed)}/s${R}`;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
actText = total > 0
|
|
169
|
+
? `downloading ${name} · ${bytes(total)}…`
|
|
170
|
+
: `downloading ${name}…`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (s.activity === "process") {
|
|
174
|
+
actIcon = `${GRN}✓${R}`;
|
|
175
|
+
actText = `processing ${s.filePath} · ${int(s.fileExamples)} examples so far`;
|
|
176
|
+
}
|
|
177
|
+
else if (s.activity === "list") {
|
|
178
|
+
actIcon = `${CYAN}⋯${R}`;
|
|
179
|
+
actText = `listing ${s.filePath} files…`;
|
|
180
|
+
}
|
|
181
|
+
const targetStr = targetKnown ? bytes(s.target) : "∞";
|
|
182
|
+
const headExamples = targetKnown
|
|
183
|
+
? `${CYAN}${bytes(s.trainedBytes)}${R} / ${targetStr} learned ${DIM}·${R} ${int(s.exampleCount)} examples`
|
|
184
|
+
: `${CYAN}${int(s.exampleCount)}${R} examples`;
|
|
185
|
+
const corpusInfo = s.bytesTotal > 0
|
|
186
|
+
? `${B}📦${R} ${bytes(s.bytesDone)}/${bytes(s.bytesTotal)} (${pct(s.bytesDone / s.bytesTotal)})`
|
|
187
|
+
: `${B}📦${R} ${bytes(s.bytesDone)} processed`;
|
|
188
|
+
const fileInfo = s.fileTotal > 0
|
|
189
|
+
? `${B}🌐${R} ${s.fileIndex}/${s.fileTotal} ${s.unitNoun} (${pct(fileFrac)})`
|
|
190
|
+
: `${B}🌐${R} ${s.unitNoun}`;
|
|
191
|
+
const panel = [
|
|
192
|
+
`${B}╭${R}${B} sema train${R} ${DIM}·${R} ${title} ${DIM}·${R} ` +
|
|
193
|
+
`D=${D} ${DIM}·${R} seed=${SEED} ${DIM}·${R} ` +
|
|
194
|
+
`store=${basename(DB_PATH)}.sqlite\n${B}╰${R} target=${CYAN}${targetStr}${R} ` +
|
|
195
|
+
`learned ${DIM}·${R} checkpoint every ${bytes(CHECKPOINT_BYTES)}`,
|
|
196
|
+
`\n${bar(40, frac)} ${B}${pct(frac)}${R} ${headExamples}`,
|
|
197
|
+
`\n${B}⚡${R} ${bytes(s.trainedRate)}/s learned ${B}🧠${R} ${bytes(s.trainedBytes)} content ${B}⏱${R} ${dur(s.elapsedS)} elapsed ${B}🕐${R} ${etaStr} ETA`,
|
|
198
|
+
`${fileInfo} ${corpusInfo} ${B}🗄${R} ${num(s.storeEntries)} entries ` +
|
|
199
|
+
`${B}💾${R} cache ${bytes(s.cacheBytes)}`,
|
|
200
|
+
`\n${actIcon} ${actText}`,
|
|
201
|
+
].join("");
|
|
202
|
+
return s.lastSample ? `${panel}\n${s.lastSample}` : panel;
|
|
203
|
+
}
|
|
204
|
+
/** A live panel pinned to the bottom of stderr. On a TTY it redraws in place,
|
|
205
|
+
* clearing only its own lines; logs are flushed into the scrollback above it.
|
|
206
|
+
* Off a TTY (piped/CI) the panel is suppressed and a plain status line is
|
|
207
|
+
* emitted occasionally, so logs stay clean and parseable. */
|
|
208
|
+
export class Progress {
|
|
209
|
+
title;
|
|
210
|
+
lines = 0; // height of the panel currently on screen
|
|
211
|
+
lastPaint = 0;
|
|
212
|
+
lastStatus = 0;
|
|
213
|
+
last = null;
|
|
214
|
+
tty = process.stderr.isTTY === true;
|
|
215
|
+
constructor(title) {
|
|
216
|
+
this.title = title;
|
|
217
|
+
}
|
|
218
|
+
/** True when attached to an interactive terminal (panel is live). */
|
|
219
|
+
get interactive() {
|
|
220
|
+
return this.tty;
|
|
221
|
+
}
|
|
222
|
+
/** Cursor sequence that returns to the top of the panel and clears it. */
|
|
223
|
+
clearPanel() {
|
|
224
|
+
if (this.lines <= 0)
|
|
225
|
+
return "";
|
|
226
|
+
const up = this.lines - 1; // cursor is on the panel's last line
|
|
227
|
+
return (up > 0 ? `${CSI}${up}F` : "\r") + `${CSI}0J`;
|
|
228
|
+
}
|
|
229
|
+
render(s, force = false) {
|
|
230
|
+
this.last = s;
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
if (!force && now - this.lastPaint < PROGRESS_MS)
|
|
233
|
+
return;
|
|
234
|
+
this.lastPaint = now;
|
|
235
|
+
if (!this.tty) {
|
|
236
|
+
if (force || now - this.lastStatus >= 10_000) {
|
|
237
|
+
this.lastStatus = now;
|
|
238
|
+
const targetKnown = isFinite(s.target);
|
|
239
|
+
const where = s.bytesTotal > 0
|
|
240
|
+
? ` ${pct(s.bytesDone / s.bytesTotal)} of corpus`
|
|
241
|
+
: "";
|
|
242
|
+
process.stderr.write(`[sema] ${bytes(s.trainedBytes)}${targetKnown ? "/" + bytes(s.target) : ""} learned · ${int(s.exampleCount)} examples · ` +
|
|
243
|
+
`${bytes(s.trainedRate)}/s · ${s.fileIndex}/${s.fileTotal} ${s.unitNoun}${where} · ` +
|
|
244
|
+
`${num(s.storeEntries)} entries\n`);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const text = renderPanel(s, this.title);
|
|
249
|
+
process.stderr.write(`${this.clearPanel()}${HIDE}${text}`);
|
|
250
|
+
this.lines = text.split("\n").length;
|
|
251
|
+
}
|
|
252
|
+
/** Emit a line (or block) into the scrollback above the panel; the panel is
|
|
253
|
+
* redrawn immediately beneath it so it never disappears between frames. */
|
|
254
|
+
log(msg) {
|
|
255
|
+
if (!this.tty) {
|
|
256
|
+
process.stderr.write(`${msg}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let out = `${this.clearPanel()}${msg}\n`;
|
|
260
|
+
this.lines = 0;
|
|
261
|
+
if (this.last) {
|
|
262
|
+
const text = renderPanel(this.last, this.title);
|
|
263
|
+
out += `${HIDE}${text}`;
|
|
264
|
+
this.lines = text.split("\n").length;
|
|
265
|
+
}
|
|
266
|
+
process.stderr.write(out);
|
|
267
|
+
}
|
|
268
|
+
dispose() {
|
|
269
|
+
if (this.tty)
|
|
270
|
+
process.stderr.write(`${SHOW}\n`);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/src/mind/mind.d.ts
CHANGED
|
@@ -282,7 +282,7 @@ export declare class Mind implements MindContext {
|
|
|
282
282
|
* "what byte separates two turns?" because nothing downstream finds
|
|
283
283
|
* boundaries by looking at content at all.
|
|
284
284
|
* 2. A separator in a CORPUS is ordinary content. If a trainer joins
|
|
285
|
-
* turns with "\n" (example/train_base
|
|
285
|
+
* turns with "\n" (example/train_base does), those newlines are
|
|
286
286
|
* simply bytes inside the stream, folded like every other byte. They
|
|
287
287
|
* are a property of that corpus, not of this API and not of the fold.
|
|
288
288
|
* 3. This API can therefore reproduce ANY corpus exactly, with no
|
package/dist/src/mind/mind.js
CHANGED
|
@@ -479,7 +479,7 @@ export class Mind {
|
|
|
479
479
|
* "what byte separates two turns?" because nothing downstream finds
|
|
480
480
|
* boundaries by looking at content at all.
|
|
481
481
|
* 2. A separator in a CORPUS is ordinary content. If a trainer joins
|
|
482
|
-
* turns with "\n" (example/train_base
|
|
482
|
+
* turns with "\n" (example/train_base does), those newlines are
|
|
483
483
|
* simply bytes inside the stream, folded like every other byte. They
|
|
484
484
|
* are a property of that corpus, not of this API and not of the fold.
|
|
485
485
|
* 3. This API can therefore reproduce ANY corpus exactly, with no
|