@hviana/sema 0.1.6 → 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 (50) hide show
  1. package/dist/example/train_base.js +42 -9
  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.d.ts +13 -2
  7. package/dist/src/geometry.js +101 -2
  8. package/dist/src/index.d.ts +1 -0
  9. package/dist/src/index.js +1 -0
  10. package/dist/src/mind/attention.js +11 -7
  11. package/dist/src/mind/learning.js +33 -1
  12. package/dist/src/mind/match.d.ts +4 -2
  13. package/dist/src/mind/match.js +30 -6
  14. package/dist/src/mind/mechanisms/cast.js +18 -4
  15. package/dist/src/mind/mechanisms/confluence.js +13 -1
  16. package/dist/src/mind/mechanisms/recall.js +115 -31
  17. package/dist/src/mind/mind.d.ts +138 -12
  18. package/dist/src/mind/mind.js +284 -22
  19. package/dist/src/mind/primitives.d.ts +22 -2
  20. package/dist/src/mind/primitives.js +95 -6
  21. package/dist/src/mind/recognition.js +31 -8
  22. package/dist/src/mind/traverse.d.ts +13 -0
  23. package/dist/src/mind/traverse.js +41 -0
  24. package/dist/src/mind/types.d.ts +33 -21
  25. package/dist/src/store-sqlite.d.ts +10 -0
  26. package/dist/src/store-sqlite.js +58 -0
  27. package/dist/src/store.d.ts +23 -0
  28. package/dist/src/store.js +13 -2
  29. package/example/train_base.ts +49 -9
  30. package/index.html +65 -0
  31. package/package.json +1 -1
  32. package/src/alu/src/parser.ts +105 -4
  33. package/src/canon.ts +65 -0
  34. package/src/geometry.ts +105 -1
  35. package/src/index.ts +1 -0
  36. package/src/mind/attention.ts +11 -6
  37. package/src/mind/learning.ts +39 -1
  38. package/src/mind/match.ts +29 -6
  39. package/src/mind/mechanisms/cast.ts +21 -6
  40. package/src/mind/mechanisms/confluence.ts +15 -1
  41. package/src/mind/mechanisms/recall.ts +132 -41
  42. package/src/mind/mind.ts +396 -22
  43. package/src/mind/primitives.ts +109 -7
  44. package/src/mind/recognition.ts +36 -8
  45. package/src/mind/traverse.ts +47 -0
  46. package/src/mind/types.ts +30 -21
  47. package/src/store-sqlite.ts +76 -0
  48. package/src/store.ts +46 -2
  49. package/test/13-conversation.test.mjs +240 -20
  50. package/test/35-prefix-edge.test.mjs +86 -0
@@ -14,14 +14,10 @@
14
14
  // The assertions describe BEHAVIOUR only — they never mention how context is
15
15
  // represented — so they stay valid for any implementation.
16
16
  //
17
- // Note: Sema trains on the accumulated context string ("turn0\nturn1\nturn2")
18
- // and queries the same accumulated string at inference. This is not a
19
- // weakness LLMs work the same way: a chat model receives the full
20
- // conversation history as its prompt, and that same history must be
21
- // provided at inference to produce the next turn. The difference is in
22
- // how the answer is produced: Sema composes it from learned graph forms;
23
- // an LLM samples it from a parametric distribution. Neither is "just a
24
- // lookup."
17
+ // Sema trains on the raw concatenation of prior turns and queries the same
18
+ // accumulated bytes at inference. The Conversation API tracks turn-boundary
19
+ // offsets explicitly so no separator character is needed the geometry never
20
+ // inspects content to find turn boundaries.
25
21
  // ─────────────────────────────────────────────────────────────────────────
26
22
 
27
23
  import { test } from "node:test";
@@ -34,16 +30,14 @@ const newMind = () => new Mind({ seed: 7 });
34
30
  // ═══════════════════════════════════════════════════════════════════════
35
31
  // teachConversation — store one conversation (an ordered list of turns).
36
32
  //
37
- // Accumulate every prior turn into the context so each episode carries the
38
- // full conversation history: (t₀ → t₁), (t₀+t₁ → t₂), (t₀+t₁+t₂ → t₃) …
39
- // A pivot turn ("what is its name?") that appears in two conversations now
40
- // produces different episode vectors because the context side encodes which
41
- // conversation it belongs to. All SEMA operations remain sublinear — the
42
- // river cuts any context into O(log N) chunks regardless of length.
33
+ // Accumulate prior turns by raw concatenation no separator character.
34
+ // Each episode carries the full conversation history: (t₀ → t₁),
35
+ // (t₀+t₁ t₂), (t₀+t₁+t₂ t₃) where + is byte concatenation.
43
36
  // ═══════════════════════════════════════════════════════════════════════
44
37
  async function teachConversation(mind, turns) {
38
+ let context = "";
45
39
  for (let i = 0; i + 1 < turns.length; i++) {
46
- const context = turns.slice(0, i + 1).join("\n");
40
+ context += turns[i];
47
41
  await mind.ingest(context, turns[i + 1]);
48
42
  }
49
43
  }
@@ -51,13 +45,24 @@ async function teachConversation(mind, turns) {
51
45
  // ═══════════════════════════════════════════════════════════════════════
52
46
  // predictNext — given the turns spoken so far, predict the next turn.
53
47
  //
54
- // Join every prior turn into one accumulated context, mirroring the storage
55
- // pattern in teachConversation. The resulting query string is the exact
56
- // context side of the matching episode, so recall resolves unambiguously.
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.
52
+ // Turns are raw strings, concatenated by the Mind — no separator.
57
53
  // ═══════════════════════════════════════════════════════════════════════
58
54
  async function predictNext(mind, priorTurns) {
59
- const context = priorTurns.join("\n");
60
- return await mind.respondText(context);
55
+ if (priorTurns.length === 0) return "";
56
+ const conv = mind.beginConversation();
57
+ for (let i = 0; i < priorTurns.length - 1; i++) {
58
+ mind.addTurn(conv, priorTurns[i]);
59
+ }
60
+ const { response } = await mind.respondTurnText(
61
+ conv,
62
+ priorTurns[priorTurns.length - 1],
63
+ );
64
+ mind.endConversation(conv);
65
+ return response;
61
66
  }
62
67
 
63
68
  // A small convenience: teach a conversation, then ask for the continuation
@@ -226,3 +231,218 @@ test("D2: prediction is deterministic across runs", async () => {
226
231
  };
227
232
  assert.equal(await run(), await run());
228
233
  });
234
+
235
+ // ═══════════════════════════════════════════════════════════════════════
236
+ // Section E — Conversation API lifecycle & cache management
237
+ //
238
+ // Tests for the beginConversation / respondTurn / endConversation API
239
+ // itself, independent of the conversation-training pattern above.
240
+ // ═══════════════════════════════════════════════════════════════════════
241
+
242
+ test("E1: begin → respondTurn → end completes a single-turn conversation", async () => {
243
+ const mind = newMind();
244
+ await mind.ingest("hello", "world");
245
+
246
+ const conv = mind.beginConversation();
247
+ const { response, state } = await mind.respondTurnText(conv, "hello");
248
+ assert.equal(response, "world");
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);
252
+ mind.endConversation(conv);
253
+ await mind.store.close();
254
+ });
255
+
256
+ test("E2: multi-turn conversation accumulates context and boundaries", async () => {
257
+ const mind = newMind();
258
+ await teachConversation(mind, CAT);
259
+
260
+ const conv = mind.beginConversation();
261
+ // First turn establishes the subject — scripted history, so it is FED
262
+ // (addTurn), not asked.
263
+ mind.addTurn(conv, "I adopted a cat");
264
+
265
+ // Second turn is the pivot query.
266
+ const t2 = await mind.respondTurnText(conv, "what is its name?");
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);
270
+ assert.equal(t2.response, "her name is Whiskers");
271
+
272
+ mind.endConversation(conv);
273
+ await mind.store.close();
274
+ });
275
+
276
+ test("E3: save → end → restore → continue works", async () => {
277
+ const mind = newMind();
278
+ await teachConversation(mind, CAT);
279
+
280
+ // First two turns (context fed, pivot asked).
281
+ const conv = mind.beginConversation();
282
+ mind.addTurn(conv, "I adopted a cat");
283
+ const { state: saved } = await mind.respondTurnText(
284
+ conv,
285
+ "what is its name?",
286
+ );
287
+ assert.equal(saved.boundaries.length, 2);
288
+
289
+ // Save and end.
290
+ mind.endConversation(conv);
291
+
292
+ // Restore from the saved state into a new handle.
293
+ const conv2 = mind.beginConversation(saved);
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");
300
+
301
+ mind.endConversation(conv2);
302
+ await mind.store.close();
303
+ });
304
+
305
+ test("E4: endConversation is idempotent", async () => {
306
+ const mind = newMind();
307
+ const conv = mind.beginConversation();
308
+ mind.endConversation(conv);
309
+ // Second end on the same handle — must not throw.
310
+ mind.endConversation(conv);
311
+ await mind.store.close();
312
+ });
313
+
314
+ test("E5: respondTurn on an ended conversation throws", async () => {
315
+ const mind = newMind();
316
+ const conv = mind.beginConversation();
317
+ mind.endConversation(conv);
318
+ await assert.rejects(
319
+ () => mind.respondTurn(conv, "hello"),
320
+ /not found/,
321
+ );
322
+ await mind.store.close();
323
+ });
324
+
325
+ test("E6: conversationState returns null after endConversation", async () => {
326
+ const mind = newMind();
327
+ const conv = mind.beginConversation();
328
+ mind.endConversation(conv);
329
+ assert.equal(mind.conversationState(conv), null);
330
+ await mind.store.close();
331
+ });
332
+
333
+ test("E7: multiple concurrent conversations are independent", async () => {
334
+ const mind = newMind();
335
+ await teachConversation(mind, CAT);
336
+ await teachConversation(mind, DOG);
337
+
338
+ const catConv = mind.beginConversation();
339
+ const dogConv = mind.beginConversation();
340
+
341
+ // Feed the distinguishing context to each.
342
+ mind.addTurn(catConv, "I adopted a cat");
343
+ mind.addTurn(dogConv, "I adopted a dog");
344
+
345
+ // The same pivot turn — different answers.
346
+ const catR = await mind.respondTurnText(catConv, "what is its name?");
347
+ const dogR = await mind.respondTurnText(dogConv, "what is its name?");
348
+
349
+ assert.equal(catR.response, "her name is Whiskers");
350
+ assert.equal(dogR.response, "his name is Rex");
351
+ assert.notEqual(catR.response, dogR.response);
352
+
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);
356
+
357
+ // Ending one does not affect the other.
358
+ mind.endConversation(catConv);
359
+ assert.equal(mind.conversationState(catConv), null);
360
+ assert.notEqual(mind.conversationState(dogConv), null);
361
+
362
+ // End the second — both are now gone.
363
+ mind.endConversation(dogConv);
364
+ assert.equal(mind.conversationState(dogConv), null);
365
+
366
+ // Ending again is idempotent.
367
+ mind.endConversation(dogConv);
368
+ await mind.store.close();
369
+ });
370
+
371
+ test("E8: respondTurn with a forged handle throws", async () => {
372
+ const mind = newMind();
373
+ // { id: 999 } was never returned by beginConversation.
374
+ await assert.rejects(
375
+ () => mind.respondTurn({ id: 999 }, "hello"),
376
+ /not found/,
377
+ );
378
+ await mind.store.close();
379
+ });
380
+
381
+ test("E9: respond and respondTurn give the same answer for the same cumulative bytes", async () => {
382
+ const mind = newMind();
383
+ await teachConversation(mind, CAT);
384
+
385
+ // Via respond() — raw concatenation, same as respondTurn.
386
+ const viaRespond = await mind.respondText(
387
+ "I adopted a catwhat is its name?",
388
+ );
389
+
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).
393
+ const conv = mind.beginConversation();
394
+ mind.addTurn(conv, "I adopted a cat");
395
+ const { response: viaConv } = await mind.respondTurnText(
396
+ conv,
397
+ "what is its name?",
398
+ );
399
+ mind.endConversation(conv);
400
+
401
+ assert.equal(viaRespond, viaConv);
402
+ await mind.store.close();
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
+ });