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