@hviana/sema 0.2.1 → 0.2.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.
@@ -90,6 +90,13 @@ export declare function consensusFloor(N: number): number;
90
90
  * replaces runtime coverage gating with a batch pass that removes
91
91
  * structurally-isolated entries. Derived, never tuned. */
92
92
  export declare function coverageBar(_maxGroup: number, D: number): number;
93
+ export interface Folded {
94
+ tree: Sema;
95
+ /** Byte length of the subtree — carried incrementally so the stable-prefix
96
+ * boundary scan never re-walks subtrees (the old per-level walk was
97
+ * O(n log n) over the whole input). */
98
+ len: number;
99
+ }
93
100
  export interface Grid {
94
101
  width: number;
95
102
  height: number;
@@ -116,6 +123,27 @@ export declare function knownPrefixLength(bytes: Uint8Array, leafAt: (i: number)
116
123
  * turn extend perception instead of refolding it: identical prefixes
117
124
  * produce identical subtrees regardless of what follows them. */
118
125
  export declare function bytesToTree(space: Space, alphabet: Alphabet, bytes: Uint8Array, leafAt?: (i: number) => number | null, lookup?: (leafIds: number[]) => number | null, boundaries?: readonly number[]): Sema;
126
+ /** A stable-prefix fold's reusable state: the segment edge offsets and each
127
+ * segment's independently-folded root ({@link riverFoldRaw} output). A
128
+ * grown stream whose boundary set EXTENDS a previous fold's reuses every
129
+ * matching segment's Folded unchanged (segments fold independently by
130
+ * construction, so reuse is bit-identical to refolding) and folds only the
131
+ * new right-edge segment — O(turn) per extension, the stable-prefix
132
+ * counterpart of {@link FoldPyramid}. Purely a cache: the produced tree
133
+ * never depends on cache state. */
134
+ export interface StableFold {
135
+ edges: number[];
136
+ segs: Folded[];
137
+ }
138
+ /** {@link stablePrefixFold} with incremental segment reuse — same cuts, same
139
+ * segment folds, same left-nested join, same single root normalize; `prev`
140
+ * only elides recomputing segments whose [start,end) offsets it already
141
+ * folded over a byte-identical prefix (the caller keys the cache by
142
+ * content). Requires a non-empty effective boundary set. */
143
+ export declare function stablePrefixFoldIncremental(space: Space, alphabet: Alphabet, bytes: Uint8Array, boundaries: readonly number[], prev?: StableFold): {
144
+ tree: Sema;
145
+ fold: StableFold;
146
+ };
119
147
  /** The PLAIN fold's full level pyramid — every level's item list, bottom
120
148
  * (leaves) to top (root). Left-grouped folding is RADIX-ALIGNED: the item
121
149
  * at level L, index i, covers exactly bytes [i·mg^L, (i+1)·mg^L) whenever
@@ -321,6 +321,41 @@ function stablePrefixFold(space, alphabet, bytes, boundaries) {
321
321
  normalize(cur.tree.v);
322
322
  return cur.tree;
323
323
  }
324
+ /** {@link stablePrefixFold} with incremental segment reuse — same cuts, same
325
+ * segment folds, same left-nested join, same single root normalize; `prev`
326
+ * only elides recomputing segments whose [start,end) offsets it already
327
+ * folded over a byte-identical prefix (the caller keys the cache by
328
+ * content). Requires a non-empty effective boundary set. */
329
+ export function stablePrefixFoldIncremental(space, alphabet, bytes, boundaries, prev) {
330
+ const cuts = [];
331
+ let prevB = 0;
332
+ for (const b of boundaries) {
333
+ if (b > prevB && b < bytes.length) {
334
+ cuts.push(b);
335
+ prevB = b;
336
+ }
337
+ }
338
+ const edges = [0, ...cuts, bytes.length];
339
+ const segs = [];
340
+ for (let i = 0; i + 1 < edges.length; i++) {
341
+ const hit = prev !== undefined && prev.edges[i] === edges[i] &&
342
+ prev.edges[i + 1] === edges[i + 1]
343
+ ? prev.segs[i]
344
+ : undefined;
345
+ segs.push(hit ??
346
+ riverFoldRaw(space, bytesToLeaves(alphabet, bytes.subarray(edges[i], edges[i + 1]))));
347
+ }
348
+ if (segs.length === 1) {
349
+ // Degenerate boundary set — the plain fold, as stablePrefixFold does.
350
+ const tree = riverFold(space, bytesToLeaves(alphabet, bytes), bytes.length).tree;
351
+ return { tree, fold: { edges, segs } };
352
+ }
353
+ let cur = segs[0];
354
+ for (let i = 1; i < segs.length; i++)
355
+ cur = fold2(space, cur, segs[i]);
356
+ normalize(cur.tree.v);
357
+ return { tree: cur.tree, fold: { edges, segs } };
358
+ }
324
359
  /** Join two folded items as one 2-kid branch — the top-level join of the
325
360
  * stable-prefix fold, identical FP ops to foldSlice's seat-bound
326
361
  * accumulation over a group of two. Unnormalized (interior). */
@@ -736,6 +736,27 @@ async function crossRegionVotes(ctx, query, regions, rvs, k, N, reachMemo) {
736
736
  continue;
737
737
  if (ra.end >= rb.start)
738
738
  continue; // overlap or adjacent — nothing between
739
+ // Candidates strictly BETWEEN ra and rb (cand is sorted by start, so
740
+ // that is exactly cand[a+1 .. b-1]) that already cast their OWN vote —
741
+ // genuine, individually-corroborated evidence about what fills the gap
742
+ // — gate the container search below: a joint container is binding
743
+ // evidence only when it is CONSISTENT with that evidence, i.e. its own
744
+ // bytes actually contain what the between-region says. This is the
745
+ // n-ary composition's normal shape (a between-attribute's bytes DO
746
+ // recur inside the joint container, credited as an "extra" below) as
747
+ // opposed to a container that silently substitutes something else for
748
+ // it (e.g. bridging past "Italy" to a container whose interior is
749
+ // "Japan" — a different, contradicting learnt whole).
750
+ // Only a KNOWN (content-addressed, exact) between-region qualifies —
751
+ // an approximate region's resonance climbing "somewhere" is ordinary
752
+ // noise (any ANN query returns SOME nearest neighbour), not evidence
753
+ // this specific gap already means something specific.
754
+ const between = [];
755
+ for (let m = a + 1; m < b; m++) {
756
+ if (strong.has(cand[m]) && !consumed.has(cand[m]) &&
757
+ regions[cand[m]].known)
758
+ between.push(cand[m]);
759
+ }
739
760
  // A single KNOWN region covering both: the whole form is already a
740
761
  // stored identity that votes directly; its pieces add nothing.
741
762
  if (regions.some((r) => r.known && r.start <= ra.start && rb.end <= r.end))
@@ -781,6 +802,14 @@ async function crossRegionVotes(ctx, query, regions, rvs, k, N, reachMemo) {
781
802
  if (indexOf(query, joined, 0) >= 0)
782
803
  continue; // query says it itself
783
804
  }
805
+ // CONTRADICTION GUARD: a between-region already carrying its own
806
+ // vote must actually recur in this container's bytes — otherwise
807
+ // the container is a different learnt whole that happens to share
808
+ // ra/rb, and letting it stand in for the gap would silently
809
+ // override evidence the query itself already resolved there.
810
+ if (between.some((bi) => indexOf(bytes, query.subarray(regions[bi].start, regions[bi].end), 0) < 0)) {
811
+ continue;
812
+ }
784
813
  let cov = left.length + right.length;
785
814
  const extras = [];
786
815
  for (const ei of cand) {
@@ -18,7 +18,7 @@ export declare function internTreeIds(ctx: MindContext, node: Sema, ids: Map<Sem
18
18
  export declare function indexSubSpans(ctx: MindContext, tree: Sema, ids: Map<Sema, number>): Promise<boolean>;
19
19
  /** Perceive, intern, and index a single input. Returns the perceived tree,
20
20
  * root id, id map, and the changed (new) subtrees for halo reinforcement. */
21
- export declare function deposit(ctx: MindContext, input: Input, track: boolean): Promise<{
21
+ export declare function deposit(ctx: MindContext, input: Input, track: boolean, conversational?: boolean): Promise<{
22
22
  tree: Sema;
23
23
  rootId: number;
24
24
  ids: Map<Sema, number>;
@@ -4,7 +4,7 @@
4
4
  // node. A fact is an EDGE between node ids; recall traverses edges.
5
5
  import { bindSeat, companySignature, isChunk } from "../sema.js";
6
6
  import { changedNodes } from "./types.js";
7
- import { inputBytes, perceiveDeposit, resolve, } from "./primitives.js";
7
+ import { inputBytes, latin1Key, perceiveDeposit, resolve, } from "./primitives.js";
8
8
  import { canonicalWindows, leafIdPrefix } from "./canonical.js";
9
9
  import { fold as foldVecs } from "../sema.js";
10
10
  /** Intern a perceived tree into node ids, bottom-up, sharing equal subtrees.
@@ -103,7 +103,7 @@ export async function indexSubSpans(ctx, tree, ids) {
103
103
  }
104
104
  /** Perceive, intern, and index a single input. Returns the perceived tree,
105
105
  * root id, id map, and the changed (new) subtrees for halo reinforcement. */
106
- export async function deposit(ctx, input, track) {
106
+ export async function deposit(ctx, input, track, conversational = false) {
107
107
  const bytes = inputBytes(ctx, input);
108
108
  // Deposit-shaped perception: stable-prefix tree SEEDING (see
109
109
  // perceiveDeposit) — an accumulated context re-folds only its new suffix,
@@ -111,8 +111,13 @@ export async function deposit(ctx, input, track) {
111
111
  // (no store-probe fallback): a knownPrefixLength scan on every novel fact
112
112
  // would cost O(n²) hashing, while conversation replays are always warm —
113
113
  // re-deposition replays from the first turn, rebuilding the cache as it
114
- // goes.
115
- const tree = perceiveDeposit(ctx, bytes);
114
+ // goes. `conversational` scopes the STABLE-PREFIX variant (turn-boundary
115
+ // folding, matching query-time perception) to ingestPair's own growing
116
+ // context argument — a bare ingestOne deposit whose bytes merely happen
117
+ // to extend an earlier UNRELATED deposit (no conversational relationship)
118
+ // must keep the plain fold, or two coincidentally-prefix-sharing facts
119
+ // would stop sharing structure with each other.
120
+ const tree = perceiveDeposit(ctx, bytes, conversational);
116
121
  const ids = new Map();
117
122
  const rootId = await internTreeIds(ctx, tree, ids);
118
123
  const indexed = await indexSubSpans(ctx, tree, ids);
@@ -185,9 +190,19 @@ async function propagateSuffixes(ctx, src, dst) {
185
190
  }
186
191
  /** Ingest a pair (context, continuation) — learn an edge and pour halos. */
187
192
  export async function ingestPair(ctx, ctxInput, cont) {
188
- const c = await deposit(ctx, ctxInput, true);
193
+ const c = await deposit(ctx, ctxInput, true, true);
189
194
  const cont_ = await deposit(ctx, cont, false);
190
195
  const ctxId = c.rootId, contId = cont_.rootId;
196
+ // Stamp this turn's continuation onto its own cache entry — the proof a
197
+ // FUTURE, longer ctxInput needs (see perceiveDeposit) to recognise itself
198
+ // as this conversation's genuine next turn rather than an unrelated fact
199
+ // that happens to share this ctxInput's byte prefix.
200
+ {
201
+ const ctxBytes = inputBytes(ctx, ctxInput);
202
+ const entry = ctx._depositTrees.get(latin1Key(ctxBytes));
203
+ if (entry !== undefined)
204
+ entry.nextBytes = inputBytes(ctx, cont);
205
+ }
191
206
  await ctx.store.link(ctxId, contId);
192
207
  await propagateSuffixes(ctx, ctxId, contId);
193
208
  // Halos pour company SIGNATURES (identity), not gists (content) — see
@@ -8,6 +8,7 @@
8
8
  // cover runs FIRST in defaultMechanisms: a computed-backed cover becomes a
9
9
  // near-zero-cost incumbent that prunes the other mechanisms through the
10
10
  // ordinary admissible-floor check, with no extension special-case anywhere.
11
+ import { bytesEqual, indexOf } from "../../bytes.js";
11
12
  import { read, resolve } from "../primitives.js";
12
13
  import { guidedFirst } from "../traverse.js";
13
14
  import { conceptHop } from "../match.js";
@@ -158,6 +159,37 @@ export const coverMechanism = {
158
159
  : "lightest derivation: the chosen spans, left to right");
159
160
  if (segs === null)
160
161
  return [];
162
+ // A chosen span's SUBSTITUTED bytes (an edge followed from a recognised
163
+ // site, not the site's own literal text read back) that equal a byte
164
+ // span the query ALREADY CONTAINS elsewhere restates part of the
165
+ // question — never an answer (the same principle recall.ts's tiers
166
+ // apply to a whole-query projection: "a projection that is a proper
167
+ // byte-subspan of the query restates part of the question"). A
168
+ // recognised site that is itself an entire PRIOR TURN of a multi-turn
169
+ // query is exactly this shape: it carries a genuine learnt
170
+ // continuation, but that continuation is something the asker already
171
+ // said moments later in the SAME query, not a new answer — the cover
172
+ // search has no notion of "turn" to gate this itself, so the check
173
+ // belongs here, over the derivation it already chose.
174
+ const W = ctx.space.maxGroup;
175
+ for (const s of segs) {
176
+ if (!s.rec)
177
+ continue;
178
+ const literal = s.j - s.i === s.bytes.length &&
179
+ bytesEqual(s.bytes, query.subarray(s.i, s.j));
180
+ if (literal)
181
+ continue;
182
+ // A span shorter than one river window is exactly the "ice"/"c"
183
+ // case: a chain hop's short terminal coincidentally recurring as a
184
+ // substring of an unrelated longer word. Below the quantum, byte
185
+ // overlap is chance, not evidence — the same floor identityBar and
186
+ // reachThreshold hold every other structural-overlap claim to.
187
+ if (s.bytes.length >= W && s.bytes.length < query.length &&
188
+ indexOf(query, s.bytes, 0) >= 0) {
189
+ ctx.trace?.step("restatedSpan", [rItem(s.bytes, "substituted", s.node, [s.i, s.j])], [], "the chosen span's substitution already occurs elsewhere in the query — restates it, not an answer");
190
+ return [];
191
+ }
192
+ }
161
193
  const composed = liftAnswer(segs, query.length);
162
194
  if (composed === null)
163
195
  return [];
@@ -74,10 +74,34 @@ export async function recallByResonance(ctx, query, pre) {
74
74
  maximal.push(s);
75
75
  maxEnd = s.end;
76
76
  }
77
- if (maximal.length === 1) {
77
+ // The wrapper must actually BE scaffolding: RC8's own premise is "the
78
+ // wrapper is scaffolding; the argument is the span that leads
79
+ // somewhere" ("How do you say 'thank you' in French?" — everything
80
+ // outside the argument is a small fixed template). When the query
81
+ // instead has ANOTHER substantial recognised form (≥ W2, the same
82
+ // constituent bar the argument itself must clear) sitting OUTSIDE the
83
+ // chosen argument, the query is not one argument in a wrapper — it is
84
+ // several complete, independently-meaningful pieces (a multi-turn
85
+ // conversation's own accumulated turns are exactly this shape), and
86
+ // binding to the argument's continuation would answer past content
87
+ // the query itself already carries forward. Derived from the same W2
88
+ // bar the argument itself is held to, never a separate tuned number.
89
+ const hasSubstantialOutside = maximal.length === 1 &&
90
+ pre.rec.sites.some((s) => s.end - s.start >= W2 &&
91
+ (s.end <= maximal[0].start || s.start >= maximal[0].end));
92
+ if (maximal.length === 1 && !hasSubstantialOutside) {
78
93
  const arg = maximal[0];
79
94
  const g = await follow(ctx, arg.payload, queryGist);
80
- if (g !== null && g.length > 0) {
95
+ // The same "no restated fragment" guard tier 2 applies below (§ "the
96
+ // anchor cleared the consensus floor..."): a followed continuation
97
+ // that is itself a proper byte-subspan of the QUERY restates part of
98
+ // the question — never an answer. A multi-turn query's own later
99
+ // turns are exact, content-addressed matches for exactly this reason
100
+ // (each turn is its own previously-learnt form), so without this
101
+ // guard the argument's OWN later restatement in the same
102
+ // conversation reads as if it were the next thing to say.
103
+ if (g !== null && g.length > 0 &&
104
+ !(g.length < query.length && indexOf(query, g, 0) >= 0)) {
81
105
  return ground(g, "argument binding — the query's sole edge-source constituent, continuation followed", [[arg.start, arg.end]], STEP);
82
106
  }
83
107
  }
@@ -1,7 +1,7 @@
1
1
  import { Vec } from "../vec.js";
2
2
  import { Sema, Space } from "../sema.js";
3
3
  import { Alphabet } from "../alphabet.js";
4
- import { type FoldPyramid, Grid } from "../geometry.js";
4
+ import { Grid } from "../geometry.js";
5
5
  import { BoundedMap, type Store } from "../store.js";
6
6
  import { type MindConfig } from "../config.js";
7
7
  import { type Canon } from "../canon.js";
@@ -111,7 +111,7 @@ export declare class Mind implements MindContext {
111
111
  * {@link MindContext._gistCache}. 32 MB ≈ 8K gists at D=1024; hub
112
112
  * candidate sets (√N at most) fit comfortably and recur across queries. */
113
113
  _gistCache: BoundedMap<number, Vec>;
114
- _depositTrees: BoundedMap<string, FoldPyramid>;
114
+ _depositTrees: BoundedMap<string, import("./types.js").DepositCacheEntry>;
115
115
  _depositLens: Set<number>;
116
116
  _internIds: WeakMap<Sema, number>;
117
117
  private _nextConvId;
@@ -10,7 +10,7 @@
10
10
  // Implementation split across src/mind/*.ts — this file assembles the Mind class.
11
11
  import { makeKeyring, rng, setVecConfig } from "../vec.js";
12
12
  import { Alphabet } from "../alphabet.js";
13
- import { bytesToTreePyramid, reachThreshold, } from "../geometry.js";
13
+ import { bytesToTree, reachThreshold, } from "../geometry.js";
14
14
  import { BoundedMap } from "../store.js";
15
15
  import { SQliteStore } from "../store-sqlite.js";
16
16
  import { resolveConfig } from "../config.js";
@@ -263,14 +263,13 @@ export class Mind {
263
263
  * where one turn ends and the next begins. */
264
264
  beginConversation(state) {
265
265
  const id = this._nextConvId++;
266
- // Build the initial pyramid: from scratch for a new conversation,
267
- // or from the saved context bytes when restoring.
268
266
  const initBytes = state?.context ?? new Uint8Array(0);
269
- const { pyramid } = bytesToTreePyramid(this.space, this.alphabet, initBytes);
267
+ const initBoundaries = state?.boundaries ? [...state.boundaries] : [];
268
+ const tree = bytesToTree(this.space, this.alphabet, initBytes, undefined, undefined, initBoundaries.length > 0 ? initBoundaries : undefined);
270
269
  this._conversations.set(id, {
271
- pyramid,
270
+ tree,
272
271
  bytes: initBytes,
273
- boundaries: state?.boundaries ? [...state.boundaries] : [],
272
+ boundaries: initBoundaries,
274
273
  perceiveMemo: new Map(),
275
274
  recogniseMemo: new Map(),
276
275
  climbMemo: new Map(),
@@ -316,21 +315,18 @@ export class Mind {
316
315
  * place a context grows ({@link addTurn} and {@link respondTurn} both
317
316
  * come through here), so the append semantics cannot drift. */
318
317
  _growContext(data, turnBytes) {
319
- const prevLen = data.pyramid.bytes;
318
+ const prevLen = data.bytes.length;
320
319
  // An empty turn neither grows the context nor marks a boundary —
321
320
  // boundaries are documented strictly increasing, and a zero-length
322
- // "turn" is no turn. The pyramid's zero-growth shortcut makes the
323
- // refold below O(1), so the existing tree is returned unchanged.
321
+ // "turn" is no turn. Nothing changed, so the existing tree stands.
324
322
  const grow = turnBytes.length > 0;
325
- const grown = !grow
326
- ? data.bytes
327
- : prevLen > 0
328
- ? concat2(data.bytes, turnBytes)
329
- : turnBytes;
330
- if (grow && prevLen > 0)
323
+ if (!grow)
324
+ return data.tree;
325
+ const grown = prevLen > 0 ? concat2(data.bytes, turnBytes) : turnBytes;
326
+ if (prevLen > 0)
331
327
  data.boundaries.push(prevLen);
332
- const { tree, pyramid } = bytesToTreePyramid(this.space, this.alphabet, grown, data.pyramid);
333
- data.pyramid = pyramid;
328
+ const tree = bytesToTree(this.space, this.alphabet, grown, undefined, undefined, data.boundaries.length > 0 ? data.boundaries : undefined);
329
+ data.tree = tree;
334
330
  data.bytes = grown;
335
331
  data.perceiveMemo.set(latin1Key(grown), tree);
336
332
  return tree;
@@ -377,42 +373,15 @@ export class Mind {
377
373
  this.canon = this._canonFor(typeof turn === "string" ? textCanon : null);
378
374
  this.canonMemo = this.canon ? new Map() : null;
379
375
  try {
380
- // Seed the recognise memo with the STANDALONE recognition of the
381
- // NEW turn, offset into the accumulated context. In the full-context
382
- // tree, content-defined chunk boundaries shift when bytes are
383
- // concatenated — foldTree no longer visits the original turn's root
384
- // node, so the structural pass misses it. Perceiving the turn alone
385
- // preserves intact structure. We seed ONLY the suffix (the new turn);
386
- // prefix forms stay visible through the full-context recognition
387
- // (they serve as context, not as questions to re-answer).
388
- if (data.boundaries.length > 0) {
389
- const suffixStart = data.boundaries[data.boundaries.length - 1];
390
- if (suffixStart < newContext.length) {
391
- const suffixBytes = newContext.subarray(suffixStart);
392
- const suffixRec = recognise(this, suffixBytes);
393
- const fullRec = recognise(this, newContext);
394
- const seen = new Set(fullRec.sites.map((s) => `${s.start},${s.end}`));
395
- const mergedSites = [...fullRec.sites];
396
- for (const site of suffixRec.sites) {
397
- const absStart = suffixStart + site.start;
398
- const absEnd = suffixStart + site.end;
399
- const key = `${absStart},${absEnd}`;
400
- if (!seen.has(key)) {
401
- seen.add(key);
402
- mergedSites.push({
403
- start: absStart,
404
- end: absEnd,
405
- payload: site.payload,
406
- });
407
- }
408
- }
409
- data.recogniseMemo.set(latin1Key(newContext), {
410
- sites: mergedSites,
411
- leaves: fullRec.leaves,
412
- splits: fullRec.splits,
413
- });
414
- }
415
- }
376
+ // No recognise-memo pre-seeding here: that used to be necessary
377
+ // because the flat/positional fold lost visibility into an earlier
378
+ // turn's own structure once later bytes shifted its position
379
+ // (foldTree no longer visited the turn's root node). The
380
+ // STABLE-PREFIX fold (see {@link ConversationData}) makes every
381
+ // turn's subtree independent of what follows it by construction, so
382
+ // recognise() finds it correctly on its own, first-touch, exactly
383
+ // once per turn no workaround needed, and none pre-empting
384
+ // recognise()'s own memo with a partial result.
416
385
  const top = this.trace?.enter("respondTurn", [
417
386
  rItem(newContext, "query"),
418
387
  ]);
@@ -15,15 +15,22 @@ export declare function latin1Key(bytes: Uint8Array): string;
15
15
  * CALLER — who assembled the multi-turn context — knows where those
16
16
  * boundaries are; the geometry never guesses them from the bytes. */
17
17
  export declare function perceive(ctx: MindContext, input: Input, leafAt?: (i: number) => number | null, lookup?: (ids: number[]) => number | null, boundaries?: readonly number[]): Sema;
18
- /** The DEPOSIT-shaped perceive: the PLAIN fold (bit-identical to inference
19
- * perception that structural train/inference agreement is load-bearing
20
- * for exact recall), computed INCREMENTALLY via the fold's level pyramid
21
- * ({@link bytesToTreePyramid}). An accumulated context (a conversation)
22
- * grows by suffixes: the previous context's pyramid is cached by CONTENT
23
- * (ctx._depositTrees), and this deposit refolds only the right edge of
24
- * each level O(turn) instead of O(context) per turn. Purely a cache:
25
- * the produced tree never depends on cache state. */
26
- export declare function perceiveDeposit(ctx: MindContext, bytes: Uint8Array): Sema;
18
+ /** The DEPOSIT-shaped perceive. A FIRST-SEEN input takes the PLAIN fold
19
+ * (bit-identical to inference perception of a standalone query that
20
+ * structural train/inference agreement is load-bearing for exact recall),
21
+ * computed incrementally via the fold's level pyramid
22
+ * ({@link bytesToTreePyramid}). An input that EXTENDS a previously
23
+ * deposited one is a conversation context grown by one turn — the cached
24
+ * prefix length IS the turn boundary (derived from the deposit sequence
25
+ * itself, never from content conventions) and takes the STABLE-PREFIX
26
+ * fold over the accumulated boundaries, bit-identical to the boundary
27
+ * fold query-time conversation perception uses, so the trained context
28
+ * node and the query's context subtree are the SAME node. Segment folds
29
+ * reuse across deposits ({@link stablePrefixFoldIncremental}) — O(turn)
30
+ * instead of O(context) per turn. The fold state is purely a cache; the
31
+ * boundary accumulation is what an evicted chain loses (falling back to
32
+ * the plain fold, the pre-boundary shape — a warm replay restores it). */
33
+ export declare function perceiveDeposit(ctx: MindContext, bytes: Uint8Array, conversational?: boolean): Sema;
27
34
  /** The raw bytes of an input — modality-neutral conversion. */
28
35
  export declare function inputBytes(ctx: MindContext, input: Input): Uint8Array;
29
36
  /** Convenience: the gist vector of a byte span. */
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Address — bytes → node (perceive, foldTree, resolve)
4
4
  // Read — node → bytes (read)
5
- import { bytesToTree, bytesToTreePyramid, gridToTree, hilbertBytes, stackGrids, } from "../geometry.js";
5
+ import { bytesToTree, bytesToTreePyramid, gridToTree, hilbertBytes, stablePrefixFoldIncremental, stackGrids, } from "../geometry.js";
6
6
  import { canonHash } from "../canon.js";
7
7
  import { bytesEqual } from "../bytes.js";
8
8
  import { ALL } from "./types.js";
@@ -58,29 +58,65 @@ export function perceive(ctx, input, leafAt, lookup, boundaries) {
58
58
  }
59
59
  return gridToTree(ctx.space, ctx.alphabet, input);
60
60
  }
61
- /** The DEPOSIT-shaped perceive: the PLAIN fold (bit-identical to inference
62
- * perception that structural train/inference agreement is load-bearing
63
- * for exact recall), computed INCREMENTALLY via the fold's level pyramid
64
- * ({@link bytesToTreePyramid}). An accumulated context (a conversation)
65
- * grows by suffixes: the previous context's pyramid is cached by CONTENT
66
- * (ctx._depositTrees), and this deposit refolds only the right edge of
67
- * each level O(turn) instead of O(context) per turn. Purely a cache:
68
- * the produced tree never depends on cache state. */
69
- export function perceiveDeposit(ctx, bytes) {
61
+ /** The DEPOSIT-shaped perceive. A FIRST-SEEN input takes the PLAIN fold
62
+ * (bit-identical to inference perception of a standalone query that
63
+ * structural train/inference agreement is load-bearing for exact recall),
64
+ * computed incrementally via the fold's level pyramid
65
+ * ({@link bytesToTreePyramid}). An input that EXTENDS a previously
66
+ * deposited one is a conversation context grown by one turn — the cached
67
+ * prefix length IS the turn boundary (derived from the deposit sequence
68
+ * itself, never from content conventions) and takes the STABLE-PREFIX
69
+ * fold over the accumulated boundaries, bit-identical to the boundary
70
+ * fold query-time conversation perception uses, so the trained context
71
+ * node and the query's context subtree are the SAME node. Segment folds
72
+ * reuse across deposits ({@link stablePrefixFoldIncremental}) — O(turn)
73
+ * instead of O(context) per turn. The fold state is purely a cache; the
74
+ * boundary accumulation is what an evicted chain loses (falling back to
75
+ * the plain fold, the pre-boundary shape — a warm replay restores it). */
76
+ export function perceiveDeposit(ctx, bytes, conversational = false) {
70
77
  let prev;
71
- // Longest cached PROPER prefix first.
72
- const lens = [...ctx._depositLens]
73
- .filter((L) => L >= 2 && L < bytes.length)
74
- .sort((a, b) => b - a);
75
- for (const L of lens) {
76
- const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
77
- if (hit !== undefined) {
78
- prev = hit;
79
- break;
78
+ let prefixLen = 0;
79
+ // Cache consult (both boundary lookup and stable-prefix reuse) is scoped
80
+ // to conversational deposits only a bare, unrelated fact whose bytes
81
+ // happen to extend an earlier deposit is NOT a conversation turn, and
82
+ // must keep the plain fold so it shares structure with ITS OWN prior
83
+ // deposits, not fragment against a coincidental byte-prefix.
84
+ if (conversational) {
85
+ // Longest cached PROPER prefix first.
86
+ const lens = [...ctx._depositLens]
87
+ .filter((L) => L >= 2 && L < bytes.length)
88
+ .sort((a, b) => b - a);
89
+ for (const L of lens) {
90
+ const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
91
+ // The suffix must bytes-equal the hit's OWN recorded continuation —
92
+ // proof this deposit is that turn's actual next turn, not a fact
93
+ // that coincidentally shares its byte prefix.
94
+ if (hit !== undefined && hit.nextBytes !== undefined &&
95
+ bytesEqual(hit.nextBytes, bytes.subarray(L))) {
96
+ prev = hit;
97
+ prefixLen = L;
98
+ break;
99
+ }
80
100
  }
81
101
  }
82
- const { tree, pyramid } = bytesToTreePyramid(ctx.space, ctx.alphabet, bytes, prev);
83
- if (bytes.length >= 2) {
102
+ let tree;
103
+ let entry;
104
+ if (prev !== undefined) {
105
+ const boundaries = [...prev.boundaries, prefixLen];
106
+ const folded = stablePrefixFoldIncremental(ctx.space, ctx.alphabet, bytes, boundaries, prev.stable);
107
+ tree = folded.tree;
108
+ entry = { boundaries, stable: folded.fold };
109
+ }
110
+ else {
111
+ const plain = bytesToTreePyramid(ctx.space, ctx.alphabet, bytes);
112
+ tree = plain.tree;
113
+ entry = { boundaries: [], pyramid: plain.pyramid };
114
+ }
115
+ // Only a conversational deposit writes the cache too — otherwise a bare
116
+ // fact's plain fold could later be misread as a conversation's turn-zero
117
+ // boundary by an unrelated conversational deposit that happens to extend
118
+ // its bytes.
119
+ if (conversational && bytes.length >= 2) {
84
120
  // The lengths set drifts as the map evicts; past the probe budget the
85
121
  // drift itself becomes the cost (each stale length is an O(len) key
86
122
  // build), so both reset together — losing only warm-up on live chains.
@@ -88,7 +124,7 @@ export function perceiveDeposit(ctx, bytes) {
88
124
  ctx._depositLens.clear();
89
125
  ctx._depositTrees.clear();
90
126
  }
91
- ctx._depositTrees.set(latin1Key(bytes), pyramid);
127
+ ctx._depositTrees.set(latin1Key(bytes), entry);
92
128
  ctx._depositLens.add(bytes.length);
93
129
  }
94
130
  return tree;
@@ -6,7 +6,25 @@ import type { Alphabet } from "../alphabet.js";
6
6
  import type { MindConfig } from "../config.js";
7
7
  import type { GraphSearch, Leaf, Seg, Site } from "./graph-search.js";
8
8
  import type { Rationale } from "./rationale.js";
9
- import type { FoldPyramid, Grid } from "../geometry.js";
9
+ import type { FoldPyramid, Grid, StableFold } from "../geometry.js";
10
+ /** One {@link MindContext._depositTrees} entry — see that field's doc. */
11
+ export interface DepositCacheEntry {
12
+ /** Turn boundaries accumulated over this content's deposit chain —
13
+ * strictly increasing proper offsets, each a previously-deposited
14
+ * whole-context length. Empty for a first-seen (single-turn) input. */
15
+ boundaries: number[];
16
+ /** Plain-fold pyramid (first-seen inputs only). */
17
+ pyramid?: FoldPyramid;
18
+ /** Stable-prefix segment folds (grown-context inputs only). */
19
+ stable?: StableFold;
20
+ /** The continuation bytes this ctxInput was paired with in ingestPair, if
21
+ * any — the ONLY thing that makes a later, longer ctxInput a genuine next
22
+ * TURN of the same conversation rather than an unrelated fact that
23
+ * happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
24
+ * later deposit only takes this entry as its stable-prefix `prev` when
25
+ * its own suffix bytes-equal this exactly. */
26
+ nextBytes?: Uint8Array;
27
+ }
10
28
  export type Input = string | Uint8Array | Grid | Grid[];
11
29
  /** The host capabilities GraphSearch consults during a cover. MindContext
12
30
  * extends this so the Mind can pass itself as the host. */
@@ -153,15 +171,19 @@ export interface MindContext extends GraphSearchHost {
153
171
  * never invalidated. Bounded LRU (byte-sized); a miss only re-perceives,
154
172
  * never a correctness risk. */
155
173
  _gistCache: BoundedMap<number, Vec>;
156
- /** DEPOSIT-path stable-prefix tree cache: content key (latin1) of a
157
- * deposited input → its plain-fold level PYRAMID, so the NEXT deposit
158
- * whose prefix it is (a conversation's accumulated context) reuses every
159
- * full aligned block and refolds only the right edge of each level
160
- * O(turn) per deposit instead of O(context), with the tree BIT-IDENTICAL
161
- * to a from-scratch fold (see FoldPyramid). Purely a performance cache.
162
- * Entry-count bounded (a pyramid is ~KB/byte of content; only the few
163
- * live conversation chains matter). */
164
- _depositTrees: BoundedMap<string, FoldPyramid>;
174
+ /** DEPOSIT-path perception cache: content key (latin1) of a deposited
175
+ * input → its accumulated turn BOUNDARIES plus reusable fold state. A
176
+ * deposit whose content extends a cached entry IS a conversation context
177
+ * grown by one turn the cached length is the new boundary so it
178
+ * folds with the SAME stable-prefix fold query-time perception uses
179
+ * (structural train/inference agreement, load-bearing for recall),
180
+ * reusing every already-folded segment via `stable` (see StableFold)
181
+ * O(turn) per deposit instead of O(context). A first-seen input keeps
182
+ * the plain fold and caches its `pyramid` (see FoldPyramid). Purely a
183
+ * performance cache for the FOLD STATE; the boundaries are semantic but
184
+ * derived only from the deposit sequence itself (an evicted chain falls
185
+ * back to plain-fold behavior, exactly the pre-boundary shape). */
186
+ _depositTrees: BoundedMap<string, DepositCacheEntry>;
165
187
  /** The byte lengths present in {@link _depositTrees} — the candidate
166
188
  * prefix lengths probed (longest first). Drifts on eviction (a stale
167
189
  * length only costs a miss); cleared with the map when it outgrows the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/geometry.ts CHANGED
@@ -145,7 +145,7 @@ export function coverageBar(_maxGroup: number, D: number): number {
145
145
 
146
146
  // ---- types ----
147
147
 
148
- interface Folded {
148
+ export interface Folded {
149
149
  tree: Sema;
150
150
  /** Byte length of the subtree — carried incrementally so the stable-prefix
151
151
  * boundary scan never re-walks subtrees (the old per-level walk was
@@ -383,6 +383,66 @@ function stablePrefixFold(
383
383
  return cur.tree;
384
384
  }
385
385
 
386
+ /** A stable-prefix fold's reusable state: the segment edge offsets and each
387
+ * segment's independently-folded root ({@link riverFoldRaw} output). A
388
+ * grown stream whose boundary set EXTENDS a previous fold's reuses every
389
+ * matching segment's Folded unchanged (segments fold independently by
390
+ * construction, so reuse is bit-identical to refolding) and folds only the
391
+ * new right-edge segment — O(turn) per extension, the stable-prefix
392
+ * counterpart of {@link FoldPyramid}. Purely a cache: the produced tree
393
+ * never depends on cache state. */
394
+ export interface StableFold {
395
+ edges: number[];
396
+ segs: Folded[];
397
+ }
398
+
399
+ /** {@link stablePrefixFold} with incremental segment reuse — same cuts, same
400
+ * segment folds, same left-nested join, same single root normalize; `prev`
401
+ * only elides recomputing segments whose [start,end) offsets it already
402
+ * folded over a byte-identical prefix (the caller keys the cache by
403
+ * content). Requires a non-empty effective boundary set. */
404
+ export function stablePrefixFoldIncremental(
405
+ space: Space,
406
+ alphabet: Alphabet,
407
+ bytes: Uint8Array,
408
+ boundaries: readonly number[],
409
+ prev?: StableFold,
410
+ ): { tree: Sema; fold: StableFold } {
411
+ const cuts: number[] = [];
412
+ let prevB = 0;
413
+ for (const b of boundaries) {
414
+ if (b > prevB && b < bytes.length) {
415
+ cuts.push(b);
416
+ prevB = b;
417
+ }
418
+ }
419
+ const edges = [0, ...cuts, bytes.length];
420
+ const segs: Folded[] = [];
421
+ for (let i = 0; i + 1 < edges.length; i++) {
422
+ const hit = prev !== undefined && prev.edges[i] === edges[i] &&
423
+ prev.edges[i + 1] === edges[i + 1]
424
+ ? prev.segs[i]
425
+ : undefined;
426
+ segs.push(
427
+ hit ??
428
+ riverFoldRaw(
429
+ space,
430
+ bytesToLeaves(alphabet, bytes.subarray(edges[i], edges[i + 1])),
431
+ ),
432
+ );
433
+ }
434
+ if (segs.length === 1) {
435
+ // Degenerate boundary set — the plain fold, as stablePrefixFold does.
436
+ const tree =
437
+ riverFold(space, bytesToLeaves(alphabet, bytes), bytes.length).tree;
438
+ return { tree, fold: { edges, segs } };
439
+ }
440
+ let cur = segs[0];
441
+ for (let i = 1; i < segs.length; i++) cur = fold2(space, cur, segs[i]);
442
+ normalize(cur.tree.v);
443
+ return { tree: cur.tree, fold: { edges, segs } };
444
+ }
445
+
386
446
  /** Join two folded items as one 2-kid branch — the top-level join of the
387
447
  * stable-prefix fold, identical FP ops to foldSlice's seat-bound
388
448
  * accumulation over a group of two. Unnormalized (interior). */
@@ -867,6 +867,28 @@ async function crossRegionVotes(
867
867
  const rb = regions[cand[b]];
868
868
  if (!strong.has(cand[a]) && !strong.has(cand[b])) continue;
869
869
  if (ra.end >= rb.start) continue; // overlap or adjacent — nothing between
870
+ // Candidates strictly BETWEEN ra and rb (cand is sorted by start, so
871
+ // that is exactly cand[a+1 .. b-1]) that already cast their OWN vote —
872
+ // genuine, individually-corroborated evidence about what fills the gap
873
+ // — gate the container search below: a joint container is binding
874
+ // evidence only when it is CONSISTENT with that evidence, i.e. its own
875
+ // bytes actually contain what the between-region says. This is the
876
+ // n-ary composition's normal shape (a between-attribute's bytes DO
877
+ // recur inside the joint container, credited as an "extra" below) as
878
+ // opposed to a container that silently substitutes something else for
879
+ // it (e.g. bridging past "Italy" to a container whose interior is
880
+ // "Japan" — a different, contradicting learnt whole).
881
+ // Only a KNOWN (content-addressed, exact) between-region qualifies —
882
+ // an approximate region's resonance climbing "somewhere" is ordinary
883
+ // noise (any ANN query returns SOME nearest neighbour), not evidence
884
+ // this specific gap already means something specific.
885
+ const between: number[] = [];
886
+ for (let m = a + 1; m < b; m++) {
887
+ if (
888
+ strong.has(cand[m]) && !consumed.has(cand[m]) &&
889
+ regions[cand[m]].known
890
+ ) between.push(cand[m]);
891
+ }
870
892
  // A single KNOWN region covering both: the whole form is already a
871
893
  // stored identity that votes directly; its pieces add nothing.
872
894
  if (
@@ -932,6 +954,22 @@ async function crossRegionVotes(
932
954
  );
933
955
  if (indexOf(query, joined, 0) >= 0) continue; // query says it itself
934
956
  }
957
+ // CONTRADICTION GUARD: a between-region already carrying its own
958
+ // vote must actually recur in this container's bytes — otherwise
959
+ // the container is a different learnt whole that happens to share
960
+ // ra/rb, and letting it stand in for the gap would silently
961
+ // override evidence the query itself already resolved there.
962
+ if (
963
+ between.some((bi) =>
964
+ indexOf(
965
+ bytes,
966
+ query.subarray(regions[bi].start, regions[bi].end),
967
+ 0,
968
+ ) < 0
969
+ )
970
+ ) {
971
+ continue;
972
+ }
935
973
  let cov = left.length + right.length;
936
974
  const extras: number[] = [];
937
975
  for (const ei of cand) {
@@ -9,6 +9,7 @@ import type { Input, MindContext } from "./types.js";
9
9
  import { changedNodes } from "./types.js";
10
10
  import {
11
11
  inputBytes,
12
+ latin1Key,
12
13
  perceive,
13
14
  perceiveDeposit,
14
15
  resolve,
@@ -119,6 +120,7 @@ export async function deposit(
119
120
  ctx: MindContext,
120
121
  input: Input,
121
122
  track: boolean,
123
+ conversational = false,
122
124
  ): Promise<
123
125
  { tree: Sema; rootId: number; ids: Map<Sema, number>; changed: Sema[] }
124
126
  > {
@@ -129,8 +131,13 @@ export async function deposit(
129
131
  // (no store-probe fallback): a knownPrefixLength scan on every novel fact
130
132
  // would cost O(n²) hashing, while conversation replays are always warm —
131
133
  // re-deposition replays from the first turn, rebuilding the cache as it
132
- // goes.
133
- const tree = perceiveDeposit(ctx, bytes);
134
+ // goes. `conversational` scopes the STABLE-PREFIX variant (turn-boundary
135
+ // folding, matching query-time perception) to ingestPair's own growing
136
+ // context argument — a bare ingestOne deposit whose bytes merely happen
137
+ // to extend an earlier UNRELATED deposit (no conversational relationship)
138
+ // must keep the plain fold, or two coincidentally-prefix-sharing facts
139
+ // would stop sharing structure with each other.
140
+ const tree = perceiveDeposit(ctx, bytes, conversational);
134
141
 
135
142
  const ids = new Map<Sema, number>();
136
143
  const rootId = await internTreeIds(ctx, tree, ids);
@@ -214,10 +221,20 @@ export async function ingestPair(
214
221
  ctxInput: Input,
215
222
  cont: Input,
216
223
  ): Promise<void> {
217
- const c = await deposit(ctx, ctxInput, true);
224
+ const c = await deposit(ctx, ctxInput, true, true);
218
225
  const cont_ = await deposit(ctx, cont, false);
219
226
  const ctxId = c.rootId, contId = cont_.rootId;
220
227
 
228
+ // Stamp this turn's continuation onto its own cache entry — the proof a
229
+ // FUTURE, longer ctxInput needs (see perceiveDeposit) to recognise itself
230
+ // as this conversation's genuine next turn rather than an unrelated fact
231
+ // that happens to share this ctxInput's byte prefix.
232
+ {
233
+ const ctxBytes = inputBytes(ctx, ctxInput);
234
+ const entry = ctx._depositTrees.get(latin1Key(ctxBytes));
235
+ if (entry !== undefined) entry.nextBytes = inputBytes(ctx, cont);
236
+ }
237
+
221
238
  await ctx.store.link(ctxId, contId);
222
239
  await propagateSuffixes(ctx, ctxId, contId);
223
240
 
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { MindContext } from "../types.js";
13
13
  import type { ComputedResult, Site } from "../graph-search.js";
14
+ import { bytesEqual, indexOf } from "../../bytes.js";
14
15
  import { read, resolve } from "../primitives.js";
15
16
  import { guidedFirst } from "../traverse.js";
16
17
  import { conceptHop } from "../match.js";
@@ -217,6 +218,43 @@ export const coverMechanism: PipelineMechanism = {
217
218
 
218
219
  if (segs === null) return [];
219
220
 
221
+ // A chosen span's SUBSTITUTED bytes (an edge followed from a recognised
222
+ // site, not the site's own literal text read back) that equal a byte
223
+ // span the query ALREADY CONTAINS elsewhere restates part of the
224
+ // question — never an answer (the same principle recall.ts's tiers
225
+ // apply to a whole-query projection: "a projection that is a proper
226
+ // byte-subspan of the query restates part of the question"). A
227
+ // recognised site that is itself an entire PRIOR TURN of a multi-turn
228
+ // query is exactly this shape: it carries a genuine learnt
229
+ // continuation, but that continuation is something the asker already
230
+ // said moments later in the SAME query, not a new answer — the cover
231
+ // search has no notion of "turn" to gate this itself, so the check
232
+ // belongs here, over the derivation it already chose.
233
+ const W = ctx.space.maxGroup;
234
+ for (const s of segs) {
235
+ if (!s.rec) continue;
236
+ const literal = s.j - s.i === s.bytes.length &&
237
+ bytesEqual(s.bytes, query.subarray(s.i, s.j));
238
+ if (literal) continue;
239
+ // A span shorter than one river window is exactly the "ice"/"c"
240
+ // case: a chain hop's short terminal coincidentally recurring as a
241
+ // substring of an unrelated longer word. Below the quantum, byte
242
+ // overlap is chance, not evidence — the same floor identityBar and
243
+ // reachThreshold hold every other structural-overlap claim to.
244
+ if (
245
+ s.bytes.length >= W && s.bytes.length < query.length &&
246
+ indexOf(query, s.bytes, 0) >= 0
247
+ ) {
248
+ ctx.trace?.step(
249
+ "restatedSpan",
250
+ [rItem(s.bytes, "substituted", s.node, [s.i, s.j])],
251
+ [],
252
+ "the chosen span's substitution already occurs elsewhere in the query — restates it, not an answer",
253
+ );
254
+ return [];
255
+ }
256
+ }
257
+
220
258
  const composed = liftAnswer(segs, query.length);
221
259
  if (composed === null) return [];
222
260
 
@@ -113,10 +113,38 @@ export async function recallByResonance(
113
113
  maximal.push(s);
114
114
  maxEnd = s.end;
115
115
  }
116
- if (maximal.length === 1) {
116
+ // The wrapper must actually BE scaffolding: RC8's own premise is "the
117
+ // wrapper is scaffolding; the argument is the span that leads
118
+ // somewhere" ("How do you say 'thank you' in French?" — everything
119
+ // outside the argument is a small fixed template). When the query
120
+ // instead has ANOTHER substantial recognised form (≥ W2, the same
121
+ // constituent bar the argument itself must clear) sitting OUTSIDE the
122
+ // chosen argument, the query is not one argument in a wrapper — it is
123
+ // several complete, independently-meaningful pieces (a multi-turn
124
+ // conversation's own accumulated turns are exactly this shape), and
125
+ // binding to the argument's continuation would answer past content
126
+ // the query itself already carries forward. Derived from the same W2
127
+ // bar the argument itself is held to, never a separate tuned number.
128
+ const hasSubstantialOutside = maximal.length === 1 &&
129
+ pre.rec.sites.some((s) =>
130
+ s.end - s.start >= W2 &&
131
+ (s.end <= maximal[0].start || s.start >= maximal[0].end)
132
+ );
133
+ if (maximal.length === 1 && !hasSubstantialOutside) {
117
134
  const arg = maximal[0];
118
135
  const g = await follow(ctx, arg.payload, queryGist);
119
- if (g !== null && g.length > 0) {
136
+ // The same "no restated fragment" guard tier 2 applies below (§ "the
137
+ // anchor cleared the consensus floor..."): a followed continuation
138
+ // that is itself a proper byte-subspan of the QUERY restates part of
139
+ // the question — never an answer. A multi-turn query's own later
140
+ // turns are exact, content-addressed matches for exactly this reason
141
+ // (each turn is its own previously-learnt form), so without this
142
+ // guard the argument's OWN later restatement in the same
143
+ // conversation reads as if it were the next thing to say.
144
+ if (
145
+ g !== null && g.length > 0 &&
146
+ !(g.length < query.length && indexOf(query, g, 0) >= 0)
147
+ ) {
120
148
  return ground(
121
149
  g,
122
150
  "argument binding — the query's sole edge-source constituent, continuation followed",
package/src/mind/mind.ts CHANGED
@@ -14,8 +14,6 @@ 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,
19
17
  Grid,
20
18
  gridToTree,
21
19
  hilbertBytes,
@@ -109,7 +107,7 @@ export interface Conversation {
109
107
  * foldTree returns their ids immediately — O(suffix) instead of
110
108
  * O(context) for every tree walk. */
111
109
  interface ConversationData {
112
- pyramid: FoldPyramid;
110
+ tree: Sema;
113
111
  bytes: Uint8Array;
114
112
  boundaries: number[];
115
113
  perceiveMemo: Map<string, Sema>;
@@ -244,9 +242,10 @@ export class Mind implements MindContext {
244
242
  // bounded: a pyramid costs ~KB per content byte (one D-float gist per
245
243
  // interior node), and only the few live conversation chains need to stay
246
244
  // warm, so 8 entries is the honest budget.
247
- _depositTrees = new BoundedMap<string, import("../geometry.js").FoldPyramid>(
248
- 8,
249
- );
245
+ _depositTrees = new BoundedMap<
246
+ string,
247
+ import("./types.js").DepositCacheEntry
248
+ >(8);
250
249
  _depositLens = new Set<number>();
251
250
  _internIds = new WeakMap<import("../sema.js").Sema, number>();
252
251
 
@@ -513,18 +512,20 @@ export class Mind implements MindContext {
513
512
  * where one turn ends and the next begins. */
514
513
  beginConversation(state?: ConversationState): Conversation {
515
514
  const id = this._nextConvId++;
516
- // Build the initial pyramid: from scratch for a new conversation,
517
- // or from the saved context bytes when restoring.
518
515
  const initBytes = state?.context ?? new Uint8Array(0);
519
- const { pyramid } = bytesToTreePyramid(
516
+ const initBoundaries = state?.boundaries ? [...state.boundaries] : [];
517
+ const tree = bytesToTree(
520
518
  this.space,
521
519
  this.alphabet,
522
520
  initBytes,
521
+ undefined,
522
+ undefined,
523
+ initBoundaries.length > 0 ? initBoundaries : undefined,
523
524
  );
524
525
  this._conversations.set(id, {
525
- pyramid,
526
+ tree,
526
527
  bytes: initBytes,
527
- boundaries: state?.boundaries ? [...state.boundaries] : [],
528
+ boundaries: initBoundaries,
528
529
  perceiveMemo: new Map(),
529
530
  recogniseMemo: new Map(),
530
531
  climbMemo: new Map(),
@@ -572,25 +573,23 @@ export class Mind implements MindContext {
572
573
  * place a context grows ({@link addTurn} and {@link respondTurn} both
573
574
  * come through here), so the append semantics cannot drift. */
574
575
  private _growContext(data: ConversationData, turnBytes: Uint8Array): Sema {
575
- const prevLen = data.pyramid.bytes;
576
+ const prevLen = data.bytes.length;
576
577
  // An empty turn neither grows the context nor marks a boundary —
577
578
  // boundaries are documented strictly increasing, and a zero-length
578
- // "turn" is no turn. The pyramid's zero-growth shortcut makes the
579
- // refold below O(1), so the existing tree is returned unchanged.
579
+ // "turn" is no turn. Nothing changed, so the existing tree stands.
580
580
  const grow = turnBytes.length > 0;
581
- const grown = !grow
582
- ? data.bytes
583
- : prevLen > 0
584
- ? concat2(data.bytes, turnBytes)
585
- : turnBytes;
586
- if (grow && prevLen > 0) data.boundaries.push(prevLen);
587
- const { tree, pyramid } = bytesToTreePyramid(
581
+ if (!grow) return data.tree;
582
+ const grown = prevLen > 0 ? concat2(data.bytes, turnBytes) : turnBytes;
583
+ if (prevLen > 0) data.boundaries.push(prevLen);
584
+ const tree = bytesToTree(
588
585
  this.space,
589
586
  this.alphabet,
590
587
  grown,
591
- data.pyramid,
588
+ undefined,
589
+ undefined,
590
+ data.boundaries.length > 0 ? data.boundaries : undefined,
592
591
  );
593
- data.pyramid = pyramid;
592
+ data.tree = tree;
594
593
  data.bytes = grown;
595
594
  data.perceiveMemo.set(latin1Key(grown), tree);
596
595
  return tree;
@@ -644,43 +643,15 @@ export class Mind implements MindContext {
644
643
  this.canonMemo = this.canon ? new Map() : null;
645
644
 
646
645
  try {
647
- // Seed the recognise memo with the STANDALONE recognition of the
648
- // NEW turn, offset into the accumulated context. In the full-context
649
- // tree, content-defined chunk boundaries shift when bytes are
650
- // concatenated — foldTree no longer visits the original turn's root
651
- // node, so the structural pass misses it. Perceiving the turn alone
652
- // preserves intact structure. We seed ONLY the suffix (the new turn);
653
- // prefix forms stay visible through the full-context recognition
654
- // (they serve as context, not as questions to re-answer).
655
- if (data.boundaries.length > 0) {
656
- const suffixStart = data.boundaries[data.boundaries.length - 1];
657
- if (suffixStart < newContext.length) {
658
- const suffixBytes = newContext.subarray(suffixStart);
659
- const suffixRec = recognise(this, suffixBytes);
660
- const fullRec = recognise(this, newContext);
661
- const seen = new Set(fullRec.sites.map((s) => `${s.start},${s.end}`));
662
- const mergedSites = [...fullRec.sites];
663
- for (const site of suffixRec.sites) {
664
- const absStart = suffixStart + site.start;
665
- const absEnd = suffixStart + site.end;
666
- const key = `${absStart},${absEnd}`;
667
- if (!seen.has(key)) {
668
- seen.add(key);
669
- mergedSites.push({
670
- start: absStart,
671
- end: absEnd,
672
- payload: site.payload,
673
- });
674
- }
675
- }
676
- data.recogniseMemo.set(latin1Key(newContext), {
677
- sites: mergedSites,
678
- leaves: fullRec.leaves,
679
- splits: fullRec.splits,
680
- });
681
- }
682
- }
683
-
646
+ // No recognise-memo pre-seeding here: that used to be necessary
647
+ // because the flat/positional fold lost visibility into an earlier
648
+ // turn's own structure once later bytes shifted its position
649
+ // (foldTree no longer visited the turn's root node). The
650
+ // STABLE-PREFIX fold (see {@link ConversationData}) makes every
651
+ // turn's subtree independent of what follows it by construction, so
652
+ // recognise() finds it correctly on its own, first-touch, exactly
653
+ // once per turn no workaround needed, and none pre-empting
654
+ // recognise()'s own memo with a partial result.
684
655
  const top = this.trace?.enter("respondTurn", [
685
656
  rItem(newContext, "query"),
686
657
  ]);
@@ -8,16 +8,16 @@ import { Sema } from "../sema.js";
8
8
  import {
9
9
  bytesToTree,
10
10
  bytesToTreePyramid,
11
- type FoldPyramid,
12
11
  Grid,
13
12
  gridToTree,
14
13
  hilbertBytes,
14
+ stablePrefixFoldIncremental,
15
15
  stackGrids,
16
16
  } from "../geometry.js";
17
17
  import { canonHash } from "../canon.js";
18
18
  import { bytesEqual } from "../bytes.js";
19
19
  import { ALL } from "./types.js";
20
- import type { Input, MindContext } from "./types.js";
20
+ import type { DepositCacheEntry, Input, MindContext } from "./types.js";
21
21
 
22
22
  // ── Address: bytes → node ──────────────────────────────────────────────
23
23
 
@@ -93,34 +93,76 @@ export function perceive(
93
93
  return gridToTree(ctx.space, ctx.alphabet, input as Grid);
94
94
  }
95
95
 
96
- /** The DEPOSIT-shaped perceive: the PLAIN fold (bit-identical to inference
97
- * perception that structural train/inference agreement is load-bearing
98
- * for exact recall), computed INCREMENTALLY via the fold's level pyramid
99
- * ({@link bytesToTreePyramid}). An accumulated context (a conversation)
100
- * grows by suffixes: the previous context's pyramid is cached by CONTENT
101
- * (ctx._depositTrees), and this deposit refolds only the right edge of
102
- * each level O(turn) instead of O(context) per turn. Purely a cache:
103
- * the produced tree never depends on cache state. */
104
- export function perceiveDeposit(ctx: MindContext, bytes: Uint8Array): Sema {
105
- let prev: FoldPyramid | undefined;
106
- // Longest cached PROPER prefix first.
107
- const lens = [...ctx._depositLens]
108
- .filter((L) => L >= 2 && L < bytes.length)
109
- .sort((a, b) => b - a);
110
- for (const L of lens) {
111
- const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
112
- if (hit !== undefined) {
113
- prev = hit;
114
- break;
96
+ /** The DEPOSIT-shaped perceive. A FIRST-SEEN input takes the PLAIN fold
97
+ * (bit-identical to inference perception of a standalone query that
98
+ * structural train/inference agreement is load-bearing for exact recall),
99
+ * computed incrementally via the fold's level pyramid
100
+ * ({@link bytesToTreePyramid}). An input that EXTENDS a previously
101
+ * deposited one is a conversation context grown by one turn — the cached
102
+ * prefix length IS the turn boundary (derived from the deposit sequence
103
+ * itself, never from content conventions) and takes the STABLE-PREFIX
104
+ * fold over the accumulated boundaries, bit-identical to the boundary
105
+ * fold query-time conversation perception uses, so the trained context
106
+ * node and the query's context subtree are the SAME node. Segment folds
107
+ * reuse across deposits ({@link stablePrefixFoldIncremental}) — O(turn)
108
+ * instead of O(context) per turn. The fold state is purely a cache; the
109
+ * boundary accumulation is what an evicted chain loses (falling back to
110
+ * the plain fold, the pre-boundary shape — a warm replay restores it). */
111
+ export function perceiveDeposit(
112
+ ctx: MindContext,
113
+ bytes: Uint8Array,
114
+ conversational = false,
115
+ ): Sema {
116
+ let prev: DepositCacheEntry | undefined;
117
+ let prefixLen = 0;
118
+ // Cache consult (both boundary lookup and stable-prefix reuse) is scoped
119
+ // to conversational deposits only — a bare, unrelated fact whose bytes
120
+ // happen to extend an earlier deposit is NOT a conversation turn, and
121
+ // must keep the plain fold so it shares structure with ITS OWN prior
122
+ // deposits, not fragment against a coincidental byte-prefix.
123
+ if (conversational) {
124
+ // Longest cached PROPER prefix first.
125
+ const lens = [...ctx._depositLens]
126
+ .filter((L) => L >= 2 && L < bytes.length)
127
+ .sort((a, b) => b - a);
128
+ for (const L of lens) {
129
+ const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
130
+ // The suffix must bytes-equal the hit's OWN recorded continuation —
131
+ // proof this deposit is that turn's actual next turn, not a fact
132
+ // that coincidentally shares its byte prefix.
133
+ if (
134
+ hit !== undefined && hit.nextBytes !== undefined &&
135
+ bytesEqual(hit.nextBytes, bytes.subarray(L))
136
+ ) {
137
+ prev = hit;
138
+ prefixLen = L;
139
+ break;
140
+ }
115
141
  }
116
142
  }
117
- const { tree, pyramid } = bytesToTreePyramid(
118
- ctx.space,
119
- ctx.alphabet,
120
- bytes,
121
- prev,
122
- );
123
- if (bytes.length >= 2) {
143
+ let tree: Sema;
144
+ let entry: DepositCacheEntry;
145
+ if (prev !== undefined) {
146
+ const boundaries = [...prev.boundaries, prefixLen];
147
+ const folded = stablePrefixFoldIncremental(
148
+ ctx.space,
149
+ ctx.alphabet,
150
+ bytes,
151
+ boundaries,
152
+ prev.stable,
153
+ );
154
+ tree = folded.tree;
155
+ entry = { boundaries, stable: folded.fold };
156
+ } else {
157
+ const plain = bytesToTreePyramid(ctx.space, ctx.alphabet, bytes);
158
+ tree = plain.tree;
159
+ entry = { boundaries: [], pyramid: plain.pyramid };
160
+ }
161
+ // Only a conversational deposit writes the cache too — otherwise a bare
162
+ // fact's plain fold could later be misread as a conversation's turn-zero
163
+ // boundary by an unrelated conversational deposit that happens to extend
164
+ // its bytes.
165
+ if (conversational && bytes.length >= 2) {
124
166
  // The lengths set drifts as the map evicts; past the probe budget the
125
167
  // drift itself becomes the cost (each stale length is an O(len) key
126
168
  // build), so both reset together — losing only warm-up on live chains.
@@ -128,7 +170,7 @@ export function perceiveDeposit(ctx: MindContext, bytes: Uint8Array): Sema {
128
170
  ctx._depositLens.clear();
129
171
  ctx._depositTrees.clear();
130
172
  }
131
- ctx._depositTrees.set(latin1Key(bytes), pyramid);
173
+ ctx._depositTrees.set(latin1Key(bytes), entry);
132
174
  ctx._depositLens.add(bytes.length);
133
175
  }
134
176
  return tree;
package/src/mind/types.ts CHANGED
@@ -19,7 +19,26 @@ import type {
19
19
  Site,
20
20
  } from "./graph-search.js";
21
21
  import type { Rationale } from "./rationale.js";
22
- import type { FoldPyramid, Grid } from "../geometry.js";
22
+ import type { FoldPyramid, Grid, StableFold } from "../geometry.js";
23
+
24
+ /** One {@link MindContext._depositTrees} entry — see that field's doc. */
25
+ export interface DepositCacheEntry {
26
+ /** Turn boundaries accumulated over this content's deposit chain —
27
+ * strictly increasing proper offsets, each a previously-deposited
28
+ * whole-context length. Empty for a first-seen (single-turn) input. */
29
+ boundaries: number[];
30
+ /** Plain-fold pyramid (first-seen inputs only). */
31
+ pyramid?: FoldPyramid;
32
+ /** Stable-prefix segment folds (grown-context inputs only). */
33
+ stable?: StableFold;
34
+ /** The continuation bytes this ctxInput was paired with in ingestPair, if
35
+ * any — the ONLY thing that makes a later, longer ctxInput a genuine next
36
+ * TURN of the same conversation rather than an unrelated fact that
37
+ * happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
38
+ * later deposit only takes this entry as its stable-prefix `prev` when
39
+ * its own suffix bytes-equal this exactly. */
40
+ nextBytes?: Uint8Array;
41
+ }
23
42
  import { concatBytes } from "../bytes.js";
24
43
  import { dominates } from "../geometry.js";
25
44
 
@@ -189,15 +208,19 @@ export interface MindContext extends GraphSearchHost {
189
208
  * never invalidated. Bounded LRU (byte-sized); a miss only re-perceives,
190
209
  * never a correctness risk. */
191
210
  _gistCache: BoundedMap<number, Vec>;
192
- /** DEPOSIT-path stable-prefix tree cache: content key (latin1) of a
193
- * deposited input → its plain-fold level PYRAMID, so the NEXT deposit
194
- * whose prefix it is (a conversation's accumulated context) reuses every
195
- * full aligned block and refolds only the right edge of each level
196
- * O(turn) per deposit instead of O(context), with the tree BIT-IDENTICAL
197
- * to a from-scratch fold (see FoldPyramid). Purely a performance cache.
198
- * Entry-count bounded (a pyramid is ~KB/byte of content; only the few
199
- * live conversation chains matter). */
200
- _depositTrees: BoundedMap<string, FoldPyramid>;
211
+ /** DEPOSIT-path perception cache: content key (latin1) of a deposited
212
+ * input → its accumulated turn BOUNDARIES plus reusable fold state. A
213
+ * deposit whose content extends a cached entry IS a conversation context
214
+ * grown by one turn the cached length is the new boundary so it
215
+ * folds with the SAME stable-prefix fold query-time perception uses
216
+ * (structural train/inference agreement, load-bearing for recall),
217
+ * reusing every already-folded segment via `stable` (see StableFold)
218
+ * O(turn) per deposit instead of O(context). A first-seen input keeps
219
+ * the plain fold and caches its `pyramid` (see FoldPyramid). Purely a
220
+ * performance cache for the FOLD STATE; the boundaries are semantic but
221
+ * derived only from the deposit sequence itself (an evicted chain falls
222
+ * back to plain-fold behavior, exactly the pre-boundary shape). */
223
+ _depositTrees: BoundedMap<string, DepositCacheEntry>;
201
224
  /** The byte lengths present in {@link _depositTrees} — the candidate
202
225
  * prefix lengths probed (longest first). Drifts on eviction (a stale
203
226
  * length only costs a miss); cleared with the map when it outgrows the