@hviana/sema 0.7.2 → 0.7.5

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 (43) hide show
  1. package/AGENTS.md +95 -843
  2. package/README.md +11 -11
  3. package/dist/src/mind/mind.js +14 -0
  4. package/dist/src/store-sqlite.js +17 -0
  5. package/dist/src/store.d.ts +51 -4
  6. package/dist/src/store.js +81 -14
  7. package/docs/INDEX.md +71 -0
  8. package/docs/INVARIANTS.md +19 -0
  9. package/docs/architecture/bounded-reads.md +85 -0
  10. package/docs/architecture/caches.md +89 -0
  11. package/docs/architecture/commonality.md +45 -0
  12. package/docs/architecture/cost-model.md +71 -0
  13. package/docs/architecture/determinism.md +73 -0
  14. package/docs/architecture/exact-vs-approximate.md +47 -0
  15. package/docs/architecture/factored-machinery.md +28 -0
  16. package/docs/architecture/fold-contract.md +87 -0
  17. package/docs/architecture/halo-sketch.md +99 -0
  18. package/docs/architecture/match-project.md +62 -0
  19. package/docs/architecture/mechanism-market.md +95 -0
  20. package/docs/architecture/memoization.md +96 -0
  21. package/docs/architecture/meter.md +55 -0
  22. package/docs/architecture/saturation.md +92 -0
  23. package/docs/architecture/store.md +79 -0
  24. package/docs/architecture/thresholds.md +79 -0
  25. package/docs/failures/tempting-but-wrong.md +144 -0
  26. package/docs/harness/gates.md +56 -0
  27. package/docs/mechanisms/alu.md +75 -0
  28. package/docs/mechanisms/cast.md +75 -0
  29. package/docs/mechanisms/confluence.md +36 -0
  30. package/docs/mechanisms/cover.md +54 -0
  31. package/docs/mechanisms/extraction.md +53 -0
  32. package/docs/mechanisms/prefix-completion.md +54 -0
  33. package/docs/mechanisms/recall.md +69 -0
  34. package/docs/mechanisms/reference.md +58 -0
  35. package/jsr.json +1 -1
  36. package/package.json +1 -1
  37. package/src/mind/mind.ts +14 -0
  38. package/src/store-sqlite.ts +19 -0
  39. package/src/store.ts +92 -16
  40. package/test/89-completion-recursion.test.mjs +30 -10
  41. package/test/96-bytes-walk-termination.test.mjs +115 -0
  42. package/test/97-store-seed.test.mjs +105 -0
  43. package/HOW_IT_WORKS.md +0 -5836
package/src/store.ts CHANGED
@@ -285,6 +285,17 @@ export class BoundedMap<K, V> {
285
285
  export interface Store {
286
286
  readonly D: number;
287
287
 
288
+ /** The seed the artifact was TRAINED with, recovered from the store's own
289
+ * `train.seed` metadata, or null for a store that was never trained.
290
+ *
291
+ * This is not decoration: the seed feeds the alphabet and the seat keyring
292
+ * (see the Mind constructor), so folding a query under any other seed lands
293
+ * in a different vector space than the one the artifact's nodes were folded
294
+ * into. A Mind opening a trained store MUST adopt this seed unless the
295
+ * caller explicitly overrides it — the same discipline that recovers
296
+ * `train.D` and `geometry.maxGroup` from the metadata. */
297
+ readonly trainSeed: number | null;
298
+
288
299
  /** The work accumulator for the inference call in flight, or null. The
289
300
  * Mind attaches one per profiled response and detaches it after (see
290
301
  * src/meter.ts). A store MUST only ever write to it — no read may reach
@@ -917,6 +928,10 @@ export abstract class AbstractStore implements Store {
917
928
 
918
929
  protected _D: number;
919
930
  protected _maxGroup: number;
931
+ /** `train.seed` recovered by the backend at open, or null when the store was
932
+ * never trained. A backend that omits it simply reports null, which leaves
933
+ * the caller's configured seed in force. */
934
+ protected _trainSeed: number | null = null;
920
935
  protected readonly minHaloMass: number;
921
936
  protected readonly efSearch: number;
922
937
  protected readonly overfetch: number;
@@ -1091,6 +1106,13 @@ export abstract class AbstractStore implements Store {
1091
1106
  return this._D;
1092
1107
  }
1093
1108
 
1109
+ /** The seed the artifact was trained with, recovered from `train.seed` at
1110
+ * open. Null for a store that was never trained. See
1111
+ * {@link Store.trainSeed} for why this must govern inference. */
1112
+ get trainSeed(): number | null {
1113
+ return this._trainSeed;
1114
+ }
1115
+
1094
1116
  /** Await the async initialisation performed by the concrete constructor. */
1095
1117
  protected async _ensureReady(): Promise<void> {
1096
1118
  if (!this._ready) throw new Error("Store: not open");
@@ -1133,9 +1155,6 @@ export abstract class AbstractStore implements Store {
1133
1155
  return rec;
1134
1156
  }
1135
1157
 
1136
- /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1137
- * Iterative post-order on an explicit stack — the call stack never sees the
1138
- * tree depth, so even an adversarial chain of nodes stays safe. */
1139
1158
  /** How many reads hit a MISSING node record this session (a dangling edge
1140
1159
  * or kid id). Zero in a healthy store; a growing count means references
1141
1160
  * outlive their records — the read degrades safely to empty bytes, this
@@ -1147,6 +1166,25 @@ export abstract class AbstractStore implements Store {
1147
1166
  * unprofiled store pays one null check per read and allocates nothing. */
1148
1167
  meter: Meter | null = null;
1149
1168
 
1169
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1170
+ * Iterative post-order on an explicit stack — the call stack never sees the
1171
+ * tree depth, so even an adversarial chain of nodes stays safe.
1172
+ *
1173
+ * TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
1174
+ * consulted only as a warm hint whose hit is immediately promoted into that
1175
+ * map. It used to use `_bytesCache` itself as the memo, which is not a
1176
+ * memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
1177
+ * freshly-resolved small children that the pending parents on the stack are
1178
+ * waiting for. A parent then finds them uncached again, re-pushes them,
1179
+ * they are re-resolved, re-inserted, re-evicted — the loop makes no
1180
+ * progress and never exits. Latent until the cache saturates, then
1181
+ * unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
1182
+ * pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
1183
+ * whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
1184
+ * Because the loop is synchronous, no timer could fire — the trainer's stall
1185
+ * watchdog never got a turn either. A local map resolves each node at most
1186
+ * once per call, so the walk terminates by construction and `_bytesCache`
1187
+ * goes back to being a pure speed hint. */
1150
1188
  bytes(id: NodeId): Uint8Array {
1151
1189
  if (this.meter) {
1152
1190
  this.meter.byteReads++;
@@ -1162,12 +1200,25 @@ export abstract class AbstractStore implements Store {
1162
1200
 
1163
1201
  const stack: NodeId[] = [id];
1164
1202
  const cache = this._bytesCache;
1203
+ // The walk's own memo. Entries are the same shared arrays `_bytesCache`
1204
+ // holds (no extra copy), and it lives exactly as long as this call.
1205
+ const done = new Map<NodeId, Uint8Array>();
1165
1206
 
1166
1207
  while (stack.length > 0) {
1167
1208
  const nid = stack[stack.length - 1]; // peek
1168
1209
 
1169
- // Already resolved by an earlier traversal.
1170
- if (cache.get(nid)) {
1210
+ // Already resolved by this walk — the ONLY authority the readiness test
1211
+ // below trusts, because it cannot be evicted underneath us.
1212
+ if (done.has(nid)) {
1213
+ stack.pop();
1214
+ continue;
1215
+ }
1216
+
1217
+ // Warm hint: a hit is promoted into `done` in the same step, so from
1218
+ // here on the entry is pinned for the rest of the walk.
1219
+ const warm = cache.get(nid);
1220
+ if (warm !== undefined) {
1221
+ done.set(nid, warm);
1171
1222
  stack.pop();
1172
1223
  continue;
1173
1224
  }
@@ -1180,22 +1231,27 @@ export abstract class AbstractStore implements Store {
1180
1231
  // The cache makes the empty read permanent for the session; the
1181
1232
  // counter survives as the visible trace.
1182
1233
  this.danglingReads++;
1234
+ done.set(nid, _ZERO);
1183
1235
  cache.set(nid, _ZERO);
1184
1236
  stack.pop();
1185
1237
  continue;
1186
1238
  }
1187
1239
  if (rec.leaf) {
1188
- cache.set(nid, new Uint8Array(rec.leaf));
1240
+ // COPY before caching: rec.leaf is the node record's own buffer, and
1241
+ // handing it out would let one mutating caller corrupt the record.
1242
+ const leaf = new Uint8Array(rec.leaf);
1243
+ done.set(nid, leaf);
1244
+ cache.set(nid, leaf);
1189
1245
  stack.pop();
1190
1246
  continue;
1191
1247
  }
1192
1248
 
1193
- // Branch — push any uncached children (reverse order so they resolve
1194
- // left-to-right). If every child is already cached, concatenate now.
1249
+ // Branch — push any unresolved children (reverse order so they resolve
1250
+ // left-to-right). If every child is resolved, concatenate now.
1195
1251
  const kids = rec.kids ?? [];
1196
1252
  let ready = true;
1197
1253
  for (let i = kids.length - 1; i >= 0; i--) {
1198
- if (!cache.get(kids[i])) {
1254
+ if (!done.has(kids[i])) {
1199
1255
  stack.push(kids[i]);
1200
1256
  ready = false;
1201
1257
  }
@@ -1203,11 +1259,12 @@ export abstract class AbstractStore implements Store {
1203
1259
  if (!ready) continue;
1204
1260
 
1205
1261
  stack.pop();
1206
- const out = concat(kids.map((k) => cache.get(k)!));
1262
+ const out = concat(kids.map((k) => done.get(k)!));
1263
+ done.set(nid, out);
1207
1264
  cache.set(nid, out);
1208
1265
  }
1209
1266
 
1210
- const out = cache.get(id) ?? _ZERO;
1267
+ const out = done.get(id) ?? _ZERO;
1211
1268
  if (this.meter) this.meter.bytesRead += out.length;
1212
1269
  return out;
1213
1270
  }
@@ -1730,16 +1787,35 @@ export abstract class AbstractStore implements Store {
1730
1787
  * common-prefix / common-suffix trim: whatever remains after both trims is
1731
1788
  * the single differing span (substitution, insertion or deletion), and both
1732
1789
  * remainders must fit the budget. Scattered differences leave a wide
1733
- * middle and are rejected. */
1790
+ * middle and are rejected.
1791
+ *
1792
+ * Every read here is CAPPED (§2.8). It used to open with
1793
+ * `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
1794
+ * full materialising `bytes()` read — on the deposit hot path, and only
1795
+ * then compare lengths. So a candidate the length test was about to reject
1796
+ * had already been reconstructed byte for byte. The LENGTHS decide first
1797
+ * instead, from the `contentLen` memo the interning order has already built
1798
+ * bottom-up, and the target's length is itself read under a cap: a target
1799
+ * longer than `la + W` is rejected without touching one of its bytes.
1800
+ * Same semantics — the old capped `b` read would have produced
1801
+ * `a.length + W + 1` here and failed the very same test — strictly fewer
1802
+ * byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
1803
+ * "complete reconstruction" test true, so the results still cache. */
1734
1804
  private differsByOneWindow(
1735
1805
  kids: NodeId[],
1736
1806
  targetId: NodeId,
1737
1807
  W: number,
1738
1808
  ): boolean {
1739
- const a = concat(
1740
- kids.map((k) => this.bytesPrefix(k, Number.MAX_SAFE_INTEGER)),
1741
- );
1742
- const b = this.bytesPrefix(targetId, a.length + W + 1);
1809
+ const lens = kids.map((k) => this.contentLen(k));
1810
+ let la = 0;
1811
+ for (const n of lens) la += n;
1812
+ const cap = la + W + 1;
1813
+ // `contentLen` under a cap returns a clamped LOWER BOUND once the partial
1814
+ // sum reaches it, so `>= cap` is exactly "longer than la + W".
1815
+ const lb = this.contentLen(targetId, cap);
1816
+ if (lb >= cap || Math.abs(la - lb) > W) return false;
1817
+ const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
1818
+ const b = this.bytesPrefix(targetId, lb + 1);
1743
1819
  if (Math.abs(a.length - b.length) > W) return false;
1744
1820
  const n = Math.min(a.length, b.length);
1745
1821
  let i = 0;
@@ -77,15 +77,35 @@ const mix = (x) => {
77
77
  return (x ^ (x >>> 15)) >>> 0;
78
78
  };
79
79
 
80
- /** Four-word windows of the repo's own English prose. Code fences, inline
81
- * code and link targets are stripped so what is left is language, which is
82
- * where the fragment overlap lives. */
80
+ /** Four-word windows of the repo's own prose, taken from the CODE's comments
81
+ * under src (TypeScript) and test (the suites) — never from documentation. A
82
+ * test that reads docs is coupled to every documentation edit: deleting one
83
+ * root file once took its corpus below the non-vacuity guard and broke every
84
+ * release. Code comments are prose too, and the code is always here.
85
+ *
86
+ * Comment markers, code fences, inline code and link targets are stripped so
87
+ * what is left is language, which is where the fragment overlap lives. */
83
88
  function fragments() {
84
89
  const out = [];
85
- for (const f of readdirSync(REPO).filter((f) => f.endsWith(".md")).sort()) {
86
- let t = readFileSync(join(REPO, f), "utf8");
87
- t = t.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
88
- t = t.replace(/\[[^\]]*\]\([^)]*\)/g, " ");
90
+ const files = [];
91
+ const walk = (dir, exts) => {
92
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
93
+ if (
94
+ e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist"
95
+ ) continue;
96
+ const p = join(dir, e.name);
97
+ if (e.isDirectory()) walk(p, exts);
98
+ else if (exts.some((x) => e.name.endsWith(x))) files.push(p);
99
+ }
100
+ };
101
+ walk(join(REPO, "src"), [".ts"]);
102
+ walk(join(REPO, "test"), [".mjs"]);
103
+ for (const f of files.sort()) {
104
+ const text = readFileSync(f, "utf8");
105
+ let t = "";
106
+ for (const m of text.matchAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g)) {
107
+ t += " " + m[0];
108
+ }
89
109
  t = t.toLowerCase().replace(/[^a-z ]+/g, " ").replace(/\s+/g, " ");
90
110
  const w = t.split(" ").filter(Boolean);
91
111
  for (let i = 0; i + 4 < w.length; i += 2) {
@@ -140,9 +160,9 @@ const SIZES = [750, 1000, 1500];
140
160
  test("completion recursion: per-query work does not grow with the corpus", async () => {
141
161
  assert.ok(
142
162
  FRAG.length > 4000,
143
- `only ${FRAG.length} prose fragments found in ${REPO}/*.md — this test ` +
144
- `draws its corpus from the repo's own documentation; with the prose gone ` +
145
- `it can no longer exercise the completion recursion at all`,
163
+ `only ${FRAG.length} prose fragments found in the source comments — this ` +
164
+ `test draws its corpus from src/**/*.ts and test/**/*.mjs; with the ` +
165
+ `comments gone it can no longer exercise the completion recursion at all`,
146
166
  );
147
167
 
148
168
  const searches = [], pops = [], answers = [], secs = [];
@@ -0,0 +1,115 @@
1
+ // 96-bytes-walk-termination.test.mjs — `bytes()` must TERMINATE when its
2
+ // traversal memo cannot hold the reconstruction's working set.
3
+ //
4
+ // `bytes()` is an iterative post-order walk: it peeks the top of an explicit
5
+ // stack, pushes any child that is not yet resolved, and concatenates once every
6
+ // child is. Its termination argument needs a memo that only ever GROWS. It
7
+ // used `_bytesCache` — a byte-accounted BoundedMap that EVICTS, and whose
8
+ // `"smallest"` policy deliberately prefers the cheapest-to-rebuild entries,
9
+ // i.e. exactly the small, freshly-resolved children the parents still sitting
10
+ // on the stack are waiting for. The parent finds them unresolved again,
11
+ // re-pushes them, they are re-resolved, re-inserted and re-evicted. No
12
+ // progress. The loop never exits.
13
+ //
14
+ // It stayed hidden because it needs the cache to be SATURATED, which only a
15
+ // long run reaches. OBSERVED IN THE FIELD: a training run at 19.9M nodes with
16
+ // the 20 MB cache pinned at 19,999,962 bytes spun for 8h45m of 100% CPU on one
17
+ // node — whose entire content was 124 bytes, with 5 kids, 2 of them
18
+ // perpetually re-evicted. Sampled 45s apart through the V8 inspector, the
19
+ // walk's root id, the node's length and the cache's byte count were all
20
+ // identical; only the stack depth oscillated between 1 and 2.
21
+ //
22
+ // And the loop is SYNCHRONOUS, so nothing could observe it: the trainer's
23
+ // 15-minute stall watchdog is a timer, and a timer cannot fire while the
24
+ // microtask/JS stack is occupied. The run looked alive for 9 hours.
25
+ //
26
+ // The fix makes the walk memoize into a LOCAL map (`_bytesCache` demoted to a
27
+ // warm hint whose hit is promoted into that map immediately), so each node
28
+ // resolves at most once per call and termination is structural.
29
+ //
30
+ // This test does NOT depend on the eviction cursor's position — the field case
31
+ // reached the defect stochastically, via the cursor sweeping the map's
32
+ // recently-inserted tail. Here the subtree's children simply sum to more bytes
33
+ // than the ceiling, so no cursor position can save it: pre-fix this file hangs
34
+ // forever, and `node --test` reports it only as a timeout.
35
+ import { test } from "node:test";
36
+ import assert from "node:assert/strict";
37
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
38
+
39
+ const D = 64;
40
+ const gist = () => {
41
+ const v = new Float32Array(D);
42
+ v[0] = 1;
43
+ return v;
44
+ };
45
+
46
+ test("bytes() terminates when its memo cannot hold the working set", async () => {
47
+ // A ceiling small enough to saturate in milliseconds. The mechanism is
48
+ // scale-free: this is the only thing scaled down from the field case.
49
+ const CEILING = 4096;
50
+ const store = new SQliteStore({
51
+ D,
52
+ bytesCacheMax: CEILING,
53
+ path: ":memory:",
54
+ });
55
+
56
+ // One ordinary 9,600-byte form: 300 distinct 32-byte children under a root.
57
+ const KIDS = 300, WIDTH = 32;
58
+ const kids = [];
59
+ for (let b = 0; b < KIDS; b++) {
60
+ const buf = new Uint8Array(WIDTH);
61
+ for (let i = 0; i < WIDTH; i++) buf[i] = (b * 131 + i * 17) & 0xff;
62
+ kids.push(await store.putLeaf(buf, gist()));
63
+ }
64
+ const root = await store.putBranch(kids, gist());
65
+ const expected = store.contentLen(root);
66
+ assert.equal(expected, KIDS * WIDTH);
67
+
68
+ // Churn the subtree out of the memo and leave it AT its ceiling — the steady
69
+ // state every long run reaches.
70
+ for (let k = 0; k < 4000; k++) {
71
+ const buf = new Uint8Array(48);
72
+ for (let i = 0; i < 48; i++) buf[i] = (k * 7919 + i * 251) & 0xff;
73
+ await store.putLeaf(buf, gist());
74
+ }
75
+
76
+ // The premise the guard rests on: the memo genuinely cannot hold the working
77
+ // set. If a future change grows the ceiling or shrinks the fixture, this
78
+ // assertion fails LOUDLY rather than letting the test pass vacuously.
79
+ assert.ok(
80
+ KIDS * WIDTH > CEILING,
81
+ `fixture must exceed the memo ceiling (${KIDS * WIDTH} vs ${CEILING})`,
82
+ );
83
+
84
+ // Pre-fix this call never returns. Post-fix it is sub-millisecond.
85
+ const out = store.bytes(root);
86
+ assert.equal(out.length, expected);
87
+ for (let b = 0; b < KIDS; b++) {
88
+ for (let i = 0; i < WIDTH; i++) {
89
+ assert.equal(out[b * WIDTH + i], (b * 131 + i * 17) & 0xff);
90
+ }
91
+ }
92
+ });
93
+
94
+ test("differsByOneWindow's reads are capped — no ALL-sentinel read on deposit", async () => {
95
+ // §2.8: the near-dedup byte check used to open with
96
+ // `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, which routes
97
+ // to the full materialising `bytes()` — and only THEN compare lengths. A
98
+ // candidate the length test was about to reject had already been rebuilt byte
99
+ // for byte, and that read is what dragged the deposit path into the walk
100
+ // above. Lengths now decide first, from the `contentLen` memo.
101
+ const src = await import("node:fs").then((fs) =>
102
+ fs.readFileSync(new URL("../src/store.ts", import.meta.url), "utf8")
103
+ );
104
+ const body = src.slice(src.indexOf("private differsByOneWindow"));
105
+ const end = body.indexOf("\n }\n");
106
+ const fn = body.slice(0, end);
107
+ assert.ok(
108
+ !fn.includes("MAX_SAFE_INTEGER"),
109
+ "differsByOneWindow must not read with the ALL sentinel",
110
+ );
111
+ assert.ok(
112
+ fn.includes("this.contentLen("),
113
+ "differsByOneWindow must decide on lengths before reading bytes",
114
+ );
115
+ });
@@ -0,0 +1,105 @@
1
+ // 97-store-seed.test.mjs — a trained store's OWN seed governs the Mind that
2
+ // opens it.
3
+ //
4
+ // `train.seed` is persisted by the trainer (example/train_base/main.ts) and the
5
+ // trainer refuses to resume a store under a different seed, so the value is
6
+ // authoritative for the artifact. The seed feeds `makeKeyring`, `Space.rand`
7
+ // and the `Alphabet` in the Mind constructor: folding a query under any other
8
+ // seed lands in a DIFFERENT vector space than the one the artifact's nodes were
9
+ // folded into, so recognition and resonance read the wrong space and every
10
+ // answer degrades silently.
11
+ //
12
+ // The store recovers `train.D` and `geometry.maxGroup` from its own metadata at
13
+ // open; `train.seed` must be recovered the same way, and a Mind that did not
14
+ // receive an explicit seed must adopt it. An explicit caller seed still wins.
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { DEFAULT_CONFIG, Mind } from "../dist/src/index.js";
22
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
23
+
24
+ /** A store that was never trained carries no seed and leaves the caller's
25
+ * configured default in force. */
26
+ test("an untrained store reports no trainSeed and keeps the default seed", async () => {
27
+ const store = new SQliteStore({ path: ":memory:", D: 256 });
28
+ const mind = new Mind({ store });
29
+ assert.equal(store.trainSeed, null);
30
+ assert.equal(mind.cfg.seed, DEFAULT_CONFIG.seed);
31
+ await store.close();
32
+ });
33
+
34
+ /** The artifact's seed is recovered at open and adopted by a Mind that was not
35
+ * given one; an explicit seed still overrides it. */
36
+ test("a trained store's seed is recovered and adopted unless overridden", async () => {
37
+ const dir = mkdtempSync(join(tmpdir(), "sema-seed-"));
38
+ const stem = join(dir, "trained");
39
+ const TRAIN_SEED = 7;
40
+
41
+ // Build the artifact: ingest under an explicit seed, then persist the seed
42
+ // exactly as the trainer does.
43
+ {
44
+ const store = new SQliteStore({ path: stem, D: 256 });
45
+ const mind = new Mind({ seed: TRAIN_SEED, store });
46
+ await mind.ingest([["the sky is blue", "blue"]]);
47
+ await store.setMeta("train.seed", String(TRAIN_SEED));
48
+ store.commit();
49
+ await store.close();
50
+ }
51
+
52
+ // Reopen WITHOUT a seed: the store's own seed must stand.
53
+ {
54
+ const store = new SQliteStore({ path: stem, D: 256 });
55
+ assert.equal(store.trainSeed, TRAIN_SEED);
56
+ const adopted = new Mind({ store });
57
+ assert.equal(adopted.cfg.seed, TRAIN_SEED);
58
+ await store.close();
59
+ }
60
+
61
+ // Reopen WITH an explicit seed: the caller wins over the artifact.
62
+ {
63
+ const store = new SQliteStore({ path: stem, D: 256 });
64
+ const explicit = new Mind({ seed: 3, store });
65
+ assert.equal(explicit.cfg.seed, 3);
66
+ await store.close();
67
+ }
68
+
69
+ rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ /** The adopted seed is the one the answer is computed under: a store ingested
73
+ * under seed 7 answers a query identically when reopened without a seed and
74
+ * when reopened with seed 7 passed explicitly. */
75
+ test("adopting the artifact seed reproduces the artifact's answer", async () => {
76
+ const dir = mkdtempSync(join(tmpdir(), "sema-seed-"));
77
+ const stem = join(dir, "trained");
78
+ const TRAIN_SEED = 7;
79
+ const QUESTION = "the sky is blue";
80
+
81
+ let artifactAnswer;
82
+ {
83
+ const store = new SQliteStore({ path: stem, D: 256 });
84
+ const mind = new Mind({ seed: TRAIN_SEED, store });
85
+ await mind.ingest([
86
+ ["the sky is blue", "blue"],
87
+ ["the grass is green", "green"],
88
+ ]);
89
+ artifactAnswer = (await mind.respondText(QUESTION)).trim();
90
+ await store.setMeta("train.seed", String(TRAIN_SEED));
91
+ store.commit();
92
+ await store.close();
93
+ }
94
+ assert.equal(artifactAnswer, "blue");
95
+
96
+ {
97
+ const store = new SQliteStore({ path: stem, D: 256 });
98
+ const adopted = new Mind({ store });
99
+ assert.equal(adopted.cfg.seed, TRAIN_SEED);
100
+ assert.equal((await adopted.respondText(QUESTION)).trim(), artifactAnswer);
101
+ await store.close();
102
+ }
103
+
104
+ rmSync(dir, { recursive: true, force: true });
105
+ });