@hviana/sema 0.4.7 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/attention.d.ts +15 -10
  10. package/dist/src/mind/attention.js +15 -10
  11. package/dist/src/mind/bridge.js +27 -1
  12. package/dist/src/mind/frame-filler.d.ts +15 -0
  13. package/dist/src/mind/frame-filler.js +535 -0
  14. package/dist/src/mind/learning.js +6 -11
  15. package/dist/src/mind/mechanisms/cast.js +72 -2
  16. package/dist/src/mind/mechanisms/cover.js +6 -1
  17. package/dist/src/mind/mechanisms/extraction.js +27 -0
  18. package/dist/src/mind/mechanisms/recall.js +214 -34
  19. package/dist/src/mind/mind.d.ts +52 -3
  20. package/dist/src/mind/mind.js +140 -12
  21. package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
  22. package/dist/src/mind/pipeline.js +29 -1
  23. package/dist/src/mind/prefix-completion.d.ts +59 -0
  24. package/dist/src/mind/prefix-completion.js +270 -0
  25. package/dist/src/mind/primitives.d.ts +29 -10
  26. package/dist/src/mind/primitives.js +98 -71
  27. package/dist/src/mind/recognition.js +153 -26
  28. package/dist/src/mind/traverse.d.ts +32 -0
  29. package/dist/src/mind/traverse.js +52 -0
  30. package/dist/src/mind/types.d.ts +61 -18
  31. package/dist/src/mind/types.js +68 -19
  32. package/dist/src/store.d.ts +21 -0
  33. package/dist/src/store.js +21 -0
  34. package/example/train_base.ts +21 -4
  35. package/package.json +1 -1
  36. package/src/canon.ts +28 -0
  37. package/src/geometry.ts +100 -1
  38. package/src/mind/attention.ts +15 -10
  39. package/src/mind/bridge.ts +34 -0
  40. package/src/mind/frame-filler.ts +604 -0
  41. package/src/mind/learning.ts +5 -9
  42. package/src/mind/mechanisms/cast.ts +70 -2
  43. package/src/mind/mechanisms/cover.ts +6 -1
  44. package/src/mind/mechanisms/extraction.ts +27 -0
  45. package/src/mind/mechanisms/recall.ts +236 -37
  46. package/src/mind/mind.ts +166 -18
  47. package/src/mind/pipeline-mechanism.ts +7 -0
  48. package/src/mind/pipeline.ts +33 -1
  49. package/src/mind/prefix-completion.ts +314 -0
  50. package/src/mind/primitives.ts +105 -80
  51. package/src/mind/recognition.ts +151 -23
  52. package/src/mind/traverse.ts +52 -0
  53. package/src/mind/types.ts +104 -44
  54. package/src/store.ts +25 -0
  55. package/test/13-conversation.test.mjs +13 -0
  56. package/test/57-fusion-order.test.mjs +65 -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 +1334 -0
@@ -0,0 +1,1334 @@
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 { foldTree, perceiveKey } from "../dist/src/mind/primitives.js";
63
+ // White-box for section G: the visit-completeness invariant is a property of
64
+ // these two functions directly, not of any number they eventually move.
65
+ import { recognise } from "../dist/src/mind/recognition.js";
66
+
67
+ const enc = (s) => new TextEncoder().encode(s);
68
+ const newMind = (opts = {}) => new Mind({ seed: 7, ...opts });
69
+
70
+ /** Structural signature: shape + leaf bytes, no vectors. */
71
+ const sig = (n) =>
72
+ n.kids === null
73
+ ? "L" + Array.from(n.leaf ?? []).join(",")
74
+ : "(" + n.kids.map(sig).join("|") + ")";
75
+
76
+ const cos = (a, b) => {
77
+ let d = 0, na = 0, nb = 0;
78
+ for (let i = 0; i < a.length; i++) {
79
+ d += a[i] * b[i];
80
+ na += a[i] * a[i];
81
+ nb += b[i] * b[i];
82
+ }
83
+ return d / Math.sqrt(na * nb);
84
+ };
85
+
86
+ /** Two trees are the SAME perception: same structure, same direction. */
87
+ const sameTree = (t1, t2) => sig(t1) === sig(t2) && cos(t1.v, t2.v) > 0.999999;
88
+
89
+ /** Every Sema object reachable from a root — identity, not content. */
90
+ const nodeSet = (n, s = new Set()) => {
91
+ s.add(n);
92
+ if (n.kids) { for (const k of n.kids) nodeSet(k, s); }
93
+ return s;
94
+ };
95
+
96
+ /** The latin1 content key the deposit cache is keyed by. */
97
+ const l1 = (b) => {
98
+ let o = "";
99
+ for (const x of b) o += String.fromCharCode(x);
100
+ return o;
101
+ };
102
+
103
+ /** Train a conversation the way ingestPair chains it: cumulative context →
104
+ * next turn, with the caller's own join string (default: none, as test/13). */
105
+ async function teach(mind, turns, join = "") {
106
+ for (let i = 1; i < turns.length; i++) {
107
+ await mind.ingest(turns.slice(0, i).join(join), turns[i]);
108
+ }
109
+ }
110
+
111
+ /** A deterministic byte-stream generator — text is one case, not the case. */
112
+ function streams(seed = 12345) {
113
+ let s = seed;
114
+ const rnd = () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
115
+ return { rnd };
116
+ }
117
+
118
+ // ═══════════════════════════════════════════════════════════════════════
119
+ // A. THE INCREMENTAL FOLD IS THE FOLD
120
+ //
121
+ // `stablePrefixFoldIncremental` and `bytesToTree` are documented as producing
122
+ // the same cuts and the same tree. If that ever stops being true, a
123
+ // conversation's perception silently diverges from every other entry point —
124
+ // and, because the incremental one carries a CACHE, the divergence could
125
+ // depend on what was folded before, which is unreproducible by construction.
126
+ // ═══════════════════════════════════════════════════════════════════════
127
+
128
+ test("A1: incremental fold ≡ bytesToTree, over random byte streams", () => {
129
+ const mind = newMind();
130
+ const { rnd } = streams();
131
+ for (let trial = 0; trial < 250; trial++) {
132
+ const len = 1 + Math.floor(rnd() * 400);
133
+ const b = new Uint8Array(len);
134
+ // full byte range — not text; a text-only corpus has hidden this class
135
+ // of bug before (see test/63's header).
136
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
137
+ const nb = Math.floor(rnd() * 6);
138
+ const bs = [
139
+ ...new Set(Array.from({ length: nb }, () => 1 + Math.floor(rnd() * len))),
140
+ ]
141
+ .sort((x, y) => x - y);
142
+ const ref = bytesToTree(
143
+ mind.space,
144
+ mind.alphabet,
145
+ b,
146
+ undefined,
147
+ undefined,
148
+ bs.length ? bs : undefined,
149
+ );
150
+ const inc =
151
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
152
+ assert.ok(
153
+ sameTree(ref, inc),
154
+ `trial ${trial}: len=${len} boundaries=[${bs}]`,
155
+ );
156
+ }
157
+ });
158
+
159
+ test("A2: SEGMENT REUSE IS TRANSPARENT — a warm fold equals a cold one", () => {
160
+ // The cache exists only to skip work. A tree that depends on cache state
161
+ // would make the store depend on eviction order.
162
+ const mind = newMind();
163
+ const { rnd } = streams(777);
164
+ for (let trial = 0; trial < 150; trial++) {
165
+ const len = 40 + Math.floor(rnd() * 300);
166
+ const b = new Uint8Array(len);
167
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
168
+ const nb = 1 + Math.floor(rnd() * 4);
169
+ const bs = [
170
+ ...new Set(
171
+ Array.from({ length: nb }, () => 1 + Math.floor(rnd() * (len - 1))),
172
+ ),
173
+ ]
174
+ .sort((x, y) => x - y);
175
+ const cut = bs[bs.length - 1];
176
+ const warm = stablePrefixFoldIncremental(
177
+ mind.space,
178
+ mind.alphabet,
179
+ b.subarray(0, cut),
180
+ bs.slice(0, -1),
181
+ );
182
+ const grown =
183
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs, warm.fold)
184
+ .tree;
185
+ const cold =
186
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
187
+ assert.ok(sameTree(cold, grown), `trial ${trial}: boundaries=[${bs}]`);
188
+ }
189
+ });
190
+
191
+ test("A3: degenerate boundary sets fold identically through both entry points", () => {
192
+ // Out-of-order is the one that bit: the cut filter is sequential (`b > prev`),
193
+ // so an unsorted entry is DROPPED rather than rejected. bytesToTree sorted
194
+ // on the way in and absorbed it; its twin must too, or the same set yields
195
+ // two different trees depending on which door it came through.
196
+ const mind = newMind();
197
+ const b = enc("abcdefghijklmnop");
198
+ const sets = [[], [0], [16], [0, 16], [5, 5], [3, 1], [9, 2, 14], [40], [
199
+ -1,
200
+ 4,
201
+ ], [4, 4, 4]];
202
+ for (const bs of sets) {
203
+ const ref = bytesToTree(
204
+ mind.space,
205
+ mind.alphabet,
206
+ b,
207
+ undefined,
208
+ undefined,
209
+ bs.length ? bs : undefined,
210
+ );
211
+ const inc =
212
+ stablePrefixFoldIncremental(mind.space, mind.alphabet, b, bs).tree;
213
+ assert.ok(sameTree(ref, inc), `boundaries=[${bs}]`);
214
+ }
215
+ });
216
+
217
+ test("A4: the stable-prefix property itself — a prefix folds free of what follows", () => {
218
+ // The property the whole multi-turn design rests on. Asserted against the
219
+ // PLAIN fold as a control, because without a control "cosine 1.0" only says
220
+ // the fold is deterministic.
221
+ const mind = newMind();
222
+ const turns = [
223
+ "the weeping woman was painted by picasso",
224
+ "picasso co-founded the cubist movement",
225
+ "cubism began in paris around 1907",
226
+ "braque worked alongside him there",
227
+ ];
228
+ let ctx = "", bounds = [];
229
+ const snaps = [];
230
+ for (const t of turns) {
231
+ if (ctx.length > 0) bounds.push(ctx.length);
232
+ ctx += t;
233
+ snaps.push({
234
+ ctx,
235
+ tree: bytesToTree(
236
+ mind.space,
237
+ mind.alphabet,
238
+ enc(ctx),
239
+ undefined,
240
+ undefined,
241
+ bounds.length ? [...bounds] : undefined,
242
+ ),
243
+ });
244
+ }
245
+ const spine = (root, down) => {
246
+ let n = root;
247
+ for (let i = 0; i < down; i++) n = n.kids[0];
248
+ return n;
249
+ };
250
+ const K = snaps.length;
251
+ for (let j = 1; j < K; j++) {
252
+ const inside = spine(snaps[K - 1].tree, K - j);
253
+ const alone = snaps[j - 1].tree;
254
+ assert.ok(
255
+ sameTree(inside, alone),
256
+ `prefix of ${j} turn(s) must fold identically inside the grown context`,
257
+ );
258
+ }
259
+ // Control: under a plain fold the previous root does not survive anywhere.
260
+ for (let i = 1; i < K; i++) {
261
+ const prevPlain = bytesToTree(
262
+ mind.space,
263
+ mind.alphabet,
264
+ enc(snaps[i - 1].ctx),
265
+ );
266
+ const nowPlain = bytesToTree(mind.space, mind.alphabet, enc(snaps[i].ctx));
267
+ let best = -1;
268
+ const walk = (n) => {
269
+ best = Math.max(best, cos(n.v, prevPlain.v));
270
+ if (n.kids) n.kids.forEach(walk);
271
+ };
272
+ walk(nowPlain);
273
+ assert.ok(
274
+ best < 0.95,
275
+ `plain fold should NOT preserve the prefix root (got ${best.toFixed(3)})`,
276
+ );
277
+ }
278
+ });
279
+
280
+ test("A5: contentFoldIncremental ≡ the plain fold, cold and warm", () => {
281
+ // The fold the conversation and deposit paths now share. It must equal
282
+ // bytesToTree with NO boundary set — that equality IS the train/infer
283
+ // agreement — both from cold and when reusing a prefix's segments.
284
+ const mind = newMind();
285
+ const { rnd } = streams(31337);
286
+ for (let trial = 0; trial < 250; trial++) {
287
+ const len = 1 + Math.floor(rnd() * 400);
288
+ const b = new Uint8Array(len);
289
+ for (let i = 0; i < len; i++) b[i] = Math.floor(rnd() * 256);
290
+ const ref = bytesToTree(mind.space, mind.alphabet, b);
291
+ const cold = contentFoldIncremental(mind.space, mind.alphabet, b).tree;
292
+ assert.ok(sameTree(ref, cold), `cold: trial ${trial}, len=${len}`);
293
+ const cut = Math.max(1, Math.floor(len * 0.6));
294
+ const pre = contentFoldIncremental(
295
+ mind.space,
296
+ mind.alphabet,
297
+ b.subarray(0, cut),
298
+ );
299
+ const warm =
300
+ contentFoldIncremental(mind.space, mind.alphabet, b, pre.fold).tree;
301
+ assert.ok(
302
+ sameTree(ref, warm),
303
+ `warm: trial ${trial}, len=${len}, cut=${cut}`,
304
+ );
305
+ // a reused segment must never be mutated by the root normalize
306
+ const again =
307
+ contentFoldIncremental(mind.space, mind.alphabet, b, pre.fold).tree;
308
+ assert.ok(
309
+ sameTree(ref, again),
310
+ `second warm fold differed: trial ${trial}`,
311
+ );
312
+ }
313
+ });
314
+
315
+ test("A6: content cuts are stable under append — the reuse this rests on", () => {
316
+ // Why a PLAIN fold is incrementally reusable at all: cuts are decided by a
317
+ // rolling hash over a local window, so bytes appended at the right edge
318
+ // cannot move a cut to their left. If this ever stops holding, the
319
+ // incremental fold silently degrades to a full refold every turn.
320
+ const mind = newMind();
321
+ const { rnd } = streams(9001);
322
+ for (let trial = 0; trial < 40; trial++) {
323
+ const base = new Uint8Array(50 + Math.floor(rnd() * 300));
324
+ for (let i = 0; i < base.length; i++) base[i] = Math.floor(rnd() * 256);
325
+ const before = contentBoundaries(mind.space, base);
326
+ const addLen = 1 + Math.floor(rnd() * 60);
327
+ const grown = new Uint8Array(base.length + addLen);
328
+ grown.set(base, 0);
329
+ for (let i = 0; i < addLen; i++) {
330
+ grown[base.length + i] = Math.floor(rnd() * 256);
331
+ }
332
+ const after = contentBoundaries(mind.space, grown);
333
+ for (let i = 0; i < before.length; i++) {
334
+ assert.equal(
335
+ after[i],
336
+ before[i],
337
+ `trial ${trial}: cut ${i} moved on append`,
338
+ );
339
+ }
340
+ }
341
+ });
342
+
343
+ // ═══════════════════════════════════════════════════════════════════════
344
+ // B. THE OPTIMISATION ACTUALLY HAPPENS
345
+ //
346
+ // This is the section that catches a silent performance regression. Rebuilding
347
+ // the context tree every turn is just as CORRECT as reusing it, so no accuracy
348
+ // test can see the difference — only node identity can.
349
+ // ═══════════════════════════════════════════════════════════════════════
350
+
351
+ const convTree = (mind, conv) => mind._conversations.get(conv.id).tree;
352
+
353
+ test("B1: a grown context REUSES the previous turn's subtree objects", () => {
354
+ const mind = newMind();
355
+ const conv = mind.beginConversation();
356
+ const turns = [
357
+ "alpha turn one here",
358
+ "beta turn two here",
359
+ "gamma turn three",
360
+ "delta turn four now",
361
+ ];
362
+ let prev = null;
363
+ const rows = [];
364
+ for (const t of turns) {
365
+ mind.addTurn(conv, t);
366
+ const set = nodeSet(convTree(mind, conv));
367
+ const shared = prev ? [...set].filter((n) => prev.has(n)).length : 0;
368
+ rows.push({ total: set.size, shared, fresh: set.size - shared });
369
+ prev = set;
370
+ }
371
+ // Turn 1 has nothing to share. Every later turn must reuse the bulk of the
372
+ // tree — with a full rebuild this is exactly 0, so any floor above 0 is a
373
+ // real guard; 40% leaves generous slack for fold-shape changes.
374
+ for (let i = 1; i < rows.length; i++) {
375
+ const frac = rows[i].shared / rows[i].total;
376
+ assert.ok(
377
+ frac > 0.4,
378
+ `turn ${i + 1}: only ${rows[i].shared}/${rows[i].total} nodes reused (${
379
+ (frac * 100).toFixed(0)
380
+ }%) — ` +
381
+ `the context is being re-folded from scratch, not extended`,
382
+ );
383
+ }
384
+ // And the fresh work must track the TURN, not the context: the last turn is
385
+ // no larger than the first, so its fresh-node count must not have grown with
386
+ // the accumulated context.
387
+ assert.ok(
388
+ rows[rows.length - 1].fresh <= rows[1].fresh * 2,
389
+ `fresh nodes per turn grew with context: ${
390
+ rows.map((r) => r.fresh).join(", ")
391
+ }`,
392
+ );
393
+ });
394
+
395
+ test("B2: per-turn perception cost does not grow with the accumulated context", async () => {
396
+ const pairs = [
397
+ [
398
+ "who painted the weeping woman",
399
+ "pablo picasso painted the weeping woman",
400
+ ],
401
+ ["what movement did he found", "he co-founded the cubist movement"],
402
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
403
+ ["where did it begin", "it began in paris"],
404
+ ["who worked with him there", "georges braque worked with him"],
405
+ ["what did they fragment", "they fragmented objects into geometric planes"],
406
+ [
407
+ "what came in nineteen thirty seven",
408
+ "guernica came in nineteen thirty seven",
409
+ ],
410
+ ["what did it protest", "it protested the bombing of guernica"],
411
+ ];
412
+ const mind = newMind({ profile: true });
413
+ let ctx = "";
414
+ for (const [u, a] of pairs) {
415
+ ctx += u;
416
+ await mind.ingest(ctx, a);
417
+ ctx += a;
418
+ }
419
+
420
+ const conv = mind.beginConversation();
421
+ const perTurn = [];
422
+ for (const [u] of pairs) {
423
+ await mind.respondTurnText(conv, u);
424
+ perTurn.push({
425
+ bytes: mind.lastCost.counters.perceivedBytes ?? 0,
426
+ align: mind.lastCost.counters.alignCells ?? 0,
427
+ ctx: mind.conversationState(conv).context.length,
428
+ });
429
+ }
430
+ const finalCtx = perTurn[perTurn.length - 1].ctx;
431
+
432
+ // ALIGNMENT IS THE LOUD ONE. When the cumulative context resolves exactly,
433
+ // the quadratic alignment family never has to run. Before the boundary
434
+ // agreement was fixed this reached 3.1e7 cells on a 579-byte context, so a
435
+ // ceiling here is the single most sensitive regression signal in the file.
436
+ const worstAlign = Math.max(...perTurn.map((p) => p.align));
437
+ assert.ok(
438
+ worstAlign < finalCtx * finalCtx,
439
+ `alignment went quadratic in the context (${worstAlign} cells over ${finalCtx} bytes) — ` +
440
+ `the trained context root is no longer resolving`,
441
+ );
442
+
443
+ // PERCEPTION IS THE STEADY ONE. Later turns must not fold more than early
444
+ // turns merely because the context is longer.
445
+ const early = perTurn.slice(1, 4).reduce((s, p) => s + p.bytes, 0) / 3;
446
+ const late = perTurn.slice(-3).reduce((s, p) => s + p.bytes, 0) / 3;
447
+ assert.ok(
448
+ late <= Math.max(early * 3, 4000),
449
+ `perceived bytes per turn grew with context (early ≈ ${early | 0}, late ≈ ${
450
+ late | 0
451
+ }): ` +
452
+ perTurn.map((p) => p.bytes).join(", "),
453
+ );
454
+ });
455
+
456
+ test("B3: a restored conversation reuses subtrees too", () => {
457
+ // A resumed conversation is otherwise identical to a live one; if restore
458
+ // dropped the segment state it would pay a full re-fold for the rest of its
459
+ // life, and nothing downstream would notice.
460
+ const mind = newMind();
461
+ const a = mind.beginConversation();
462
+ for (
463
+ const t of [
464
+ "first turn text here",
465
+ "second turn text here",
466
+ "third turn text",
467
+ ]
468
+ ) mind.addTurn(a, t);
469
+ const state = mind.conversationState(a);
470
+
471
+ const b = mind.beginConversation(state);
472
+ const before = nodeSet(convTree(mind, b));
473
+ mind.addTurn(b, "fourth turn text");
474
+ const after = nodeSet(convTree(mind, b));
475
+ const shared = [...after].filter((n) => before.has(n)).length;
476
+ assert.ok(
477
+ shared / after.size > 0.4,
478
+ `a restored conversation re-folded from scratch (${shared}/${after.size} reused)`,
479
+ );
480
+ });
481
+
482
+ // ═══════════════════════════════════════════════════════════════════════
483
+ // C. THE MEMO KEY CARRIES THE BOUNDARIES
484
+ // ═══════════════════════════════════════════════════════════════════════
485
+
486
+ test("C1: the same bytes under different boundary sets are different memo keys", () => {
487
+ const b = enc("one two three four five six");
488
+ assert.notEqual(
489
+ perceiveKey(b, [7]),
490
+ perceiveKey(b),
491
+ "boundaries must change the key",
492
+ );
493
+ assert.notEqual(
494
+ perceiveKey(b, [7]),
495
+ perceiveKey(b, [11]),
496
+ "different cuts, different keys",
497
+ );
498
+ assert.equal(
499
+ perceiveKey(b, []),
500
+ perceiveKey(b),
501
+ "an empty set is the plain fold",
502
+ );
503
+ assert.equal(
504
+ perceiveKey(b, undefined),
505
+ perceiveKey(b),
506
+ "undefined is the plain fold",
507
+ );
508
+ assert.equal(
509
+ perceiveKey(b, [7]),
510
+ perceiveKey(b, [7]),
511
+ "the key is a function of its inputs",
512
+ );
513
+ });
514
+
515
+ test("C2: no content can forge the key's boundary separator", () => {
516
+ // The key is content + separator + rendered boundaries. Two DIFFERENT
517
+ // (bytes, boundaries) pairs must never collide, including when the content
518
+ // itself contains digits, commas, or the separator byte.
519
+ const seen = new Map();
520
+ const cases = [
521
+ [enc("abc"), [1, 2]],
522
+ [enc("abc"), [12]],
523
+ [enc("abc1,2"), undefined],
524
+ [enc("abc1"), [2]],
525
+ [enc("ab"), [1]],
526
+ [enc("ab"), undefined],
527
+ [enc("a"), [1, 2]],
528
+ [enc("a"), [12]],
529
+ [enc("1,2"), [3]],
530
+ [enc(""), [1]],
531
+ [enc(""), undefined],
532
+ ];
533
+ for (const [b, bs] of cases) {
534
+ const k = perceiveKey(b, bs);
535
+ const label = `${JSON.stringify(new TextDecoder().decode(b))}/${
536
+ bs ?? "none"
537
+ }`;
538
+ if (seen.has(k)) {
539
+ const other = seen.get(k);
540
+ // A collision is only legal when the two really are the same perception.
541
+ const sameInput = other.b === l1(b) &&
542
+ JSON.stringify(other.bs ?? []) === JSON.stringify(bs ?? []);
543
+ assert.ok(sameInput, `key collision between ${other.label} and ${label}`);
544
+ }
545
+ seen.set(k, { b: l1(b), bs, label });
546
+ }
547
+ });
548
+
549
+ // ═══════════════════════════════════════════════════════════════════════
550
+ // D. THE DEPOSIT PATH RECORDS THE BOUNDARIES INFERENCE READS
551
+ //
552
+ // The guard must be PRECISE (never chain unrelated deposits — most of a real
553
+ // corpus is single-turn facts) and have RECALL (always chain a genuine turn).
554
+ // ═══════════════════════════════════════════════════════════════════════
555
+
556
+ test("D1: TRAIN/INFER AGREEMENT — deposit and inference fold identically", () => {
557
+ // THE headline invariant, and the one the whole review turned on. Neither
558
+ // side imposes a boundary set: the deposit path and the conversation path
559
+ // both fold a stream over its OWN content cuts, so the trained context node
560
+ // and the node inference resolves are the same node by construction.
561
+ //
562
+ // When they disagreed, the trained and inferred roots for identical bytes
563
+ // sat at cosine 0.02-0.21, multi-turn recall collapsed, and the alignment
564
+ // family went quadratic (5.2M cells on a 476-byte context, against 0 when
565
+ // they agree).
566
+ const mind = newMind();
567
+ const conv = mind.beginConversation();
568
+ const turns = [
569
+ "user turn one asks a thing",
570
+ "assistant replies to one",
571
+ "user turn two asks more",
572
+ "assistant replies to two",
573
+ "user turn three asks again",
574
+ "assistant replies to three",
575
+ ];
576
+ for (const t of turns) {
577
+ mind.addTurn(conv, t);
578
+ const data = mind._conversations.get(conv.id);
579
+ const plain = bytesToTree(mind.space, mind.alphabet, data.bytes);
580
+ assert.ok(
581
+ sameTree(plain, data.tree),
582
+ `the conversation folded ${data.bytes.length}B differently from perceive() on the same bytes`,
583
+ );
584
+ }
585
+ });
586
+
587
+ test("D2: no fold imposes a boundary set — reuse is transparent, not structural", async () => {
588
+ // A deposit's tree must be a pure function of its BYTES. Depositing a chain
589
+ // with a hot segment cache must store exactly what depositing it with a cold
590
+ // one stores, or the store would depend on cache residency and eviction
591
+ // order. Compared by what is STORED (node count + read-back bytes), never by
592
+ // raw node ids: ids are mint order, so two stores that saw different numbers
593
+ // of deposits number the same content differently.
594
+ const turns = [
595
+ "one asks a question here",
596
+ "one answers it plainly",
597
+ "two asks a question here",
598
+ "two answers it plainly",
599
+ "three asks a question",
600
+ "three answers it plainly",
601
+ ];
602
+ const cumulative = [];
603
+ {
604
+ let c = "";
605
+ for (const t of turns) {
606
+ c += t;
607
+ cumulative.push(c);
608
+ }
609
+ }
610
+
611
+ const deposit = async (cold) => {
612
+ const mind = newMind();
613
+ for (let i = 0; i + 1 < turns.length; i++) {
614
+ if (cold) {
615
+ mind._depositTrees.clear();
616
+ mind._depositLens.clear();
617
+ }
618
+ await mind.ingest(cumulative[i], turns[i + 1]);
619
+ }
620
+ const readback = [];
621
+ for (let i = 0; i + 1 < turns.length; i++) {
622
+ const id = mind.resolve(enc(cumulative[i]));
623
+ readback.push(
624
+ id === null
625
+ ? null
626
+ : new TextDecoder().decode(await mind.store.bytes(id)),
627
+ );
628
+ }
629
+ return { nodes: mind.store.nodeCount(), readback };
630
+ };
631
+
632
+ const warm = await deposit(false);
633
+ const cold = await deposit(true);
634
+ assert.ok(
635
+ warm.readback.every((x) => x !== null),
636
+ "a deposited context did not resolve",
637
+ );
638
+ assert.deepEqual(
639
+ warm.readback,
640
+ cumulative.slice(0, -1),
641
+ "a resolved context node does not hold that context's bytes",
642
+ );
643
+ assert.deepEqual(
644
+ warm.readback,
645
+ cold.readback,
646
+ "segment reuse changed what was stored",
647
+ );
648
+ assert.equal(
649
+ warm.nodes,
650
+ cold.nodes,
651
+ "segment reuse changed how many nodes were minted",
652
+ );
653
+ });
654
+
655
+ test("D3: a deposited context resolves through the plain path", async () => {
656
+ // The consequence of agreement, stated as the property callers depend on:
657
+ // whatever was deposited can be found again by content addressing, with no
658
+ // boundary set and no conversation handle.
659
+ const mind = newMind();
660
+ const turns = [
661
+ "ask about the painting",
662
+ "it was painted by picasso",
663
+ "ask about the movement",
664
+ "he founded cubism",
665
+ ];
666
+ let ctx = "";
667
+ const ctxs = [];
668
+ for (let i = 0; i + 1 < turns.length; i++) {
669
+ ctx += turns[i];
670
+ ctxs.push(ctx);
671
+ await mind.ingest(ctx, turns[i + 1]);
672
+ }
673
+ for (const c of ctxs) {
674
+ assert.notEqual(
675
+ mind.resolve(enc(c)),
676
+ null,
677
+ `deposited context did not resolve: ${JSON.stringify(c.slice(0, 40))}`,
678
+ );
679
+ }
680
+ });
681
+
682
+ test("D4: an unrelated deposit sharing a byte prefix is harmless", async () => {
683
+ // This used to need a continuation-bytes proof, because a wrong guess
684
+ // changed the TREE. Nothing is imposed now, so a coincidental prefix simply
685
+ // reuses identical segments and both deposits keep their own correct trees.
686
+ const mind = newMind();
687
+ await mind.ingest("what is two plus two", "four");
688
+ await mind.ingest("what is two plus two hundred", "two hundred and two");
689
+ const a = mind.resolve(enc("what is two plus two"));
690
+ const b = mind.resolve(enc("what is two plus two hundred"));
691
+ assert.notEqual(a, null);
692
+ assert.notEqual(b, null);
693
+ assert.notEqual(a, b, "two different facts collapsed to one node");
694
+ assert.equal((await mind.respondText("what is two plus two")).trim(), "four");
695
+ assert.equal(
696
+ (await mind.respondText("what is two plus two hundred")).trim(),
697
+ "two hundred and two",
698
+ );
699
+ });
700
+
701
+ test("D5: re-deposition is idempotent — no new nodes, same answers", async () => {
702
+ const turns = [
703
+ "alpha asks one",
704
+ "beta says one",
705
+ "alpha asks two",
706
+ "beta says two",
707
+ ];
708
+ const mind = newMind();
709
+ const rounds = [];
710
+ for (let r = 0; r < 2; r++) {
711
+ await teach(mind, turns);
712
+ const conv = mind.beginConversation();
713
+ const outs = [];
714
+ for (let i = 0; i + 1 < turns.length; i += 2) {
715
+ outs.push((await mind.respondTurnText(conv, turns[i])).response);
716
+ }
717
+ mind.endConversation(conv);
718
+ rounds.push({ nodes: mind.store.nodeCount(), outs });
719
+ }
720
+ assert.equal(
721
+ rounds[0].nodes,
722
+ rounds[1].nodes,
723
+ "re-depositing the same chain minted new nodes",
724
+ );
725
+ assert.deepEqual(
726
+ rounds[0].outs,
727
+ rounds[1].outs,
728
+ "re-deposition changed the answers",
729
+ );
730
+ });
731
+
732
+ test("D6: a long chain and the 8-entry cache — correctness never depends on it", async () => {
733
+ // The cache is a work cache with a hard bound, so a long conversation WILL
734
+ // evict its early links. Every context must still resolve: an evicted entry
735
+ // costs a refold, never a different tree.
736
+ const mind = newMind();
737
+ const N = 25;
738
+ let ctx = "";
739
+ const ctxs = [];
740
+ for (let i = 0; i < N; i++) {
741
+ ctx += `u${i} question text here`;
742
+ ctxs.push(ctx);
743
+ await mind.ingest(ctx, `a${i} answer text here`);
744
+ ctx += `a${i} answer text here`;
745
+ }
746
+ for (let i = 0; i < ctxs.length; i++) {
747
+ assert.notEqual(
748
+ mind.resolve(enc(ctxs[i])),
749
+ null,
750
+ `context ${i} did not resolve after eviction`,
751
+ );
752
+ }
753
+ });
754
+
755
+ test("D7: interleaved conversations stay independent", async () => {
756
+ // Six conversations against an 8-entry cache: entries evict constantly.
757
+ // Every context of every conversation must still resolve to its own node.
758
+ const mind = newMind();
759
+ const C = 6, T = 4;
760
+ const ctxs = Array.from({ length: C }, () => "");
761
+ const all = [];
762
+ for (let t = 0; t < T; t++) {
763
+ for (let c = 0; c < C; c++) {
764
+ ctxs[c] += `c${c}u${t} the question `;
765
+ all.push(ctxs[c]);
766
+ await mind.ingest(ctxs[c], `c${c}a${t} the answer `);
767
+ ctxs[c] += `c${c}a${t} the answer `;
768
+ }
769
+ }
770
+ const ids = all.map((c) => mind.resolve(enc(c)));
771
+ assert.ok(
772
+ ids.every((x) => x !== null),
773
+ "an interleaved deposit did not resolve",
774
+ );
775
+ assert.equal(
776
+ new Set(ids).size,
777
+ ids.length,
778
+ "two distinct contexts collapsed to one node",
779
+ );
780
+ });
781
+
782
+ // ═══════════════════════════════════════════════════════════════════════
783
+ // E. CONVERSATION STATE IS SOUND ACROSS SAVE AND RESTORE
784
+ // ═══════════════════════════════════════════════════════════════════════
785
+
786
+ test("E1: boundaries are always strictly increasing and inside the context", () => {
787
+ const mind = newMind();
788
+ const conv = mind.beginConversation();
789
+ for (const t of ["one", "", "two turns", "three turns now", "", "four"]) {
790
+ mind.addTurn(conv, t);
791
+ }
792
+ const st = mind.conversationState(conv);
793
+ for (let i = 1; i < st.boundaries.length; i++) {
794
+ assert.ok(
795
+ st.boundaries[i] > st.boundaries[i - 1],
796
+ `boundaries not strictly increasing: ${st.boundaries}`,
797
+ );
798
+ }
799
+ for (const b of st.boundaries) {
800
+ assert.ok(
801
+ b > 0 && b < st.context.length,
802
+ `boundary ${b} outside a ${st.context.length}-byte context`,
803
+ );
804
+ }
805
+ });
806
+
807
+ test("E2: restoring between every turn equals an uninterrupted conversation", async () => {
808
+ const turns = [
809
+ "who painted it",
810
+ "picasso painted it",
811
+ "what movement",
812
+ "cubism was the movement",
813
+ "when did it start",
814
+ "it started in nineteen oh seven",
815
+ ];
816
+ const build = async () => {
817
+ const m = newMind();
818
+ await teach(m, turns);
819
+ return m;
820
+ };
821
+
822
+ const m1 = await build();
823
+ const c1 = m1.beginConversation();
824
+ const live = [];
825
+ for (let i = 0; i < turns.length; i += 2) {
826
+ live.push((await m1.respondTurnText(c1, turns[i])).response);
827
+ }
828
+
829
+ const m2 = await build();
830
+ let st;
831
+ const restored = [];
832
+ for (let i = 0; i < turns.length; i += 2) {
833
+ const c = m2.beginConversation(st);
834
+ const r = await m2.respondTurnText(c, turns[i]);
835
+ restored.push(r.response);
836
+ st = r.state;
837
+ m2.endConversation(c);
838
+ }
839
+ assert.deepEqual(
840
+ restored,
841
+ live,
842
+ "save/restore between turns changed the conversation",
843
+ );
844
+ });
845
+
846
+ test("E3: out-of-order restored boundaries are normalised, not silently dropped", () => {
847
+ // A ConversationState can arrive from outside — hand-built, migrated, or
848
+ // round-tripped. The folds filter cuts sequentially, so an unsorted entry
849
+ // would be dropped and the conversation would fold over a different set
850
+ // than the caller believes it restored.
851
+ const mind = newMind();
852
+ const conv = mind.beginConversation();
853
+ for (
854
+ const t of [
855
+ "turn one here",
856
+ "turn two here",
857
+ "turn three here",
858
+ "turn four",
859
+ ]
860
+ ) mind.addTurn(conv, t);
861
+ const good = mind.conversationState(conv);
862
+
863
+ const shuffled = { ...good, boundaries: [...good.boundaries].reverse() };
864
+ const dupes = {
865
+ ...good,
866
+ boundaries: [...good.boundaries, ...good.boundaries],
867
+ };
868
+ const oob = {
869
+ ...good,
870
+ boundaries: [
871
+ 0,
872
+ ...good.boundaries,
873
+ good.context.length,
874
+ good.context.length + 99,
875
+ ],
876
+ };
877
+
878
+ const ref = mind.conversationState(mind.beginConversation(good)).boundaries;
879
+ for (
880
+ const [label, st] of [["reversed", shuffled], ["duplicated", dupes], [
881
+ "out-of-range",
882
+ oob,
883
+ ]]
884
+ ) {
885
+ const got = mind.conversationState(mind.beginConversation(st)).boundaries;
886
+ assert.deepEqual(
887
+ got,
888
+ ref,
889
+ `${label} boundaries were not normalised to the same set`,
890
+ );
891
+ }
892
+ });
893
+
894
+ test("E4: answeredSpans track the assistant's own replies", async () => {
895
+ const turns = [
896
+ "ask one thing",
897
+ "reply to one",
898
+ "ask two things",
899
+ "reply to two",
900
+ ];
901
+ const mind = newMind();
902
+ await teach(mind, turns);
903
+ const conv = mind.beginConversation();
904
+ const st1 = (await mind.respondTurnText(conv, turns[0])).state;
905
+ const ctx = new TextDecoder().decode(st1.context);
906
+ for (const [s, e] of st1.answeredSpans) {
907
+ assert.ok(
908
+ e > s && e <= st1.context.length,
909
+ `answered span [${s},${e}) outside the context`,
910
+ );
911
+ // the span must name bytes the mind produced, not bytes the user supplied
912
+ assert.ok(
913
+ !ctx.slice(0, s).endsWith(ctx.slice(s, e)),
914
+ "an answered span duplicates user text",
915
+ );
916
+ }
917
+ });
918
+
919
+ // ═══════════════════════════════════════════════════════════════════════
920
+ // F. END TO END — the behaviour all of the above exists to protect
921
+ // ═══════════════════════════════════════════════════════════════════════
922
+
923
+ test("F1: every turn of a trained conversation is answered exactly", async () => {
924
+ const pairs = [
925
+ [
926
+ "who painted the weeping woman",
927
+ "pablo picasso painted the weeping woman",
928
+ ],
929
+ ["what movement did he found", "he co-founded the cubist movement"],
930
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
931
+ ["where did it begin", "it began in paris"],
932
+ ["who worked with him there", "georges braque worked with him"],
933
+ ["what did they fragment", "they fragmented objects into geometric planes"],
934
+ [
935
+ "what came in nineteen thirty seven",
936
+ "guernica came in nineteen thirty seven",
937
+ ],
938
+ ["what did it protest", "it protested the bombing of guernica"],
939
+ ["where does it hang now", "it hangs in madrid"],
940
+ ["thank you for the summary", "you are welcome"],
941
+ ];
942
+ const mind = newMind();
943
+ let ctx = "";
944
+ for (const [u, a] of pairs) {
945
+ ctx += u;
946
+ await mind.ingest(ctx, a);
947
+ ctx += a;
948
+ }
949
+
950
+ const conv = mind.beginConversation();
951
+ const wrong = [];
952
+ for (const [u, want] of pairs) {
953
+ const got = (await mind.respondTurnText(conv, u)).response.trim();
954
+ if (got !== want) wrong.push({ u, want, got });
955
+ }
956
+ // Before the boundary agreement was fixed this scored 3/10, and the failures
957
+ // were not silence but CONFIDENT WRONG ANSWERS from later turns — so a floor
958
+ // here guards meaning, not just recall.
959
+ assert.deepEqual(
960
+ wrong,
961
+ [],
962
+ `${wrong.length}/${pairs.length} turns answered wrongly`,
963
+ );
964
+ });
965
+
966
+ test("F1b: attaching a trace changes no answer — the audit layer is inert", async () => {
967
+ // The mind's ONLY text-shaped code lives in the rationale/trace payloads:
968
+ // attention.ts's `dec` helper decodes bytes and collapses whitespace so an
969
+ // audit line is readable, and frame-filler builds diagnostic strings the
970
+ // same way. Neither may ever reach a decision — nothing in the core knows
971
+ // what "whitespace" is (see canon.ts's header, and AGENTS §2.11: profile
972
+ // and trace must not move an answer). Asserted here rather than assumed,
973
+ // because the formatting sits inside the same functions that decide.
974
+ const pairs = [
975
+ [
976
+ "who painted the weeping woman",
977
+ "pablo picasso painted the weeping woman",
978
+ ],
979
+ ["what movement did he found", "he co-founded the cubist movement"],
980
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
981
+ ["where did it begin", "it began in paris"],
982
+ ["who worked with him there", "georges braque worked with him"],
983
+ ];
984
+ const run = async (traced) => {
985
+ const mind = newMind();
986
+ let ctx = "";
987
+ const ctxs = [];
988
+ for (const [u, a] of pairs) {
989
+ ctx += u;
990
+ ctxs.push(ctx);
991
+ await mind.ingest(ctx, a);
992
+ ctx += a;
993
+ }
994
+ const outs = [];
995
+ const sink = traced ? () => {} : undefined;
996
+ for (const c of ctxs) outs.push(await mind.respondText(c, sink));
997
+ const conv = mind.beginConversation();
998
+ for (const [u] of pairs) {
999
+ outs.push((await mind.respondTurnText(conv, u, sink)).response);
1000
+ }
1001
+ return outs;
1002
+ };
1003
+ assert.deepEqual(
1004
+ await run(true),
1005
+ await run(false),
1006
+ "a trace changed an answer",
1007
+ );
1008
+ });
1009
+
1010
+ test("F2: a conversation is deterministic — identical bytes, identical replies and ids", async () => {
1011
+ const pairs = [["q one here", "a one here"], ["q two here", "a two here"], [
1012
+ "q three here",
1013
+ "a three here",
1014
+ ]];
1015
+ const run = async () => {
1016
+ const mind = newMind();
1017
+ let ctx = "";
1018
+ for (const [u, a] of pairs) {
1019
+ ctx += u;
1020
+ await mind.ingest(ctx, a);
1021
+ ctx += a;
1022
+ }
1023
+ const conv = mind.beginConversation();
1024
+ const outs = [];
1025
+ for (const [u] of pairs) {
1026
+ outs.push((await mind.respondTurnText(conv, u)).response);
1027
+ }
1028
+ const st = mind.conversationState(conv);
1029
+ return { outs, boundaries: st.boundaries, nodes: mind.store.nodeCount() };
1030
+ };
1031
+ const a = await run(), b = await run();
1032
+ assert.deepEqual(a, b, "the conversation path is not reproducible");
1033
+ });
1034
+
1035
+ test("F3: the conversation API is never WORSE than respond() on the same bytes", async () => {
1036
+ // The original defect, stated as a property: respondTurn diverged from
1037
+ // respond on byte-identical input and lost. The Conversation API knows
1038
+ // strictly more (the turn boundaries), so it must never do worse.
1039
+ const pairs = [
1040
+ [
1041
+ "who painted the weeping woman",
1042
+ "pablo picasso painted the weeping woman",
1043
+ ],
1044
+ ["what movement did he found", "he co-founded the cubist movement"],
1045
+ ["when did that movement begin", "cubism began around nineteen oh seven"],
1046
+ ["where did it begin", "it began in paris"],
1047
+ ["who worked with him there", "georges braque worked with him"],
1048
+ ];
1049
+ const build = async () => {
1050
+ const m = newMind();
1051
+ let ctx = "";
1052
+ const trained = [];
1053
+ for (const [u, a] of pairs) {
1054
+ ctx += u;
1055
+ trained.push(ctx);
1056
+ await m.ingest(ctx, a);
1057
+ ctx += a;
1058
+ }
1059
+ return { m, trained };
1060
+ };
1061
+ const A = await build();
1062
+ let plain = 0;
1063
+ for (let i = 0; i < pairs.length; i++) {
1064
+ if ((await A.m.respondText(A.trained[i])).trim() === pairs[i][1]) plain++;
1065
+ }
1066
+ const B = await build();
1067
+ const conv = B.m.beginConversation();
1068
+ let turnwise = 0;
1069
+ for (let i = 0; i < pairs.length; i++) {
1070
+ if (
1071
+ (await B.m.respondTurnText(conv, pairs[i][0])).response.trim() ===
1072
+ pairs[i][1]
1073
+ ) turnwise++;
1074
+ }
1075
+ assert.ok(
1076
+ turnwise >= plain,
1077
+ `respondTurn scored ${turnwise}/${pairs.length} against respond()'s ${plain}/${pairs.length} — ` +
1078
+ `the path that KNOWS the turn boundaries must not lose to the one that does not`,
1079
+ );
1080
+ assert.equal(
1081
+ turnwise,
1082
+ pairs.length,
1083
+ `respondTurn should answer every trained turn`,
1084
+ );
1085
+ });
1086
+
1087
+ // ═══════════════════════════════════════════════════════════════════════
1088
+ // G. A CACHE MAY ELIDE WORK, NEVER OBSERVATION
1089
+ //
1090
+ // THE FIFTH BUG THIS FILE EXISTS TO PREVENT RECURRING. `_resolvedSubtrees`
1091
+ // records a subtree's {id, len} and NOTHING about its descendants' spans.
1092
+ // foldTree's fast path returned on a hit without recursing, so it fired
1093
+ // `visit` ONCE for the subtree root where a cold walk fires once per node.
1094
+ //
1095
+ // `visit` is not instrumentation. recognise() emits its SITES from it and
1096
+ // attention's collectRegions votes over what it yields, so a warm cache
1097
+ // silently shrank the evidence those mechanisms saw — the answer could change
1098
+ // because of what had been computed BEFORE, which is unreproducible by
1099
+ // construction and is the same hazard as bug 4, one level up.
1100
+ //
1101
+ // It was not an edge case. The incremental fold deliberately shares prefix
1102
+ // segment OBJECTS across turns (~99% reuse, section B), so a conversation's
1103
+ // prefix is warm from its second turn on; meanwhile recogniseMemo/climbMemo
1104
+ // are keyed on exact query BYTES, which a GROWING context never repeats.
1105
+ // Warm subtrees + missed memos is the unprotected quadrant, and it is where
1106
+ // every real conversation lives. Measured before the fix: recognising an
1107
+ // identical context with a warm prefix lost 67-92% of its leaves (772->204,
1108
+ // 589->47, 872->291, 377->37) while `sites` stayed EQUAL — invisible to any
1109
+ // coarse count, and invisible to F1/F3, which passed throughout.
1110
+ //
1111
+ // The fix: take the fast path only when NOBODY IS WATCHING. With a visitor
1112
+ // present foldTree still walks, and the cache degrades to what it soundly is —
1113
+ // an elision of the store probes, not of the traversal. G1-G3 pin the
1114
+ // observation; G4 pins that the elision itself survives, so a future
1115
+ // optimisation cannot "fix" the cost by quietly deleting the cache, and G5
1116
+ // pins the behaviour the whole machine exists for.
1117
+ // ═══════════════════════════════════════════════════════════════════════
1118
+
1119
+ /** Every node reachable from a root that carries a subtree-cache entry AND has
1120
+ * children — a branch entry is exactly what the old fast path skipped INTO,
1121
+ * so a test with none of these proves nothing. */
1122
+ const cachedBranches = (mind, n) => {
1123
+ let c = 0;
1124
+ const go = (x) => {
1125
+ if (x.kids !== null) {
1126
+ if (mind._resolvedSubtrees.get(x) !== undefined) c++;
1127
+ for (const k of x.kids) go(k);
1128
+ }
1129
+ };
1130
+ go(n);
1131
+ return c;
1132
+ };
1133
+
1134
+ /** The full observation a foldTree walk makes: one record per visit, in order.
1135
+ * Spans AND ids — a walk that reports the same ids over fewer spans is still
1136
+ * a different observation. */
1137
+ const observe = (mind, tree) => {
1138
+ const seen = [];
1139
+ foldTree(mind, tree, 0, (n, s, e, id) => seen.push(`${s}:${e}:${id}`));
1140
+ return seen;
1141
+ };
1142
+
1143
+ const CONV = [
1144
+ "who painted guernica",
1145
+ "pablo picasso painted guernica",
1146
+ "what year was it made",
1147
+ "it was made in nineteen thirty seven",
1148
+ "where is it kept now",
1149
+ "it hangs in madrid",
1150
+ ];
1151
+
1152
+ test("G1: foldTree's visit set is identical warm and cold", async () => {
1153
+ const mind = newMind();
1154
+ await teach(mind, CONV, "");
1155
+ const bytes = enc(CONV.join(""));
1156
+ // ONE tree object, reused across both walks — this is precisely what a
1157
+ // conversation hands its next turn, and the only thing an identity-keyed
1158
+ // cache can hit on. Rebuild it per walk and the test goes vacuous.
1159
+ const tree = contentFoldIncremental(mind.space, mind.alphabet, bytes).tree;
1160
+
1161
+ mind._resolvedSubtrees = new WeakMap();
1162
+ const cold = observe(mind, tree);
1163
+
1164
+ // NON-VACUITY, asserted rather than assumed: the cold walk must have left
1165
+ // the cache genuinely warm, with entries on BRANCH nodes. Without this the
1166
+ // comparison below could pass on an empty cache and guard nothing.
1167
+ const branches = cachedBranches(mind, tree);
1168
+ assert.ok(
1169
+ branches >= 5,
1170
+ `only ${branches} cached branch entries — the warm walk would skip nothing ` +
1171
+ `and this test would prove nothing`,
1172
+ );
1173
+
1174
+ const warm = observe(mind, tree);
1175
+ assert.deepEqual(
1176
+ warm,
1177
+ cold,
1178
+ "a warm subtree cache changed what foldTree reported to its visitor",
1179
+ );
1180
+ // And stays stable — the old failure got progressively worse per call.
1181
+ assert.deepEqual(observe(mind, tree), cold, "third walk diverged");
1182
+ });
1183
+
1184
+ test("G2: a warm PREFIX cannot change recognition of a GROWN context", async () => {
1185
+ // The unprotected quadrant, stated exactly: the query BYTES differ between
1186
+ // turns (so recogniseMemo misses) while the prefix SUBTREES are shared (so
1187
+ // the identity-keyed cache hits). This is the real multi-turn shape.
1188
+ const mind = newMind();
1189
+ await teach(mind, CONV, "");
1190
+ const prefix = enc(CONV.slice(0, 3).join(""));
1191
+ const full = enc(CONV.join(""));
1192
+ const f1 = contentFoldIncremental(mind.space, mind.alphabet, prefix);
1193
+ // grown from f1 — prefix segments are the SAME objects in both trees
1194
+ const f2 = contentFoldIncremental(mind.space, mind.alphabet, full, f1.fold);
1195
+
1196
+ const shape = (r) => ({
1197
+ sites: r.sites.map((s) => `${s.start}:${s.end}`),
1198
+ leaves: r.leaves.length,
1199
+ splits: r.splits.size,
1200
+ starts: r.starts.size,
1201
+ });
1202
+
1203
+ // COLD: nothing seen before.
1204
+ mind._resolvedSubtrees = new WeakMap();
1205
+ mind.recogniseMemo = new Map();
1206
+ mind.perceiveMemo = new Map([[perceiveKey(full), f2.tree]]);
1207
+ const cold = shape(recognise(mind, full));
1208
+
1209
+ // WARM: an earlier turn already recognised the prefix.
1210
+ mind._resolvedSubtrees = new WeakMap();
1211
+ mind.recogniseMemo = new Map();
1212
+ mind.perceiveMemo = new Map([[perceiveKey(prefix), f1.tree]]);
1213
+ recognise(mind, prefix);
1214
+ const warmedBranches = cachedBranches(mind, f2.tree);
1215
+ assert.ok(
1216
+ warmedBranches >= 3,
1217
+ `recognising the prefix warmed only ${warmedBranches} branches of the grown ` +
1218
+ `tree — the two folds are not sharing objects and this test is vacuous`,
1219
+ );
1220
+ mind.recogniseMemo = new Map(); // the query GREW: the byte-keyed memo misses
1221
+ mind.perceiveMemo.set(perceiveKey(full), f2.tree);
1222
+ const warm = shape(recognise(mind, full));
1223
+
1224
+ assert.deepEqual(
1225
+ warm,
1226
+ cold,
1227
+ "recognition of the same context depended on whether its prefix was seen first",
1228
+ );
1229
+ });
1230
+
1231
+ test("G3: recognise() is idempotent under a warm cache, memo bypassed", async () => {
1232
+ // The memo used to be load-bearing for CORRECTNESS: with it bypassed, a
1233
+ // second call on the SAME bytes found fewer sites than the first (observed
1234
+ // live: 31 -> 5). The memo is an accelerator again only while this holds.
1235
+ const mind = newMind();
1236
+ await teach(mind, CONV, "");
1237
+ const bytes = enc(CONV.join(""));
1238
+ const tree = contentFoldIncremental(mind.space, mind.alphabet, bytes).tree;
1239
+ mind._resolvedSubtrees = new WeakMap();
1240
+ mind.recogniseMemo = null; // bypassed: nothing is hiding the walk
1241
+ mind.perceiveMemo = new Map([[perceiveKey(bytes), tree]]);
1242
+
1243
+ const shape = (r) =>
1244
+ `${r.sites.map((s) => `${s.start}:${s.end}`).join(",")}|${r.leaves.length}`;
1245
+ const first = shape(recognise(mind, bytes));
1246
+ assert.ok(
1247
+ cachedBranches(mind, tree) >= 5,
1248
+ "the first call left no branch entries — nothing would be skipped",
1249
+ );
1250
+ assert.equal(shape(recognise(mind, bytes)), first, "second call diverged");
1251
+ assert.equal(shape(recognise(mind, bytes)), first, "third call diverged");
1252
+ });
1253
+
1254
+ test("G4: the cache still ELIDES STORE PROBES — completeness is not a rollback", async () => {
1255
+ // The other half of the contract. Making the walk complete must not be
1256
+ // achieved by neutering the cache: a warm visiting walk must still cost
1257
+ // strictly fewer findLeaf/findBranch probes than a cold one. Without this,
1258
+ // deleting `_resolvedSubtrees` outright would pass G1-G3.
1259
+ const mind = newMind();
1260
+ await teach(mind, CONV, "");
1261
+ const bytes = enc(CONV.join(""));
1262
+ const tree = contentFoldIncremental(mind.space, mind.alphabet, bytes).tree;
1263
+ const store = mind.store;
1264
+ const realLeaf = store.findLeaf.bind(store);
1265
+ const realBranch = store.findBranch.bind(store);
1266
+ let probes = 0;
1267
+ store.findLeaf = (b) => {
1268
+ probes++;
1269
+ return realLeaf(b);
1270
+ };
1271
+ store.findBranch = (k) => {
1272
+ probes++;
1273
+ return realBranch(k);
1274
+ };
1275
+ try {
1276
+ mind._resolvedSubtrees = new WeakMap();
1277
+ probes = 0;
1278
+ observe(mind, tree);
1279
+ const cold = probes;
1280
+ probes = 0;
1281
+ observe(mind, tree);
1282
+ const warm = probes;
1283
+ assert.ok(cold > 0, "cold walk made no probes — nothing to elide");
1284
+ // Not zero: a node that resolves to null is never cached (foldTree stores
1285
+ // only non-null ids), so the unresolved few are re-probed on every walk.
1286
+ // The property is ELISION, and it must stay overwhelming — a rollback to
1287
+ // "no cache" would put warm back at cold.
1288
+ assert.ok(
1289
+ warm * 10 <= cold,
1290
+ `warm visiting walk made ${warm} store probes against the cold walk's ` +
1291
+ `${cold} — the cache has stopped eliding probes`,
1292
+ );
1293
+ } finally {
1294
+ store.findLeaf = realLeaf;
1295
+ store.findBranch = realBranch;
1296
+ }
1297
+ });
1298
+
1299
+ test("G5: accumulated context is load-bearing, not decorative", async () => {
1300
+ // What the whole multi-turn machine is FOR, as a property. Measured on the
1301
+ // real 15.7M-node store: 19/20 from full context, 0/20 from the last turn
1302
+ // alone, 0/20 with a genuinely foreign prefix. A regression that quietly
1303
+ // began answering from the latest turn only would keep every accuracy test
1304
+ // in this file green.
1305
+ const mind = newMind();
1306
+ await teach(mind, CONV, "");
1307
+ const other = [
1308
+ "who wrote hamlet",
1309
+ "william shakespeare wrote hamlet",
1310
+ "what century was that",
1311
+ "it was the sixteenth century",
1312
+ ];
1313
+ await teach(mind, other, "");
1314
+
1315
+ const ctx = CONV.slice(0, 5).join("");
1316
+ const want = CONV[5];
1317
+ const last = CONV[4];
1318
+
1319
+ assert.equal(
1320
+ (await mind.respondText(ctx)).trim(),
1321
+ want,
1322
+ "the trained cumulative context must answer",
1323
+ );
1324
+ assert.notEqual(
1325
+ (await mind.respondText(last)).trim(),
1326
+ want,
1327
+ `"${last}" alone reached "${want}" — the answer is not using the history`,
1328
+ );
1329
+ assert.notEqual(
1330
+ (await mind.respondText(other.join("") + last)).trim(),
1331
+ want,
1332
+ "a foreign history still produced this conversation's answer",
1333
+ );
1334
+ });