@hviana/sema 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +43 -4
  2. package/DATASETS.md +160 -0
  3. package/README.md +12 -0
  4. package/dist/example/train_base/cache.d.ts +35 -0
  5. package/dist/example/train_base/cache.js +211 -0
  6. package/dist/example/train_base/config.d.ts +21 -0
  7. package/dist/example/train_base/config.js +94 -0
  8. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  9. package/dist/example/train_base/corpora/aya.js +76 -0
  10. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  11. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  12. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  13. package/dist/example/train_base/corpora/genknow.js +83 -0
  14. package/dist/example/train_base/corpora/index.d.ts +29 -0
  15. package/dist/example/train_base/corpora/index.js +81 -0
  16. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  17. package/dist/example/train_base/corpora/massive.js +98 -0
  18. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  19. package/dist/example/train_base/corpora/oasst2.js +120 -0
  20. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  21. package/dist/example/train_base/corpora/smolsent.js +156 -0
  22. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  23. package/dist/example/train_base/corpora/soda.js +113 -0
  24. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  25. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  26. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  27. package/dist/example/train_base/corpora/wiki2.js +132 -0
  28. package/dist/example/train_base/corpus.d.ts +88 -0
  29. package/dist/example/train_base/corpus.js +65 -0
  30. package/dist/example/train_base/discovery.d.ts +48 -0
  31. package/dist/example/train_base/discovery.js +143 -0
  32. package/dist/example/train_base/http.d.ts +82 -0
  33. package/dist/example/train_base/http.js +219 -0
  34. package/dist/example/train_base/items.d.ts +46 -0
  35. package/dist/example/train_base/items.js +98 -0
  36. package/dist/example/train_base/main.d.ts +4 -0
  37. package/dist/example/train_base/main.js +207 -0
  38. package/dist/example/train_base/progress.d.ts +34 -0
  39. package/dist/example/train_base/progress.js +114 -0
  40. package/dist/example/train_base/readers.d.ts +125 -0
  41. package/dist/example/train_base/readers.js +391 -0
  42. package/dist/example/train_base/runtime.d.ts +115 -0
  43. package/dist/example/train_base/runtime.js +637 -0
  44. package/dist/example/train_base/stage.d.ts +3 -0
  45. package/dist/example/train_base/stage.js +246 -0
  46. package/dist/example/train_base/ui.d.ts +88 -0
  47. package/dist/example/train_base/ui.js +272 -0
  48. package/dist/src/geometry.d.ts +20 -0
  49. package/dist/src/geometry.js +22 -0
  50. package/dist/src/mind/attention.d.ts +6 -0
  51. package/dist/src/mind/attention.js +44 -4
  52. package/dist/src/mind/learning.js +134 -50
  53. package/dist/src/mind/mechanisms/cast.js +45 -1
  54. package/dist/src/mind/mind.d.ts +7 -2
  55. package/dist/src/mind/mind.js +15 -3
  56. package/dist/src/mind/reasoning.js +59 -5
  57. package/dist/src/mind/recognition.js +29 -3
  58. package/dist/src/mind/traverse.d.ts +16 -0
  59. package/dist/src/mind/traverse.js +18 -0
  60. package/dist/src/store-sqlite.d.ts +4 -0
  61. package/dist/src/store-sqlite.js +47 -0
  62. package/dist/src/store.d.ts +7 -0
  63. package/example/train_base/cache.ts +251 -0
  64. package/example/train_base/config.ts +128 -0
  65. package/example/train_base/corpora/aya.ts +106 -0
  66. package/example/train_base/corpora/converted-parquet.ts +64 -0
  67. package/example/train_base/corpora/genknow.ts +114 -0
  68. package/example/train_base/corpora/index.ts +88 -0
  69. package/example/train_base/corpora/massive.ts +111 -0
  70. package/example/train_base/corpora/oasst2.ts +163 -0
  71. package/example/train_base/corpora/smolsent.ts +203 -0
  72. package/example/train_base/corpora/soda.ts +130 -0
  73. package/example/train_base/corpora/taskmaster.ts +217 -0
  74. package/example/train_base/corpora/wiki2.ts +190 -0
  75. package/example/train_base/corpus.ts +150 -0
  76. package/example/train_base/discovery.ts +203 -0
  77. package/example/train_base/http.ts +284 -0
  78. package/example/train_base/items.ts +118 -0
  79. package/example/train_base/main.ts +240 -0
  80. package/example/train_base/progress.ts +149 -0
  81. package/example/train_base/readers.ts +505 -0
  82. package/example/train_base/runtime.ts +894 -0
  83. package/example/train_base/stage.ts +276 -0
  84. package/example/train_base/ui.ts +333 -0
  85. package/jsr.json +1 -1
  86. package/package.json +2 -4
  87. package/src/geometry.ts +23 -0
  88. package/src/mind/attention.ts +54 -1
  89. package/src/mind/learning.ts +137 -43
  90. package/src/mind/mechanisms/cast.ts +48 -1
  91. package/src/mind/mind.ts +13 -2
  92. package/src/mind/reasoning.ts +64 -5
  93. package/src/mind/recognition.ts +29 -3
  94. package/src/mind/traverse.ts +19 -0
  95. package/src/store-sqlite.ts +53 -0
  96. package/src/store.ts +28 -0
  97. package/test/13-conversation.test.mjs +1 -1
  98. package/test/29-counterfactual.test.mjs +43 -6
  99. package/test/77-company-saturation.test.mjs +302 -0
  100. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  101. package/test/84-composed-answer-honesty.test.mjs +137 -0
  102. package/test/85-answered-directly.test.mjs +126 -0
  103. package/test/86-cast-voices-committed.test.mjs +164 -0
  104. package/test/87-codominant-commitment.test.mjs +250 -0
  105. package/test/88-dependency-footprint.test.mjs +99 -0
  106. package/dist/example/train_base.d.ts +0 -93
  107. package/dist/example/train_base.js +0 -2269
  108. package/example/train_base.ts +0 -2735
@@ -0,0 +1,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
+ }
@@ -86,6 +86,26 @@ export declare function dominates(partLen: number, wholeLen: number): boolean;
86
86
  * recallByResonance trusting a climb anchor, and commitVotes admitting a
87
87
  * further point of attention. Defined once here so the two can never
88
88
  * drift apart. Derived from N, never tuned. */
89
+ /** SUPERPOSITION CAPACITY — how many quasi-orthogonal terms one vector can
90
+ * carry before an individual term stops being readable. `√D`.
91
+ *
92
+ * A superposition of m unit signatures has ‖acc‖² ≈ m, so one term's
93
+ * contribution to any cosine taken against that vector is ≈ 1/m. Setting
94
+ * that against the representation's own floor {@link estimatorNoise} = 1/√D:
95
+ *
96
+ * 1/m < 1/√D ⟺ m > √D
97
+ *
98
+ * Past √D terms a single shared constituent can no longer move a halo cosine
99
+ * above quantisation noise — and because the result is normalized, each extra
100
+ * term also shrinks every ALREADY-accepted term toward that floor. So this is
101
+ * not a budget that trades accuracy for time: beyond capacity, more evidence
102
+ * makes the representation strictly worse. It composes with
103
+ * {@link significanceBar} (3/√D) as it should — three shared units of √D is
104
+ * exactly the significance bar.
105
+ *
106
+ * Consumer: `companyProfile` (mind/learning.ts), which sizes its constituent
107
+ * sketch at this capacity instead of a visit budget. */
108
+ export declare function profileCapacity(D: number): number;
89
109
  export declare function consensusFloor(N: number): number;
90
110
  /** The coverage bar for the reach (interior) index, when vector-similarity
91
111
  * gating is used. Returns the concept threshold — the structural midpoint
@@ -121,6 +121,28 @@ export function dominates(partLen, wholeLen) {
121
121
  * recallByResonance trusting a climb anchor, and commitVotes admitting a
122
122
  * further point of attention. Defined once here so the two can never
123
123
  * drift apart. Derived from N, never tuned. */
124
+ /** SUPERPOSITION CAPACITY — how many quasi-orthogonal terms one vector can
125
+ * carry before an individual term stops being readable. `√D`.
126
+ *
127
+ * A superposition of m unit signatures has ‖acc‖² ≈ m, so one term's
128
+ * contribution to any cosine taken against that vector is ≈ 1/m. Setting
129
+ * that against the representation's own floor {@link estimatorNoise} = 1/√D:
130
+ *
131
+ * 1/m < 1/√D ⟺ m > √D
132
+ *
133
+ * Past √D terms a single shared constituent can no longer move a halo cosine
134
+ * above quantisation noise — and because the result is normalized, each extra
135
+ * term also shrinks every ALREADY-accepted term toward that floor. So this is
136
+ * not a budget that trades accuracy for time: beyond capacity, more evidence
137
+ * makes the representation strictly worse. It composes with
138
+ * {@link significanceBar} (3/√D) as it should — three shared units of √D is
139
+ * exactly the significance bar.
140
+ *
141
+ * Consumer: `companyProfile` (mind/learning.ts), which sizes its constituent
142
+ * sketch at this capacity instead of a visit budget. */
143
+ export function profileCapacity(D) {
144
+ return Math.max(1, Math.floor(Math.sqrt(D)));
145
+ }
124
146
  export function consensusFloor(N) {
125
147
  return Math.log(N) + 1 / 2;
126
148
  }
@@ -87,6 +87,12 @@ export interface ConsensusAnchorTrace {
87
87
  passesNaturalBreak?: boolean;
88
88
  passesConsensusFloor?: boolean;
89
89
  pastLeadingSaturation?: boolean;
90
+ /** Committed because its margin from the dominant is inside the estimator's
91
+ * own resolution — the co-dominant band in {@link commitVotes}. Recorded
92
+ * because commit decisions are kept in the exact shape the gates applied
93
+ * them: a root admitted this way must never read, in the trace, as one
94
+ * that cleared the two vote gates. */
95
+ tiedWithDominant?: boolean;
90
96
  rejectionReasons: AnchorRejectionReason[];
91
97
  };
92
98
  }