@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.
- package/AGENTS.md +290 -77
- package/HOW_IT_WORKS.md +2170 -735
- package/dist/example/train_base.d.ts +9 -3
- package/dist/example/train_base.js +21 -4
- package/dist/src/canon.d.ts +19 -0
- package/dist/src/canon.js +28 -0
- package/dist/src/geometry.d.ts +52 -0
- package/dist/src/geometry.js +87 -1
- package/dist/src/mind/attention.d.ts +15 -10
- package/dist/src/mind/attention.js +15 -10
- package/dist/src/mind/bridge.js +27 -1
- package/dist/src/mind/frame-filler.d.ts +15 -0
- package/dist/src/mind/frame-filler.js +535 -0
- package/dist/src/mind/learning.js +6 -11
- package/dist/src/mind/mechanisms/cast.js +72 -2
- package/dist/src/mind/mechanisms/cover.js +6 -1
- package/dist/src/mind/mechanisms/extraction.js +27 -0
- package/dist/src/mind/mechanisms/recall.js +214 -34
- package/dist/src/mind/mind.d.ts +52 -3
- package/dist/src/mind/mind.js +140 -12
- package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
- package/dist/src/mind/pipeline.js +29 -1
- package/dist/src/mind/prefix-completion.d.ts +59 -0
- package/dist/src/mind/prefix-completion.js +270 -0
- package/dist/src/mind/primitives.d.ts +29 -10
- package/dist/src/mind/primitives.js +98 -71
- package/dist/src/mind/recognition.js +153 -26
- package/dist/src/mind/traverse.d.ts +32 -0
- package/dist/src/mind/traverse.js +52 -0
- package/dist/src/mind/types.d.ts +61 -18
- package/dist/src/mind/types.js +68 -19
- package/dist/src/store.d.ts +21 -0
- package/dist/src/store.js +21 -0
- package/example/train_base.ts +21 -4
- package/package.json +1 -1
- package/src/canon.ts +28 -0
- package/src/geometry.ts +100 -1
- package/src/mind/attention.ts +15 -10
- package/src/mind/bridge.ts +34 -0
- package/src/mind/frame-filler.ts +604 -0
- package/src/mind/learning.ts +5 -9
- package/src/mind/mechanisms/cast.ts +70 -2
- package/src/mind/mechanisms/cover.ts +6 -1
- package/src/mind/mechanisms/extraction.ts +27 -0
- package/src/mind/mechanisms/recall.ts +236 -37
- package/src/mind/mind.ts +166 -18
- package/src/mind/pipeline-mechanism.ts +7 -0
- package/src/mind/pipeline.ts +33 -1
- package/src/mind/prefix-completion.ts +314 -0
- package/src/mind/primitives.ts +105 -80
- package/src/mind/recognition.ts +151 -23
- package/src/mind/traverse.ts +52 -0
- package/src/mind/types.ts +104 -44
- package/src/store.ts +25 -0
- package/test/13-conversation.test.mjs +13 -0
- package/test/57-fusion-order.test.mjs +65 -0
- package/test/66-query-edge-whitespace.test.mjs +99 -0
- package/test/67-climb-anchor-breadth.test.mjs +113 -0
- package/test/68-extraction-unanchored.test.mjs +79 -0
- package/test/69-frame-filler.test.mjs +115 -0
- package/test/70-prefix-completion.test.mjs +170 -0
- package/test/71-embedded-canon-equivalence.test.mjs +121 -0
- package/test/72-prefix-candidate-supply.test.mjs +114 -0
- package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
- package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
- 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
|
+
});
|