@hviana/sema 0.1.6 → 0.1.7

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/src/geometry.ts CHANGED
@@ -314,17 +314,32 @@ export function knownPrefixLength(
314
314
  /** Bytes → Sema tree. `leafAt` and `lookup` are store capabilities for
315
315
  * detecting previously-stored prefixes so the river can split at the
316
316
  * correct boundary. Pass them through from `perceive`; the geometry
317
- * computes the stable prefix internally. */
317
+ * computes the stable prefix internally.
318
+ *
319
+ * `boundaries` is the CALLER-computed stable-prefix boundary set (§10.3):
320
+ * strictly-increasing proper byte offsets, each the length of a prefix that
321
+ * is already a stored whole-stream form. When given, the fold splits into
322
+ * the segments between consecutive boundaries — each folded independently,
323
+ * exactly as it folded when it was learned — and the segment roots join
324
+ * LEFT-NESTED (((s₀·s₁)·s₂)…), so every learnt cumulative-context root
325
+ * reappears as an identical subtree (and, by hash-consing, the very same
326
+ * node) inside the grown stream. This is what lets a conversation's next
327
+ * turn extend perception instead of refolding it: identical prefixes
328
+ * produce identical subtrees regardless of what follows them. */
318
329
  export function bytesToTree(
319
330
  space: Space,
320
331
  alphabet: Alphabet,
321
332
  bytes: Uint8Array,
322
333
  leafAt?: (i: number) => number | null,
323
334
  lookup?: (leafIds: number[]) => number | null,
335
+ boundaries?: readonly number[],
324
336
  ): Sema {
325
337
  if (bytes.length === 0) {
326
338
  return sema(alphabet.vecs[0], new Uint8Array(0), null);
327
339
  }
340
+ if (boundaries !== undefined && boundaries.length > 0) {
341
+ return stablePrefixFold(space, alphabet, bytes, boundaries);
342
+ }
328
343
  const sb = (leafAt && lookup) ? knownPrefixLength(bytes, leafAt, lookup) : 0;
329
344
  return riverFold(
330
345
  space,
@@ -333,6 +348,83 @@ export function bytesToTree(
333
348
  ).tree;
334
349
  }
335
350
 
351
+ /** The stable-prefix segmented fold (§10.3). Each segment between
352
+ * consecutive boundaries folds PLAINLY and independently; segment roots
353
+ * join left-nested, and only the final root is normalized (the linear-fold
354
+ * contract: one normalize per perception). A segment's own inner splits
355
+ * need no recursion here: a nested learnt prefix is itself an earlier
356
+ * boundary, so the left-nested join reproduces every intermediate learnt
357
+ * root ((s₀·s₁) IS the root the store learnt for the first two segments'
358
+ * bytes, and so on). */
359
+ function stablePrefixFold(
360
+ space: Space,
361
+ alphabet: Alphabet,
362
+ bytes: Uint8Array,
363
+ boundaries: readonly number[],
364
+ ): Sema {
365
+ const cuts: number[] = [];
366
+ let prev = 0;
367
+ for (const b of boundaries) {
368
+ if (b > prev && b < bytes.length) {
369
+ cuts.push(b);
370
+ prev = b;
371
+ }
372
+ }
373
+ if (cuts.length === 0) {
374
+ return riverFold(space, bytesToLeaves(alphabet, bytes), bytes.length).tree;
375
+ }
376
+ const edges = [0, ...cuts, bytes.length];
377
+ const segs: Folded[] = [];
378
+ for (let i = 0; i + 1 < edges.length; i++) {
379
+ const seg = bytes.subarray(edges[i], edges[i + 1]);
380
+ segs.push(riverFoldRaw(space, bytesToLeaves(alphabet, seg)));
381
+ }
382
+ let cur = segs[0];
383
+ for (let i = 1; i < segs.length; i++) cur = fold2(space, cur, segs[i]);
384
+ normalize(cur.tree.v);
385
+ return cur.tree;
386
+ }
387
+
388
+ /** Join two folded items as one 2-kid branch — the top-level join of the
389
+ * stable-prefix fold, identical FP ops to foldSlice's seat-bound
390
+ * accumulation over a group of two. Unnormalized (interior). */
391
+ function fold2(space: Space, a: Folded, b: Folded): Folded {
392
+ const D = space.D;
393
+ const gist = new Float32Array(D);
394
+ const kids = [a.tree, b.tree];
395
+ for (let k = 0; k < 2; k++) {
396
+ const seat = space.seats[k].fwd;
397
+ const v = kids[k].v;
398
+ for (let d = 0; d < D; d++) gist[d] += v[seat[d]];
399
+ }
400
+ return { tree: sema(gist, null, kids), len: a.len + b.len };
401
+ }
402
+
403
+ /** Plain river fold WITHOUT the final root normalize — the segment-level
404
+ * building block of {@link stablePrefixFold} (interiors must keep their
405
+ * byte-proportional magnitude; only the whole perception's root is ever
406
+ * normalized). */
407
+ function riverFoldRaw(space: Space, row: Folded[]): Folded {
408
+ if (row.length === 0) {
409
+ const z = new Float32Array(space.D);
410
+ return { tree: sema(z, new Uint8Array(0), null), len: 0 };
411
+ }
412
+ if (row.length === 1) return row[0];
413
+ let level = row;
414
+ while (level.length > 1) {
415
+ const next: Folded[] = [];
416
+ foldSlice(space, level, 0, level.length, next, false);
417
+ if (next.length === level.length) {
418
+ const forced: Folded[] = [];
419
+ foldSlice(space, next, 0, next.length, forced, true);
420
+ level = forced;
421
+ } else {
422
+ level = next;
423
+ }
424
+ }
425
+ return level[0];
426
+ }
427
+
336
428
  // ---- pyramid fold (incremental plain perception) ----
337
429
 
338
430
  /** The PLAIN fold's full level pyramid — every level's item list, bottom
@@ -27,7 +27,7 @@ import type {
27
27
  SaturationInfo,
28
28
  } from "./types.js";
29
29
  import { consensusFloor, dominates, estimatorNoise } from "../geometry.js";
30
- import { foldTree, gistOf, perceive, read } from "./primitives.js";
30
+ import { foldTree, gistOf, latin1Key, perceive, read } from "./primitives.js";
31
31
  import { recognise } from "./recognition.js";
32
32
  import { leafIdRun } from "./canonical.js";
33
33
  import { corpusN, edgeAncestors } from "./traverse.js";
@@ -65,14 +65,19 @@ export async function climbAttentionAll(
65
65
  k: number,
66
66
  mode: DFMode = "inverse",
67
67
  ): Promise<AttentionRead> {
68
+ // Content-keyed memo — works for both single-turn respond() and multi-turn
69
+ // respondTurn(). Skipped while tracing.
68
70
  if (ctx.climbMemo && !ctx.trace) {
69
- const key = `${k}:${mode}`;
70
- let byRead = ctx.climbMemo.get(query);
71
- if (byRead === undefined) ctx.climbMemo.set(query, byRead = new Map());
72
- const hit = byRead.get(key);
71
+ const contentKey = latin1Key(query);
72
+ const modeKey = `${k}:${mode}`;
73
+ let byRead = ctx.climbMemo.get(contentKey);
74
+ if (byRead === undefined) {
75
+ ctx.climbMemo.set(contentKey, byRead = new Map());
76
+ }
77
+ const hit = byRead.get(modeKey);
73
78
  if (hit !== undefined) return hit;
74
79
  const read = await computeAttention(ctx, query, k, mode);
75
- byRead.set(key, read);
80
+ byRead.set(modeKey, read);
76
81
  return read;
77
82
  }
78
83
  return computeAttention(ctx, query, k, mode);
@@ -168,8 +168,21 @@ export async function recallByResonance(
168
168
  // long answer sharing only scaffolding pass; the query-relative fraction
169
169
  // measures exactly what the reach bar means: how much of THE QUERY the
170
170
  // store accounts for.
171
+ // Chance similarity survives the length conversion AMPLIFIED: the same
172
+ // √(lenG/lenQ) factor that converts an honest shared fraction into a
173
+ // query-relative one multiplies the estimator/chance floor too, so a long
174
+ // stored form (√(lenG/lenQ) ≈ 10 at 100×) lifted a noise-level cosine past
175
+ // the reach bar and grounded pure gibberish (observed). Only the
176
+ // ABOVE-CHANCE part of the similarity is evidence of shared content —
177
+ // subtract the significance bar (3/√D, §8.3) before converting. Derived
178
+ // from the existing bars; never tuned.
179
+ const sig = significanceBar(ctx.store.D);
171
180
  const fracOfQuery = (cos: number, otherLen: number): number =>
172
- Math.min(1, cos * Math.sqrt(otherLen / Math.max(1, query.length)));
181
+ Math.min(
182
+ 1,
183
+ Math.max(0, cos - sig) *
184
+ Math.sqrt(otherLen / Math.max(1, query.length)),
185
+ );
173
186
  for (const h of whole) {
174
187
  const g = await project(ctx, h.id, queryGist);
175
188
  if (g) {
@@ -186,18 +199,21 @@ export async function recallByResonance(
186
199
  }
187
200
  }
188
201
  }
189
- // The refusal/echo decision, in the same query-relative units the top
190
- // hit's magnitude read from the store (contentLen: √bytes IS the linear
191
- // fold's gist norm), never from re-reading its bytes.
192
- // The magnitude read SATURATES at the decision point: frac reaches the
193
- // reach bar exactly when lenH = lenQ·(reach/score)², so the walk never
194
- // needs to see past that a huge conversation root costs a capped read,
195
- // and a clamped return decides "pass" identically (frac reach).
202
+ // The refusal/echo decision. The echo returns a stored form's bytes AS
203
+ // the answer a near-identity claim about the query and identity-grade
204
+ // decisions are never made on an estimated score ("approximate scores may
205
+ // rank and propose; they may never decide", §6.2): the RaBitQ estimate
206
+ // overshooting the reach bar echoed a WRONG-entity neighbour ("capital of
207
+ // Zamunda?" echoed the Armenia fact, observed). The bytes are read
208
+ // anyway to be echoed, so the decision uses their EXACT fold: one river
209
+ // fold of the top hit, measured in the same query-relative,
210
+ // chance-corrected units as the tier above.
196
211
  const reach = reachThreshold(ctx.space.maxGroup);
197
- const lenCap = Math.ceil(
198
- query.length * (reach / Math.max(top.score, 1e-6)) ** 2,
199
- ) + 1;
200
- if (fracOfQuery(top.score, ctx.store.contentLen(top.id, lenCap)) < reach) {
212
+ const topBytes = read(ctx, top.id);
213
+ const exact = topBytes.length > 0
214
+ ? cosine(queryGist, gistOf(ctx, topBytes))
215
+ : 0;
216
+ if (fracOfQuery(exact, topBytes.length) < reach) {
201
217
  return ground(
202
218
  null,
203
219
  "below reach threshold — nothing in the store relates to this query",
@@ -207,7 +223,7 @@ export async function recallByResonance(
207
223
  }
208
224
  // Honest echo.
209
225
  return ground(
210
- read(ctx, top.id),
226
+ topBytes,
211
227
  "last resort: the nearest resonant form's own bytes (echo, not grounded)",
212
228
  [],
213
229
  0,
package/src/mind/mind.ts CHANGED
@@ -14,6 +14,8 @@ import { bindSeat, fold, Sema, Space } from "../sema.js";
14
14
  import { Alphabet } from "../alphabet.js";
15
15
  import {
16
16
  bytesToTree,
17
+ bytesToTreePyramid,
18
+ type FoldPyramid,
17
19
  Grid,
18
20
  gridToTree,
19
21
  hilbertBytes,
@@ -68,6 +70,53 @@ export interface Response {
68
70
  provenance?: import("./pipeline.js").Provenance;
69
71
  }
70
72
 
73
+ /** Serializable state of a conversation — can be saved and restored across
74
+ * sessions. The Mind never interprets the bytes; it only tracks their
75
+ * cumulative lengths so the caller can reconstruct turn boundaries later
76
+ * without inspecting content. */
77
+ export interface ConversationState {
78
+ /** The accumulated context bytes — raw concatenation of every turn's
79
+ * bytes in order. No separator is inserted; the boundary offsets
80
+ * ({@link boundaries}) tell the caller where each turn ends. */
81
+ context: Uint8Array;
82
+ /** Cumulative byte length after each completed turn. Sorted, strictly
83
+ * increasing, each {@code < context.length}. The first turn's length
84
+ * is `boundaries[0]`; the second turn starts at that offset, and so
85
+ * on. Empty for a single-turn or new conversation. */
86
+ boundaries: number[];
87
+ }
88
+
89
+ /** An active conversation handle. Opaque — interact through the Mind's
90
+ * conversation methods ({@link Mind.beginConversation},
91
+ * {@link Mind.respondTurn}, {@link Mind.endConversation}). */
92
+ export interface Conversation {
93
+ readonly id: number;
94
+ }
95
+
96
+ /** Internal per-conversation state.
97
+ *
98
+ * The {@link pyramid} IS the conversation — the accumulated internal
99
+ * processing state. {@link bytes} is the raw accumulated input, kept
100
+ * in sync with the pyramid for O(1) concatenation.
101
+ *
102
+ * Memos persist across turns so the inference pipeline does not
103
+ * re-process the prefix. Content-keyed (latin1) — each turn's fresh
104
+ * {@code Uint8Array} would never hit an object-identity key.
105
+ *
106
+ * {@link resolvedSubtrees} caches foldTree resolutions at the Sema-node
107
+ * level. When the pyramid reuses prefix subtrees (identical objects),
108
+ * foldTree returns their ids immediately — O(suffix) instead of
109
+ * O(context) for every tree walk. */
110
+ interface ConversationData {
111
+ pyramid: FoldPyramid;
112
+ bytes: Uint8Array;
113
+ boundaries: number[];
114
+ perceiveMemo: Map<string, Sema>;
115
+ recogniseMemo: Map<string, Recognition>;
116
+ climbMemo: Map<string, Map<string, AttentionRead>>;
117
+ resolvedSubtrees: WeakMap<Sema, { id: number; len: number }>;
118
+ }
119
+
71
120
  // Mind module imports
72
121
  import type { AttentionRead, MindContext, Recognition } from "./types.js";
73
122
  import { changedNodes, liftAnswer, spliceAll } from "./types.js";
@@ -75,6 +124,7 @@ import {
75
124
  foldTree,
76
125
  gistOf,
77
126
  inputBytes,
127
+ latin1Key,
78
128
  perceive as perceiveImpl,
79
129
  read,
80
130
  resolve as resolveImpl,
@@ -132,19 +182,23 @@ export class Mind implements MindContext {
132
182
  /** The live rationale tracer for the inference currently in flight, or null. */
133
183
  trace: Rationale | null = null;
134
184
 
135
- /** Per-response memo of the consensus climb. NOTE: this memo and
136
- * {@link recogniseMemo} are BYPASSED while a rationale trace is attached
137
- * (every mechanism must emit its own steps), so a traced respond re-pays
138
- * up to four consensus climbs plus repeat recognitions — that is where the
139
- * traced-vs-untraced latency multiple comes from, by design. */
140
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null = null;
185
+ /** Memo of the consensus climb — content-keyed. See {@link MindContext.climbMemo}. */
186
+ climbMemo: Map<string, Map<string, AttentionRead>> | null = null;
141
187
 
142
- /** Per-response memo of recognise() — see {@link MindContext.recogniseMemo}. */
143
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null = null;
188
+ /** Memo of recognise() — content-keyed. See {@link MindContext.recogniseMemo}. */
189
+ recogniseMemo: Map<string, Recognition> | null = null;
144
190
 
145
- /** Per-response memo of perceive() — see {@link MindContext.perceiveMemo}. */
191
+ /** Memo of perceive() — content-keyed. See {@link MindContext.perceiveMemo}. */
146
192
  perceiveMemo: Map<string, import("../sema.js").Sema> | null = null;
147
193
 
194
+ /** Subtree-resolution cache. See {@link MindContext._resolvedSubtrees}. */
195
+ _resolvedSubtrees:
196
+ | WeakMap<
197
+ import("../sema.js").Sema,
198
+ { id: number; len: number }
199
+ >
200
+ | null = null;
201
+
148
202
  /** The perceived gist of the query currently being answered. Set by `think`
149
203
  * before the graph search runs; `chooseNext` consults it as a gate (a null
150
204
  * guide means no query is in flight, so structural walkers keep plain
@@ -176,6 +230,11 @@ export class Mind implements MindContext {
176
230
  _depositLens = new Set<number>();
177
231
  _internIds = new WeakMap<import("../sema.js").Sema, number>();
178
232
 
233
+ // ── Conversation state ──────────────────────────────────────────────────
234
+
235
+ private _nextConvId = 1;
236
+ private _conversations = new Map<number, ConversationData>();
237
+
179
238
  // ── GraphSearchHost implementation ─────────────────────────────────────
180
239
 
181
240
  /** Canonical node id of a byte span. Required by GraphSearchHost & MindContext. */
@@ -321,8 +380,8 @@ export class Mind implements MindContext {
321
380
  * created, so adding a memo cannot forget its reset. */
322
381
  private beginResponse(inspectRationale?: InspectRationale): void {
323
382
  this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
324
- this.climbMemo = new WeakMap();
325
- this.recogniseMemo = new WeakMap();
383
+ this.climbMemo = new Map();
384
+ this.recogniseMemo = new Map();
326
385
  this.perceiveMemo = new Map();
327
386
  }
328
387
 
@@ -337,24 +396,26 @@ export class Mind implements MindContext {
337
396
  this._edgeChoice.clear();
338
397
  }
339
398
 
340
- async respond(
341
- input: Input,
399
+ /** Shared response core — the one path from bytes to voiced answer.
400
+ * `respond` calls this directly; `respondTurn` has its own path
401
+ * with conversation-persistent memos and incremental perception. */
402
+ private async _respondImpl(
403
+ queryBytes: Uint8Array,
342
404
  inspectRationale?: InspectRationale,
405
+ traceLabel = "respond",
343
406
  ): Promise<Response> {
344
407
  this.beginResponse(inspectRationale);
345
408
  try {
346
- const inBytes = inputBytes(this, input);
347
- const top = this.trace?.enter("respond", [
348
- rItem(inBytes, "query"),
409
+ const top = this.trace?.enter(traceLabel, [
410
+ rItem(queryBytes, "query"),
349
411
  ]);
350
-
351
- const thought = await think(this, inBytes, this.mechanisms);
412
+ const thought = await think(this, queryBytes, this.mechanisms);
352
413
  if (thought === null) {
353
414
  top?.done([], "nothing to perceive or an empty store — no answer");
354
415
  return { v: null, bytes: new Uint8Array(0) };
355
416
  }
356
417
 
357
- const voiced = await articulate(this, thought.bytes, inBytes);
418
+ const voiced = await articulate(this, thought.bytes, queryBytes);
358
419
  top?.done(
359
420
  [rItem(voiced, "answer", resolveImpl(this, voiced) ?? undefined)],
360
421
  "the answer, re-voiced in the asker's words",
@@ -369,6 +430,13 @@ export class Mind implements MindContext {
369
430
  }
370
431
  }
371
432
 
433
+ async respond(
434
+ input: Input,
435
+ inspectRationale?: InspectRationale,
436
+ ): Promise<Response> {
437
+ return this._respondImpl(inputBytes(this, input), inspectRationale);
438
+ }
439
+
372
440
  /** Text view of {@link respond}. NUL bytes (0x00) are stripped before
373
441
  * decoding — they are structural padding in text answers. LOSSY for a
374
442
  * binary answer that legitimately contains NULs: use {@link respond} and
@@ -381,6 +449,169 @@ export class Mind implements MindContext {
381
449
  return decodeText(r.bytes);
382
450
  }
383
451
 
452
+ // ── Conversation API ────────────────────────────────────────────────────
453
+
454
+ /** Begin a new conversation, optionally restoring from a previously-saved
455
+ * {@link ConversationState}. The returned handle is required for
456
+ * {@link respondTurn} and {@link endConversation}.
457
+ *
458
+ * Conversations are independent — a Mind can manage several concurrently.
459
+ * Each tracks the fold pyramid (accumulated internal processing) and
460
+ * turn-boundary offsets; the geometry never inspects content to guess
461
+ * where one turn ends and the next begins. */
462
+ beginConversation(state?: ConversationState): Conversation {
463
+ const id = this._nextConvId++;
464
+ // Build the initial pyramid: from scratch for a new conversation,
465
+ // or from the saved context bytes when restoring.
466
+ const initBytes = state?.context ?? new Uint8Array(0);
467
+ const { pyramid } = bytesToTreePyramid(
468
+ this.space,
469
+ this.alphabet,
470
+ initBytes,
471
+ );
472
+ this._conversations.set(id, {
473
+ pyramid,
474
+ bytes: initBytes,
475
+ boundaries: state?.boundaries ? [...state.boundaries] : [],
476
+ perceiveMemo: new Map(),
477
+ recogniseMemo: new Map(),
478
+ climbMemo: new Map(),
479
+ resolvedSubtrees: new WeakMap(),
480
+ });
481
+ return { id };
482
+ }
483
+
484
+ /** End a conversation, releasing its internal resources (accumulated
485
+ * context, boundary offsets, and the fold-pyramid cache). Idempotent. */
486
+ endConversation(conv: Conversation): void {
487
+ this._conversations.delete(conv.id);
488
+ }
489
+
490
+ /** The current serialisable state of an active conversation. Save this
491
+ * to resume the conversation later via {@link beginConversation}. */
492
+ conversationState(conv: Conversation): ConversationState | null {
493
+ const data = this._conversations.get(conv.id);
494
+ if (!data) return null;
495
+ return {
496
+ context: data.bytes,
497
+ boundaries: [...data.boundaries],
498
+ };
499
+ }
500
+
501
+ /** Process one turn of a conversation.
502
+ *
503
+ * `turn` is the raw input for the latest turn — its bytes are appended
504
+ * to the accumulated context directly (raw concatenation). The Mind
505
+ * tracks the byte offset where each turn ends; no separator is ever
506
+ * inserted or inspected.
507
+ *
508
+ * Returns the response AND the updated {@link ConversationState} so the
509
+ * caller can persist it. The conversation handle's internal state is
510
+ * updated in place — the returned state is a snapshot for storage. */
511
+ async respondTurn(
512
+ conv: Conversation,
513
+ turn: Input,
514
+ inspectRationale?: InspectRationale,
515
+ ): Promise<{ response: Response; state: ConversationState }> {
516
+ const data = this._conversations.get(conv.id);
517
+ if (!data) throw new Error(`Conversation ${conv.id} not found`);
518
+
519
+ const turnBytes = inputBytes(this, turn);
520
+ const prevLen = data.pyramid.bytes;
521
+
522
+ const prevBytes = data.bytes;
523
+ const newContext = prevLen > 0 ? concat2(prevBytes, turnBytes) : turnBytes;
524
+ data.bytes = newContext;
525
+
526
+ if (prevLen > 0) data.boundaries.push(prevLen);
527
+
528
+ // Incremental perception — O(turn) instead of O(context).
529
+ const { tree, pyramid } = bytesToTreePyramid(
530
+ this.space,
531
+ this.alphabet,
532
+ newContext,
533
+ data.pyramid,
534
+ );
535
+ data.pyramid = pyramid;
536
+
537
+ // Swap in the conversation's persistent state so the inference
538
+ // pipeline does not re-process the prefix from scratch.
539
+ // perceiveMemo / recogniseMemo / climbMemo — content-keyed;
540
+ // the prefix's results from the previous turn are found by
541
+ // the current turn's sub-span calls.
542
+ // _resolvedSubtrees — Sema-node-keyed; when the pyramid reuses
543
+ // prefix subtrees (identical objects), foldTree returns their
544
+ // ids immediately — O(suffix) instead of O(context).
545
+ this.perceiveMemo = data.perceiveMemo;
546
+ this.recogniseMemo = data.recogniseMemo;
547
+ this.climbMemo = data.climbMemo;
548
+ this._resolvedSubtrees = data.resolvedSubtrees;
549
+ this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
550
+
551
+ try {
552
+ this.perceiveMemo.set(latin1Key(newContext), tree);
553
+
554
+ const top = this.trace?.enter("respondTurn", [
555
+ rItem(newContext, "query"),
556
+ ]);
557
+
558
+ const thought = await think(this, newContext, this.mechanisms);
559
+ if (thought === null) {
560
+ top?.done([], "nothing to perceive or an empty store — no answer");
561
+ return {
562
+ response: { v: null, bytes: new Uint8Array(0) },
563
+ state: this.conversationState(conv)!,
564
+ };
565
+ }
566
+
567
+ const voiced = await articulate(this, thought.bytes, newContext);
568
+ top?.done(
569
+ [rItem(voiced, "answer", resolveImpl(this, voiced) ?? undefined)],
570
+ "the answer, re-voiced in the asker's words",
571
+ );
572
+
573
+ return {
574
+ response: {
575
+ v: gistOf(this, voiced),
576
+ bytes: voiced,
577
+ provenance: thought.provenance,
578
+ },
579
+ state: this.conversationState(conv)!,
580
+ };
581
+ } finally {
582
+ // Save back to the conversation for the next turn.
583
+ data.perceiveMemo = this.perceiveMemo;
584
+ data.recogniseMemo = this.recogniseMemo;
585
+ data.climbMemo = this.climbMemo;
586
+ data.resolvedSubtrees = this._resolvedSubtrees!;
587
+ // Clear Mind references — non-conversation respond() calls get
588
+ // fresh per-response memos.
589
+ this.trace = null;
590
+ this.perceiveMemo = null;
591
+ this.recogniseMemo = null;
592
+ this.climbMemo = null;
593
+ this._resolvedSubtrees = null;
594
+ this._edgeGuide = null;
595
+ this._edgeChoice.clear();
596
+ }
597
+ }
598
+
599
+ /** Text view of {@link respondTurn}. See {@link respondText} for the
600
+ * NUL-stripping caveat. For binary or grid turns use {@link respondTurn}
601
+ * directly — this is a text-only convenience, like {@link respondText}. */
602
+ async respondTurnText(
603
+ conv: Conversation,
604
+ turn: string,
605
+ inspectRationale?: InspectRationale,
606
+ ): Promise<{ response: string; state: ConversationState }> {
607
+ const { response, state } = await this.respondTurn(
608
+ conv,
609
+ turn,
610
+ inspectRationale,
611
+ );
612
+ return { response: decodeText(response.bytes), state };
613
+ }
614
+
384
615
  async embedding(input: Input): Promise<Vec | null> {
385
616
  return (await this.respond(input)).v;
386
617
  }
@@ -4,8 +4,7 @@
4
4
  // Read — node → bytes (read)
5
5
 
6
6
  import { Vec } from "../vec.js";
7
- import { Sema, Space } from "../sema.js";
8
- import { Alphabet } from "../alphabet.js";
7
+ import { Sema } from "../sema.js";
9
8
  import {
10
9
  bytesToTree,
11
10
  bytesToTreePyramid,
@@ -24,7 +23,7 @@ import type { Input, MindContext } from "./types.js";
24
23
  * collision-free encoding. Spans on the perception path are query-scale
25
24
  * (windows, regions, candidate spans), so key construction is far cheaper
26
25
  * than the river fold it deduplicates. */
27
- function latin1Key(bytes: Uint8Array): string {
26
+ export function latin1Key(bytes: Uint8Array): string {
28
27
  // Batched String.fromCharCode — avoids the O(n²) cost of repeated += on
29
28
  // potentially-large query spans, and stays well under the ~65536 arg limit.
30
29
  const n = bytes.length;
@@ -36,12 +35,19 @@ function latin1Key(bytes: Uint8Array): string {
36
35
  }
37
36
 
38
37
  /** Perceive input into a content-defined tree (the river fold).
39
- * Deterministic — identical bytes always produce an identical tree. */
38
+ * Deterministic — identical bytes always produce an identical tree.
39
+ *
40
+ * `boundaries` is an optional sorted list of proper byte offsets where the
41
+ * fold must split so that each prefix segment folds identically to how it
42
+ * folded when it was learned (§10.3 stable-prefix contract). Only the
43
+ * CALLER — who assembled the multi-turn context — knows where those
44
+ * boundaries are; the geometry never guesses them from the bytes. */
40
45
  export function perceive(
41
46
  ctx: MindContext,
42
47
  input: Input,
43
48
  leafAt?: (i: number) => number | null,
44
49
  lookup?: (ids: number[]) => number | null,
50
+ boundaries?: readonly number[],
45
51
  ): Sema {
46
52
  if (typeof input === "string" || input instanceof Uint8Array) {
47
53
  const bytes = typeof input === "string"
@@ -57,11 +63,25 @@ export function perceive(
57
63
  const key = latin1Key(bytes);
58
64
  const hit = memo.get(key);
59
65
  if (hit !== undefined) return hit;
60
- const tree = bytesToTree(ctx.space, ctx.alphabet, bytes);
66
+ const tree = bytesToTree(
67
+ ctx.space,
68
+ ctx.alphabet,
69
+ bytes,
70
+ undefined,
71
+ undefined,
72
+ boundaries,
73
+ );
61
74
  memo.set(key, tree);
62
75
  return tree;
63
76
  }
64
- return bytesToTree(ctx.space, ctx.alphabet, bytes);
77
+ return bytesToTree(
78
+ ctx.space,
79
+ ctx.alphabet,
80
+ bytes,
81
+ undefined,
82
+ undefined,
83
+ boundaries,
84
+ );
65
85
  }
66
86
  return bytesToTree(ctx.space, ctx.alphabet, bytes, leafAt, lookup);
67
87
  }
@@ -136,11 +156,25 @@ export function foldTree(
136
156
  start: number,
137
157
  visit?: (n: Sema, start: number, end: number, node: number | null) => void,
138
158
  ): { end: number; node: number | null } {
159
+ // Fast path: subtree already resolved (from a previous conversation turn
160
+ // or an earlier recognition pass). The pyramid reuses prefix subtrees as
161
+ // identical Sema objects, so this cache turns foldTree into O(suffix)
162
+ // instead of O(context) for multi-turn recognition.
163
+ const cached = ctx._resolvedSubtrees?.get(n);
164
+ if (cached !== undefined) {
165
+ const end = start + cached.len;
166
+ visit?.(n, start, end, cached.id);
167
+ return { end, node: cached.id };
168
+ }
169
+
139
170
  if (n.kids === null) {
140
171
  const b = n.leaf ?? new Uint8Array(0);
141
172
  const end = start + b.length;
142
173
  const node = ctx.store.findLeaf(b);
143
174
  visit?.(n, start, end, node);
175
+ if (node !== null && ctx._resolvedSubtrees) {
176
+ ctx._resolvedSubtrees.set(n, { id: node, len: b.length });
177
+ }
144
178
  return { end, node };
145
179
  }
146
180
  let pos = start;
@@ -154,6 +188,9 @@ export function foldTree(
154
188
  }
155
189
  const node = known ? ctx.store.findBranch(kids) : null;
156
190
  visit?.(n, start, pos, node);
191
+ if (node !== null && ctx._resolvedSubtrees) {
192
+ ctx._resolvedSubtrees.set(n, { id: node, len: pos - start });
193
+ }
157
194
  return { end: pos, node };
158
195
  }
159
196