@hviana/sema 0.3.0 → 0.4.1

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.
Files changed (79) hide show
  1. package/AGENTS.md +71 -5
  2. package/CONTRIBUTING.md +92 -10
  3. package/LICENSE.md +2 -2
  4. package/dist/src/derive/src/deduction.d.ts +12 -1
  5. package/dist/src/derive/src/deduction.js +5 -1
  6. package/dist/src/derive/src/index.d.ts +1 -0
  7. package/dist/src/geometry.d.ts +3 -30
  8. package/dist/src/geometry.js +330 -82
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/index.js +1 -0
  11. package/dist/src/meter.d.ts +171 -0
  12. package/dist/src/meter.js +269 -0
  13. package/dist/src/mind/attention.d.ts +0 -4
  14. package/dist/src/mind/attention.js +251 -23
  15. package/dist/src/mind/bridge.d.ts +10 -1
  16. package/dist/src/mind/bridge.js +179 -10
  17. package/dist/src/mind/canonical.d.ts +6 -1
  18. package/dist/src/mind/canonical.js +6 -1
  19. package/dist/src/mind/graph-search.d.ts +9 -0
  20. package/dist/src/mind/graph-search.js +59 -19
  21. package/dist/src/mind/junction.d.ts +10 -0
  22. package/dist/src/mind/junction.js +14 -0
  23. package/dist/src/mind/match.d.ts +40 -0
  24. package/dist/src/mind/match.js +125 -1
  25. package/dist/src/mind/mechanisms/cast.js +63 -6
  26. package/dist/src/mind/mechanisms/extraction.d.ts +0 -34
  27. package/dist/src/mind/mechanisms/extraction.js +1 -88
  28. package/dist/src/mind/mechanisms/recall.d.ts +3 -0
  29. package/dist/src/mind/mechanisms/recall.js +77 -14
  30. package/dist/src/mind/mind.d.ts +55 -4
  31. package/dist/src/mind/mind.js +110 -91
  32. package/dist/src/mind/pipeline-mechanism.d.ts +33 -3
  33. package/dist/src/mind/pipeline-mechanism.js +179 -10
  34. package/dist/src/mind/pipeline.js +52 -10
  35. package/dist/src/mind/primitives.d.ts +11 -15
  36. package/dist/src/mind/primitives.js +47 -28
  37. package/dist/src/mind/reasoning.d.ts +7 -1
  38. package/dist/src/mind/reasoning.js +40 -8
  39. package/dist/src/mind/recognition.js +93 -20
  40. package/dist/src/mind/traverse.d.ts +11 -0
  41. package/dist/src/mind/traverse.js +58 -5
  42. package/dist/src/mind/types.d.ts +28 -5
  43. package/dist/src/store.d.ts +15 -0
  44. package/dist/src/store.js +91 -6
  45. package/package.json +1 -1
  46. package/src/derive/src/deduction.ts +15 -0
  47. package/src/derive/src/index.ts +1 -0
  48. package/src/geometry.ts +350 -122
  49. package/src/index.ts +1 -0
  50. package/src/meter.ts +333 -0
  51. package/src/mind/attention.ts +268 -31
  52. package/src/mind/bridge.ts +187 -10
  53. package/src/mind/canonical.ts +6 -1
  54. package/src/mind/graph-search.ts +60 -21
  55. package/src/mind/junction.ts +12 -0
  56. package/src/mind/match.ts +146 -1
  57. package/src/mind/mechanisms/cast.ts +62 -6
  58. package/src/mind/mechanisms/extraction.ts +2 -103
  59. package/src/mind/mechanisms/recall.ts +84 -17
  60. package/src/mind/mind.ts +139 -98
  61. package/src/mind/pipeline-mechanism.ts +203 -13
  62. package/src/mind/pipeline.ts +65 -8
  63. package/src/mind/primitives.ts +49 -33
  64. package/src/mind/reasoning.ts +39 -7
  65. package/src/mind/recognition.ts +89 -19
  66. package/src/mind/traverse.ts +59 -5
  67. package/src/mind/types.ts +28 -5
  68. package/src/store.ts +75 -6
  69. package/test/14-scaling.test.mjs +17 -7
  70. package/test/31-audit.test.mjs +4 -1
  71. package/test/33-multi-candidate.test.mjs +13 -1
  72. package/test/36-already-answered-fusion.test.mjs +10 -3
  73. package/test/46-recognise-multibyte-edge.test.mjs +3 -3
  74. package/test/53-cross-region-probe-instrumentation.test.mjs +36 -6
  75. package/test/55-cost-meter.test.mjs +284 -0
  76. package/test/56-bridge-identity-admission.test.mjs +209 -0
  77. package/test/57-fusion-order.test.mjs +104 -0
  78. package/test/58-subquantum-sites.test.mjs +112 -0
  79. package/test/59-fold-invariance.test.mjs +226 -0
@@ -32,12 +32,18 @@ export { aluToMechanism } from "./mechanisms/alu.js";
32
32
  // ── Extension dispatch (pre-loop parse) ─────────────────────────────────────
33
33
 
34
34
  async function collectComputed(
35
+ ctx: MindContext,
35
36
  mechanisms: readonly PipelineMechanism[],
36
37
  query: Uint8Array,
37
38
  ): Promise<ComputedSpan[]> {
38
39
  const out: ComputedSpan[] = [];
40
+ const meter = ctx.meter;
39
41
  for (const m of mechanisms) {
40
- if (m.parse) out.push(...await m.parse(query));
42
+ if (!m.parse) continue;
43
+ const spans = meter
44
+ ? await meter.time(`${m.name}.parse`, () => m.parse!(query))
45
+ : await m.parse(query);
46
+ out.push(...spans);
41
47
  }
42
48
  return out;
43
49
  }
@@ -143,7 +149,7 @@ export async function think(
143
149
  const rec = recognise(ctx, query);
144
150
 
145
151
  // Phase 1: collect computed spans from mechanisms that implement parse()
146
- const computed = await collectComputed(mechanisms, query);
152
+ const computed = await collectComputed(ctx, mechanisms, query);
147
153
 
148
154
  if (computed.length > 0) {
149
155
  ctx.trace?.step(
@@ -181,6 +187,7 @@ export async function think(
181
187
  used?: ReadonlySet<number>;
182
188
  accounted: ReadonlyArray<[number, number]>;
183
189
  unexplained: string;
190
+ complete?: boolean;
184
191
  }
185
192
  const grade = (w: number) => Math.floor(w / STEP);
186
193
  const unaccounted = (spans: ReadonlyArray<[number, number]>): number =>
@@ -195,6 +202,7 @@ export async function think(
195
202
  let best: Candidate | null = null;
196
203
  const consider = (c: Candidate) => {
197
204
  if (c.bytes.length === 0) return;
205
+ if (ctx.meter) ctx.meter.candidates++;
198
206
  candidates.push(c);
199
207
  if (best === null || grade(c.weight) < grade(best.weight)) best = c;
200
208
  };
@@ -202,8 +210,21 @@ export async function think(
202
210
  best === null || grade(floor) < grade(best.weight);
203
211
 
204
212
  // Phase 3: grounding loop
213
+ // Per-mechanism accounting (src/meter.ts). The market's whole premise is
214
+ // that mechanisms compete on one cost scale — so the profiling read-out is
215
+ // also per-mechanism, uniformly: the loop never asks which one it holds.
216
+ const meter = ctx.meter;
205
217
  for (const mech of mechanisms) {
206
- const floor = await mech.floor(ctx, query, pre, worthRunning);
218
+ const floor = meter
219
+ ? await meter.time(
220
+ `${mech.name}.floor`,
221
+ () => mech.floor(ctx, query, pre, worthRunning),
222
+ )
223
+ : await mech.floor(ctx, query, pre, worthRunning);
224
+ if (meter) {
225
+ if (floor === null) meter.mechanismSkips++;
226
+ else meter.mechanismFloors++;
227
+ }
207
228
  if (floor === null) {
208
229
  ctx.trace?.step(
209
230
  "skipMechanism",
@@ -224,7 +245,10 @@ export async function think(
224
245
  );
225
246
  continue;
226
247
  }
227
- const results = await mech.run(ctx, query, pre);
248
+ if (meter) meter.mechanismRuns++;
249
+ const results = meter
250
+ ? await meter.time(`${mech.name}.run`, () => mech.run(ctx, query, pre))
251
+ : await mech.run(ctx, query, pre);
228
252
  for (const r of results) {
229
253
  const weight = r.weight ?? weigh(r.accounted, r.moves);
230
254
  consider({
@@ -234,6 +258,7 @@ export async function think(
234
258
  used: r.used,
235
259
  accounted: r.accounted,
236
260
  unexplained: r.unexplained,
261
+ complete: r.complete,
237
262
  });
238
263
  }
239
264
  }
@@ -338,7 +363,16 @@ export async function think(
338
363
  : provenance === "recall" || provenance === "recall-echo"
339
364
  ? new Set<number>()
340
365
  : new Set(recognise(ctx, answer).sites.map((s) => s.payload));
341
- const reasoned = await reason(ctx, query, answer, preConsumed, pre);
366
+ // A grounding that DECLARED itself complete is not extended: the answer is
367
+ // already a trained form's own continuation, reached through an identity
368
+ // claim about the query, so a multi-hop pivot could only chain past the
369
+ // fact that produced it (see MechanismResult.complete).
370
+ const reasoned = decided.complete ? answer : meter
371
+ ? await meter.time(
372
+ "reason",
373
+ () => reason(ctx, query, answer, preConsumed, pre),
374
+ )
375
+ : await reason(ctx, query, answer, preConsumed, pre);
342
376
 
343
377
  // Fuse only when the query has a genuine REMAINDER no mechanism's
344
378
  // structural evidence touched at all. `decided.accounted` alone
@@ -374,9 +408,32 @@ export async function think(
374
408
  decided.accounted.every(([i, j]) =>
375
409
  pre.computed.some((u) => u.i === i && u.j === j)
376
410
  );
377
- const fused = remainder >= ctx.space.maxGroup
378
- ? await fuseAttention(ctx, query, reasoned, pre, unclimbed)
379
- : reasoned;
411
+ // Where the winning grounding stands in the query — fusion places primary
412
+ // by it (see fuseAttention's `primarySpans`). `accounted` is the
413
+ // cost-ladder read and is authoritative when non-empty; when it is empty
414
+ // the grounding is a pure COMPUTATION, whose evidence is its computed span.
415
+ // Exactly the cost-ladder-vs-coverage distinction `explained` above draws,
416
+ // read here for POSITION instead of for coverage — and resolved here, where
417
+ // both readings are in hand, rather than inside fuseAttention.
418
+ const primarySpans: ReadonlyArray<[number, number]> =
419
+ decided.accounted.length > 0
420
+ ? decided.accounted
421
+ : pre.computed.map((u): [number, number] => [u.i, u.j]);
422
+ const fused = remainder < ctx.space.maxGroup
423
+ ? reasoned
424
+ : meter
425
+ ? await meter.time(
426
+ "fuse",
427
+ () => fuseAttention(ctx, query, reasoned, pre, unclimbed, primarySpans),
428
+ )
429
+ : await fuseAttention(
430
+ ctx,
431
+ query,
432
+ reasoned,
433
+ pre,
434
+ unclimbed,
435
+ decided.accounted,
436
+ );
380
437
 
381
438
  done(
382
439
  fused,
@@ -7,7 +7,6 @@ import { Vec } from "../vec.js";
7
7
  import { Sema } from "../sema.js";
8
8
  import {
9
9
  bytesToTree,
10
- bytesToTreePyramid,
11
10
  Grid,
12
11
  gridToTree,
13
12
  hilbertBytes,
@@ -64,7 +63,14 @@ export function perceive(
64
63
  if (memo) {
65
64
  const key = latin1Key(bytes);
66
65
  const hit = memo.get(key);
67
- if (hit !== undefined) return hit;
66
+ if (hit !== undefined) {
67
+ if (ctx.meter) ctx.meter.perceiveHits++;
68
+ return hit;
69
+ }
70
+ if (ctx.meter) {
71
+ ctx.meter.perceptions++;
72
+ ctx.meter.perceivedBytes += bytes.length;
73
+ }
68
74
  const tree = bytesToTree(
69
75
  ctx.space,
70
76
  ctx.alphabet,
@@ -76,6 +82,10 @@ export function perceive(
76
82
  memo.set(key, tree);
77
83
  return tree;
78
84
  }
85
+ if (ctx.meter) {
86
+ ctx.meter.perceptions++;
87
+ ctx.meter.perceivedBytes += bytes.length;
88
+ }
79
89
  return bytesToTree(
80
90
  ctx.space,
81
91
  ctx.alphabet,
@@ -93,21 +103,17 @@ export function perceive(
93
103
  return gridToTree(ctx.space, ctx.alphabet, input as Grid);
94
104
  }
95
105
 
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). */
106
+ /** The DEPOSIT-shaped perceive. Folds over the stream's own content cuts —
107
+ * bit-identical to what inference computes for the same bytes, and that
108
+ * train/inference agreement is load-bearing for exact recall. An input that
109
+ * EXTENDS a previously deposited one is a conversation context grown by one
110
+ * turn; the cached prefix length IS the turn boundary (derived from the deposit
111
+ * sequence itself, never from a content convention) and joins the cut set, so
112
+ * the trained context node and the query's context subtree are the SAME node.
113
+ * Segment folds reuse across deposits ({@link stablePrefixFoldIncremental}) —
114
+ * O(turn) instead of O(context) per turn. All of it is purely a cache: an
115
+ * evicted chain loses only the turn boundaries, and since the content cuts do
116
+ * not depend on the cache, the segments themselves are unchanged. */
111
117
  export function perceiveDeposit(
112
118
  ctx: MindContext,
113
119
  bytes: Uint8Array,
@@ -140,24 +146,32 @@ export function perceiveDeposit(
140
146
  }
141
147
  }
142
148
  }
143
- let tree: Sema;
144
- let entry: DepositCacheEntry;
149
+ // ONLY turn boundaries belong here. The stream's own content cuts are NOT
150
+ // passed in: `bytesToTree` and `stablePrefixFoldIncremental` both derive them
151
+ // per span, at every level, and handing the level-0 cuts in as stable-prefix
152
+ // boundaries instead produces a LEFT-NESTED join of flat segments — a
153
+ // different tree from the one inference builds for the same bytes. When that
154
+ // happened, a deposit's context root and `resolve(question)` were different
155
+ // nodes, so the trained edge hung off a node inference never reached and
156
+ // recall went silent (test/44 caught it as a site that could not be emitted
157
+ // because the resolved node led nowhere). Train and infer must fold
158
+ // identically; the way to guarantee that is to give this function nothing
159
+ // extra to say.
160
+ const cuts = new Set<number>();
145
161
  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 };
162
+ for (const b of prev.boundaries) cuts.add(b);
163
+ cuts.add(prefixLen);
160
164
  }
165
+ const boundaries = [...cuts].sort((a, b) => a - b);
166
+ const folded = stablePrefixFoldIncremental(
167
+ ctx.space,
168
+ ctx.alphabet,
169
+ bytes,
170
+ boundaries,
171
+ prev?.stable,
172
+ );
173
+ const tree = folded.tree;
174
+ const entry: DepositCacheEntry = { boundaries, stable: folded.fold };
161
175
  // Only a conversational deposit writes the cache too — otherwise a bare
162
176
  // fact's plain fold could later be misread as a conversation's turn-zero
163
177
  // boundary by an unrelated conversational deposit that happens to extend
@@ -243,6 +257,7 @@ export function foldTree(
243
257
  * unknown. */
244
258
  export function resolve(ctx: MindContext, bytes: Uint8Array): number | null {
245
259
  if (bytes.length === 0) return null;
260
+ if (ctx.meter) ctx.meter.resolves++;
246
261
  const exact = foldTree(ctx, perceive(ctx, bytes), 0).node;
247
262
  if (exact !== null) return exact;
248
263
  return canonResolve(ctx, bytes);
@@ -283,6 +298,7 @@ export function canonResolve(
283
298
  const direct = foldTree(ctx, perceive(ctx, key), 0).node;
284
299
  if (direct !== null) return set(direct);
285
300
  }
301
+ if (ctx.meter) ctx.meter.canonLookups++;
286
302
  const candidates = store.canonFind(canonHash(key));
287
303
  if (candidates.length === 0) return set(null);
288
304
  let best: number | null = null;
@@ -7,7 +7,7 @@ import { rItem, rNode } from "./trace.js";
7
7
  import { bytesEqual, indexOf } from "../bytes.js";
8
8
  import type { MindContext } from "./types.js";
9
9
  import { resolve } from "./primitives.js";
10
- import { corpusN } from "./traverse.js";
10
+ import { corpusN, hubBound } from "./traverse.js";
11
11
  import { follow, haloSiblings, project } from "./match.js";
12
12
  import { joinWithBridge, pivotInto } from "./resonance.js";
13
13
  import type { Precomputed } from "./pipeline-mechanism.js";
@@ -57,16 +57,16 @@ export async function reason(
57
57
  // convention every fan-out decision uses (first √N in the relation's own
58
58
  // read order); a pivot suppressed only by a beyond-cap neighbour may now
59
59
  // fire — the same visibility trade chooseNext documents.
60
- const hubBound = Math.ceil(Math.sqrt(corpusN(ctx)));
60
+ const bound = hubBound(ctx);
61
61
  const consumeNode = (id: number | null) => {
62
62
  if (id === null) return;
63
63
  consumed.add(id);
64
- for (const p of ctx.store.prevFirst(id, hubBound)) consumed.add(p);
64
+ for (const p of ctx.store.prevFirst(id, bound)) consumed.add(p);
65
65
  };
66
66
  const consumeAll = (id: number | null) => {
67
67
  if (id === null) return;
68
68
  consumeNode(id);
69
- for (const n of ctx.store.nextFirst(id, hubBound)) consumed.add(n);
69
+ for (const n of ctx.store.nextFirst(id, bound)) consumed.add(n);
70
70
  };
71
71
 
72
72
  // Pre-consume whatever the grounding stage already spoke for. The halo
@@ -99,7 +99,7 @@ export async function reason(
99
99
  // for, so a consumed fixpoint falls through to the pivot step instead.
100
100
  if (
101
101
  curId !== null &&
102
- ctx.store.nextFirst(curId, hubBound).some((n) => !consumed.has(n))
102
+ ctx.store.nextFirst(curId, bound).some((n) => !consumed.has(n))
103
103
  ) {
104
104
  const fwd = await follow(ctx, curId, qv);
105
105
  const fwdId = fwd !== null ? resolve(ctx, fwd) : null;
@@ -164,6 +164,12 @@ export async function fuseAttention(
164
164
  * primary is unclimbed. Absent or false preserves the original
165
165
  * behaviour exactly. */
166
166
  unclimbed = false,
167
+ /** The query spans `primary`'s own grounding stands on — used ONLY to place
168
+ * primary in the fused reading order (see below). Resolved by the caller,
169
+ * which is the layer that knows how a given grounding records its evidence;
170
+ * fuseAttention just reads a position from it. Empty or absent preserves
171
+ * the original behaviour exactly. */
172
+ primarySpans: ReadonlyArray<readonly [number, number]> = [],
167
173
  ): Promise<Uint8Array> {
168
174
  // When the answer is structurally drawn from the query itself
169
175
  // (extraction), it already spans all the query's pieces — fusion
@@ -194,8 +200,34 @@ export async function fuseAttention(
194
200
  return primary;
195
201
  }
196
202
 
203
+ // WHERE THE QUERY ASKED FOR IT. The sort below orders the fused pieces by
204
+ // query position, which is the whole point of the `start` field: a
205
+ // multi-topic answer should read in the order the question posed its
206
+ // topics. Every ROOT carries its own start. `primary` did not — it was
207
+ // given forest[0].start, the FIRST attention root's position, which is
208
+ // primary's own source only when primary happens to come from that root.
209
+ // When it does not, primary is sorted to a position it never occupied.
210
+ //
211
+ // Observed live: "What is the capital of France? And what is 2 + 2?"
212
+ // answered "4The capital city of France is Paris." — the ALU result, whose
213
+ // evidence is the "2 + 2" span near the END of the query, inherited the
214
+ // France root's start of 0 and sorted ahead of the France answer. Both
215
+ // pieces were right; only the order was.
216
+ //
217
+ // primary's own position is the earliest query byte its grounding stands
218
+ // on. `accounted` is the cost-ladder read of that and is authoritative
219
+ // when non-empty; when it is empty the grounding is a pure COMPUTATION,
220
+ // whose evidence is its computed span — the same cost-ladder-vs-coverage
221
+ // distinction think() already draws for the fusion remainder ("`accounted`
222
+ // alone undercounts this ... cover prices its computed spans at near-zero
223
+ // and deliberately leaves them out"), read here for position instead of
224
+ // for coverage. With neither, nothing is known and the old behaviour
225
+ // (forest[0].start) stands.
226
+ const primaryStart = primarySpans.length > 0
227
+ ? primarySpans.reduce((m, [s]) => Math.min(m, s), Infinity)
228
+ : forest[0].start;
197
229
  const pieces: Array<{ start: number; bytes: Uint8Array }> = [
198
- { start: forest[0].start, bytes: primary },
230
+ { start: primaryStart, bytes: primary },
199
231
  ];
200
232
  const qv = pre.guide; // once, not per root
201
233
  const rest = lonePromotes ? forest : forest.slice(1);
@@ -287,4 +319,4 @@ export async function fuseAttention(
287
319
 
288
320
  // (resonance.js is already a static dependency above — `bridge` — so the old
289
321
  // dynamic import of pivotInto guarded against a cycle that does not exist.)
290
- import { containsSpan } from "./mechanisms/extraction.js";
322
+ import { containsSpan } from "./match.js";
@@ -58,6 +58,7 @@ export function recognise(ctx: MindContext, bytes: Uint8Array): Recognition {
58
58
  const key = latin1Key(bytes);
59
59
  const hit = ctx.recogniseMemo.get(key);
60
60
  if (hit !== undefined) {
61
+ if (ctx.meter) ctx.meter.recogniseHits++;
61
62
  ctx.trace?.step(
62
63
  "recognise",
63
64
  [rItem(bytes, "query")],
@@ -80,11 +81,20 @@ export function recognise(ctx: MindContext, bytes: Uint8Array): Recognition {
80
81
  }
81
82
 
82
83
  function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
84
+ if (ctx.meter) {
85
+ ctx.meter.recognitions++;
86
+ ctx.meter.recognisedBytes += bytes.length;
87
+ }
83
88
  const store = ctx.store;
84
89
  const sites: Site[] = [];
85
90
  const leaves: Leaf[] = [];
86
91
  const splits = new Set<number>();
87
92
  const starts = new Set<number>();
93
+ // The same cuts in ASCENDING order. The post-order walk below visits
94
+ // leaf-parents left to right, so appending as they are added keeps this
95
+ // sorted with no comparison — which is what lets the composite search find
96
+ // its candidates by binary search instead of rescanning the whole set.
97
+ const startList: number[] = [];
88
98
  if (bytes.length === 0) return { sites, leaves, splits, starts };
89
99
 
90
100
  // Span-resolve memo for THIS call: the structural pass (sub-runs inside
@@ -124,6 +134,34 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
124
134
  const seen = new Set<string>();
125
135
  const emit = (start: number, end: number, id: number) => {
126
136
  if (id < 0 && atomsAreHubs) return;
137
+ // A SITE MUST SPAN ONE RIVER WINDOW. Below W, byte overlap is chance,
138
+ // not evidence — the principle identityBar already states ("below one
139
+ // river window, byte overlap is chance") and the bridge's attestedQ
140
+ // already applies ("spans shorter than W carry no window of their own").
141
+ // No new constant.
142
+ //
143
+ // This REPLACES the false premise it used to share with fuse() and
144
+ // tryChain: those gates asked "does this offset sit on a fold boundary?"
145
+ // and read the answer from `starts`, which is exactly {0, W, 2W, …}
146
+ // because riverFold groups fixed-arity — arithmetic, not evidence.
147
+ //
148
+ // Measured on the 17.9M-node store, over the sites of 7 probes (1 good,
149
+ // 11 junk by hand-labelling, corrected for whole-query forms):
150
+ // len >= W rejects "hi"(2) "of"(2) "is"(2) "di"(2) "the"(3),
151
+ // admits "Eiffel Tower"(12) and both whole-query forms
152
+ // len >= W-1 admits "the" — W-1 is the write side's straddle
153
+ // neighbour for RETRIEVAL, never a claim about units
154
+ // §2.7 saturation admits 11/11 junk: edgeAncestors on a site node
155
+ // reaches 1..48 contexts, so dominates(ctx, N) needs
156
+ // ctx > 162805 and never fires; every site reads DISC
157
+ // rarity does not separate: "hi" has 1 container, "the" 572
158
+ //
159
+ // A span covering the WHOLE query is exempt: then it is not a fragment of
160
+ // something longer, it is the question ("hi" asked on its own).
161
+ if (
162
+ atomsAreHubs && end - start < ctx.space.maxGroup &&
163
+ !(start === 0 && end === bytes.length)
164
+ ) return;
127
165
  const key = start + "," + end + "," + id;
128
166
  if (seen.has(key)) return;
129
167
  seen.add(key);
@@ -134,6 +172,7 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
134
172
 
135
173
  // ── structural: the query's own perceived tree ──────────────────────
136
174
  starts.add(0);
175
+ startList.push(0);
137
176
  foldTree(ctx, perceive(ctx, bytes), 0, (n, start, end, node) => {
138
177
  if (n.kids === null) {
139
178
  leaves.push({ start, end, bytes: n.leaf ?? new Uint8Array(0), node });
@@ -207,25 +246,47 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
207
246
  // "And " prepended to a follow-up turn — not boundary noise, actual
208
247
  // content the injected canonicalizer has no equivalence for) shows
209
248
  // up as a canon-miss too big for the chunk-scale search above: the
210
- // turn is its OWN segment (stable-prefix folded independently see
211
- // mind.ts's _growContext), so it can be turn/segment-scale, not
212
- // chunk-scale. Widening the size bound itself reopens the root-
213
- // scale false-positive this module already fixed once (test/46);
214
- // widening the SEARCH instead trying every position up to W
215
- // chunk-widths from the left edge that the query's OWN fold treats
216
- // as a chunk boundary (`starts`, the same set the canonical pass
217
- // privileges with full chain reach) does not: `starts.has(p)` is
218
- // fold EVIDENCE the query produced on its own (a leaf-parent chunk
219
- // like "And " is visited, and its start added to `starts`, before
220
- // this composite ever runs foldTree is post-order), never a blind
221
- // guess. Bounded to W candidates, each an O(1) set lookup before
222
- // paying for the real canonResolve fold canonResolve, not
223
- // resolve()/findBranch, because the gap here is often exactly the
224
- // kind of equivalence (case, in the live trace) canon exists for,
225
- // not just an exact-content coincidence.
226
- for (let k = 1; k <= W; k++) {
227
- const p = start + k * W;
228
- if (p >= end - 1 || !starts.has(p)) continue;
249
+ // turn is its OWN segment, so it can be turn/segment-scale, not
250
+ // chunk-scale. Widening the size bound itself reopens the root-scale
251
+ // false-positive this module already fixed once (test/46); widening the
252
+ // SEARCH instead does not, because every candidate is a cut the query's
253
+ // OWN fold drew (`starts`, the same set the canonical pass privileges
254
+ // with full chain reach) fold EVIDENCE, never a blind guess.
255
+ //
256
+ // The candidates are the fold's own segment starts inside this span, in
257
+ // order. They used to be probed at `start + k*W`, which assumed cuts
258
+ // land on multiples of W; content-defined cuts do not, so that stride
259
+ // tested offsets no segment ever began at and this search silently
260
+ // never fired (test/44 pins it). Still bounded to W candidates, each
261
+ // one O(1) from the sorted cut list before paying for a real
262
+ // canonResolve fold — canonResolve, not resolve()/findBranch, because
263
+ // the gap here is often exactly the kind of equivalence (case, in the
264
+ // live trace) canon exists for, not an exact-content coincidence.
265
+ // A deposit's ROOT is a whole-stream node, and a stream's ends are not
266
+ // content cuts so an embedded occurrence of a trained form reproduces
267
+ // its SEGMENTS (which are offset-free) but never its root. What is
268
+ // being looked for is therefore a suffix of this span that happens to be
269
+ // a whole trained form, and its left edge can only be a cut the fold
270
+ // itself drew. Candidates are taken from the RIGHT, nearest the end
271
+ // first: the form ends where this node ends, so its start is near it.
272
+ // Left-to-right was wrong — in test/44 the target's start is the 6th cut
273
+ // from the end but the 12th from the beginning.
274
+ //
275
+ // `starts` is still filling (this runs inside the post-order walk), but
276
+ // post-order guarantees every chunk BELOW this span is already in it —
277
+ // exactly the set wanted. Bounded to chainReach(W) candidates, the same
278
+ // reach the canonical pass trusts, so cost stays O(reach · span).
279
+ let hi = startList.length; // first index past the last usable cut
280
+ let lo = 0;
281
+ while (lo < hi) {
282
+ const mid = (lo + hi) >> 1;
283
+ if (startList[mid] < end - 1) lo = mid + 1;
284
+ else hi = mid;
285
+ }
286
+ const reach = chainReach(W);
287
+ for (let k = 0; k < reach; k++) {
288
+ const p = startList[lo - 1 - k];
289
+ if (p === undefined || p <= start) break;
229
290
  const cid = canonResolve(ctx, bytes.subarray(p, end));
230
291
  if (cid !== null) emit(p, end, cid);
231
292
  }
@@ -233,6 +294,7 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
233
294
  }
234
295
  if (isChunk(n)) {
235
296
  starts.add(start);
297
+ if (startList[startList.length - 1] !== start) startList.push(start);
236
298
  // Try every sub-span within this leaf-parent.
237
299
  const leafOffsets: number[] = [];
238
300
  let off = start;
@@ -304,6 +366,14 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
304
366
  // hub-scale atoms, chained at an offset nothing in the query's own fold
305
367
  // selected). Chains that start ON a boundary carry the fold's own
306
368
  // evidence instead and are exempt.
369
+ //
370
+ // NOTE (2026-07-24): that last sentence is FALSE — `starts` is exactly
371
+ // {0, W, 2W, …} (riverFold groups fixed-arity), so the exemption is
372
+ // arithmetic, not evidence. Removing it wholesale was measured and
373
+ // REVERTED: it also drops legitimate multi-byte chains (the 12-byte
374
+ // "Eiffel Tower" site vanished with it). The premise is wrong but the
375
+ // trust it stood in for is real; a replacement signal is still open work.
376
+ // See bench/README.md.
307
377
  const tryChain = (
308
378
  p: number,
309
379
  maxIds: number,
@@ -32,6 +32,50 @@ interface StructCache {
32
32
  }
33
33
  const structCaches = new WeakMap<object, StructCache>();
34
34
 
35
+ // ── The shared ancestor-reach memo ──────────────────────────────────────
36
+ //
37
+ // `edgeAncestors` is a pure function of (node, N) over a read-only store —
38
+ // asking never writes — so its result is reusable for as long as the store
39
+ // is not written to. There used to be TWO memos and they never met: the
40
+ // climb built a private one per call (computeAttention), while
41
+ // `Precomputed.reachMemo` — documented as "one response-scoped memo serves
42
+ // every mechanism that prices commonality" — was reached only by confluence.
43
+ // The climb is by far the biggest consumer.
44
+ //
45
+ // Keyed off `ctx.climbMemo`'s OBJECT IDENTITY, exactly like the struct cache
46
+ // above, which buys the right lifetime for free: a plain respond() has a
47
+ // fresh climbMemo, so the memo is response-scoped; a conversation turn has
48
+ // the conversation's persistent one, so it is conversation-scoped. That
49
+ // matters — the stable-prefix fold makes each turn's subtree independent of
50
+ // what follows, so 59–70% of a later turn's climb regions are byte-identical
51
+ // repeats of an earlier turn's (measured on a 4-turn session), and every one
52
+ // of them used to re-climb from cold.
53
+ //
54
+ // Budgeted, not unbounded (AGENTS §2.12): past the cap the whole map is
55
+ // dropped and re-derived, costing a cold climb and never a wrong answer.
56
+ const REACH_MEMO_MAX = 100_000;
57
+ const reachCaches = new WeakMap<object, Map<number, AncestorReach>>();
58
+
59
+ /** The reach memo this response should use — see the note above.
60
+ *
61
+ * A TRACED response always gets a fresh, empty one. `AncestorReach`'s
62
+ * `visited`/`maxDepth`/`saturation` fields are populated only when a trace
63
+ * is attached, so an entry deposited by an untraced earlier turn would
64
+ * silently black out the reach detail of a later traced one; and the trace's
65
+ * reach payload is serialised by ITERATING this map, which must therefore
66
+ * hold what THIS climb consulted, not the whole conversation's history.
67
+ * Consistent with AGENTS §2.11: a traced response is a different machine —
68
+ * never benchmark with a trace attached. */
69
+ export function sharedReachMemo(
70
+ ctx: MindContext,
71
+ ): Map<number, AncestorReach> {
72
+ if (ctx.trace !== null || ctx.climbMemo === null) return new Map();
73
+ let m = reachCaches.get(ctx.climbMemo);
74
+ if (m === undefined) reachCaches.set(ctx.climbMemo, m = new Map());
75
+ else if (m.size >= REACH_MEMO_MAX) m.clear();
76
+ return m;
77
+ }
78
+
35
79
  function getStructCache(ctx: MindContext): StructCache | null {
36
80
  if (ctx.climbMemo === null) return null;
37
81
  let c = structCaches.get(ctx.climbMemo);
@@ -134,7 +178,7 @@ export function edgeAncestors(
134
178
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
135
179
  // climbs exactly as before, so single-letter facts keep working.
136
180
  if (id < 0 && atomIsHub(ctx, contextCount)) {
137
- const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
181
+ const bound0 = boundFor(contextCount);
138
182
  const reach: AncestorReach = {
139
183
  roots: [],
140
184
  contextsReached: 0,
@@ -156,7 +200,7 @@ export function edgeAncestors(
156
200
  return reach;
157
201
  }
158
202
 
159
- const bound = Math.ceil(Math.sqrt(contextCount));
203
+ const bound = boundFor(contextCount);
160
204
  const roots: number[] = [];
161
205
  const seen = new Set<number>([id]);
162
206
  const ctxSeen = new Set<number>();
@@ -213,6 +257,7 @@ export function edgeAncestors(
213
257
  let maxDepth = 0;
214
258
 
215
259
  const visit = (x: number): boolean => {
260
+ if (ctx.meter) ctx.meter.ancestorVisits++;
216
261
  if (depths) {
217
262
  visitedCount++;
218
263
  if (curDepth > maxDepth) maxDepth = curDepth;
@@ -395,8 +440,7 @@ export function atomReach(ctx: MindContext, contextCount: number): number {
395
440
  * atom votes and is recognised exactly as any stored form; above it the
396
441
  * alphabet is scaffolding everywhere and abstains. */
397
442
  export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
398
- return atomReach(ctx, contextCount) >
399
- Math.ceil(Math.sqrt(Math.max(2, contextCount)));
443
+ return atomReach(ctx, contextCount) > boundFor(contextCount);
400
444
  }
401
445
 
402
446
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
@@ -444,7 +488,17 @@ export function corpusN(ctx: MindContext): number {
444
488
  * materialised list. {@link hubCap} is the list-side reading of the same
445
489
  * convention. */
446
490
  export function hubBound(ctx: MindContext): number {
447
- return Math.ceil(Math.sqrt(corpusN(ctx)));
491
+ return boundFor(corpusN(ctx));
492
+ }
493
+
494
+ /** √N for an EXPLICIT context count — the ctx-free reading of {@link
495
+ * hubBound}, for the callers inside this module that are handed a count
496
+ * rather than a context ({@link edgeAncestors}, {@link atomIsHub}). The
497
+ * floor at 2 matches {@link corpusN}'s, so both readings agree for every
498
+ * input: the two used to be spelled out inline, once WITH the floor and
499
+ * once without, in the same function. */
500
+ function boundFor(contextCount: number): number {
501
+ return Math.ceil(Math.sqrt(Math.max(2, contextCount)));
448
502
  }
449
503
 
450
504
  /** Cap a candidate list at the hub bound √N (insertion order) — the ONE