@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.
@@ -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) must
277
- * never be skipped while tracing: computeAttention's collectRegions walks
278
- * the query's perceived tree via the same foldTree whose subtree-resolution
279
- * fast path makes a second call on identical bytes non-idempotent once
280
- * ctx._resolvedSubtrees is warm (which a multi-turn conversation's shared
281
- * prefix subtrees guarantee by the second turn). A cache hit still emits
282
- * a trace step abbreviated, since the full per-sub-region voting detail
283
- * {@link traceAttention} builds isn't preserved by the cached read-out
284
- * so a traced response is never silently blacked out for a repeated
285
- * query. */
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) must
58
- * never be skipped while tracing: computeAttention's collectRegions walks
59
- * the query's perceived tree via the same foldTree whose subtree-resolution
60
- * fast path makes a second call on identical bytes non-idempotent once
61
- * ctx._resolvedSubtrees is warm (which a multi-turn conversation's shared
62
- * prefix subtrees guarantee by the second turn). A cache hit still emits
63
- * a trace step abbreviated, since the full per-sub-region voting detail
64
- * {@link traceAttention} builds isn't preserved by the cached read-out
65
- * so a traced response is never silently blacked out for a repeated
66
- * query. */
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().
@@ -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` makes foldTree O(suffix)
185
- * instead of O(context). respondTurn used to inline its own copy of this
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;
@@ -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` makes foldTree O(suffix)
210
- * instead of O(context). respondTurn used to inline its own copy of this
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
- // Fast path: subtree already resolved (from a previous conversation turn
159
- // or an earlier recognition pass). The pyramid reuses prefix subtrees as
160
- // identical Sema objects, so this cache turns foldTree into O(suffix)
161
- // instead of O(context) for multi-turn recognition.
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
- const end = start + cached.len;
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
- const node = known ? ctx.store.findBranch(kids) : null;
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. This memo is NOT an optional accelerator: recogniseImpl
30
- // walks the query's perceived tree via foldTree, whose subtree-resolution
31
- // fast path (see primitives.ts) skips invoking `visit` and therefore
32
- // skips EMITTING SITES for any subtree already cached in
33
- // ctx._resolvedSubtrees. A multi-turn conversation's stable-prefix fold
34
- // deliberately shares node OBJECTS across turns, so by the second call on
35
- // the exact same bytes, large swaths of the tree are already cached and
36
- // foldTree stops short of recursing into them a second recogniseImpl
37
- // call on the SAME bytes is not idempotent; it silently finds FEWER sites
38
- // than the first (observed live: 31 sites → 5 on an immediate repeat
39
- // call). Skipping this memo "only while tracing" used to mean every
40
- // traced turn re-ran recogniseImpl from scratch at every one of the many
41
- // call sites that recognise the same query (cover, reason, articulate...),
42
- // each subsequent call silently more incomplete than the last measurably
43
- // changing which mechanism grounds the answer, not just costing time. The
44
- // trace step must still fire on every call regardless (a cache hit is not
45
- // silent), so it is emitted here directly instead of only inside
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. */
@@ -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 lets {@link recognise} skip them entirely
288
- * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
289
- * the Sema objects the pyramid keeps alive).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -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) must
464
- * never be skipped while tracing: computeAttention's collectRegions walks
465
- * the query's perceived tree via the same foldTree whose subtree-resolution
466
- * fast path makes a second call on identical bytes non-idempotent once
467
- * ctx._resolvedSubtrees is warm (which a multi-turn conversation's shared
468
- * prefix subtrees guarantee by the second turn). A cache hit still emits
469
- * a trace step abbreviated, since the full per-sub-region voting detail
470
- * {@link traceAttention} builds isn't preserved by the cached read-out
471
- * so a traced response is never silently blacked out for a repeated
472
- * query. */
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 returns their ids immediately O(suffix) instead of
114
- * O(context) for every tree walk. */
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, which is what makes recognition
124
- * O(suffix). Undefined until the first grow.
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` makes foldTree O(suffix)
484
- * instead of O(context). respondTurn used to inline its own copy of this
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(
@@ -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
- // Fast path: subtree already resolved (from a previous conversation turn
207
- // or an earlier recognition pass). The pyramid reuses prefix subtrees as
208
- // identical Sema objects, so this cache turns foldTree into O(suffix)
209
- // instead of O(context) for multi-turn recognition.
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
- const end = start + cached.len;
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
- const node = known ? ctx.store.findBranch(kids) : null;
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 });
@@ -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. This memo is NOT an optional accelerator: recogniseImpl
41
- // walks the query's perceived tree via foldTree, whose subtree-resolution
42
- // fast path (see primitives.ts) skips invoking `visit` and therefore
43
- // skips EMITTING SITES for any subtree already cached in
44
- // ctx._resolvedSubtrees. A multi-turn conversation's stable-prefix fold
45
- // deliberately shares node OBJECTS across turns, so by the second call on
46
- // the exact same bytes, large swaths of the tree are already cached and
47
- // foldTree stops short of recursing into them a second recogniseImpl
48
- // call on the SAME bytes is not idempotent; it silently finds FEWER sites
49
- // than the first (observed live: 31 sites → 5 on an immediate repeat
50
- // call). Skipping this memo "only while tracing" used to mean every
51
- // traced turn re-ran recogniseImpl from scratch at every one of the many
52
- // call sites that recognise the same query (cover, reason, articulate...),
53
- // each subsequent call silently more incomplete than the last measurably
54
- // changing which mechanism grounds the answer, not just costing time. The
55
- // trace step must still fire on every call regardless (a cache hit is not
56
- // silent), so it is emitted here directly instead of only inside
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);
@@ -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 lets {@link recognise} skip them entirely
335
- * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
336
- * the Sema objects the pyramid keeps alive).
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
+ });