@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
|
@@ -92,9 +92,18 @@ export async function think(ctx, query, mechs) {
|
|
|
92
92
|
};
|
|
93
93
|
// ── Pre-computation ──────────────────────────────────────────────────
|
|
94
94
|
const mechanisms = mechs ?? defaultMechanisms;
|
|
95
|
-
const
|
|
95
|
+
const meter = ctx.meter;
|
|
96
|
+
// recognition is a shared analysis (§2.14 contract 5): it does the query's
|
|
97
|
+
// own store work (perceive → foldTree → resolve), which used to land in
|
|
98
|
+
// `think` and in nothing narrower — the meter's one accounting surface must
|
|
99
|
+
// charge it to itself, exactly as attention/weave/resonance are charged.
|
|
100
|
+
const rec = meter
|
|
101
|
+
? await meter.time("recognise", async () => recognise(ctx, query))
|
|
102
|
+
: recognise(ctx, query);
|
|
96
103
|
// Phase 1: collect computed spans from mechanisms that implement parse()
|
|
97
|
-
const computed =
|
|
104
|
+
const computed = meter
|
|
105
|
+
? await meter.time("collectComputed", () => collectComputed(ctx, mechanisms, query))
|
|
106
|
+
: await collectComputed(ctx, mechanisms, query);
|
|
98
107
|
if (computed.length > 0) {
|
|
99
108
|
ctx.trace?.step("computeExtensions", [rItem(query, "query")], computed.map((u) => rItem(query.subarray(u.i, u.j), "operand", undefined, [u.i, u.j])), `extensions recognised and evaluated ${computed.length} computation(s)`);
|
|
100
109
|
for (const u of computed) {
|
|
@@ -107,6 +116,9 @@ export async function think(ctx, query, mechs) {
|
|
|
107
116
|
// method on Precomputed, first-touched by whichever mechanism's floor
|
|
108
117
|
// survives its cheap gates and the worthRunning check. A query no
|
|
109
118
|
// mechanism climbs for (e.g. one an extension decided) never climbs.
|
|
119
|
+
// NOT phased: the constructor itself is trivial (it only derives `k`), so a
|
|
120
|
+
// phase here would add a zero-work entry to every profiled report — the meter
|
|
121
|
+
// attributes WORK (§2.14); the trace already represents structure.
|
|
110
122
|
const pre = new Precomputed(ctx, query, rec, computed, ctx._edgeGuide);
|
|
111
123
|
const grade = (w) => Math.floor(w / STEP);
|
|
112
124
|
const unaccounted = (spans) => unexplainedSpans(query.length, spans)
|
|
@@ -155,7 +167,7 @@ export async function think(ctx, query, mechs) {
|
|
|
155
167
|
// Per-mechanism accounting (src/meter.ts). The market's whole premise is
|
|
156
168
|
// that mechanisms compete on one cost scale — so the profiling read-out is
|
|
157
169
|
// also per-mechanism, uniformly: the loop never asks which one it holds.
|
|
158
|
-
|
|
170
|
+
let regimeReported = false;
|
|
159
171
|
for (const mech of mechanisms) {
|
|
160
172
|
const floor = meter
|
|
161
173
|
? await meter.time(`${mech.name}.floor`, () => mech.floor(ctx, query, pre, worthRunning))
|
|
@@ -192,6 +204,42 @@ export async function think(ctx, query, mechs) {
|
|
|
192
204
|
scaffolding: r.scaffolding,
|
|
193
205
|
});
|
|
194
206
|
}
|
|
207
|
+
// REGIME PREDICTION (R8) — observational only. After the FIRST mechanism
|
|
208
|
+
// runs (cover, which §2.6 places first and floors at 0), the market's
|
|
209
|
+
// outcome is already determined: the consensus climb runs exactly when
|
|
210
|
+
// `worthRunning(2 * STEP)` is true — CAST (floor 2·STEP) is the cheapest
|
|
211
|
+
// mechanism that first-touches it, so an incumbent at or below grade 2
|
|
212
|
+
// prunes CAST and, with it, confluence (3·STEP) and extraction
|
|
213
|
+
// (CONCEPT+STEP) (retrieval); anything above — or no incumbent — runs the
|
|
214
|
+
// full market and the climb (composition). The predicate is
|
|
215
|
+
// `worthRunning`, the same function the loop just used — nothing is
|
|
216
|
+
// computed here that the engine had not already computed, and nothing is
|
|
217
|
+
// read back by inference.
|
|
218
|
+
if (!regimeReported) {
|
|
219
|
+
regimeReported = true;
|
|
220
|
+
const climbFloorGrade = grade(2 * STEP);
|
|
221
|
+
// TS narrows `best` to null in the outer flow (it cannot see the closure
|
|
222
|
+
// assignments in `consider`) — cast back, the same read-back as `decided`
|
|
223
|
+
// below.
|
|
224
|
+
const incumbent = best;
|
|
225
|
+
const incumbentGrade = incumbent === null
|
|
226
|
+
? null
|
|
227
|
+
: grade(incumbent.weight);
|
|
228
|
+
const regime = worthRunning(2 * STEP)
|
|
229
|
+
? "composition"
|
|
230
|
+
: "retrieval";
|
|
231
|
+
ctx.trace?.step("regimePrediction", [rItem(query, "query")], [], regime === "retrieval"
|
|
232
|
+
? `retrieval regime — incumbent grade ${incumbentGrade} ≤ climb floor ${climbFloorGrade}, so no composition mechanism runs; ` +
|
|
233
|
+
`the consensus climb will not run`
|
|
234
|
+
: `composition regime — ${incumbentGrade === null
|
|
235
|
+
? "no incumbent (nothing grounded)"
|
|
236
|
+
: `incumbent grade ${incumbentGrade}`} above climb floor ${climbFloorGrade}, so the full market and climb run`, undefined, {
|
|
237
|
+
version: 1,
|
|
238
|
+
regime,
|
|
239
|
+
incumbentGrade,
|
|
240
|
+
climbFloorGrade,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
195
243
|
}
|
|
196
244
|
// (TS cannot see the closure assignments into `best` and narrows it to its
|
|
197
245
|
// initial null, so the read-back needs the assertion.)
|
|
@@ -11,7 +11,12 @@ import type { MindContext, Recognition, Segment } from "./types.js";
|
|
|
11
11
|
* query's own cut cannot, and records sub-leaf boundaries as `splits`.
|
|
12
12
|
*
|
|
13
13
|
* Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
|
|
14
|
-
|
|
14
|
+
/** Decompose `bytes` into the learnt forms it contains. `trimmed` skips the
|
|
15
|
+
* edge-trim fallbacks (which recover misaligned FRAGMENTS) — for callers whose
|
|
16
|
+
* own gate rejects fragments anyway (the pivot), so the O(n·W²) trim search is
|
|
17
|
+
* paid only where its output can be used. Byte-identical for every caller
|
|
18
|
+
* that keeps only top-level forms. */
|
|
19
|
+
export declare function recognise(ctx: MindContext, bytes: Uint8Array, trimmed?: boolean): Recognition;
|
|
15
20
|
/** Segment bytes using the geometry's own groupings — leaf-parent
|
|
16
21
|
* nodes from the perceived tree, with consecutive bare leaves merged
|
|
17
22
|
* into one segment. Each segment's gist is perceived from its bytes
|
|
@@ -22,7 +22,12 @@ import { isChunk } from "../sema.js";
|
|
|
22
22
|
* query's own cut cannot, and records sub-leaf boundaries as `splits`.
|
|
23
23
|
*
|
|
24
24
|
* Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
|
|
25
|
-
|
|
25
|
+
/** Decompose `bytes` into the learnt forms it contains. `trimmed` skips the
|
|
26
|
+
* edge-trim fallbacks (which recover misaligned FRAGMENTS) — for callers whose
|
|
27
|
+
* own gate rejects fragments anyway (the pivot), so the O(n·W²) trim search is
|
|
28
|
+
* paid only where its output can be used. Byte-identical for every caller
|
|
29
|
+
* that keeps only top-level forms. */
|
|
30
|
+
export function recognise(ctx, bytes, trimmed = false) {
|
|
26
31
|
// Content-keyed memo — works for both single-turn respond() and multi-turn
|
|
27
32
|
// respondTurn() (where the map persists across calls). ALWAYS consulted,
|
|
28
33
|
// regardless of tracing — matching perceive()'s own memo, which carries no
|
|
@@ -62,7 +67,7 @@ export function recognise(ctx, bytes) {
|
|
|
62
67
|
// not silent), so it is emitted here directly rather than only inside
|
|
63
68
|
// recogniseImpl.
|
|
64
69
|
if (ctx.recogniseMemo) {
|
|
65
|
-
const key = latin1Key(bytes);
|
|
70
|
+
const key = (trimmed ? "t" : "f") + latin1Key(bytes);
|
|
66
71
|
const hit = ctx.recogniseMemo.get(key);
|
|
67
72
|
if (hit !== undefined) {
|
|
68
73
|
if (ctx.meter)
|
|
@@ -74,13 +79,13 @@ export function recognise(ctx, bytes) {
|
|
|
74
79
|
`lead somewhere (over ${hit.leaves.length} perceived leaves) [cached]`);
|
|
75
80
|
return hit;
|
|
76
81
|
}
|
|
77
|
-
const fresh = recogniseImpl(ctx, bytes);
|
|
82
|
+
const fresh = recogniseImpl(ctx, bytes, trimmed);
|
|
78
83
|
ctx.recogniseMemo.set(key, fresh);
|
|
79
84
|
return fresh;
|
|
80
85
|
}
|
|
81
|
-
return recogniseImpl(ctx, bytes);
|
|
86
|
+
return recogniseImpl(ctx, bytes, trimmed);
|
|
82
87
|
}
|
|
83
|
-
function recogniseImpl(ctx, bytes) {
|
|
88
|
+
function recogniseImpl(ctx, bytes, trimmed = false) {
|
|
84
89
|
if (ctx.meter) {
|
|
85
90
|
ctx.meter.recognitions++;
|
|
86
91
|
ctx.meter.recognisedBytes += bytes.length;
|
|
@@ -194,7 +199,7 @@ function recogniseImpl(ctx, bytes) {
|
|
|
194
199
|
// n.kids !== null enforces above) rather than degenerate into
|
|
195
200
|
// single-byte-atom territory, which atomIsHub already governs
|
|
196
201
|
// separately.
|
|
197
|
-
else if (end - start - 1 >= 2) {
|
|
202
|
+
else if (!trimmed && end - start - 1 >= 2) {
|
|
198
203
|
// The chunk's own boundary is drawn by content geometry, not by
|
|
199
204
|
// any notion of "form" — it can include one edge byte the query's
|
|
200
205
|
// fold happened to attach here that the trained span never had
|
|
@@ -9,7 +9,7 @@ import { mergeThreshold } from "../geometry.js";
|
|
|
9
9
|
import { concat2, concatBytes, indexOf } from "../bytes.js";
|
|
10
10
|
import { gistOf, read, resolve, walkTree } from "./primitives.js";
|
|
11
11
|
import { perceive } from "./primitives.js";
|
|
12
|
-
import {
|
|
12
|
+
import { argmaxCosine, candidateGist, hubBound } from "./traverse.js";
|
|
13
13
|
import { cachedRead, junctionContainers, junctionSynonyms, walkCache, } from "./junction.js";
|
|
14
14
|
import { recognise } from "./recognition.js";
|
|
15
15
|
// ── The bridge — the junction between two adjacent results ──────────────────
|
|
@@ -288,7 +288,13 @@ export async function pivotInto(ctx, answer, consumed, voiced = []) {
|
|
|
288
288
|
for (const c of n.kids)
|
|
289
289
|
queue.push(c); // breadth-first: larger regions first
|
|
290
290
|
}
|
|
291
|
-
|
|
291
|
+
// TRIMMED recognition: the pivot's own filter below rejects fragments
|
|
292
|
+
// (`hasParents || hasContainers → -Infinity`), and recognition's edge-trim
|
|
293
|
+
// fallbacks exist to find exactly those misaligned FRAGMENTS. Skipping them
|
|
294
|
+
// (the structural pass + canonResolve still run) is byte-identical for every
|
|
295
|
+
// pivot — the fallbacks' output is discarded by the filter — and halves the
|
|
296
|
+
// O(n·W²) recognition of a long answer (measured: 36KB recognise 4.0s → 2.0s).
|
|
297
|
+
const rec = recognise(ctx, answer, true);
|
|
292
298
|
for (const s of rec.sites) {
|
|
293
299
|
if (!consumed.has(s.payload) && ctx.store.hasNext(s.payload)) {
|
|
294
300
|
scored.set(s.payload, Math.max(scored.get(s.payload) ?? 0, 1));
|
|
@@ -296,7 +302,26 @@ export async function pivotInto(ctx, answer, consumed, voiced = []) {
|
|
|
296
302
|
}
|
|
297
303
|
// Byte containment, longest wins — the answer literally contains the
|
|
298
304
|
// pivot's bytes, and the biggest well-evidenced span is the real pivot.
|
|
299
|
-
|
|
305
|
+
//
|
|
306
|
+
// REAL SATURATION, not a hard cap: the score IS the candidate's byte
|
|
307
|
+
// length, so the scan is DECIDED the moment the first candidate that passes
|
|
308
|
+
// every filter is found in DESCENDING length order — a shorter candidate can
|
|
309
|
+
// never outscore it. `contentLen` (the prefix-capped length read, §2.8) is
|
|
310
|
+
// the cheap ordering key, and the first-inserted tie-break is made explicit
|
|
311
|
+
// (`a.index - b.index`) so equal lengths keep `scored`'s insertion order —
|
|
312
|
+
// exactly the tie argmaxBy(strict) used to keep. The bytes of at most ONE
|
|
313
|
+
// winning candidate are read; every shorter candidate the probes proposed is
|
|
314
|
+
// skipped without reconstruction, where the old argmax read them all.
|
|
315
|
+
const ranked = [...scored.keys()]
|
|
316
|
+
.map((id, index) => ({
|
|
317
|
+
id,
|
|
318
|
+
index,
|
|
319
|
+
len: ctx.store.contentLen(id, answer.length + 1),
|
|
320
|
+
}))
|
|
321
|
+
.sort((a, b) => b.len - a.len || a.index - b.index);
|
|
322
|
+
let pivotId = null;
|
|
323
|
+
for (const c of ranked) {
|
|
324
|
+
const id = c.id;
|
|
300
325
|
// A PIVOT MUST BE A THING THE CORPUS DEPOSITED, NOT A PIECE OF ONE.
|
|
301
326
|
// "Longest wins" ranks candidates but never asks whether the winner is
|
|
302
327
|
// an entity at all, and by the time a chain reaches here `consumeAll`
|
|
@@ -330,18 +355,28 @@ export async function pivotInto(ctx, answer, consumed, voiced = []) {
|
|
|
330
355
|
// what `parents`/`containers` record. Reasoning steps THROUGH a fact;
|
|
331
356
|
// a span that was never a fact on its own is not one to step through.
|
|
332
357
|
// No constant enters — it is a structural predicate, not a threshold.
|
|
333
|
-
if (ctx.store.hasParents(id) || ctx.store.hasContainers(id))
|
|
334
|
-
|
|
335
|
-
|
|
358
|
+
if (ctx.store.hasParents(id) || ctx.store.hasContainers(id))
|
|
359
|
+
continue;
|
|
360
|
+
// A candidate whose bytes are LONGER than the answer cannot be a
|
|
361
|
+
// substring of it — `indexOf` would return −1 regardless. Prune by
|
|
362
|
+
// length BEFORE reconstructing the bytes: `read` is an UNCAPPED read
|
|
363
|
+
// (AGENTS §2.8), and a resonated context far longer than the answer is
|
|
364
|
+
// exactly the candidate that makes it cost a whole deposit's worth of
|
|
365
|
+
// reconstruction for a containment test that must fail. `contentLen`
|
|
366
|
+
// with the `answer.length + 1` cap is the prefix-capped length read the
|
|
367
|
+
// same contract prescribes; the prune is byte-identical to the old
|
|
368
|
+
// `indexOf` miss (it returns −1 for a needle longer than the haystack).
|
|
369
|
+
if (c.len > answer.length)
|
|
370
|
+
continue;
|
|
336
371
|
const bytes = read(ctx, id);
|
|
337
372
|
if (indexOf(answer, bytes, 0) < 0)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
return
|
|
373
|
+
continue;
|
|
374
|
+
if (voiced.some((v) => indexOf(v, bytes, 0) >= 0))
|
|
375
|
+
continue;
|
|
376
|
+
pivotId = id;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
return pivotId;
|
|
345
380
|
}
|
|
346
381
|
/** Which of the given labelled forms a span MEANS — generic resonance over
|
|
347
382
|
* perceived gists. Each anchor form's gist is memoised; the span's gist
|
package/dist/src/store.js
CHANGED
|
@@ -723,7 +723,28 @@ export class AbstractStore {
|
|
|
723
723
|
parts.push(child);
|
|
724
724
|
got += child.length;
|
|
725
725
|
}
|
|
726
|
-
|
|
726
|
+
const out = concat(parts);
|
|
727
|
+
// Cache the BRANCH too, not just the leaf above. Reconstruction is a pure
|
|
728
|
+
// function of the store, so this is a transparent cache in the strict sense
|
|
729
|
+
// — an eviction costs a re-walk and nothing else — which is exactly what
|
|
730
|
+
// `_bytesCache`'s "smallest"/"clock" configuration is for.
|
|
731
|
+
//
|
|
732
|
+
// Caching only leaves made every branch re-walk its whole subtree on every
|
|
733
|
+
// request, and the DAG is hash-consed, so the same children recur under many
|
|
734
|
+
// parents. Measured on the 18.9M-node store, ONE 1,314-byte query:
|
|
735
|
+
// 20,021,474 `_prefix` calls over 469,083 distinct ids (42.7x reuse) to
|
|
736
|
+
// produce 87,789 results — 97.7% of the work re-derived bytes it had already
|
|
737
|
+
// built. One single-byte leaf was reconstructed 2,599,984 times. Measuring
|
|
738
|
+
// reuse at the TOP level only shows 1.1x and hides all of it.
|
|
739
|
+
//
|
|
740
|
+
// Only a COMPLETE reconstruction may be cached: `_prefix` is also called
|
|
741
|
+
// with a cap, and a truncated prefix stored under `id` would be served as
|
|
742
|
+
// if it were the node's whole content by the `_bytesCache` hit above.
|
|
743
|
+
// `got < maxLen` is that proof — the walk ran out of children before it ran
|
|
744
|
+
// out of budget, so nothing below was truncated either.
|
|
745
|
+
if (got < maxLen)
|
|
746
|
+
this._bytesCache.set(id, out);
|
|
747
|
+
return out;
|
|
727
748
|
}
|
|
728
749
|
contentLen(id, cap = Infinity) {
|
|
729
750
|
if (this.meter)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// train_base/cache.ts — the durable disk cache and the download sink.
|
|
2
|
+
//
|
|
3
|
+
// This is the ONE place the trainer needs Node rather than the web platform:
|
|
4
|
+
// every other byte in the pipeline moves through fetch, WHATWG streams,
|
|
5
|
+
// DecompressionStream, TextDecoderStream and Blob, but writing a file is the
|
|
6
|
+
// single capability the web platform does not expose. So the sink below wraps a
|
|
7
|
+
// raw fs descriptor, and nothing else here does.
|
|
8
|
+
//
|
|
9
|
+
// Two invariants the rest of the trainer relies on:
|
|
10
|
+
// • ATOMIC — a download streams to "<file>.part", is fsync'd, then renamed
|
|
11
|
+
// into place. A file at its final path is, by construction, complete, so an
|
|
12
|
+
// interrupted download can never be mistaken for a cached one.
|
|
13
|
+
// • BOUNDED — a download blocks under the MAX_CACHE_GB ceiling, and a fully
|
|
14
|
+
// processed file is deleted by its caller immediately.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
CACHE_DIR,
|
|
18
|
+
CACHE_WAIT_MS,
|
|
19
|
+
MAX_CACHE_BYTES,
|
|
20
|
+
PART_SUFFIX,
|
|
21
|
+
} from "./config.js";
|
|
22
|
+
import { httpError, retry, waitMs } from "./http.js";
|
|
23
|
+
import {
|
|
24
|
+
closeSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
fsyncSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
openSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
statSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
writeSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { basename, join } from "node:path";
|
|
36
|
+
|
|
37
|
+
/** Delete every orphaned "<file>.part" in the cache, returning how many were
|
|
38
|
+
* removed and the bytes they held.
|
|
39
|
+
*
|
|
40
|
+
* A .part file at rest is by definition the debris of a download that never
|
|
41
|
+
* finished — the rename that promotes one is the last step of `downloadFile`,
|
|
42
|
+
* so a live .part exists only while THIS process is writing it. Sweeping at
|
|
43
|
+
* startup is therefore safe, and it is load-bearing rather than cosmetic:
|
|
44
|
+
* `cacheSize` deliberately counts .part files (an in-flight download really
|
|
45
|
+
* does occupy the disk), so debris left by a killed run consumes ceiling
|
|
46
|
+
* budget that nothing would ever free, and `ensureCacheRoom` would wait for
|
|
47
|
+
* room that cannot appear.
|
|
48
|
+
*
|
|
49
|
+
* The one assumption is that a cache directory belongs to ONE run at a time.
|
|
50
|
+
* That was already true — two trainers sharing CACHE_DIR would write the same
|
|
51
|
+
* .part path — so this adds no constraint that did not exist. */
|
|
52
|
+
export function sweepPartials(): { files: number; bytes: number } {
|
|
53
|
+
const out = { files: 0, bytes: 0 };
|
|
54
|
+
if (!existsSync(CACHE_DIR)) return out;
|
|
55
|
+
for (const name of readdirSync(CACHE_DIR)) {
|
|
56
|
+
if (!name.endsWith(PART_SUFFIX)) continue;
|
|
57
|
+
const p = join(CACHE_DIR, name);
|
|
58
|
+
try {
|
|
59
|
+
const size = statSync(p).size;
|
|
60
|
+
unlinkSync(p);
|
|
61
|
+
out.files++;
|
|
62
|
+
out.bytes += size;
|
|
63
|
+
} catch { /* raced with another delete — nothing to reclaim */ }
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Total bytes currently held in the cache directory — INCLUDING any .part
|
|
69
|
+
* file, because an in-flight download occupies the disk like any other file.
|
|
70
|
+
* Orphaned ones are removed by {@link sweepPartials} at startup. */
|
|
71
|
+
export function cacheSize(): number {
|
|
72
|
+
if (!existsSync(CACHE_DIR)) return 0;
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const name of readdirSync(CACHE_DIR)) {
|
|
75
|
+
try {
|
|
76
|
+
total += statSync(join(CACHE_DIR, name)).size;
|
|
77
|
+
} catch { /* raced with a delete */ }
|
|
78
|
+
}
|
|
79
|
+
return total;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Block until there is room for a file of `fileBytes` under the ceiling.
|
|
83
|
+
* A single file larger than the whole ceiling can never "fit", so we let it
|
|
84
|
+
* through (it is deleted right after processing) rather than wait forever. */
|
|
85
|
+
export async function ensureCacheRoom(
|
|
86
|
+
fileBytes: number,
|
|
87
|
+
signal: AbortSignal,
|
|
88
|
+
warn?: (msg: string) => void,
|
|
89
|
+
maxWaitMs = CACHE_WAIT_MS,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
92
|
+
if (fileBytes >= MAX_CACHE_BYTES) return;
|
|
93
|
+
let warned = false;
|
|
94
|
+
const until = Date.now() + maxWaitMs;
|
|
95
|
+
// Stop waiting the moment a shutdown is requested — the abort signal unblocks
|
|
96
|
+
// a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
|
|
97
|
+
while (!signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES) {
|
|
98
|
+
// BOUNDED. Room appears when this run consumes and deletes a file, so a
|
|
99
|
+
// cache already over the ceiling with nothing left to consume — stale files
|
|
100
|
+
// from another run, a ceiling set below one corpus — would otherwise wait
|
|
101
|
+
// for room that cannot arrive, forever, after a single warning line.
|
|
102
|
+
if (Date.now() >= until) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`cache still full after ${Math.round(maxWaitMs / 60_000)} min ` +
|
|
105
|
+
`(${(cacheSize() / 1e9).toFixed(1)} GB of a ` +
|
|
106
|
+
`${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling) — raise ` +
|
|
107
|
+
`MAX_CACHE_GB or clear ${CACHE_DIR}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!warned) {
|
|
111
|
+
warn?.(
|
|
112
|
+
`cache at ${
|
|
113
|
+
(MAX_CACHE_BYTES / 1e9).toFixed(0)
|
|
114
|
+
} GB ceiling — waiting for room…`,
|
|
115
|
+
);
|
|
116
|
+
warned = true;
|
|
117
|
+
}
|
|
118
|
+
await waitMs(5_000, signal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DownloadOptions {
|
|
123
|
+
signal: AbortSignal;
|
|
124
|
+
tries: number;
|
|
125
|
+
onFail?: (attempt: number, err: Error) => void;
|
|
126
|
+
onProgress?: (done: number, total: number) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stream `url` to `destPath`, atomically and with backpressure. */
|
|
130
|
+
export async function downloadFile(
|
|
131
|
+
url: string,
|
|
132
|
+
destPath: string,
|
|
133
|
+
opts: DownloadOptions,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const partPath = destPath + PART_SUFFIX;
|
|
136
|
+
await retry(
|
|
137
|
+
`download ${basename(destPath)}`,
|
|
138
|
+
async () => {
|
|
139
|
+
// Abort promptly on shutdown rather than waiting out a slow socket.
|
|
140
|
+
if (opts.signal.aborted) {
|
|
141
|
+
const e: Error & { fatal?: boolean } = new Error("aborted");
|
|
142
|
+
e.fatal = true;
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
const res = await fetch(url, { signal: opts.signal });
|
|
146
|
+
if (!res.ok) throw httpError(res);
|
|
147
|
+
if (!res.body) throw new Error("empty response body");
|
|
148
|
+
|
|
149
|
+
// `content-length` describes the bytes ON THE WIRE. When the server
|
|
150
|
+
// applied a content-coding, fetch hands us the DECODED body, so the
|
|
151
|
+
// header no longer describes what gets written to disk and the integrity
|
|
152
|
+
// guard below must not use it. Measured: raw.githubusercontent.com sends
|
|
153
|
+
// `content-encoding: gzip` with content-length 110,928 for a file that
|
|
154
|
+
// decodes to 1,607,931 bytes — a size check against that rejects every
|
|
155
|
+
// healthy download. (The bug stayed latent because Hugging Face sends
|
|
156
|
+
// `content-encoding: br` and NO content-length, leaving total = 0, which
|
|
157
|
+
// already disables the guard.)
|
|
158
|
+
const encoding = (res.headers.get("content-encoding") ?? "").trim()
|
|
159
|
+
.toLowerCase();
|
|
160
|
+
const decoded = encoding !== "" && encoding !== "identity";
|
|
161
|
+
const total = decoded
|
|
162
|
+
? 0
|
|
163
|
+
: Number(res.headers.get("content-length")) || 0;
|
|
164
|
+
let done = 0;
|
|
165
|
+
|
|
166
|
+
// Stream straight to a ".part" sibling using pure WHATWG streams. A
|
|
167
|
+
// TransformStream meters progress; pipeTo into a WritableStream gives REAL
|
|
168
|
+
// backpressure natively — the sink's write() returns a promise the
|
|
169
|
+
// readable side awaits, so a fast server can never outrun the disk (no
|
|
170
|
+
// whole-file heap buffering). The sink wraps a single raw fs descriptor
|
|
171
|
+
// (the one capability the web platform lacks); writing to disk is the only
|
|
172
|
+
// Node operation in the whole pipeline. The final, valid file only ever
|
|
173
|
+
// appears via the atomic rename below, so a crash mid-transfer can never
|
|
174
|
+
// leave a truncated file at the real path.
|
|
175
|
+
const meter = new TransformStream<Uint8Array, Uint8Array>({
|
|
176
|
+
transform(chunk, controller) {
|
|
177
|
+
done += chunk.length;
|
|
178
|
+
opts.onProgress?.(done, total);
|
|
179
|
+
controller.enqueue(chunk);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const fd = openSync(partPath, "w");
|
|
184
|
+
let closed = false;
|
|
185
|
+
const closeFd = () => {
|
|
186
|
+
if (closed) return;
|
|
187
|
+
closed = true;
|
|
188
|
+
try {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
} catch { /* already closed */ }
|
|
191
|
+
};
|
|
192
|
+
const sink = new WritableStream<Uint8Array>({
|
|
193
|
+
write(chunk) {
|
|
194
|
+
// writeSync drains the whole chunk before returning, so the readable
|
|
195
|
+
// side is paused for exactly as long as the disk needs — backpressure.
|
|
196
|
+
let off = 0;
|
|
197
|
+
while (off < chunk.length) {
|
|
198
|
+
off += writeSync(fd, chunk, off, chunk.length - off);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
close() {
|
|
202
|
+
fsyncSync(fd); // durable bytes before the rename promotes them
|
|
203
|
+
closeFd();
|
|
204
|
+
},
|
|
205
|
+
abort() {
|
|
206
|
+
closeFd();
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await res.body.pipeThrough(meter).pipeTo(sink, {
|
|
212
|
+
signal: opts.signal,
|
|
213
|
+
});
|
|
214
|
+
} catch (e) {
|
|
215
|
+
// pipeTo's abort() ran the sink's abort() (closing the descriptor); if
|
|
216
|
+
// it didn't (a non-abort throw), make sure the descriptor is not leaked.
|
|
217
|
+
closeFd();
|
|
218
|
+
try {
|
|
219
|
+
unlinkSync(partPath);
|
|
220
|
+
} catch { /* best effort */ }
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Optional integrity guard: when the server advertised a size FOR THE
|
|
225
|
+
// BYTES WE WRITE (see the content-encoding note above — `total` is 0 for
|
|
226
|
+
// a decoded body, which disables this), a complete file must match it. A
|
|
227
|
+
// short read (silent truncation) is retried rather than promoted, so the
|
|
228
|
+
// parser never sees a partial file.
|
|
229
|
+
try {
|
|
230
|
+
const got = statSync(partPath).size;
|
|
231
|
+
if (total > 0 && got !== total) {
|
|
232
|
+
try {
|
|
233
|
+
unlinkSync(partPath);
|
|
234
|
+
} catch { /* best effort */ }
|
|
235
|
+
throw new Error(`size mismatch: got ${got}, expected ${total}`);
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e instanceof Error && e.message.startsWith("size mismatch")) {
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
// statSync failure is non-fatal here; the rename below will surface it.
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Atomic publish: rename is atomic within a filesystem, so the final path
|
|
245
|
+
// flips from "absent" to "complete" in one step — never an in-between.
|
|
246
|
+
renameSync(partPath, destPath);
|
|
247
|
+
},
|
|
248
|
+
opts.tries,
|
|
249
|
+
{ signal: opts.signal, onFail: opts.onFail },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// train_base/config.ts — RUN-LEVEL configuration, all from the environment.
|
|
2
|
+
//
|
|
3
|
+
// Only knobs that describe the RUN live here: the store, the checkpoint cadence,
|
|
4
|
+
// the cache ceiling, the read budgets, the caps. A knob that describes ONE
|
|
5
|
+
// CORPUS (which pairs of SmolSent, how many SODA dialogues, how long an Aya
|
|
6
|
+
// field may be) belongs next to that corpus's adapter, together with the
|
|
7
|
+
// evidence that fixed its default — see AGENTS.md §2.16: a comment carries the
|
|
8
|
+
// constraint, and a constraint is only readable beside the code it constrains.
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Read an environment variable, or `d` when it is unset. */
|
|
13
|
+
export const env = (k: string, d: string): string => process.env[k] ?? d;
|
|
14
|
+
|
|
15
|
+
export const DB_PATH = env("DB_PATH", "sema"); // → {DB_PATH}.sqlite
|
|
16
|
+
export const D = Number(env("D", "1024"));
|
|
17
|
+
export const SEED = Number(env("SEED", "7"));
|
|
18
|
+
|
|
19
|
+
// Checkpoint cadence is measured in LEARNED CONTENT, not deposits: a snapshot
|
|
20
|
+
// every CHECKPOINT_MB megabytes of trained UTF-8 content (decimal MB, matching
|
|
21
|
+
// the bytes() helper). A floor of 1 MB: a zero/NaN value must not make every
|
|
22
|
+
// deposit checkpoint, nor silently disable checkpointing. The tail (a run that
|
|
23
|
+
// learns less than one interval, or the remainder past the last interval) is
|
|
24
|
+
// always saved by finish() at exit — a complete point.
|
|
25
|
+
export const CHECKPOINT_BYTES = Math.max(
|
|
26
|
+
1_000_000,
|
|
27
|
+
Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Target size of ONE materialised Parquet read, in uncompressed source bytes.
|
|
31
|
+
// A row-GROUP is a layout choice made by whoever wrote the file, not a memory
|
|
32
|
+
// budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
|
|
33
|
+
// group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
|
|
34
|
+
// (666 MB). Reading "exactly one row-group" is therefore safe for the first and
|
|
35
|
+
// fatal for the others, so reads are sized in BYTES instead — see
|
|
36
|
+
// `parquetBatchRows`. Materialised JS objects cost several times their source
|
|
37
|
+
// bytes, hence a default well under available memory.
|
|
38
|
+
export const PARQUET_BATCH_BYTES = Math.max(
|
|
39
|
+
1_000_000,
|
|
40
|
+
Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir
|
|
44
|
+
export const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
|
|
45
|
+
export const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
|
|
46
|
+
export const PROGRESS_MS = Number(env("PROGRESS_MS", "250")); // panel cadence
|
|
47
|
+
|
|
48
|
+
// Index maintenance at checkpoints: compact (remove garbage), repair (fill
|
|
49
|
+
// gaps), then refresh the canonical-form index (equivalence-class resolution —
|
|
50
|
+
// src/canon.ts). All three are idempotent batch operations (the canon build is
|
|
51
|
+
// additionally incremental via the store's `canon.upto` cursor);
|
|
52
|
+
// INDEX_MAINTENANCE=0 disables.
|
|
53
|
+
export const INDEX_MAINTENANCE = env("INDEX_MAINTENANCE", "1") !== "0";
|
|
54
|
+
export const DOWNLOAD_TRIES = 5;
|
|
55
|
+
|
|
56
|
+
// In-progress downloads are written to a sibling "<dest>.part" and atomically
|
|
57
|
+
// renamed into place only after the bytes are fully flushed to disk. The cache
|
|
58
|
+
// invariant is therefore absolute: a file at its final path is, by definition,
|
|
59
|
+
// complete. Partial transfers (a crash, a kill, a dropped socket) leave only a
|
|
60
|
+
// .part file, which is never fed to the parser and is swept at startup by
|
|
61
|
+
// cache.ts's sweepPartials() — without which the debris would consume cache
|
|
62
|
+
// ceiling that nothing frees.
|
|
63
|
+
export const PART_SUFFIX = ".part";
|
|
64
|
+
|
|
65
|
+
// The checkpoint recall is a best-effort diagnostic — it must NEVER stall
|
|
66
|
+
// training. We bound it so a slow/large store cannot freeze the deposit loop.
|
|
67
|
+
export const INFER_TIMEOUT_MS = Number(env("INFER_TIMEOUT_MS", "15000"));
|
|
68
|
+
|
|
69
|
+
// How long the run may make NO progress before it gives up and exits non-zero.
|
|
70
|
+
//
|
|
71
|
+
// A long training run's worst failure is not a crash — a crash resumes. It is a
|
|
72
|
+
// HANG: the uncaught-exception handler deliberately swallows dropped-connection
|
|
73
|
+
// errors so a long run survives them, and the keep-alive timer deliberately
|
|
74
|
+
// holds the process open; together, an error that escapes and leaves an await
|
|
75
|
+
// unsettled produces a live process that will never do anything again. No error,
|
|
76
|
+
// no exit, and a supervisor that sees a healthy pid. Exiting instead turns that
|
|
77
|
+
// into a resume, which costs at most the work since the last checkpoint.
|
|
78
|
+
//
|
|
79
|
+
// "Progress" is any deposit, downloaded chunk, or rate-limit wait; time inside
|
|
80
|
+
// index maintenance and the checkpoint recall does not count against it, since
|
|
81
|
+
// those legitimately deposit nothing. Generous by default — this is a
|
|
82
|
+
// last-resort backstop, not a latency budget. 0 disables it.
|
|
83
|
+
export const STALL_MS = Math.max(
|
|
84
|
+
0,
|
|
85
|
+
Math.floor(Number(env("STALL_MIN", "15")) * 60_000) || 900_000,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// How long a download may wait for room under the cache ceiling before failing
|
|
89
|
+
// the unit instead of waiting forever. The wait exists so a bounded cache can
|
|
90
|
+
// throttle a fast source; it is not meant to outlast the run. The unit stays
|
|
91
|
+
// resumable, so a genuine ceiling problem costs a retry, not the corpus.
|
|
92
|
+
export const CACHE_WAIT_MS = Math.max(
|
|
93
|
+
60_000,
|
|
94
|
+
Math.floor(Number(env("CACHE_WAIT_MIN", "10")) * 60_000) || 600_000,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// The vector indices' memory knob (MiB) — each index's SQLite page cache.
|
|
98
|
+
// The IVF index routes inserts through a RAM-resident pivot table and
|
|
99
|
+
// appends to chunk blobs, so this cache mostly serves query-time cluster
|
|
100
|
+
// scans; 256 MiB comfortably covers the probed working set of a trained
|
|
101
|
+
// store. Override with VECTOR_CACHE_MB (64 is the library default).
|
|
102
|
+
export const VECTOR_CACHE_MB = Math.max(
|
|
103
|
+
0,
|
|
104
|
+
Number(env("VECTOR_CACHE_MB", "256")),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Page cache for the MAIN DAG database (node/kid/edge/contain tables).
|
|
108
|
+
// Training issues millions of content-addressed point probes per session
|
|
109
|
+
// against a GB-scale file; the library default (64 MiB) is sized for a
|
|
110
|
+
// small machine — a training box affords more. Override with
|
|
111
|
+
// SQLITE_CACHE_MB.
|
|
112
|
+
export const SQLITE_CACHE_MB = Math.max(
|
|
113
|
+
0,
|
|
114
|
+
Number(env("SQLITE_CACHE_MB", "256")),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Optional ceiling on how much LEARNED CONTENT to train, in megabytes (decimal,
|
|
118
|
+
// like CHECKPOINT_MB). Default Infinity = unbounded. The cap is checked against
|
|
119
|
+
// trainedContentBytes after each deposit, so a run stops at the first item that
|
|
120
|
+
// carries the running total to/past the ceiling (that item is still counted).
|
|
121
|
+
export const MAX_MB = Number(env("MAX_MB", "Infinity"));
|
|
122
|
+
if (isNaN(MAX_MB) || MAX_MB < 0) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`fatal: MAX_MB must be a non-negative number or "Infinity"\n`,
|
|
125
|
+
);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
export const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
|