@hviana/sema 0.5.0 → 0.5.2
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/dist/src/mind/attention.d.ts +15 -10
- package/dist/src/mind/attention.js +15 -10
- package/dist/src/mind/mind.d.ts +3 -2
- package/dist/src/mind/mind.js +3 -2
- package/dist/src/mind/primitives.js +46 -10
- package/dist/src/mind/recognition.js +34 -17
- package/dist/src/mind/traverse.js +14 -0
- package/dist/src/mind/types.d.ts +7 -3
- package/package.json +1 -1
- package/src/mind/attention.ts +15 -10
- package/src/mind/mind.ts +14 -6
- package/src/mind/primitives.ts +46 -10
- package/src/mind/recognition.ts +34 -17
- package/src/mind/traverse.ts +15 -0
- package/src/mind/types.ts +7 -3
- package/test/75-multiturn-context-optimisation.test.mjs +253 -1
|
@@ -273,16 +273,21 @@ interface ClimbConsensusCfg {
|
|
|
273
273
|
export declare function climbAttention(ctx: MindContext, query: Uint8Array, k: number, mode?: DFMode): Promise<Attention[]>;
|
|
274
274
|
/** Full read-out of one consensus climb: both the roots (dominant points of
|
|
275
275
|
* attention) and the entire ranked list. Cached via ctx.climbMemo, ALWAYS —
|
|
276
|
-
* see {@link recognise} for why this memo (and recognise()'s own)
|
|
277
|
-
*
|
|
278
|
-
* the query's perceived tree
|
|
279
|
-
* fast path
|
|
280
|
-
*
|
|
281
|
-
* prefix subtrees
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
276
|
+
* see {@link recognise} for why this memo (and recognise()'s own) is never
|
|
277
|
+
* gated on tracing. The short of it: computeAttention's collectRegions
|
|
278
|
+
* votes over what walking the query's perceived tree EMITS, and foldTree's
|
|
279
|
+
* subtree-resolution fast path used to skip that walk on a warm cache, so a
|
|
280
|
+
* second climb over identical bytes saw less evidence than the first — which
|
|
281
|
+
* a conversation's shared prefix subtrees guaranteed by the second turn.
|
|
282
|
+
* foldTree now takes that fast path only when nothing is watching the walk
|
|
283
|
+
* (see primitives.ts), so the climb is idempotent on its own and this memo
|
|
284
|
+
* is an accelerator again. It stays unconditional anyway: attaching a trace
|
|
285
|
+
* must not change which regions attention weighs.
|
|
286
|
+
*
|
|
287
|
+
* A cache hit still emits a trace step — abbreviated, since the full
|
|
288
|
+
* per-sub-region voting detail {@link traceAttention} builds isn't preserved
|
|
289
|
+
* by the cached read-out — so a traced response is never silently blacked
|
|
290
|
+
* out for a repeated query. */
|
|
286
291
|
export declare function climbAttentionAll(ctx: MindContext, query: Uint8Array, k: number, mode?: DFMode): Promise<AttentionRead>;
|
|
287
292
|
export declare function computeAttention(ctx: MindContext, query: Uint8Array, k: number, mode: DFMode): Promise<AttentionRead>;
|
|
288
293
|
export declare function collectRegions(ctx: MindContext, query: Uint8Array): Region[];
|
|
@@ -54,16 +54,21 @@ export async function climbAttention(ctx, query, k, mode = "inverse") {
|
|
|
54
54
|
}
|
|
55
55
|
/** Full read-out of one consensus climb: both the roots (dominant points of
|
|
56
56
|
* attention) and the entire ranked list. Cached via ctx.climbMemo, ALWAYS —
|
|
57
|
-
* see {@link recognise} for why this memo (and recognise()'s own)
|
|
58
|
-
*
|
|
59
|
-
* the query's perceived tree
|
|
60
|
-
* fast path
|
|
61
|
-
*
|
|
62
|
-
* prefix subtrees
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
57
|
+
* see {@link recognise} for why this memo (and recognise()'s own) is never
|
|
58
|
+
* gated on tracing. The short of it: computeAttention's collectRegions
|
|
59
|
+
* votes over what walking the query's perceived tree EMITS, and foldTree's
|
|
60
|
+
* subtree-resolution fast path used to skip that walk on a warm cache, so a
|
|
61
|
+
* second climb over identical bytes saw less evidence than the first — which
|
|
62
|
+
* a conversation's shared prefix subtrees guaranteed by the second turn.
|
|
63
|
+
* foldTree now takes that fast path only when nothing is watching the walk
|
|
64
|
+
* (see primitives.ts), so the climb is idempotent on its own and this memo
|
|
65
|
+
* is an accelerator again. It stays unconditional anyway: attaching a trace
|
|
66
|
+
* must not change which regions attention weighs.
|
|
67
|
+
*
|
|
68
|
+
* A cache hit still emits a trace step — abbreviated, since the full
|
|
69
|
+
* per-sub-region voting detail {@link traceAttention} builds isn't preserved
|
|
70
|
+
* by the cached read-out — so a traced response is never silently blacked
|
|
71
|
+
* out for a repeated query. */
|
|
67
72
|
export async function climbAttentionAll(ctx, query, k, mode = "inverse") {
|
|
68
73
|
// Content-keyed memo — works for both single-turn respond() and multi-turn
|
|
69
74
|
// respondTurn().
|
package/dist/src/mind/mind.d.ts
CHANGED
|
@@ -181,8 +181,9 @@ export declare class Mind implements MindContext {
|
|
|
181
181
|
* serves BOTH entry points: `respond` takes fresh per-response memos,
|
|
182
182
|
* `respondTurn` passes its conversation, whose memos persist across turns
|
|
183
183
|
* (content-keyed, so the previous turn's results are found by this turn's
|
|
184
|
-
* sub-span calls) and whose `resolvedSubtrees`
|
|
185
|
-
*
|
|
184
|
+
* sub-span calls) and whose `resolvedSubtrees` spares foldTree the store
|
|
185
|
+
* probes for every prefix subtree — and, for walks that pass no visitor,
|
|
186
|
+
* the descent as well. respondTurn used to inline its own copy of this
|
|
186
187
|
* and of {@link endResponse}; the two drifted (a memo added to one was
|
|
187
188
|
* silently absent from the other), so there is exactly one pair now. */
|
|
188
189
|
private beginResponse;
|
package/dist/src/mind/mind.js
CHANGED
|
@@ -206,8 +206,9 @@ export class Mind {
|
|
|
206
206
|
* serves BOTH entry points: `respond` takes fresh per-response memos,
|
|
207
207
|
* `respondTurn` passes its conversation, whose memos persist across turns
|
|
208
208
|
* (content-keyed, so the previous turn's results are found by this turn's
|
|
209
|
-
* sub-span calls) and whose `resolvedSubtrees`
|
|
210
|
-
*
|
|
209
|
+
* sub-span calls) and whose `resolvedSubtrees` spares foldTree the store
|
|
210
|
+
* probes for every prefix subtree — and, for walks that pass no visitor,
|
|
211
|
+
* the descent as well. respondTurn used to inline its own copy of this
|
|
211
212
|
* and of {@link endResponse}; the two drifted (a memo added to one was
|
|
212
213
|
* silently absent from the other), so there is exactly one pair now. */
|
|
213
214
|
beginResponse(inspectRationale, canon, conv) {
|
|
@@ -155,20 +155,47 @@ export function gistOf(ctx, bytes) {
|
|
|
155
155
|
* node with its byte span and resolved id. Returns the node's byte end and
|
|
156
156
|
* resolved id. */
|
|
157
157
|
export function foldTree(ctx, n, start, visit) {
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// instead of O(context)
|
|
158
|
+
// Subtree already resolved (from a previous conversation turn or an earlier
|
|
159
|
+
// recognition pass). The pyramid reuses prefix subtrees as identical Sema
|
|
160
|
+
// objects, so a conversation's prefix is warm from its second turn on.
|
|
161
|
+
// Without a visitor that makes foldTree O(suffix) instead of O(context);
|
|
162
|
+
// with one it stays O(context) and saves the per-node store probes instead
|
|
163
|
+
// (see below for why the distinction is not negotiable).
|
|
164
|
+
//
|
|
165
|
+
// WHAT THE CACHE KNOWS, AND WHAT IT DOES NOT. An entry records this
|
|
166
|
+
// subtree's id and byte length — nothing about its DESCENDANTS' spans.
|
|
167
|
+
// Returning here therefore emits ONE visit() where a cold walk emits one per
|
|
168
|
+
// node, and `visit` is not instrumentation: recognise() emits its sites from
|
|
169
|
+
// it (recognition.ts) and attention's collectRegions votes over what it
|
|
170
|
+
// yields (attention.ts). Skipping the descent silently shrinks the evidence
|
|
171
|
+
// those mechanisms see, purely because the cache happened to be warm.
|
|
172
|
+
//
|
|
173
|
+
// That is not hypothetical and not an edge case — it is every conversation
|
|
174
|
+
// turn after the first. `contentFoldIncremental` deliberately shares prefix
|
|
175
|
+
// segment OBJECTS across turns (~99% reuse), so by turn 2 the prefix is
|
|
176
|
+
// warm; meanwhile recogniseMemo/climbMemo are keyed on exact query BYTES,
|
|
177
|
+
// which a growing context never repeats. Warm subtrees + missed memos is
|
|
178
|
+
// the unprotected quadrant. Measured over real trained conversations,
|
|
179
|
+
// recognising the same context with a warm prefix lost 67-92% of its leaves
|
|
180
|
+
// (772->204, 589->47, 872->291, 377->37) with `sites` unchanged, so the loss
|
|
181
|
+
// is invisible to the coarse counts; a direct foldTree probe on identical
|
|
182
|
+
// bytes and an identical tree object fired visit() 661 times cold and 37
|
|
183
|
+
// warm. respond() is immune only because it never sets _resolvedSubtrees
|
|
184
|
+
// (mind.ts) — the degradation was unique to the multi-turn API.
|
|
185
|
+
//
|
|
186
|
+
// So the fast path is taken only when NOBODY IS WATCHING. With a visitor
|
|
187
|
+
// present we still walk, and the cache degrades to the thing it soundly is:
|
|
188
|
+
// an elision of the store probes (findLeaf/findBranch) at each node, not an
|
|
189
|
+
// elision of the traversal. Ids still come from the cache, so a warm walk
|
|
190
|
+
// is cheaper than a cold one; it is no longer *different* from one.
|
|
162
191
|
const cached = ctx._resolvedSubtrees?.get(n);
|
|
163
|
-
if (cached !== undefined) {
|
|
164
|
-
|
|
165
|
-
visit?.(n, start, end, cached.id);
|
|
166
|
-
return { end, node: cached.id };
|
|
192
|
+
if (cached !== undefined && visit === undefined) {
|
|
193
|
+
return { end: start + cached.len, node: cached.id };
|
|
167
194
|
}
|
|
168
195
|
if (n.kids === null) {
|
|
169
196
|
const b = n.leaf ?? new Uint8Array(0);
|
|
170
197
|
const end = start + b.length;
|
|
171
|
-
const node = ctx.store.findLeaf(b);
|
|
198
|
+
const node = cached !== undefined ? cached.id : ctx.store.findLeaf(b);
|
|
172
199
|
visit?.(n, start, end, node);
|
|
173
200
|
if (node !== null && ctx._resolvedSubtrees) {
|
|
174
201
|
ctx._resolvedSubtrees.set(n, { id: node, len: b.length });
|
|
@@ -186,7 +213,16 @@ export function foldTree(ctx, n, start, visit) {
|
|
|
186
213
|
kids.push(r.node);
|
|
187
214
|
pos = r.end;
|
|
188
215
|
}
|
|
189
|
-
|
|
216
|
+
// Same store-probe elision as the leaf case: a cached entry already names
|
|
217
|
+
// this subtree, so the descent above was for `visit`'s benefit alone and the
|
|
218
|
+
// id need not be re-derived. Using it also keeps a warm walk's ids
|
|
219
|
+
// bit-identical to a cold walk's rather than re-deriving them from children
|
|
220
|
+
// that may themselves have come from cache.
|
|
221
|
+
const node = cached !== undefined
|
|
222
|
+
? cached.id
|
|
223
|
+
: known
|
|
224
|
+
? ctx.store.findBranch(kids)
|
|
225
|
+
: null;
|
|
190
226
|
visit?.(n, start, pos, node);
|
|
191
227
|
if (node !== null && ctx._resolvedSubtrees) {
|
|
192
228
|
ctx._resolvedSubtrees.set(n, { id: node, len: pos - start });
|
|
@@ -26,23 +26,40 @@ export function recognise(ctx, bytes) {
|
|
|
26
26
|
// Content-keyed memo — works for both single-turn respond() and multi-turn
|
|
27
27
|
// respondTurn() (where the map persists across calls). ALWAYS consulted,
|
|
28
28
|
// regardless of tracing — matching perceive()'s own memo, which carries no
|
|
29
|
-
// trace gate at all.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
29
|
+
// trace gate at all.
|
|
30
|
+
//
|
|
31
|
+
// This memo is an accelerator, and that is now the whole of it: repeated
|
|
32
|
+
// recognition of the same query is ordinary within one response (cover,
|
|
33
|
+
// reason and articulate all recognise it) and recogniseImpl is O(n ·
|
|
34
|
+
// maxGroup) probes each time.
|
|
35
|
+
//
|
|
36
|
+
// IT USED TO BE LOAD-BEARING FOR CORRECTNESS, and the history is worth
|
|
37
|
+
// keeping because it explains why there is no trace gate here. foldTree's
|
|
38
|
+
// subtree-resolution fast path (primitives.ts) once returned on a cache hit
|
|
39
|
+
// WITHOUT recursing, so it skipped invoking `visit` — and therefore skipped
|
|
40
|
+
// EMITTING SITES — for any subtree already in ctx._resolvedSubtrees. A
|
|
41
|
+
// conversation's incremental fold deliberately shares node OBJECTS across
|
|
42
|
+
// turns, so by the second call on the same bytes large swaths of the tree
|
|
43
|
+
// were already cached and recogniseImpl silently found FEWER sites than the
|
|
44
|
+
// first call (observed live: 31 → 5). Skipping this memo "only while
|
|
45
|
+
// tracing" therefore meant every traced turn re-ran recogniseImpl at each of
|
|
46
|
+
// those call sites, each result more incomplete than the last — changing
|
|
47
|
+
// which mechanism grounded the answer, not merely costing time.
|
|
48
|
+
//
|
|
49
|
+
// foldTree no longer does that: it takes the fast path only when no `visit`
|
|
50
|
+
// is supplied, so a walk that emits sites always walks in full and the id
|
|
51
|
+
// cache is reduced to eliding store probes (see primitives.ts). recognise()
|
|
52
|
+
// is idempotent on its own now — verified with the memo bypassed, the
|
|
53
|
+
// subtree cache warm and the tree object shared: three consecutive calls on
|
|
54
|
+
// the same 544-byte context returned sites=2 leaves=544 splits=0 starts=88,
|
|
55
|
+
// identical every time.
|
|
56
|
+
//
|
|
57
|
+
// The unconditional consult STAYS regardless. A memo whose absence can only
|
|
58
|
+
// cost time is still not something to gate on whether an audit happens to be
|
|
59
|
+
// attached: tracing must not change what the pipeline computes, and the
|
|
60
|
+
// cheapest way to guarantee that is for the trace flag to touch nothing but
|
|
61
|
+
// the trace. The trace step must still fire on every call (a cache hit is
|
|
62
|
+
// not silent), so it is emitted here directly rather than only inside
|
|
46
63
|
// recogniseImpl.
|
|
47
64
|
if (ctx.recogniseMemo) {
|
|
48
65
|
const key = latin1Key(bytes);
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
import { cosine } from "../vec.js";
|
|
9
9
|
import { gistOf, read } from "./primitives.js";
|
|
10
10
|
import { leafIdRun } from "./canonical.js";
|
|
11
|
+
//
|
|
12
|
+
// Budgeted on the same terms as the reach memo below (AGENTS §2.12): these
|
|
13
|
+
// three maps are cleared on every write, but a long read-only session over a
|
|
14
|
+
// large store converges on one entry per node per map with nothing to bound
|
|
15
|
+
// it. Past the cap all three are dropped together and re-derived, costing
|
|
16
|
+
// cold structural probes and never a wrong answer.
|
|
17
|
+
const STRUCT_MEMO_MAX = 100_000;
|
|
11
18
|
const structCaches = new WeakMap();
|
|
12
19
|
// ── The shared ancestor-reach memo ──────────────────────────────────────
|
|
13
20
|
//
|
|
@@ -59,6 +66,13 @@ function getStructCache(ctx) {
|
|
|
59
66
|
hasParents: new Map(),
|
|
60
67
|
});
|
|
61
68
|
}
|
|
69
|
+
else if (c.hasNext.size >= STRUCT_MEMO_MAX ||
|
|
70
|
+
c.prevCount.size >= STRUCT_MEMO_MAX ||
|
|
71
|
+
c.hasParents.size >= STRUCT_MEMO_MAX) {
|
|
72
|
+
c.hasNext.clear();
|
|
73
|
+
c.prevCount.clear();
|
|
74
|
+
c.hasParents.clear();
|
|
75
|
+
}
|
|
62
76
|
return c;
|
|
63
77
|
}
|
|
64
78
|
/** Invalidate every session-lifetime structural read after a write. */
|
package/dist/src/mind/types.d.ts
CHANGED
|
@@ -284,9 +284,13 @@ export interface MindContext extends GraphSearchHost {
|
|
|
284
284
|
/** Subtree-resolution cache: Sema node → its store id and byte length.
|
|
285
285
|
* Populated by {@link foldTree} during inference; checked before
|
|
286
286
|
* walking children. When a conversation's pyramid reuses prefix
|
|
287
|
-
* subtrees, this cache
|
|
288
|
-
*
|
|
289
|
-
*
|
|
287
|
+
* subtrees, this cache names them without a store probe. It does NOT let
|
|
288
|
+
* {@link recognise} skip them: recognise walks with a `visit` callback and
|
|
289
|
+
* emits its sites from it, so a skipped descent would mean fewer sites on
|
|
290
|
+
* a warm cache than a cold one. foldTree short-circuits only for
|
|
291
|
+
* visitor-less walks (O(suffix) there); a visiting walk stays O(context)
|
|
292
|
+
* and banks the elided probes. Mind-lifetime (WeakMap keys are the Sema
|
|
293
|
+
* objects the pyramid keeps alive).
|
|
290
294
|
*
|
|
291
295
|
* THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
|
|
292
296
|
* so it hits only while the conversation's fold hands back the SAME Sema
|
package/package.json
CHANGED
package/src/mind/attention.ts
CHANGED
|
@@ -460,16 +460,21 @@ export async function climbAttention(
|
|
|
460
460
|
|
|
461
461
|
/** Full read-out of one consensus climb: both the roots (dominant points of
|
|
462
462
|
* attention) and the entire ranked list. Cached via ctx.climbMemo, ALWAYS —
|
|
463
|
-
* see {@link recognise} for why this memo (and recognise()'s own)
|
|
464
|
-
*
|
|
465
|
-
* the query's perceived tree
|
|
466
|
-
* fast path
|
|
467
|
-
*
|
|
468
|
-
* prefix subtrees
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
463
|
+
* see {@link recognise} for why this memo (and recognise()'s own) is never
|
|
464
|
+
* gated on tracing. The short of it: computeAttention's collectRegions
|
|
465
|
+
* votes over what walking the query's perceived tree EMITS, and foldTree's
|
|
466
|
+
* subtree-resolution fast path used to skip that walk on a warm cache, so a
|
|
467
|
+
* second climb over identical bytes saw less evidence than the first — which
|
|
468
|
+
* a conversation's shared prefix subtrees guaranteed by the second turn.
|
|
469
|
+
* foldTree now takes that fast path only when nothing is watching the walk
|
|
470
|
+
* (see primitives.ts), so the climb is idempotent on its own and this memo
|
|
471
|
+
* is an accelerator again. It stays unconditional anyway: attaching a trace
|
|
472
|
+
* must not change which regions attention weighs.
|
|
473
|
+
*
|
|
474
|
+
* A cache hit still emits a trace step — abbreviated, since the full
|
|
475
|
+
* per-sub-region voting detail {@link traceAttention} builds isn't preserved
|
|
476
|
+
* by the cached read-out — so a traced response is never silently blacked
|
|
477
|
+
* out for a repeated query. */
|
|
473
478
|
export async function climbAttentionAll(
|
|
474
479
|
ctx: MindContext,
|
|
475
480
|
query: Uint8Array,
|
package/src/mind/mind.ts
CHANGED
|
@@ -110,8 +110,12 @@ export interface Conversation {
|
|
|
110
110
|
*
|
|
111
111
|
* {@link resolvedSubtrees} caches foldTree resolutions at the Sema-node
|
|
112
112
|
* level. When the pyramid reuses prefix subtrees (identical objects),
|
|
113
|
-
* foldTree
|
|
114
|
-
*
|
|
113
|
+
* foldTree recovers their ids without touching the store. A walk that
|
|
114
|
+
* passes no `visit` callback can stop at a cached subtree outright and is
|
|
115
|
+
* O(suffix); a walk that DOES pass one — recognition and attention both do —
|
|
116
|
+
* still descends in full and spends O(context), banking the elided store
|
|
117
|
+
* probes rather than an elided traversal. That asymmetry is deliberate and
|
|
118
|
+
* load-bearing: see foldTree in primitives.ts. */
|
|
115
119
|
interface ConversationData {
|
|
116
120
|
tree: Sema;
|
|
117
121
|
bytes: Uint8Array;
|
|
@@ -120,8 +124,11 @@ interface ConversationData {
|
|
|
120
124
|
* grown context reuses every content segment it already folded and folds
|
|
121
125
|
* only the new turn — O(turn) instead of O(context) — and, because the
|
|
122
126
|
* reused segments are the SAME Sema objects, `resolvedSubtrees` (keyed by
|
|
123
|
-
* node identity) hits across turns,
|
|
124
|
-
*
|
|
127
|
+
* node identity) hits across turns, so recognition recovers the prefix's
|
|
128
|
+
* ids without re-probing the store for any of them. It still WALKS the
|
|
129
|
+
* prefix — it must, or it would emit fewer sites on a warm cache than a
|
|
130
|
+
* cold one (see foldTree) — so the saving is in probes, not in traversal.
|
|
131
|
+
* Undefined until the first grow.
|
|
125
132
|
*
|
|
126
133
|
* No turn boundaries are involved: reuse comes from content cuts being
|
|
127
134
|
* stable under append, and imposing boundaries would only change the tree
|
|
@@ -480,8 +487,9 @@ export class Mind implements MindContext {
|
|
|
480
487
|
* serves BOTH entry points: `respond` takes fresh per-response memos,
|
|
481
488
|
* `respondTurn` passes its conversation, whose memos persist across turns
|
|
482
489
|
* (content-keyed, so the previous turn's results are found by this turn's
|
|
483
|
-
* sub-span calls) and whose `resolvedSubtrees`
|
|
484
|
-
*
|
|
490
|
+
* sub-span calls) and whose `resolvedSubtrees` spares foldTree the store
|
|
491
|
+
* probes for every prefix subtree — and, for walks that pass no visitor,
|
|
492
|
+
* the descent as well. respondTurn used to inline its own copy of this
|
|
485
493
|
* and of {@link endResponse}; the two drifted (a memo added to one was
|
|
486
494
|
* silently absent from the other), so there is exactly one pair now. */
|
|
487
495
|
private beginResponse(
|
package/src/mind/primitives.ts
CHANGED
|
@@ -203,21 +203,48 @@ export function foldTree(
|
|
|
203
203
|
start: number,
|
|
204
204
|
visit?: (n: Sema, start: number, end: number, node: number | null) => void,
|
|
205
205
|
): { end: number; node: number | null } {
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// instead of O(context)
|
|
206
|
+
// Subtree already resolved (from a previous conversation turn or an earlier
|
|
207
|
+
// recognition pass). The pyramid reuses prefix subtrees as identical Sema
|
|
208
|
+
// objects, so a conversation's prefix is warm from its second turn on.
|
|
209
|
+
// Without a visitor that makes foldTree O(suffix) instead of O(context);
|
|
210
|
+
// with one it stays O(context) and saves the per-node store probes instead
|
|
211
|
+
// (see below for why the distinction is not negotiable).
|
|
212
|
+
//
|
|
213
|
+
// WHAT THE CACHE KNOWS, AND WHAT IT DOES NOT. An entry records this
|
|
214
|
+
// subtree's id and byte length — nothing about its DESCENDANTS' spans.
|
|
215
|
+
// Returning here therefore emits ONE visit() where a cold walk emits one per
|
|
216
|
+
// node, and `visit` is not instrumentation: recognise() emits its sites from
|
|
217
|
+
// it (recognition.ts) and attention's collectRegions votes over what it
|
|
218
|
+
// yields (attention.ts). Skipping the descent silently shrinks the evidence
|
|
219
|
+
// those mechanisms see, purely because the cache happened to be warm.
|
|
220
|
+
//
|
|
221
|
+
// That is not hypothetical and not an edge case — it is every conversation
|
|
222
|
+
// turn after the first. `contentFoldIncremental` deliberately shares prefix
|
|
223
|
+
// segment OBJECTS across turns (~99% reuse), so by turn 2 the prefix is
|
|
224
|
+
// warm; meanwhile recogniseMemo/climbMemo are keyed on exact query BYTES,
|
|
225
|
+
// which a growing context never repeats. Warm subtrees + missed memos is
|
|
226
|
+
// the unprotected quadrant. Measured over real trained conversations,
|
|
227
|
+
// recognising the same context with a warm prefix lost 67-92% of its leaves
|
|
228
|
+
// (772->204, 589->47, 872->291, 377->37) with `sites` unchanged, so the loss
|
|
229
|
+
// is invisible to the coarse counts; a direct foldTree probe on identical
|
|
230
|
+
// bytes and an identical tree object fired visit() 661 times cold and 37
|
|
231
|
+
// warm. respond() is immune only because it never sets _resolvedSubtrees
|
|
232
|
+
// (mind.ts) — the degradation was unique to the multi-turn API.
|
|
233
|
+
//
|
|
234
|
+
// So the fast path is taken only when NOBODY IS WATCHING. With a visitor
|
|
235
|
+
// present we still walk, and the cache degrades to the thing it soundly is:
|
|
236
|
+
// an elision of the store probes (findLeaf/findBranch) at each node, not an
|
|
237
|
+
// elision of the traversal. Ids still come from the cache, so a warm walk
|
|
238
|
+
// is cheaper than a cold one; it is no longer *different* from one.
|
|
210
239
|
const cached = ctx._resolvedSubtrees?.get(n);
|
|
211
|
-
if (cached !== undefined) {
|
|
212
|
-
|
|
213
|
-
visit?.(n, start, end, cached.id);
|
|
214
|
-
return { end, node: cached.id };
|
|
240
|
+
if (cached !== undefined && visit === undefined) {
|
|
241
|
+
return { end: start + cached.len, node: cached.id };
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
if (n.kids === null) {
|
|
218
245
|
const b = n.leaf ?? new Uint8Array(0);
|
|
219
246
|
const end = start + b.length;
|
|
220
|
-
const node = ctx.store.findLeaf(b);
|
|
247
|
+
const node = cached !== undefined ? cached.id : ctx.store.findLeaf(b);
|
|
221
248
|
visit?.(n, start, end, node);
|
|
222
249
|
if (node !== null && ctx._resolvedSubtrees) {
|
|
223
250
|
ctx._resolvedSubtrees.set(n, { id: node, len: b.length });
|
|
@@ -233,7 +260,16 @@ export function foldTree(
|
|
|
233
260
|
else if (known) kids.push(r.node);
|
|
234
261
|
pos = r.end;
|
|
235
262
|
}
|
|
236
|
-
|
|
263
|
+
// Same store-probe elision as the leaf case: a cached entry already names
|
|
264
|
+
// this subtree, so the descent above was for `visit`'s benefit alone and the
|
|
265
|
+
// id need not be re-derived. Using it also keeps a warm walk's ids
|
|
266
|
+
// bit-identical to a cold walk's rather than re-deriving them from children
|
|
267
|
+
// that may themselves have come from cache.
|
|
268
|
+
const node = cached !== undefined
|
|
269
|
+
? cached.id
|
|
270
|
+
: known
|
|
271
|
+
? ctx.store.findBranch(kids)
|
|
272
|
+
: null;
|
|
237
273
|
visit?.(n, start, pos, node);
|
|
238
274
|
if (node !== null && ctx._resolvedSubtrees) {
|
|
239
275
|
ctx._resolvedSubtrees.set(n, { id: node, len: pos - start });
|
package/src/mind/recognition.ts
CHANGED
|
@@ -37,23 +37,40 @@ export function recognise(ctx: MindContext, bytes: Uint8Array): Recognition {
|
|
|
37
37
|
// Content-keyed memo — works for both single-turn respond() and multi-turn
|
|
38
38
|
// respondTurn() (where the map persists across calls). ALWAYS consulted,
|
|
39
39
|
// regardless of tracing — matching perceive()'s own memo, which carries no
|
|
40
|
-
// trace gate at all.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
40
|
+
// trace gate at all.
|
|
41
|
+
//
|
|
42
|
+
// This memo is an accelerator, and that is now the whole of it: repeated
|
|
43
|
+
// recognition of the same query is ordinary within one response (cover,
|
|
44
|
+
// reason and articulate all recognise it) and recogniseImpl is O(n ·
|
|
45
|
+
// maxGroup) probes each time.
|
|
46
|
+
//
|
|
47
|
+
// IT USED TO BE LOAD-BEARING FOR CORRECTNESS, and the history is worth
|
|
48
|
+
// keeping because it explains why there is no trace gate here. foldTree's
|
|
49
|
+
// subtree-resolution fast path (primitives.ts) once returned on a cache hit
|
|
50
|
+
// WITHOUT recursing, so it skipped invoking `visit` — and therefore skipped
|
|
51
|
+
// EMITTING SITES — for any subtree already in ctx._resolvedSubtrees. A
|
|
52
|
+
// conversation's incremental fold deliberately shares node OBJECTS across
|
|
53
|
+
// turns, so by the second call on the same bytes large swaths of the tree
|
|
54
|
+
// were already cached and recogniseImpl silently found FEWER sites than the
|
|
55
|
+
// first call (observed live: 31 → 5). Skipping this memo "only while
|
|
56
|
+
// tracing" therefore meant every traced turn re-ran recogniseImpl at each of
|
|
57
|
+
// those call sites, each result more incomplete than the last — changing
|
|
58
|
+
// which mechanism grounded the answer, not merely costing time.
|
|
59
|
+
//
|
|
60
|
+
// foldTree no longer does that: it takes the fast path only when no `visit`
|
|
61
|
+
// is supplied, so a walk that emits sites always walks in full and the id
|
|
62
|
+
// cache is reduced to eliding store probes (see primitives.ts). recognise()
|
|
63
|
+
// is idempotent on its own now — verified with the memo bypassed, the
|
|
64
|
+
// subtree cache warm and the tree object shared: three consecutive calls on
|
|
65
|
+
// the same 544-byte context returned sites=2 leaves=544 splits=0 starts=88,
|
|
66
|
+
// identical every time.
|
|
67
|
+
//
|
|
68
|
+
// The unconditional consult STAYS regardless. A memo whose absence can only
|
|
69
|
+
// cost time is still not something to gate on whether an audit happens to be
|
|
70
|
+
// attached: tracing must not change what the pipeline computes, and the
|
|
71
|
+
// cheapest way to guarantee that is for the trace flag to touch nothing but
|
|
72
|
+
// the trace. The trace step must still fire on every call (a cache hit is
|
|
73
|
+
// not silent), so it is emitted here directly rather than only inside
|
|
57
74
|
// recogniseImpl.
|
|
58
75
|
if (ctx.recogniseMemo) {
|
|
59
76
|
const key = latin1Key(bytes);
|
package/src/mind/traverse.ts
CHANGED
|
@@ -31,6 +31,13 @@ interface StructCache {
|
|
|
31
31
|
prevCount: Map<number, number>;
|
|
32
32
|
hasParents: Map<number, boolean>;
|
|
33
33
|
}
|
|
34
|
+
//
|
|
35
|
+
// Budgeted on the same terms as the reach memo below (AGENTS §2.12): these
|
|
36
|
+
// three maps are cleared on every write, but a long read-only session over a
|
|
37
|
+
// large store converges on one entry per node per map with nothing to bound
|
|
38
|
+
// it. Past the cap all three are dropped together and re-derived, costing
|
|
39
|
+
// cold structural probes and never a wrong answer.
|
|
40
|
+
const STRUCT_MEMO_MAX = 100_000;
|
|
34
41
|
const structCaches = new WeakMap<object, StructCache>();
|
|
35
42
|
|
|
36
43
|
// ── The shared ancestor-reach memo ──────────────────────────────────────
|
|
@@ -85,6 +92,14 @@ function getStructCache(ctx: MindContext): StructCache | null {
|
|
|
85
92
|
hasParents: new Map(),
|
|
86
93
|
},
|
|
87
94
|
);
|
|
95
|
+
} else if (
|
|
96
|
+
c.hasNext.size >= STRUCT_MEMO_MAX ||
|
|
97
|
+
c.prevCount.size >= STRUCT_MEMO_MAX ||
|
|
98
|
+
c.hasParents.size >= STRUCT_MEMO_MAX
|
|
99
|
+
) {
|
|
100
|
+
c.hasNext.clear();
|
|
101
|
+
c.prevCount.clear();
|
|
102
|
+
c.hasParents.clear();
|
|
88
103
|
}
|
|
89
104
|
return c;
|
|
90
105
|
}
|
package/src/mind/types.ts
CHANGED
|
@@ -331,9 +331,13 @@ export interface MindContext extends GraphSearchHost {
|
|
|
331
331
|
/** Subtree-resolution cache: Sema node → its store id and byte length.
|
|
332
332
|
* Populated by {@link foldTree} during inference; checked before
|
|
333
333
|
* walking children. When a conversation's pyramid reuses prefix
|
|
334
|
-
* subtrees, this cache
|
|
335
|
-
*
|
|
336
|
-
*
|
|
334
|
+
* subtrees, this cache names them without a store probe. It does NOT let
|
|
335
|
+
* {@link recognise} skip them: recognise walks with a `visit` callback and
|
|
336
|
+
* emits its sites from it, so a skipped descent would mean fewer sites on
|
|
337
|
+
* a warm cache than a cold one. foldTree short-circuits only for
|
|
338
|
+
* visitor-less walks (O(suffix) there); a visiting walk stays O(context)
|
|
339
|
+
* and banks the elided probes. Mind-lifetime (WeakMap keys are the Sema
|
|
340
|
+
* objects the pyramid keeps alive).
|
|
337
341
|
*
|
|
338
342
|
* THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
|
|
339
343
|
* so it hits only while the conversation's fold hands back the SAME Sema
|
|
@@ -59,7 +59,10 @@ import {
|
|
|
59
59
|
// White-box: the memo key is internal, but its soundness is exactly what
|
|
60
60
|
// section C is about, so it is imported directly rather than inferred from
|
|
61
61
|
// downstream accuracy.
|
|
62
|
-
import { perceiveKey } from "../dist/src/mind/primitives.js";
|
|
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";
|
|
63
66
|
|
|
64
67
|
const enc = (s) => new TextEncoder().encode(s);
|
|
65
68
|
const newMind = (opts = {}) => new Mind({ seed: 7, ...opts });
|
|
@@ -1080,3 +1083,252 @@ test("F3: the conversation API is never WORSE than respond() on the same bytes",
|
|
|
1080
1083
|
`respondTurn should answer every trained turn`,
|
|
1081
1084
|
);
|
|
1082
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
|
+
});
|