@hviana/sema 0.1.7 → 0.1.8

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 (42) hide show
  1. package/dist/example/train_base.js +24 -4
  2. package/dist/src/alu/src/parser.d.ts +9 -0
  3. package/dist/src/alu/src/parser.js +110 -2
  4. package/dist/src/canon.d.ts +26 -0
  5. package/dist/src/canon.js +57 -0
  6. package/dist/src/geometry.js +12 -0
  7. package/dist/src/index.d.ts +1 -0
  8. package/dist/src/index.js +1 -0
  9. package/dist/src/mind/learning.js +33 -1
  10. package/dist/src/mind/match.d.ts +4 -2
  11. package/dist/src/mind/match.js +30 -6
  12. package/dist/src/mind/mechanisms/cast.js +18 -4
  13. package/dist/src/mind/mechanisms/confluence.js +13 -1
  14. package/dist/src/mind/mechanisms/recall.js +97 -28
  15. package/dist/src/mind/mind.d.ts +64 -2
  16. package/dist/src/mind/mind.js +152 -23
  17. package/dist/src/mind/primitives.d.ts +9 -0
  18. package/dist/src/mind/primitives.js +68 -1
  19. package/dist/src/mind/recognition.js +11 -1
  20. package/dist/src/mind/types.d.ts +10 -0
  21. package/dist/src/store-sqlite.d.ts +8 -0
  22. package/dist/src/store-sqlite.js +52 -0
  23. package/dist/src/store.d.ts +12 -0
  24. package/example/train_base.ts +29 -4
  25. package/package.json +1 -1
  26. package/src/alu/src/parser.ts +105 -4
  27. package/src/canon.ts +65 -0
  28. package/src/geometry.ts +12 -0
  29. package/src/index.ts +1 -0
  30. package/src/mind/learning.ts +39 -1
  31. package/src/mind/match.ts +29 -6
  32. package/src/mind/mechanisms/cast.ts +21 -6
  33. package/src/mind/mechanisms/confluence.ts +15 -1
  34. package/src/mind/mechanisms/recall.ts +116 -41
  35. package/src/mind/mind.ts +172 -29
  36. package/src/mind/primitives.ts +66 -1
  37. package/src/mind/recognition.ts +10 -0
  38. package/src/mind/types.ts +10 -0
  39. package/src/store-sqlite.ts +68 -0
  40. package/src/store.ts +27 -0
  41. package/test/13-conversation.test.mjs +77 -27
  42. package/test/35-prefix-edge.test.mjs +86 -0
@@ -14,6 +14,8 @@ import {
14
14
  hilbertBytes,
15
15
  stackGrids,
16
16
  } from "../geometry.js";
17
+ import { canonHash } from "../canon.js";
18
+ import { bytesEqual } from "../bytes.js";
17
19
  import { ALL } from "./types.js";
18
20
  import type { Input, MindContext } from "./types.js";
19
21
 
@@ -199,7 +201,70 @@ export function foldTree(
199
201
  * unknown. */
200
202
  export function resolve(ctx: MindContext, bytes: Uint8Array): number | null {
201
203
  if (bytes.length === 0) return null;
202
- return foldTree(ctx, perceive(ctx, bytes), 0).node;
204
+ const exact = foldTree(ctx, perceive(ctx, bytes), 0).node;
205
+ if (exact !== null) return exact;
206
+ return canonResolve(ctx, bytes);
207
+ }
208
+
209
+ /** Equivalence-class resolution: when the exact content-addressed lookup
210
+ * misses, find a stored node whose CANONICAL key equals the span's — the
211
+ * store's canon index proposes candidates by key hash, and each is verified
212
+ * by re-canonicalizing its bytes (hash-then-verify, like every content
213
+ * lookup). Among verified candidates, one that leads somewhere (has a
214
+ * continuation edge) is preferred; ties break to the lowest id — a corpus
215
+ * property, not a seed property. Null when the response carries no
216
+ * canonicalizer, the store has no canon index, or nothing verifies. */
217
+ export function canonResolve(
218
+ ctx: MindContext,
219
+ bytes: Uint8Array,
220
+ ): number | null {
221
+ const canon = ctx.canon;
222
+ const store = ctx.store;
223
+ if (canon === null || !store.canonFind) return null;
224
+ if (bytes.length < 2) return null;
225
+ const memo = ctx.canonMemo;
226
+ const memoKey = memo ? latin1Key(bytes) : "";
227
+ if (memo) {
228
+ const hit = memo.get(memoKey);
229
+ if (hit !== undefined) return hit;
230
+ }
231
+ const set = (v: number | null): number | null => {
232
+ memo?.set(memoKey, v);
233
+ return v;
234
+ };
235
+ const key = canon(bytes);
236
+ if (key.length === 0) return set(null);
237
+ // A stored form that IS canonical is not in the index (buildCanonIndex
238
+ // skips identity rows) — the exact content-addressed lookup of the
239
+ // canonical bytes finds it directly.
240
+ if (key.length !== bytes.length || !bytesEqual(key, bytes)) {
241
+ const direct = foldTree(ctx, perceive(ctx, key), 0).node;
242
+ if (direct !== null) return set(direct);
243
+ }
244
+ const candidates = store.canonFind(canonHash(key));
245
+ if (candidates.length === 0) return set(null);
246
+ let best: number | null = null;
247
+ let bestLeads = false;
248
+ for (const id of candidates) {
249
+ const bytesOf = read(ctx, id);
250
+ const stored = canon(bytesOf);
251
+ if (stored.length !== key.length || !bytesEqual(stored, key)) continue;
252
+ // The index stores FLAT content twins; the id the exact path would have
253
+ // resolved for these bytes is their FOLD — the deposit-shaped node that
254
+ // carries the edges and halos. Re-folding the candidate's bytes lands
255
+ // on exactly the node the canonical-case query would have found.
256
+ const folded = foldTree(ctx, perceive(ctx, bytesOf), 0).node;
257
+ const use = folded ?? id;
258
+ const leads = store.hasNext(use) || store.haloMass(use) > 0;
259
+ if (
260
+ best === null || (leads && !bestLeads) ||
261
+ (leads === bestLeads && use < best)
262
+ ) {
263
+ best = use;
264
+ bestLeads = leads;
265
+ }
266
+ }
267
+ return set(best);
203
268
  }
204
269
 
205
270
  /** Walk a perceived tree in POST-ORDER with byte offsets — children before
@@ -8,6 +8,7 @@ import { rItem } from "./trace.js";
8
8
 
9
9
  import type { MindContext, Recognition, Segment } from "./types.js";
10
10
  import {
11
+ canonResolve,
11
12
  foldTree,
12
13
  gistOf,
13
14
  latin1Key,
@@ -96,6 +97,15 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
96
97
  leaves.push({ start, end, bytes: n.leaf ?? new Uint8Array(0), node });
97
98
  }
98
99
  if (node !== null) emit(start, end, node);
100
+ // Canonical fallback: a subtree whose exact content-addressed lookup
101
+ // missed may still be a stored form under the response's equivalence
102
+ // (case, width, whitespace — whatever the injected canonicalizer says).
103
+ // O(subtree bytes) per miss, memoised per response; a no-op when no
104
+ // canonicalizer was injected or the store has no canon index.
105
+ else if (end - start >= 2) {
106
+ const cid = canonResolve(ctx, bytes.subarray(start, end));
107
+ if (cid !== null) emit(start, end, cid);
108
+ }
99
109
  if (isChunk(n)) {
100
110
  starts.add(start);
101
111
  // Try every sub-span within this leaf-parent.
package/src/mind/types.ts CHANGED
@@ -144,6 +144,16 @@ export interface MindContext extends GraphSearchHost {
144
144
  cfg: MindConfig;
145
145
  search: GraphSearch;
146
146
  trace: Rationale | null;
147
+ /** The content canonicalizer for THIS response, or null — injected by the
148
+ * modality entry point (respondText passes the text canonicalizer; a
149
+ * binary respond passes none). Resolution uses it as a fallback: when
150
+ * the exact content-addressed lookup misses, the span's canonical key is
151
+ * probed against the store's canon index (see src/canon.ts). The core
152
+ * never inspects what the equivalence IS. */
153
+ canon: ((bytes: Uint8Array) => Uint8Array) | null;
154
+ /** Per-response memo of canonical-fallback resolutions, keyed by the
155
+ * span's latin1 content key. Null outside respond(). */
156
+ canonMemo: Map<string, number | null> | null;
147
157
  /** Memo of the consensus climb — content-keyed (latin1) so results
148
158
  * persist across conversation turns where the same byte spans recur.
149
159
  * Null outside respond(); during respondTurn() the conversation's
@@ -134,6 +134,18 @@ CREATE TABLE IF NOT EXISTS contain (
134
134
  id INTEGER PRIMARY KEY,
135
135
  parents BLOB NOT NULL
136
136
  );
137
+ -- CANONICAL-FORM index (Store.canonAdd/canonFind): h is the 32-bit hash of a
138
+ -- node's CANONICAL content key (the modality's canonicalizer output — case-
139
+ -- folded, whitespace-collapsed text, etc.; the store never sees the key
140
+ -- itself, only its hash). Same hash-then-verify discipline as idx_node_h:
141
+ -- the caller re-canonicalizes each candidate's bytes before trusting it, so
142
+ -- a collision costs a read, never a wrong id. WITHOUT ROWID on (h, id):
143
+ -- the composite key IS the lookup index and gives free dedup.
144
+ CREATE TABLE IF NOT EXISTS canon (
145
+ h INTEGER NOT NULL,
146
+ id INTEGER NOT NULL,
147
+ PRIMARY KEY (h, id)
148
+ ) WITHOUT ROWID;
137
149
  CREATE TABLE IF NOT EXISTS snapshot (
138
150
  id INTEGER PRIMARY KEY CHECK (id = 1),
139
151
  data BLOB NOT NULL
@@ -241,6 +253,10 @@ export class SQliteStore extends AbstractStore implements Store {
241
253
  private _selPrev: any = null;
242
254
  private _setMeta: any = null;
243
255
  private _getMeta: any = null;
256
+ private _insCanon: any = null;
257
+ private _selCanon: any = null;
258
+ private _cntCanon: any = null;
259
+ private _selContentFrom: any = null;
244
260
  private _delMeta: any = null;
245
261
  private _insSnapshot: any = null;
246
262
  private _selSnapshot: any = null;
@@ -990,6 +1006,58 @@ export class SQliteStore extends AbstractStore implements Store {
990
1006
  this._delMeta.run(key);
991
1007
  }
992
1008
 
1009
+ // -- Canonical-form index (Store optional capability) --
1010
+
1011
+ canonAdd(h: number, id: number): void {
1012
+ if (!this._insCanon) {
1013
+ this._insCanon = this.sqlite!.prepare(
1014
+ "INSERT OR IGNORE INTO canon (h, id) VALUES (?, ?)",
1015
+ );
1016
+ }
1017
+ // Join the deferred write transaction (committed by flush/commit), so a
1018
+ // bulk index build coalesces instead of paying autocommit per row.
1019
+ this._dbBeginTx();
1020
+ this._insCanon.run(h, id);
1021
+ }
1022
+
1023
+ canonFind(h: number): number[] {
1024
+ if (!this._selCanon) {
1025
+ this._selCanon = this.sqlite!.prepare(
1026
+ "SELECT id FROM canon WHERE h = ?",
1027
+ );
1028
+ }
1029
+ return (this._selCanon.all(h) as Array<{ id: number }>).map((r) => r.id);
1030
+ }
1031
+
1032
+ canonCount(): number {
1033
+ if (!this._cntCanon) {
1034
+ this._cntCanon = this.sqlite!.prepare(
1035
+ "SELECT count(*) AS c FROM canon",
1036
+ );
1037
+ }
1038
+ return (this._cntCanon.get() as { c: number }).c;
1039
+ }
1040
+
1041
+ eachContent(
1042
+ cb: (id: number, bytes: Uint8Array) => void,
1043
+ fromId = 0,
1044
+ ): void {
1045
+ if (!this._selContentFrom) {
1046
+ // Content-bearing nodes are FLAT branches: leaf present, kids an
1047
+ // empty (zero-length) blob — the same population PART 2 of the
1048
+ // diagnostics calls "distinct content spans".
1049
+ this._selContentFrom = this.sqlite!.prepare(
1050
+ "SELECT id, leaf FROM node " +
1051
+ "WHERE id >= ? AND leaf IS NOT NULL " +
1052
+ "AND (kids IS NULL OR length(kids) = 0)",
1053
+ );
1054
+ }
1055
+ for (const row of this._selContentFrom.iterate(fromId)) {
1056
+ const r = row as { id: number; leaf: Uint8Array };
1057
+ cb(r.id, new Uint8Array(r.leaf));
1058
+ }
1059
+ }
1060
+
993
1061
  // -- Snapshot --
994
1062
 
995
1063
  protected _dbSaveSnapshot(bytes: Uint8Array): void {
package/src/store.ts CHANGED
@@ -417,6 +417,33 @@ export interface Store {
417
417
  * the node has no halo row. */
418
418
  haloMass(id: NodeId): number;
419
419
 
420
+ // ── canonical-form index (optional capability) ─────────────────────────
421
+ // A small hash index from CANONICAL content keys to node ids, enabling
422
+ // equivalence-class resolution ("WHAT" finds the stored "What") without
423
+ // the store knowing what the equivalence IS: the canonicalizer lives with
424
+ // the modality (see src/canon.ts) and is applied by the CALLER — the store
425
+ // only maps 32-bit key hashes to candidate ids, and the caller verifies
426
+ // canon(stored bytes) === key before trusting any candidate (the same
427
+ // hash-then-verify discipline as the node table's own `h` index).
428
+ // Backends that do not implement the capability leave all three absent;
429
+ // resolution then simply has no canonical fallback.
430
+
431
+ /** Record that node `id`'s canonical key hashes to `h`. Idempotent. */
432
+ canonAdd?(h: number, id: NodeId): void;
433
+ /** All candidate node ids whose canonical key hashes to `h` (collisions
434
+ * included — the caller verifies). */
435
+ canonFind?(h: number): NodeId[];
436
+ /** Number of (h, id) rows in the canon index — 0 means never built. */
437
+ canonCount?(): number;
438
+ /** Visit every content-bearing node (flat branch: `leaf` present, no
439
+ * kids) — the population a canonical index is built over. `fromId`
440
+ * restricts the scan to ids ≥ fromId, so an index refresh after further
441
+ * training only visits the new rows. */
442
+ eachContent?(
443
+ cb: (id: NodeId, bytes: Uint8Array) => void,
444
+ fromId?: NodeId,
445
+ ): void;
446
+
420
447
  // ── lifecycle ──────────────────────────────────────────────────────────
421
448
  size(): Promise<number>;
422
449
  saveSnapshot(bytes: Uint8Array): Promise<void>;
@@ -45,15 +45,17 @@ async function teachConversation(mind, turns) {
45
45
  // ═══════════════════════════════════════════════════════════════════════
46
46
  // predictNext — given the turns spoken so far, predict the next turn.
47
47
  //
48
- // Uses the Conversation API: beginConversation → respondTurn for each
49
- // prior turn respondTurnText for the query endConversation.
48
+ // Uses the Conversation API: beginConversation → addTurn for each prior
49
+ // turn (BOTH speakers' lines are history being replayed, not questions to
50
+ // answer — respondTurn would answer each one AND append its own reply to
51
+ // the context) → respondTurnText for the final turn → endConversation.
50
52
  // Turns are raw strings, concatenated by the Mind — no separator.
51
53
  // ═══════════════════════════════════════════════════════════════════════
52
54
  async function predictNext(mind, priorTurns) {
53
55
  if (priorTurns.length === 0) return "";
54
56
  const conv = mind.beginConversation();
55
57
  for (let i = 0; i < priorTurns.length - 1; i++) {
56
- await mind.respondTurn(conv, priorTurns[i]);
58
+ mind.addTurn(conv, priorTurns[i]);
57
59
  }
58
60
  const { response } = await mind.respondTurnText(
59
61
  conv,
@@ -244,8 +246,9 @@ test("E1: begin → respondTurn → end completes a single-turn conversation", a
244
246
  const conv = mind.beginConversation();
245
247
  const { response, state } = await mind.respondTurnText(conv, "hello");
246
248
  assert.equal(response, "world");
247
- // Single turn no boundaries yet.
248
- assert.equal(state.boundaries.length, 0);
249
+ // The reply is part of the exchange: one boundary, between the turn and
250
+ // the appended reply.
251
+ assert.equal(state.boundaries.length, 1);
249
252
  mind.endConversation(conv);
250
253
  await mind.store.close();
251
254
  });
@@ -255,13 +258,15 @@ test("E2: multi-turn conversation accumulates context and boundaries", async ()
255
258
  await teachConversation(mind, CAT);
256
259
 
257
260
  const conv = mind.beginConversation();
258
- // First turn establishes the subject.
259
- await mind.respondTurn(conv, "I adopted a cat");
261
+ // First turn establishes the subject — scripted history, so it is FED
262
+ // (addTurn), not asked.
263
+ mind.addTurn(conv, "I adopted a cat");
260
264
 
261
265
  // Second turn is the pivot query.
262
266
  const t2 = await mind.respondTurnText(conv, "what is its name?");
263
- // Now we have one boundary: the byte position after the first turn.
264
- assert.equal(t2.state.boundaries.length, 1);
267
+ // Two boundaries: after the first turn, and after the pivot (the reply
268
+ // is part of the exchange).
269
+ assert.equal(t2.state.boundaries.length, 2);
265
270
  assert.equal(t2.response, "her name is Whiskers");
266
271
 
267
272
  mind.endConversation(conv);
@@ -272,28 +277,26 @@ test("E3: save → end → restore → continue works", async () => {
272
277
  const mind = newMind();
273
278
  await teachConversation(mind, CAT);
274
279
 
275
- // First two turns.
280
+ // First two turns (context fed, pivot asked).
276
281
  const conv = mind.beginConversation();
277
- await mind.respondTurn(conv, "I adopted a cat");
282
+ mind.addTurn(conv, "I adopted a cat");
278
283
  const { state: saved } = await mind.respondTurnText(
279
284
  conv,
280
285
  "what is its name?",
281
286
  );
282
- assert.equal(saved.boundaries.length, 1);
287
+ assert.equal(saved.boundaries.length, 2);
283
288
 
284
289
  // Save and end.
285
290
  mind.endConversation(conv);
286
291
 
287
292
  // Restore from the saved state into a new handle.
288
293
  const conv2 = mind.beginConversation(saved);
289
- const { response, state: state2 } = await mind.respondTurnText(
290
- conv2,
291
- "her name is Whiskers",
292
- );
293
- // The restored conversation continues from where it left off.
294
- assert.ok(response.length > 0 || response === "");
295
- // Boundaries are intact from the restore.
296
- assert.equal(state2.boundaries.length, 2);
294
+ // Boundaries are intact from the restore, and the conversation continues
295
+ // from where it left off.
296
+ const state2 = mind.addTurn(conv2, "and she likes to nap");
297
+ assert.equal(state2.boundaries.length, saved.boundaries.length + 1);
298
+ const { response } = await mind.respondTurnText(conv2, "what is its name?");
299
+ assert.ok(typeof response === "string");
297
300
 
298
301
  mind.endConversation(conv2);
299
302
  await mind.store.close();
@@ -336,8 +339,8 @@ test("E7: multiple concurrent conversations are independent", async () => {
336
339
  const dogConv = mind.beginConversation();
337
340
 
338
341
  // Feed the distinguishing context to each.
339
- await mind.respondTurn(catConv, "I adopted a cat");
340
- await mind.respondTurn(dogConv, "I adopted a dog");
342
+ mind.addTurn(catConv, "I adopted a cat");
343
+ mind.addTurn(dogConv, "I adopted a dog");
341
344
 
342
345
  // The same pivot turn — different answers.
343
346
  const catR = await mind.respondTurnText(catConv, "what is its name?");
@@ -347,9 +350,9 @@ test("E7: multiple concurrent conversations are independent", async () => {
347
350
  assert.equal(dogR.response, "his name is Rex");
348
351
  assert.notEqual(catR.response, dogR.response);
349
352
 
350
- // Each conversation has its own boundaries.
351
- assert.equal(catR.state.boundaries.length, 1);
352
- assert.equal(dogR.state.boundaries.length, 1);
353
+ // Each conversation has its own boundaries (turn|pivot, pivot|reply).
354
+ assert.equal(catR.state.boundaries.length, 2);
355
+ assert.equal(dogR.state.boundaries.length, 2);
353
356
 
354
357
  // Ending one does not affect the other.
355
358
  mind.endConversation(catConv);
@@ -384,9 +387,11 @@ test("E9: respond and respondTurn give the same answer for the same cumulative b
384
387
  "I adopted a catwhat is its name?",
385
388
  );
386
389
 
387
- // Via respondTurn — the new way.
390
+ // Via the Conversation API — the first turn is fed (addTurn keeps the
391
+ // cumulative bytes identical to the raw concatenation above; respondTurn
392
+ // would append its own reply between the turns).
388
393
  const conv = mind.beginConversation();
389
- await mind.respondTurnText(conv, "I adopted a cat");
394
+ mind.addTurn(conv, "I adopted a cat");
390
395
  const { response: viaConv } = await mind.respondTurnText(
391
396
  conv,
392
397
  "what is its name?",
@@ -396,3 +401,48 @@ test("E9: respond and respondTurn give the same answer for the same cumulative b
396
401
  assert.equal(viaRespond, viaConv);
397
402
  await mind.store.close();
398
403
  });
404
+
405
+ test("E10: an empty turn neither grows the context nor marks a boundary", async () => {
406
+ const mind = newMind();
407
+ await teachConversation(mind, CAT);
408
+
409
+ const conv = mind.beginConversation();
410
+ mind.addTurn(conv, "I adopted a cat");
411
+ const before = mind.conversationState(conv);
412
+
413
+ // Empty turns are no turns: addTurn and respondTurn alike must leave the
414
+ // context bytes and the (strictly increasing) boundaries untouched.
415
+ const viaAdd = mind.addTurn(conv, "");
416
+ assert.equal(viaAdd.context.length, before.context.length);
417
+ assert.deepEqual(viaAdd.boundaries, before.boundaries);
418
+
419
+ // respondTurn("") answers the ACCUMULATED context (an empty turn adds no
420
+ // bytes and no boundary of its own) — only its reply may grow the state,
421
+ // through the same append+boundary path as any turn.
422
+ const { response: r0, state: viaTurn } = await mind.respondTurnText(
423
+ conv,
424
+ "",
425
+ );
426
+ const replyLen = new TextEncoder().encode(r0).length;
427
+ assert.equal(viaTurn.context.length, before.context.length + replyLen);
428
+ assert.equal(
429
+ viaTurn.boundaries.length,
430
+ before.boundaries.length + (replyLen > 0 ? 1 : 0),
431
+ );
432
+ for (let i = 1; i < viaTurn.boundaries.length; i++) {
433
+ assert.ok(viaTurn.boundaries[i] > viaTurn.boundaries[i - 1]);
434
+ }
435
+
436
+ // The conversation still answers afterwards — the zero-growth refold must
437
+ // not have corrupted the pyramid's shared raw interiors. (The empty
438
+ // turn's own reply may have advanced the exchange, so the answer is
439
+ // asserted by content, not byte-exactly.)
440
+ const { response } = await mind.respondTurnText(conv, "what is its name?");
441
+ assert.ok(
442
+ response.includes("her name is Whiskers"),
443
+ `expected the name to surface, got ${JSON.stringify(response)}`,
444
+ );
445
+
446
+ mind.endConversation(conv);
447
+ await mind.store.close();
448
+ });
@@ -0,0 +1,86 @@
1
+ // test/35-prefix-edge.test.mjs — RC7: right-edge suffix inheritance
2
+ //
3
+ // When ingestPair learns C → D, every right-aligned byte suffix of C
4
+ // that is already a known form inherits the edge. Gate: ≥ 2 structural
5
+ // parents, or (halo > 0 ∧ already an edge source). Pure answers do
6
+ // not qualify — they are destinations, not sources.
7
+ //
8
+ // All phrases verified via instrumentation first (see MISTAKES.md).
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { Mind } from "../dist/src/index.js";
13
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
14
+ import { resolve } from "../dist/src/mind/primitives.js";
15
+
16
+ const enc = new TextEncoder();
17
+ const dec = new TextDecoder();
18
+ const text = (b) => dec.decode(b.filter((x) => x !== 0));
19
+ const mk = (s = 7) =>
20
+ new Mind({ seed: s, store: new SQliteStore({ path: ":memory:" }) });
21
+
22
+ // Establish via pair (gives halo mass). The second deposit where the
23
+ // form appears as a fold-tree constituent gives ≥ 1 structural parent.
24
+ async function establish(m, phrase) {
25
+ await m.ingest([phrase, "established"]);
26
+ }
27
+
28
+ test("A1 — right-aligned known suffix gains edge", async () => {
29
+ const m = mk();
30
+ await establish(m, "planet in our solar system");
31
+ const sid = resolve(m, enc.encode("planet in our solar system"));
32
+
33
+ await m.ingest([
34
+ "What is the largest planet in our solar system",
35
+ "Jupiter.",
36
+ ]);
37
+ const next = m.store.nextFirst(sid, 10).map((n) => text(m.store.bytes(n)));
38
+ assert.ok(next.some((s) => s.includes("Jupiter")));
39
+ await m.store.close();
40
+ });
41
+
42
+ test("A2 — suffix NOT previously known gets NO edge", async () => {
43
+ const m = mk();
44
+ const before = m.store.edgeSourceCount();
45
+ await m.ingest(["What is the largest planet", "Jupiter."]);
46
+ assert.equal(m.store.edgeSourceCount() - before, 1);
47
+ await m.store.close();
48
+ });
49
+
50
+ test("A3 — unestablished ≥ W suffix does NOT inherit", async () => {
51
+ const m = mk();
52
+ const before = m.store.edgeSourceCount();
53
+ // "prefix abcd" has "abcd" as a ≥W suffix, but it was never established.
54
+ await m.ingest(["prefix abcd", "answer"]);
55
+ // Only the full context should be a new edge source — no suffix edge.
56
+ assert.equal(m.store.edgeSourceCount() - before, 1);
57
+ await m.store.close();
58
+ });
59
+
60
+ test("B1 — inherited edge is present after propagation", async () => {
61
+ const m = mk();
62
+ await establish(m, "largest planet in our solar system");
63
+ await m.ingest([
64
+ "What is the largest planet in our solar system",
65
+ "Jupiter.",
66
+ ]);
67
+ const sid = resolve(m, enc.encode("largest planet in our solar system"));
68
+ const next = m.store.nextFirst(sid, 10).map((n) => text(m.store.bytes(n)));
69
+ assert.ok(next.some((s) => s.includes("Jupiter")));
70
+ await m.store.close();
71
+ });
72
+
73
+ test("B2 — bidirectional pair via suffix inheritance", async () => {
74
+ const m = mk();
75
+ await establish(m, "thank you");
76
+ await establish(m, "merci");
77
+ await m.ingest(["translates to merci", "thank you"]);
78
+ await m.ingest(["translates to thank you", "merci"]);
79
+
80
+ const sid = resolve(m, enc.encode("thank you"));
81
+ const next = m.store.nextFirst(sid, 10).map((n) => text(m.store.bytes(n)));
82
+ // The establish edge ("established") is still there, but the inherited
83
+ // edge ("merci") must ALSO be present.
84
+ assert.ok(next.some((s) => s.includes("merci")));
85
+ await m.store.close();
86
+ });