@hviana/sema 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +290 -77
  2. package/HOW_IT_WORKS.md +2170 -735
  3. package/dist/example/train_base.d.ts +9 -3
  4. package/dist/example/train_base.js +21 -4
  5. package/dist/src/canon.d.ts +19 -0
  6. package/dist/src/canon.js +28 -0
  7. package/dist/src/geometry.d.ts +52 -0
  8. package/dist/src/geometry.js +87 -1
  9. package/dist/src/mind/bridge.js +27 -1
  10. package/dist/src/mind/frame-filler.d.ts +15 -0
  11. package/dist/src/mind/frame-filler.js +535 -0
  12. package/dist/src/mind/learning.js +6 -11
  13. package/dist/src/mind/mechanisms/cast.js +72 -2
  14. package/dist/src/mind/mechanisms/cover.js +6 -1
  15. package/dist/src/mind/mechanisms/extraction.js +27 -0
  16. package/dist/src/mind/mechanisms/recall.js +214 -34
  17. package/dist/src/mind/mind.d.ts +49 -1
  18. package/dist/src/mind/mind.js +137 -10
  19. package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
  20. package/dist/src/mind/pipeline.js +29 -1
  21. package/dist/src/mind/prefix-completion.d.ts +59 -0
  22. package/dist/src/mind/prefix-completion.js +270 -0
  23. package/dist/src/mind/primitives.d.ts +29 -10
  24. package/dist/src/mind/primitives.js +52 -61
  25. package/dist/src/mind/recognition.js +119 -9
  26. package/dist/src/mind/traverse.d.ts +32 -0
  27. package/dist/src/mind/traverse.js +52 -0
  28. package/dist/src/mind/types.d.ts +55 -16
  29. package/dist/src/mind/types.js +68 -19
  30. package/dist/src/rabitq-ivf/src/rabitq.js +31 -1
  31. package/dist/src/store.d.ts +21 -0
  32. package/dist/src/store.js +21 -0
  33. package/example/train_base.ts +21 -4
  34. package/package.json +1 -1
  35. package/src/canon.ts +28 -0
  36. package/src/geometry.ts +100 -1
  37. package/src/mind/bridge.ts +34 -0
  38. package/src/mind/frame-filler.ts +604 -0
  39. package/src/mind/learning.ts +5 -9
  40. package/src/mind/mechanisms/cast.ts +70 -2
  41. package/src/mind/mechanisms/cover.ts +6 -1
  42. package/src/mind/mechanisms/extraction.ts +27 -0
  43. package/src/mind/mechanisms/recall.ts +236 -37
  44. package/src/mind/mind.ts +154 -14
  45. package/src/mind/pipeline-mechanism.ts +7 -0
  46. package/src/mind/pipeline.ts +33 -1
  47. package/src/mind/prefix-completion.ts +314 -0
  48. package/src/mind/primitives.ts +59 -70
  49. package/src/mind/recognition.ts +117 -6
  50. package/src/mind/traverse.ts +52 -0
  51. package/src/mind/types.ts +98 -42
  52. package/src/rabitq-ivf/src/rabitq.ts +31 -1
  53. package/src/store.ts +25 -0
  54. package/test/13-conversation.test.mjs +13 -0
  55. package/test/57-fusion-order.test.mjs +65 -0
  56. package/test/65-ann-recall.test.mjs +331 -0
  57. package/test/66-query-edge-whitespace.test.mjs +99 -0
  58. package/test/67-climb-anchor-breadth.test.mjs +113 -0
  59. package/test/68-extraction-unanchored.test.mjs +79 -0
  60. package/test/69-frame-filler.test.mjs +115 -0
  61. package/test/70-prefix-completion.test.mjs +170 -0
  62. package/test/71-embedded-canon-equivalence.test.mjs +121 -0
  63. package/test/72-prefix-candidate-supply.test.mjs +114 -0
  64. package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
  65. package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
  66. package/test/75-multiturn-context-optimisation.test.mjs +1082 -0
@@ -0,0 +1,1082 @@
1
+ // 75-multiturn-context-optimisation.test.mjs — the multi-turn context
2
+ // machinery: the incremental PLAIN fold, the perceive-memo key, and the
3
+ // agreement between what the deposit path folds and what inference reads.
4
+ //
5
+ // WHAT THIS FILE IS FOR. test/63 pins the FOLD's contract and test/13 pins the
6
+ // conversation API's BEHAVIOUR. Neither covers the layer between them: that a
7
+ // conversation's context is folded ONCE per turn instead of from scratch, that
8
+ // the tree it produces is the same tree a cold fold produces, and that the
9
+ // deposit path records the same turn boundaries inference reads. Every bug
10
+ // this file guards against was live in the code and invisible to the other 480
11
+ // tests, so each one is asserted here as a property rather than left to be
12
+ // caught by an accuracy number somewhere downstream.
13
+ //
14
+ // THE FOUR BUGS THIS FILE EXISTS TO PREVENT RECURRING:
15
+ //
16
+ // 1. TRAIN/INFER FOLD DISAGREEMENT. Inference imposed a turn-boundary set
17
+ // on the fold while the deposit path folded plainly, so the trained and
18
+ // inferred roots for identical bytes sat at cosine 0.02-0.21 and the
19
+ // alignment family went quadratic (5.2M cells on a 476-byte context,
20
+ // against 0 when the two agree). NEITHER SIDE IMPOSES BOUNDARIES NOW:
21
+ // both fold a stream over its own content cuts, so agreement is
22
+ // structural rather than something a heuristic has to keep re-deriving.
23
+ // Section D.
24
+ //
25
+ // 2. A BOUNDARY-BLIND MEMO KEY. `perceive` memoised by content alone though
26
+ // its tree depends on the boundary set too, so the first shape computed
27
+ // for a byte string was served to every later caller. Section C.
28
+ //
29
+ // 3. THE OPTIMISATION SILENTLY NOT HAPPENING. `_growContext` rebuilt the
30
+ // whole tree with `bytesToTree` every turn. `_resolvedSubtrees` is a
31
+ // WeakMap keyed by NODE IDENTITY, so a rebuilt tree meant it could not
32
+ // hit even once — the documented O(suffix) recognition was an intention,
33
+ // not the code. A regression here is invisible to every accuracy test,
34
+ // because rebuilding is SLOWER but just as CORRECT. Section B.
35
+ //
36
+ // 4. A CACHE THAT CHANGES THE ANSWER. The incremental fold reuses already-
37
+ // folded segments. If reuse ever produced a different tree from a cold
38
+ // fold, the store would depend on cache residency and eviction order.
39
+ // Sections A and D.
40
+ //
41
+ // ON TURN BOUNDARIES. A conversation still TRACKS them — ConversationState,
42
+ // answeredSpans and currentTurnStart all need them — but they are API metadata
43
+ // and never reach the fold. Incremental reuse does not come from them: it
44
+ // comes from content cuts being stable under append, which is a property of
45
+ // the rolling hash and holds with no boundary set at all (A5).
46
+ //
47
+ // NUMBERS HERE ARE CEILINGS AND FLOORS WITH SLACK, never equalities, except
48
+ // where the property IS an exact identity (fold equivalence, determinism).
49
+
50
+ import { test } from "node:test";
51
+ import assert from "node:assert/strict";
52
+ import {
53
+ bytesToTree,
54
+ contentBoundaries,
55
+ contentFoldIncremental,
56
+ Mind,
57
+ stablePrefixFoldIncremental,
58
+ } from "../dist/src/index.js";
59
+ // White-box: the memo key is internal, but its soundness is exactly what
60
+ // section C is about, so it is imported directly rather than inferred from
61
+ // downstream accuracy.
62
+ import { perceiveKey } from "../dist/src/mind/primitives.js";
63
+
64
+ const enc = (s) => new TextEncoder().encode(s);
65
+ const newMind = (opts = {}) => new Mind({ seed: 7, ...opts });
66
+
67
+ /** Structural signature: shape + leaf bytes, no vectors. */
68
+ const sig = (n) =>
69
+ n.kids === null
70
+ ? "L" + Array.from(n.leaf ?? []).join(",")
71
+ : "(" + n.kids.map(sig).join("|") + ")";
72
+
73
+ const cos = (a, b) => {
74
+ let d = 0, na = 0, nb = 0;
75
+ for (let i = 0; i < a.length; i++) {
76
+ d += a[i] * b[i];
77
+ na += a[i] * a[i];
78
+ nb += b[i] * b[i];
79
+ }
80
+ return d / Math.sqrt(na * nb);
81
+ };
82
+
83
+ /** Two trees are the SAME perception: same structure, same direction. */
84
+ const sameTree = (t1, t2) => sig(t1) === sig(t2) && cos(t1.v, t2.v) > 0.999999;
85
+
86
+ /** Every Sema object reachable from a root — identity, not content. */
87
+ const nodeSet = (n, s = new Set()) => {
88
+ s.add(n);
89
+ if (n.kids) { for (const k of n.kids) nodeSet(k, s); }
90
+ return s;
91
+ };
92
+
93
+ /** The latin1 content key the deposit cache is keyed by. */
94
+ const l1 = (b) => {
95
+ let o = "";
96
+ for (const x of b) o += String.fromCharCode(x);
97
+ return o;
98
+ };
99
+
100
+ /** Train a conversation the way ingestPair chains it: cumulative context →
101
+ * next turn, with the caller's own join string (default: none, as test/13). */
102
+ async function teach(mind, turns, join = "") {
103
+ for (let i = 1; i < turns.length; i++) {
104
+ await mind.ingest(turns.slice(0, i).join(join), turns[i]);
105
+ }
106
+ }
107
+
108
+ /** A deterministic byte-stream generator — text is one case, not the case. */
109
+ function streams(seed = 12345) {
110
+ let s = seed;
111
+ const rnd = () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
112
+ return { rnd };
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════════════════
116
+ // A. THE INCREMENTAL FOLD IS THE FOLD
117
+ //
118
+ // `stablePrefixFoldIncremental` and `bytesToTree` are documented as producing
119
+ // the same cuts and the same tree. If that ever stops being true, a
120
+ // conversation's perception silently diverges from every other entry point —
121
+ // and, because the incremental one carries a CACHE, the divergence could
122
+ // depend on what was folded before, which is unreproducible by construction.
123
+ // ═══════════════════════════════════════════════════════════════════════
124
+
125
+ test("A1: incremental fold ≡ bytesToTree, over random byte streams", () => {
126
+ const mind = newMind();
127
+ const { rnd } = streams();
128
+ for (let trial = 0; trial < 250; trial++) {
129
+ const len = 1 + Math.floor(rnd() * 400);
130
+ const b = new Uint8Array(len);
131
+ // full byte range — not text; a text-only corpus has hidden this class
132
+ // of bug before (see test/63's header).
133
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
134
+ const nb = Math.floor(rnd() * 6);
135
+ const bs = [
136
+ ...new Set(Array.from({ length: nb }, () => 1 + Math.floor(rnd() * len))),
137
+ ]
138
+ .sort((x, y) => x - y);
139
+ const ref = bytesToTree(
140
+ mind.space,
141
+ mind.alphabet,
142
+ b,
143
+ undefined,
144
+ undefined,
145
+ bs.length ? bs : undefined,
146
+ );
147
+ const inc =
148
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
149
+ assert.ok(
150
+ sameTree(ref, inc),
151
+ `trial ${trial}: len=${len} boundaries=[${bs}]`,
152
+ );
153
+ }
154
+ });
155
+
156
+ test("A2: SEGMENT REUSE IS TRANSPARENT — a warm fold equals a cold one", () => {
157
+ // The cache exists only to skip work. A tree that depends on cache state
158
+ // would make the store depend on eviction order.
159
+ const mind = newMind();
160
+ const { rnd } = streams(777);
161
+ for (let trial = 0; trial < 150; trial++) {
162
+ const len = 40 + Math.floor(rnd() * 300);
163
+ const b = new Uint8Array(len);
164
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
165
+ const nb = 1 + Math.floor(rnd() * 4);
166
+ const bs = [
167
+ ...new Set(
168
+ Array.from({ length: nb }, () => 1 + Math.floor(rnd() * (len - 1))),
169
+ ),
170
+ ]
171
+ .sort((x, y) => x - y);
172
+ const cut = bs[bs.length - 1];
173
+ const warm = stablePrefixFoldIncremental(
174
+ mind.space,
175
+ mind.alphabet,
176
+ b.subarray(0, cut),
177
+ bs.slice(0, -1),
178
+ );
179
+ const grown =
180
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs, warm.fold)
181
+ .tree;
182
+ const cold =
183
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
184
+ assert.ok(sameTree(cold, grown), `trial ${trial}: boundaries=[${bs}]`);
185
+ }
186
+ });
187
+
188
+ test("A3: degenerate boundary sets fold identically through both entry points", () => {
189
+ // Out-of-order is the one that bit: the cut filter is sequential (`b > prev`),
190
+ // so an unsorted entry is DROPPED rather than rejected. bytesToTree sorted
191
+ // on the way in and absorbed it; its twin must too, or the same set yields
192
+ // two different trees depending on which door it came through.
193
+ const mind = newMind();
194
+ const b = enc("abcdefghijklmnop");
195
+ const sets = [[], [0], [16], [0, 16], [5, 5], [3, 1], [9, 2, 14], [40], [
196
+ -1,
197
+ 4,
198
+ ], [4, 4, 4]];
199
+ for (const bs of sets) {
200
+ const ref = bytesToTree(
201
+ mind.space,
202
+ mind.alphabet,
203
+ b,
204
+ undefined,
205
+ undefined,
206
+ bs.length ? bs : undefined,
207
+ );
208
+ const inc =
209
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
210
+ assert.ok(sameTree(ref, inc), `boundaries=[${bs}]`);
211
+ }
212
+ });
213
+
214
+ test("A4: the stable-prefix property itself — a prefix folds free of what follows", () => {
215
+ // The property the whole multi-turn design rests on. Asserted against the
216
+ // PLAIN fold as a control, because without a control "cosine 1.0" only says
217
+ // the fold is deterministic.
218
+ const mind = newMind();
219
+ const turns = [
220
+ "the weeping woman was painted by picasso",
221
+ "picasso co-founded the cubist movement",
222
+ "cubism began in paris around 1907",
223
+ "braque worked alongside him there",
224
+ ];
225
+ let ctx = "", bounds = [];
226
+ const snaps = [];
227
+ for (const t of turns) {
228
+ if (ctx.length > 0) bounds.push(ctx.length);
229
+ ctx += t;
230
+ snaps.push({
231
+ ctx,
232
+ tree: bytesToTree(
233
+ mind.space,
234
+ mind.alphabet,
235
+ enc(ctx),
236
+ undefined,
237
+ undefined,
238
+ bounds.length ? [...bounds] : undefined,
239
+ ),
240
+ });
241
+ }
242
+ const spine = (root, down) => {
243
+ let n = root;
244
+ for (let i = 0; i < down; i++) n = n.kids[0];
245
+ return n;
246
+ };
247
+ const K = snaps.length;
248
+ for (let j = 1; j < K; j++) {
249
+ const inside = spine(snaps[K - 1].tree, K - j);
250
+ const alone = snaps[j - 1].tree;
251
+ assert.ok(
252
+ sameTree(inside, alone),
253
+ `prefix of ${j} turn(s) must fold identically inside the grown context`,
254
+ );
255
+ }
256
+ // Control: under a plain fold the previous root does not survive anywhere.
257
+ for (let i = 1; i < K; i++) {
258
+ const prevPlain = bytesToTree(
259
+ mind.space,
260
+ mind.alphabet,
261
+ enc(snaps[i - 1].ctx),
262
+ );
263
+ const nowPlain = bytesToTree(mind.space, mind.alphabet, enc(snaps[i].ctx));
264
+ let best = -1;
265
+ const walk = (n) => {
266
+ best = Math.max(best, cos(n.v, prevPlain.v));
267
+ if (n.kids) n.kids.forEach(walk);
268
+ };
269
+ walk(nowPlain);
270
+ assert.ok(
271
+ best < 0.95,
272
+ `plain fold should NOT preserve the prefix root (got ${best.toFixed(3)})`,
273
+ );
274
+ }
275
+ });
276
+
277
+ test("A5: contentFoldIncremental ≡ the plain fold, cold and warm", () => {
278
+ // The fold the conversation and deposit paths now share. It must equal
279
+ // bytesToTree with NO boundary set — that equality IS the train/infer
280
+ // agreement — both from cold and when reusing a prefix's segments.
281
+ const mind = newMind();
282
+ const { rnd } = streams(31337);
283
+ for (let trial = 0; trial < 250; trial++) {
284
+ const len = 1 + Math.floor(rnd() * 400);
285
+ const b = new Uint8Array(len);
286
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
287
+ const ref = bytesToTree(mind.space, mind.alphabet, b);
288
+ const cold = contentFoldIncremental(mind.space, mind.alphabet, b).tree;
289
+ assert.ok(sameTree(ref, cold), `cold: trial ${trial}, len=${len}`);
290
+ const cut = Math.max(1, Math.floor(len * 0.6));
291
+ const pre = contentFoldIncremental(
292
+ mind.space,
293
+ mind.alphabet,
294
+ b.subarray(0, cut),
295
+ );
296
+ const warm =
297
+ contentFoldIncremental(mind.space, mind.alphabet, b, pre.fold).tree;
298
+ assert.ok(
299
+ sameTree(ref, warm),
300
+ `warm: trial ${trial}, len=${len}, cut=${cut}`,
301
+ );
302
+ // a reused segment must never be mutated by the root normalize
303
+ const again =
304
+ contentFoldIncremental(mind.space, mind.alphabet, b, pre.fold).tree;
305
+ assert.ok(
306
+ sameTree(ref, again),
307
+ `second warm fold differed: trial ${trial}`,
308
+ );
309
+ }
310
+ });
311
+
312
+ test("A6: content cuts are stable under append — the reuse this rests on", () => {
313
+ // Why a PLAIN fold is incrementally reusable at all: cuts are decided by a
314
+ // rolling hash over a local window, so bytes appended at the right edge
315
+ // cannot move a cut to their left. If this ever stops holding, the
316
+ // incremental fold silently degrades to a full refold every turn.
317
+ const mind = newMind();
318
+ const { rnd } = streams(9001);
319
+ for (let trial = 0; trial < 40; trial++) {
320
+ const base = new Uint8Array(50 + Math.floor(rnd() * 300));
321
+ for (let i = 0; i < base.length; i++) base[i] = Math.floor(rnd() * 256);
322
+ const before = contentBoundaries(mind.space, base);
323
+ const addLen = 1 + Math.floor(rnd() * 60);
324
+ const grown = new Uint8Array(base.length + addLen);
325
+ grown.set(base, 0);
326
+ for (let i = 0; i < addLen; i++) {
327
+ grown[base.length + i] = Math.floor(rnd() * 256);
328
+ }
329
+ const after = contentBoundaries(mind.space, grown);
330
+ for (let i = 0; i < before.length; i++) {
331
+ assert.equal(
332
+ after[i],
333
+ before[i],
334
+ `trial ${trial}: cut ${i} moved on append`,
335
+ );
336
+ }
337
+ }
338
+ });
339
+
340
+ // ═══════════════════════════════════════════════════════════════════════
341
+ // B. THE OPTIMISATION ACTUALLY HAPPENS
342
+ //
343
+ // This is the section that catches a silent performance regression. Rebuilding
344
+ // the context tree every turn is just as CORRECT as reusing it, so no accuracy
345
+ // test can see the difference — only node identity can.
346
+ // ═══════════════════════════════════════════════════════════════════════
347
+
348
+ const convTree = (mind, conv) => mind._conversations.get(conv.id).tree;
349
+
350
+ test("B1: a grown context REUSES the previous turn's subtree objects", () => {
351
+ const mind = newMind();
352
+ const conv = mind.beginConversation();
353
+ const turns = [
354
+ "alpha turn one here",
355
+ "beta turn two here",
356
+ "gamma turn three",
357
+ "delta turn four now",
358
+ ];
359
+ let prev = null;
360
+ const rows = [];
361
+ for (const t of turns) {
362
+ mind.addTurn(conv, t);
363
+ const set = nodeSet(convTree(mind, conv));
364
+ const shared = prev ? [...set].filter((n) => prev.has(n)).length : 0;
365
+ rows.push({ total: set.size, shared, fresh: set.size - shared });
366
+ prev = set;
367
+ }
368
+ // Turn 1 has nothing to share. Every later turn must reuse the bulk of the
369
+ // tree — with a full rebuild this is exactly 0, so any floor above 0 is a
370
+ // real guard; 40% leaves generous slack for fold-shape changes.
371
+ for (let i = 1; i < rows.length; i++) {
372
+ const frac = rows[i].shared / rows[i].total;
373
+ assert.ok(
374
+ frac > 0.4,
375
+ `turn ${i + 1}: only ${rows[i].shared}/${rows[i].total} nodes reused (${
376
+ (frac * 100).toFixed(0)
377
+ }%) — ` +
378
+ `the context is being re-folded from scratch, not extended`,
379
+ );
380
+ }
381
+ // And the fresh work must track the TURN, not the context: the last turn is
382
+ // no larger than the first, so its fresh-node count must not have grown with
383
+ // the accumulated context.
384
+ assert.ok(
385
+ rows[rows.length - 1].fresh <= rows[1].fresh * 2,
386
+ `fresh nodes per turn grew with context: ${
387
+ rows.map((r) => r.fresh).join(", ")
388
+ }`,
389
+ );
390
+ });
391
+
392
+ test("B2: per-turn perception cost does not grow with the accumulated context", async () => {
393
+ const pairs = [
394
+ [
395
+ "who painted the weeping woman",
396
+ "pablo picasso painted the weeping woman",
397
+ ],
398
+ ["what movement did he found", "he co-founded the cubist movement"],
399
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
400
+ ["where did it begin", "it began in paris"],
401
+ ["who worked with him there", "georges braque worked with him"],
402
+ ["what did they fragment", "they fragmented objects into geometric planes"],
403
+ [
404
+ "what came in nineteen thirty seven",
405
+ "guernica came in nineteen thirty seven",
406
+ ],
407
+ ["what did it protest", "it protested the bombing of guernica"],
408
+ ];
409
+ const mind = newMind({ profile: true });
410
+ let ctx = "";
411
+ for (const [u, a] of pairs) {
412
+ ctx += u;
413
+ await mind.ingest(ctx, a);
414
+ ctx += a;
415
+ }
416
+
417
+ const conv = mind.beginConversation();
418
+ const perTurn = [];
419
+ for (const [u] of pairs) {
420
+ await mind.respondTurnText(conv, u);
421
+ perTurn.push({
422
+ bytes: mind.lastCost.counters.perceivedBytes ?? 0,
423
+ align: mind.lastCost.counters.alignCells ?? 0,
424
+ ctx: mind.conversationState(conv).context.length,
425
+ });
426
+ }
427
+ const finalCtx = perTurn[perTurn.length - 1].ctx;
428
+
429
+ // ALIGNMENT IS THE LOUD ONE. When the cumulative context resolves exactly,
430
+ // the quadratic alignment family never has to run. Before the boundary
431
+ // agreement was fixed this reached 3.1e7 cells on a 579-byte context, so a
432
+ // ceiling here is the single most sensitive regression signal in the file.
433
+ const worstAlign = Math.max(...perTurn.map((p) => p.align));
434
+ assert.ok(
435
+ worstAlign < finalCtx * finalCtx,
436
+ `alignment went quadratic in the context (${worstAlign} cells over ${finalCtx} bytes) — ` +
437
+ `the trained context root is no longer resolving`,
438
+ );
439
+
440
+ // PERCEPTION IS THE STEADY ONE. Later turns must not fold more than early
441
+ // turns merely because the context is longer.
442
+ const early = perTurn.slice(1, 4).reduce((s, p) => s + p.bytes, 0) / 3;
443
+ const late = perTurn.slice(-3).reduce((s, p) => s + p.bytes, 0) / 3;
444
+ assert.ok(
445
+ late <= Math.max(early * 3, 4000),
446
+ `perceived bytes per turn grew with context (early ≈ ${early | 0}, late ≈ ${
447
+ late | 0
448
+ }): ` +
449
+ perTurn.map((p) => p.bytes).join(", "),
450
+ );
451
+ });
452
+
453
+ test("B3: a restored conversation reuses subtrees too", () => {
454
+ // A resumed conversation is otherwise identical to a live one; if restore
455
+ // dropped the segment state it would pay a full re-fold for the rest of its
456
+ // life, and nothing downstream would notice.
457
+ const mind = newMind();
458
+ const a = mind.beginConversation();
459
+ for (
460
+ const t of [
461
+ "first turn text here",
462
+ "second turn text here",
463
+ "third turn text",
464
+ ]
465
+ ) mind.addTurn(a, t);
466
+ const state = mind.conversationState(a);
467
+
468
+ const b = mind.beginConversation(state);
469
+ const before = nodeSet(convTree(mind, b));
470
+ mind.addTurn(b, "fourth turn text");
471
+ const after = nodeSet(convTree(mind, b));
472
+ const shared = [...after].filter((n) => before.has(n)).length;
473
+ assert.ok(
474
+ shared / after.size > 0.4,
475
+ `a restored conversation re-folded from scratch (${shared}/${after.size} reused)`,
476
+ );
477
+ });
478
+
479
+ // ═══════════════════════════════════════════════════════════════════════
480
+ // C. THE MEMO KEY CARRIES THE BOUNDARIES
481
+ // ═══════════════════════════════════════════════════════════════════════
482
+
483
+ test("C1: the same bytes under different boundary sets are different memo keys", () => {
484
+ const b = enc("one two three four five six");
485
+ assert.notEqual(
486
+ perceiveKey(b, [7]),
487
+ perceiveKey(b),
488
+ "boundaries must change the key",
489
+ );
490
+ assert.notEqual(
491
+ perceiveKey(b, [7]),
492
+ perceiveKey(b, [11]),
493
+ "different cuts, different keys",
494
+ );
495
+ assert.equal(
496
+ perceiveKey(b, []),
497
+ perceiveKey(b),
498
+ "an empty set is the plain fold",
499
+ );
500
+ assert.equal(
501
+ perceiveKey(b, undefined),
502
+ perceiveKey(b),
503
+ "undefined is the plain fold",
504
+ );
505
+ assert.equal(
506
+ perceiveKey(b, [7]),
507
+ perceiveKey(b, [7]),
508
+ "the key is a function of its inputs",
509
+ );
510
+ });
511
+
512
+ test("C2: no content can forge the key's boundary separator", () => {
513
+ // The key is content + separator + rendered boundaries. Two DIFFERENT
514
+ // (bytes, boundaries) pairs must never collide, including when the content
515
+ // itself contains digits, commas, or the separator byte.
516
+ const seen = new Map();
517
+ const cases = [
518
+ [enc("abc"), [1, 2]],
519
+ [enc("abc"), [12]],
520
+ [enc("abc1,2"), undefined],
521
+ [enc("abc1"), [2]],
522
+ [enc("ab"), [1]],
523
+ [enc("ab"), undefined],
524
+ [enc("a"), [1, 2]],
525
+ [enc("a"), [12]],
526
+ [enc("1,2"), [3]],
527
+ [enc(""), [1]],
528
+ [enc(""), undefined],
529
+ ];
530
+ for (const [b, bs] of cases) {
531
+ const k = perceiveKey(b, bs);
532
+ const label = `${JSON.stringify(new TextDecoder().decode(b))}/${
533
+ bs ?? "none"
534
+ }`;
535
+ if (seen.has(k)) {
536
+ const other = seen.get(k);
537
+ // A collision is only legal when the two really are the same perception.
538
+ const sameInput = other.b === l1(b) &&
539
+ JSON.stringify(other.bs ?? []) === JSON.stringify(bs ?? []);
540
+ assert.ok(sameInput, `key collision between ${other.label} and ${label}`);
541
+ }
542
+ seen.set(k, { b: l1(b), bs, label });
543
+ }
544
+ });
545
+
546
+ // ═══════════════════════════════════════════════════════════════════════
547
+ // D. THE DEPOSIT PATH RECORDS THE BOUNDARIES INFERENCE READS
548
+ //
549
+ // The guard must be PRECISE (never chain unrelated deposits — most of a real
550
+ // corpus is single-turn facts) and have RECALL (always chain a genuine turn).
551
+ // ═══════════════════════════════════════════════════════════════════════
552
+
553
+ test("D1: TRAIN/INFER AGREEMENT — deposit and inference fold identically", () => {
554
+ // THE headline invariant, and the one the whole review turned on. Neither
555
+ // side imposes a boundary set: the deposit path and the conversation path
556
+ // both fold a stream over its OWN content cuts, so the trained context node
557
+ // and the node inference resolves are the same node by construction.
558
+ //
559
+ // When they disagreed, the trained and inferred roots for identical bytes
560
+ // sat at cosine 0.02-0.21, multi-turn recall collapsed, and the alignment
561
+ // family went quadratic (5.2M cells on a 476-byte context, against 0 when
562
+ // they agree).
563
+ const mind = newMind();
564
+ const conv = mind.beginConversation();
565
+ const turns = [
566
+ "user turn one asks a thing",
567
+ "assistant replies to one",
568
+ "user turn two asks more",
569
+ "assistant replies to two",
570
+ "user turn three asks again",
571
+ "assistant replies to three",
572
+ ];
573
+ for (const t of turns) {
574
+ mind.addTurn(conv, t);
575
+ const data = mind._conversations.get(conv.id);
576
+ const plain = bytesToTree(mind.space, mind.alphabet, data.bytes);
577
+ assert.ok(
578
+ sameTree(plain, data.tree),
579
+ `the conversation folded ${data.bytes.length}B differently from perceive() on the same bytes`,
580
+ );
581
+ }
582
+ });
583
+
584
+ test("D2: no fold imposes a boundary set — reuse is transparent, not structural", async () => {
585
+ // A deposit's tree must be a pure function of its BYTES. Depositing a chain
586
+ // with a hot segment cache must store exactly what depositing it with a cold
587
+ // one stores, or the store would depend on cache residency and eviction
588
+ // order. Compared by what is STORED (node count + read-back bytes), never by
589
+ // raw node ids: ids are mint order, so two stores that saw different numbers
590
+ // of deposits number the same content differently.
591
+ const turns = [
592
+ "one asks a question here",
593
+ "one answers it plainly",
594
+ "two asks a question here",
595
+ "two answers it plainly",
596
+ "three asks a question",
597
+ "three answers it plainly",
598
+ ];
599
+ const cumulative = [];
600
+ {
601
+ let c = "";
602
+ for (const t of turns) {
603
+ c += t;
604
+ cumulative.push(c);
605
+ }
606
+ }
607
+
608
+ const deposit = async (cold) => {
609
+ const mind = newMind();
610
+ for (let i = 0; i + 1 < turns.length; i++) {
611
+ if (cold) {
612
+ mind._depositTrees.clear();
613
+ mind._depositLens.clear();
614
+ }
615
+ await mind.ingest(cumulative[i], turns[i + 1]);
616
+ }
617
+ const readback = [];
618
+ for (let i = 0; i + 1 < turns.length; i++) {
619
+ const id = mind.resolve(enc(cumulative[i]));
620
+ readback.push(
621
+ id === null
622
+ ? null
623
+ : new TextDecoder().decode(await mind.store.bytes(id)),
624
+ );
625
+ }
626
+ return { nodes: mind.store.nodeCount(), readback };
627
+ };
628
+
629
+ const warm = await deposit(false);
630
+ const cold = await deposit(true);
631
+ assert.ok(
632
+ warm.readback.every((x) => x !== null),
633
+ "a deposited context did not resolve",
634
+ );
635
+ assert.deepEqual(
636
+ warm.readback,
637
+ cumulative.slice(0, -1),
638
+ "a resolved context node does not hold that context's bytes",
639
+ );
640
+ assert.deepEqual(
641
+ warm.readback,
642
+ cold.readback,
643
+ "segment reuse changed what was stored",
644
+ );
645
+ assert.equal(
646
+ warm.nodes,
647
+ cold.nodes,
648
+ "segment reuse changed how many nodes were minted",
649
+ );
650
+ });
651
+
652
+ test("D3: a deposited context resolves through the plain path", async () => {
653
+ // The consequence of agreement, stated as the property callers depend on:
654
+ // whatever was deposited can be found again by content addressing, with no
655
+ // boundary set and no conversation handle.
656
+ const mind = newMind();
657
+ const turns = [
658
+ "ask about the painting",
659
+ "it was painted by picasso",
660
+ "ask about the movement",
661
+ "he founded cubism",
662
+ ];
663
+ let ctx = "";
664
+ const ctxs = [];
665
+ for (let i = 0; i + 1 < turns.length; i++) {
666
+ ctx += turns[i];
667
+ ctxs.push(ctx);
668
+ await mind.ingest(ctx, turns[i + 1]);
669
+ }
670
+ for (const c of ctxs) {
671
+ assert.notEqual(
672
+ mind.resolve(enc(c)),
673
+ null,
674
+ `deposited context did not resolve: ${JSON.stringify(c.slice(0, 40))}`,
675
+ );
676
+ }
677
+ });
678
+
679
+ test("D4: an unrelated deposit sharing a byte prefix is harmless", async () => {
680
+ // This used to need a continuation-bytes proof, because a wrong guess
681
+ // changed the TREE. Nothing is imposed now, so a coincidental prefix simply
682
+ // reuses identical segments and both deposits keep their own correct trees.
683
+ const mind = newMind();
684
+ await mind.ingest("what is two plus two", "four");
685
+ await mind.ingest("what is two plus two hundred", "two hundred and two");
686
+ const a = mind.resolve(enc("what is two plus two"));
687
+ const b = mind.resolve(enc("what is two plus two hundred"));
688
+ assert.notEqual(a, null);
689
+ assert.notEqual(b, null);
690
+ assert.notEqual(a, b, "two different facts collapsed to one node");
691
+ assert.equal((await mind.respondText("what is two plus two")).trim(), "four");
692
+ assert.equal(
693
+ (await mind.respondText("what is two plus two hundred")).trim(),
694
+ "two hundred and two",
695
+ );
696
+ });
697
+
698
+ test("D5: re-deposition is idempotent — no new nodes, same answers", async () => {
699
+ const turns = [
700
+ "alpha asks one",
701
+ "beta says one",
702
+ "alpha asks two",
703
+ "beta says two",
704
+ ];
705
+ const mind = newMind();
706
+ const rounds = [];
707
+ for (let r = 0; r < 2; r++) {
708
+ await teach(mind, turns);
709
+ const conv = mind.beginConversation();
710
+ const outs = [];
711
+ for (let i = 0; i + 1 < turns.length; i += 2) {
712
+ outs.push((await mind.respondTurnText(conv, turns[i])).response);
713
+ }
714
+ mind.endConversation(conv);
715
+ rounds.push({ nodes: mind.store.nodeCount(), outs });
716
+ }
717
+ assert.equal(
718
+ rounds[0].nodes,
719
+ rounds[1].nodes,
720
+ "re-depositing the same chain minted new nodes",
721
+ );
722
+ assert.deepEqual(
723
+ rounds[0].outs,
724
+ rounds[1].outs,
725
+ "re-deposition changed the answers",
726
+ );
727
+ });
728
+
729
+ test("D6: a long chain and the 8-entry cache — correctness never depends on it", async () => {
730
+ // The cache is a work cache with a hard bound, so a long conversation WILL
731
+ // evict its early links. Every context must still resolve: an evicted entry
732
+ // costs a refold, never a different tree.
733
+ const mind = newMind();
734
+ const N = 25;
735
+ let ctx = "";
736
+ const ctxs = [];
737
+ for (let i = 0; i < N; i++) {
738
+ ctx += `u${i} question text here`;
739
+ ctxs.push(ctx);
740
+ await mind.ingest(ctx, `a${i} answer text here`);
741
+ ctx += `a${i} answer text here`;
742
+ }
743
+ for (let i = 0; i < ctxs.length; i++) {
744
+ assert.notEqual(
745
+ mind.resolve(enc(ctxs[i])),
746
+ null,
747
+ `context ${i} did not resolve after eviction`,
748
+ );
749
+ }
750
+ });
751
+
752
+ test("D7: interleaved conversations stay independent", async () => {
753
+ // Six conversations against an 8-entry cache: entries evict constantly.
754
+ // Every context of every conversation must still resolve to its own node.
755
+ const mind = newMind();
756
+ const C = 6, T = 4;
757
+ const ctxs = Array.from({ length: C }, () => "");
758
+ const all = [];
759
+ for (let t = 0; t < T; t++) {
760
+ for (let c = 0; c < C; c++) {
761
+ ctxs[c] += `c${c}u${t} the question `;
762
+ all.push(ctxs[c]);
763
+ await mind.ingest(ctxs[c], `c${c}a${t} the answer `);
764
+ ctxs[c] += `c${c}a${t} the answer `;
765
+ }
766
+ }
767
+ const ids = all.map((c) => mind.resolve(enc(c)));
768
+ assert.ok(
769
+ ids.every((x) => x !== null),
770
+ "an interleaved deposit did not resolve",
771
+ );
772
+ assert.equal(
773
+ new Set(ids).size,
774
+ ids.length,
775
+ "two distinct contexts collapsed to one node",
776
+ );
777
+ });
778
+
779
+ // ═══════════════════════════════════════════════════════════════════════
780
+ // E. CONVERSATION STATE IS SOUND ACROSS SAVE AND RESTORE
781
+ // ═══════════════════════════════════════════════════════════════════════
782
+
783
+ test("E1: boundaries are always strictly increasing and inside the context", () => {
784
+ const mind = newMind();
785
+ const conv = mind.beginConversation();
786
+ for (const t of ["one", "", "two turns", "three turns now", "", "four"]) {
787
+ mind.addTurn(conv, t);
788
+ }
789
+ const st = mind.conversationState(conv);
790
+ for (let i = 1; i < st.boundaries.length; i++) {
791
+ assert.ok(
792
+ st.boundaries[i] > st.boundaries[i - 1],
793
+ `boundaries not strictly increasing: ${st.boundaries}`,
794
+ );
795
+ }
796
+ for (const b of st.boundaries) {
797
+ assert.ok(
798
+ b > 0 && b < st.context.length,
799
+ `boundary ${b} outside a ${st.context.length}-byte context`,
800
+ );
801
+ }
802
+ });
803
+
804
+ test("E2: restoring between every turn equals an uninterrupted conversation", async () => {
805
+ const turns = [
806
+ "who painted it",
807
+ "picasso painted it",
808
+ "what movement",
809
+ "cubism was the movement",
810
+ "when did it start",
811
+ "it started in nineteen oh seven",
812
+ ];
813
+ const build = async () => {
814
+ const m = newMind();
815
+ await teach(m, turns);
816
+ return m;
817
+ };
818
+
819
+ const m1 = await build();
820
+ const c1 = m1.beginConversation();
821
+ const live = [];
822
+ for (let i = 0; i < turns.length; i += 2) {
823
+ live.push((await m1.respondTurnText(c1, turns[i])).response);
824
+ }
825
+
826
+ const m2 = await build();
827
+ let st;
828
+ const restored = [];
829
+ for (let i = 0; i < turns.length; i += 2) {
830
+ const c = m2.beginConversation(st);
831
+ const r = await m2.respondTurnText(c, turns[i]);
832
+ restored.push(r.response);
833
+ st = r.state;
834
+ m2.endConversation(c);
835
+ }
836
+ assert.deepEqual(
837
+ restored,
838
+ live,
839
+ "save/restore between turns changed the conversation",
840
+ );
841
+ });
842
+
843
+ test("E3: out-of-order restored boundaries are normalised, not silently dropped", () => {
844
+ // A ConversationState can arrive from outside — hand-built, migrated, or
845
+ // round-tripped. The folds filter cuts sequentially, so an unsorted entry
846
+ // would be dropped and the conversation would fold over a different set
847
+ // than the caller believes it restored.
848
+ const mind = newMind();
849
+ const conv = mind.beginConversation();
850
+ for (
851
+ const t of [
852
+ "turn one here",
853
+ "turn two here",
854
+ "turn three here",
855
+ "turn four",
856
+ ]
857
+ ) mind.addTurn(conv, t);
858
+ const good = mind.conversationState(conv);
859
+
860
+ const shuffled = { ...good, boundaries: [...good.boundaries].reverse() };
861
+ const dupes = {
862
+ ...good,
863
+ boundaries: [...good.boundaries, ...good.boundaries],
864
+ };
865
+ const oob = {
866
+ ...good,
867
+ boundaries: [
868
+ 0,
869
+ ...good.boundaries,
870
+ good.context.length,
871
+ good.context.length + 99,
872
+ ],
873
+ };
874
+
875
+ const ref = mind.conversationState(mind.beginConversation(good)).boundaries;
876
+ for (
877
+ const [label, st] of [["reversed", shuffled], ["duplicated", dupes], [
878
+ "out-of-range",
879
+ oob,
880
+ ]]
881
+ ) {
882
+ const got = mind.conversationState(mind.beginConversation(st)).boundaries;
883
+ assert.deepEqual(
884
+ got,
885
+ ref,
886
+ `${label} boundaries were not normalised to the same set`,
887
+ );
888
+ }
889
+ });
890
+
891
+ test("E4: answeredSpans track the assistant's own replies", async () => {
892
+ const turns = [
893
+ "ask one thing",
894
+ "reply to one",
895
+ "ask two things",
896
+ "reply to two",
897
+ ];
898
+ const mind = newMind();
899
+ await teach(mind, turns);
900
+ const conv = mind.beginConversation();
901
+ const st1 = (await mind.respondTurnText(conv, turns[0])).state;
902
+ const ctx = new TextDecoder().decode(st1.context);
903
+ for (const [s, e] of st1.answeredSpans) {
904
+ assert.ok(
905
+ e > s && e <= st1.context.length,
906
+ `answered span [${s},${e}) outside the context`,
907
+ );
908
+ // the span must name bytes the mind produced, not bytes the user supplied
909
+ assert.ok(
910
+ !ctx.slice(0, s).endsWith(ctx.slice(s, e)),
911
+ "an answered span duplicates user text",
912
+ );
913
+ }
914
+ });
915
+
916
+ // ═══════════════════════════════════════════════════════════════════════
917
+ // F. END TO END — the behaviour all of the above exists to protect
918
+ // ═══════════════════════════════════════════════════════════════════════
919
+
920
+ test("F1: every turn of a trained conversation is answered exactly", async () => {
921
+ const pairs = [
922
+ [
923
+ "who painted the weeping woman",
924
+ "pablo picasso painted the weeping woman",
925
+ ],
926
+ ["what movement did he found", "he co-founded the cubist movement"],
927
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
928
+ ["where did it begin", "it began in paris"],
929
+ ["who worked with him there", "georges braque worked with him"],
930
+ ["what did they fragment", "they fragmented objects into geometric planes"],
931
+ [
932
+ "what came in nineteen thirty seven",
933
+ "guernica came in nineteen thirty seven",
934
+ ],
935
+ ["what did it protest", "it protested the bombing of guernica"],
936
+ ["where does it hang now", "it hangs in madrid"],
937
+ ["thank you for the summary", "you are welcome"],
938
+ ];
939
+ const mind = newMind();
940
+ let ctx = "";
941
+ for (const [u, a] of pairs) {
942
+ ctx += u;
943
+ await mind.ingest(ctx, a);
944
+ ctx += a;
945
+ }
946
+
947
+ const conv = mind.beginConversation();
948
+ const wrong = [];
949
+ for (const [u, want] of pairs) {
950
+ const got = (await mind.respondTurnText(conv, u)).response.trim();
951
+ if (got !== want) wrong.push({ u, want, got });
952
+ }
953
+ // Before the boundary agreement was fixed this scored 3/10, and the failures
954
+ // were not silence but CONFIDENT WRONG ANSWERS from later turns — so a floor
955
+ // here guards meaning, not just recall.
956
+ assert.deepEqual(
957
+ wrong,
958
+ [],
959
+ `${wrong.length}/${pairs.length} turns answered wrongly`,
960
+ );
961
+ });
962
+
963
+ test("F1b: attaching a trace changes no answer — the audit layer is inert", async () => {
964
+ // The mind's ONLY text-shaped code lives in the rationale/trace payloads:
965
+ // attention.ts's `dec` helper decodes bytes and collapses whitespace so an
966
+ // audit line is readable, and frame-filler builds diagnostic strings the
967
+ // same way. Neither may ever reach a decision — nothing in the core knows
968
+ // what "whitespace" is (see canon.ts's header, and AGENTS §2.11: profile
969
+ // and trace must not move an answer). Asserted here rather than assumed,
970
+ // because the formatting sits inside the same functions that decide.
971
+ const pairs = [
972
+ [
973
+ "who painted the weeping woman",
974
+ "pablo picasso painted the weeping woman",
975
+ ],
976
+ ["what movement did he found", "he co-founded the cubist movement"],
977
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
978
+ ["where did it begin", "it began in paris"],
979
+ ["who worked with him there", "georges braque worked with him"],
980
+ ];
981
+ const run = async (traced) => {
982
+ const mind = newMind();
983
+ let ctx = "";
984
+ const ctxs = [];
985
+ for (const [u, a] of pairs) {
986
+ ctx += u;
987
+ ctxs.push(ctx);
988
+ await mind.ingest(ctx, a);
989
+ ctx += a;
990
+ }
991
+ const outs = [];
992
+ const sink = traced ? () => {} : undefined;
993
+ for (const c of ctxs) outs.push(await mind.respondText(c, sink));
994
+ const conv = mind.beginConversation();
995
+ for (const [u] of pairs) {
996
+ outs.push((await mind.respondTurnText(conv, u, sink)).response);
997
+ }
998
+ return outs;
999
+ };
1000
+ assert.deepEqual(
1001
+ await run(true),
1002
+ await run(false),
1003
+ "a trace changed an answer",
1004
+ );
1005
+ });
1006
+
1007
+ test("F2: a conversation is deterministic — identical bytes, identical replies and ids", async () => {
1008
+ const pairs = [["q one here", "a one here"], ["q two here", "a two here"], [
1009
+ "q three here",
1010
+ "a three here",
1011
+ ]];
1012
+ const run = async () => {
1013
+ const mind = newMind();
1014
+ let ctx = "";
1015
+ for (const [u, a] of pairs) {
1016
+ ctx += u;
1017
+ await mind.ingest(ctx, a);
1018
+ ctx += a;
1019
+ }
1020
+ const conv = mind.beginConversation();
1021
+ const outs = [];
1022
+ for (const [u] of pairs) {
1023
+ outs.push((await mind.respondTurnText(conv, u)).response);
1024
+ }
1025
+ const st = mind.conversationState(conv);
1026
+ return { outs, boundaries: st.boundaries, nodes: mind.store.nodeCount() };
1027
+ };
1028
+ const a = await run(), b = await run();
1029
+ assert.deepEqual(a, b, "the conversation path is not reproducible");
1030
+ });
1031
+
1032
+ test("F3: the conversation API is never WORSE than respond() on the same bytes", async () => {
1033
+ // The original defect, stated as a property: respondTurn diverged from
1034
+ // respond on byte-identical input and lost. The Conversation API knows
1035
+ // strictly more (the turn boundaries), so it must never do worse.
1036
+ const pairs = [
1037
+ [
1038
+ "who painted the weeping woman",
1039
+ "pablo picasso painted the weeping woman",
1040
+ ],
1041
+ ["what movement did he found", "he co-founded the cubist movement"],
1042
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
1043
+ ["where did it begin", "it began in paris"],
1044
+ ["who worked with him there", "georges braque worked with him"],
1045
+ ];
1046
+ const build = async () => {
1047
+ const m = newMind();
1048
+ let ctx = "";
1049
+ const trained = [];
1050
+ for (const [u, a] of pairs) {
1051
+ ctx += u;
1052
+ trained.push(ctx);
1053
+ await m.ingest(ctx, a);
1054
+ ctx += a;
1055
+ }
1056
+ return { m, trained };
1057
+ };
1058
+ const A = await build();
1059
+ let plain = 0;
1060
+ for (let i = 0; i < pairs.length; i++) {
1061
+ if ((await A.m.respondText(A.trained[i])).trim() === pairs[i][1]) plain++;
1062
+ }
1063
+ const B = await build();
1064
+ const conv = B.m.beginConversation();
1065
+ let turnwise = 0;
1066
+ for (let i = 0; i < pairs.length; i++) {
1067
+ if (
1068
+ (await B.m.respondTurnText(conv, pairs[i][0])).response.trim() ===
1069
+ pairs[i][1]
1070
+ ) turnwise++;
1071
+ }
1072
+ assert.ok(
1073
+ turnwise >= plain,
1074
+ `respondTurn scored ${turnwise}/${pairs.length} against respond()'s ${plain}/${pairs.length} — ` +
1075
+ `the path that KNOWS the turn boundaries must not lose to the one that does not`,
1076
+ );
1077
+ assert.equal(
1078
+ turnwise,
1079
+ pairs.length,
1080
+ `respondTurn should answer every trained turn`,
1081
+ );
1082
+ });