@hviana/sema 0.2.9 → 0.4.0

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 (84) hide show
  1. package/AGENTS.md +71 -5
  2. package/dist/src/derive/src/deduction.d.ts +12 -1
  3. package/dist/src/derive/src/deduction.js +5 -1
  4. package/dist/src/derive/src/index.d.ts +1 -0
  5. package/dist/src/geometry.d.ts +3 -30
  6. package/dist/src/geometry.js +330 -82
  7. package/dist/src/index.d.ts +1 -0
  8. package/dist/src/index.js +1 -0
  9. package/dist/src/meter.d.ts +171 -0
  10. package/dist/src/meter.js +269 -0
  11. package/dist/src/mind/attention.d.ts +5 -4
  12. package/dist/src/mind/attention.js +254 -23
  13. package/dist/src/mind/bridge.d.ts +10 -1
  14. package/dist/src/mind/bridge.js +179 -10
  15. package/dist/src/mind/canonical.d.ts +6 -1
  16. package/dist/src/mind/canonical.js +6 -1
  17. package/dist/src/mind/graph-search.d.ts +9 -0
  18. package/dist/src/mind/graph-search.js +59 -19
  19. package/dist/src/mind/index.d.ts +2 -0
  20. package/dist/src/mind/junction.d.ts +10 -0
  21. package/dist/src/mind/junction.js +14 -0
  22. package/dist/src/mind/learning.d.ts +32 -4
  23. package/dist/src/mind/learning.js +26 -4
  24. package/dist/src/mind/match.d.ts +40 -0
  25. package/dist/src/mind/match.js +125 -1
  26. package/dist/src/mind/mechanisms/cast.js +63 -6
  27. package/dist/src/mind/mechanisms/extraction.d.ts +0 -34
  28. package/dist/src/mind/mechanisms/extraction.js +1 -88
  29. package/dist/src/mind/mechanisms/recall.d.ts +3 -0
  30. package/dist/src/mind/mechanisms/recall.js +77 -14
  31. package/dist/src/mind/mind.d.ts +59 -5
  32. package/dist/src/mind/mind.js +115 -93
  33. package/dist/src/mind/pipeline-mechanism.d.ts +33 -3
  34. package/dist/src/mind/pipeline-mechanism.js +179 -10
  35. package/dist/src/mind/pipeline.d.ts +29 -0
  36. package/dist/src/mind/pipeline.js +79 -21
  37. package/dist/src/mind/primitives.d.ts +11 -15
  38. package/dist/src/mind/primitives.js +47 -28
  39. package/dist/src/mind/reasoning.d.ts +7 -1
  40. package/dist/src/mind/reasoning.js +40 -8
  41. package/dist/src/mind/recognition.js +93 -20
  42. package/dist/src/mind/traverse.d.ts +11 -0
  43. package/dist/src/mind/traverse.js +88 -7
  44. package/dist/src/mind/types.d.ts +39 -5
  45. package/dist/src/store.d.ts +15 -0
  46. package/dist/src/store.js +91 -6
  47. package/package.json +1 -1
  48. package/src/derive/src/deduction.ts +15 -0
  49. package/src/derive/src/index.ts +1 -0
  50. package/src/geometry.ts +350 -122
  51. package/src/index.ts +1 -0
  52. package/src/meter.ts +333 -0
  53. package/src/mind/attention.ts +276 -31
  54. package/src/mind/bridge.ts +187 -10
  55. package/src/mind/canonical.ts +6 -1
  56. package/src/mind/graph-search.ts +60 -21
  57. package/src/mind/index.ts +6 -0
  58. package/src/mind/junction.ts +12 -0
  59. package/src/mind/learning.ts +46 -5
  60. package/src/mind/match.ts +146 -1
  61. package/src/mind/mechanisms/cast.ts +62 -6
  62. package/src/mind/mechanisms/extraction.ts +2 -103
  63. package/src/mind/mechanisms/recall.ts +84 -17
  64. package/src/mind/mind.ts +144 -99
  65. package/src/mind/pipeline-mechanism.ts +203 -13
  66. package/src/mind/pipeline.ts +144 -36
  67. package/src/mind/primitives.ts +49 -33
  68. package/src/mind/reasoning.ts +39 -7
  69. package/src/mind/recognition.ts +89 -19
  70. package/src/mind/traverse.ts +89 -8
  71. package/src/mind/types.ts +39 -5
  72. package/src/store.ts +75 -6
  73. package/test/14-scaling.test.mjs +17 -7
  74. package/test/31-audit.test.mjs +4 -1
  75. package/test/33-multi-candidate.test.mjs +13 -1
  76. package/test/36-already-answered-fusion.test.mjs +10 -3
  77. package/test/46-recognise-multibyte-edge.test.mjs +3 -3
  78. package/test/53-cross-region-probe-instrumentation.test.mjs +36 -6
  79. package/test/54-evidence-k-instrumentation.test.mjs +175 -0
  80. package/test/55-cost-meter.test.mjs +284 -0
  81. package/test/56-bridge-identity-admission.test.mjs +209 -0
  82. package/test/57-fusion-order.test.mjs +104 -0
  83. package/test/58-subquantum-sites.test.mjs +112 -0
  84. package/test/59-fold-invariance.test.mjs +226 -0
@@ -5,7 +5,7 @@
5
5
  import { rItem, rNode } from "./trace.js";
6
6
  import { bytesEqual, indexOf } from "../bytes.js";
7
7
  import { resolve } from "./primitives.js";
8
- import { corpusN } from "./traverse.js";
8
+ import { hubBound } from "./traverse.js";
9
9
  import { follow, haloSiblings, project } from "./match.js";
10
10
  import { joinWithBridge, pivotInto } from "./resonance.js";
11
11
  /** Whether `bytes` is a proper byte-subspan of `query` — already present in
@@ -45,19 +45,19 @@ export async function reason(ctx, query, answer, preConsumed, pre) {
45
45
  // convention every fan-out decision uses (first √N in the relation's own
46
46
  // read order); a pivot suppressed only by a beyond-cap neighbour may now
47
47
  // fire — the same visibility trade chooseNext documents.
48
- const hubBound = Math.ceil(Math.sqrt(corpusN(ctx)));
48
+ const bound = hubBound(ctx);
49
49
  const consumeNode = (id) => {
50
50
  if (id === null)
51
51
  return;
52
52
  consumed.add(id);
53
- for (const p of ctx.store.prevFirst(id, hubBound))
53
+ for (const p of ctx.store.prevFirst(id, bound))
54
54
  consumed.add(p);
55
55
  };
56
56
  const consumeAll = (id) => {
57
57
  if (id === null)
58
58
  return;
59
59
  consumeNode(id);
60
- for (const n of ctx.store.nextFirst(id, hubBound))
60
+ for (const n of ctx.store.nextFirst(id, bound))
61
61
  consumed.add(n);
62
62
  };
63
63
  // Pre-consume whatever the grounding stage already spoke for. The halo
@@ -90,7 +90,7 @@ export async function reason(ctx, query, answer, preConsumed, pre) {
90
90
  // absorbing it would repeat content the grounding stage already spoke
91
91
  // for, so a consumed fixpoint falls through to the pivot step instead.
92
92
  if (curId !== null &&
93
- ctx.store.nextFirst(curId, hubBound).some((n) => !consumed.has(n))) {
93
+ ctx.store.nextFirst(curId, bound).some((n) => !consumed.has(n))) {
94
94
  const fwd = await follow(ctx, curId, qv);
95
95
  const fwdId = fwd !== null ? resolve(ctx, fwd) : null;
96
96
  if (fwd !== null && !bytesEqual(fwd, cur) &&
@@ -133,7 +133,13 @@ export async function fuseAttention(ctx, query, primary, pre,
133
133
  * primary's own source; that assumption is exactly backwards when
134
134
  * primary is unclimbed. Absent or false preserves the original
135
135
  * behaviour exactly. */
136
- unclimbed = false) {
136
+ unclimbed = false,
137
+ /** The query spans `primary`'s own grounding stands on — used ONLY to place
138
+ * primary in the fused reading order (see below). Resolved by the caller,
139
+ * which is the layer that knows how a given grounding records its evidence;
140
+ * fuseAttention just reads a position from it. Empty or absent preserves
141
+ * the original behaviour exactly. */
142
+ primarySpans = []) {
137
143
  // When the answer is structurally drawn from the query itself
138
144
  // (extraction), it already spans all the query's pieces — fusion
139
145
  // would only add noise from unrelated stored contexts. The gate is
@@ -162,8 +168,34 @@ unclimbed = false) {
162
168
  if (forest.length === 0 || (forest.length <= 1 && !lonePromotes)) {
163
169
  return primary;
164
170
  }
171
+ // WHERE THE QUERY ASKED FOR IT. The sort below orders the fused pieces by
172
+ // query position, which is the whole point of the `start` field: a
173
+ // multi-topic answer should read in the order the question posed its
174
+ // topics. Every ROOT carries its own start. `primary` did not — it was
175
+ // given forest[0].start, the FIRST attention root's position, which is
176
+ // primary's own source only when primary happens to come from that root.
177
+ // When it does not, primary is sorted to a position it never occupied.
178
+ //
179
+ // Observed live: "What is the capital of France? And what is 2 + 2?"
180
+ // answered "4The capital city of France is Paris." — the ALU result, whose
181
+ // evidence is the "2 + 2" span near the END of the query, inherited the
182
+ // France root's start of 0 and sorted ahead of the France answer. Both
183
+ // pieces were right; only the order was.
184
+ //
185
+ // primary's own position is the earliest query byte its grounding stands
186
+ // on. `accounted` is the cost-ladder read of that and is authoritative
187
+ // when non-empty; when it is empty the grounding is a pure COMPUTATION,
188
+ // whose evidence is its computed span — the same cost-ladder-vs-coverage
189
+ // distinction think() already draws for the fusion remainder ("`accounted`
190
+ // alone undercounts this ... cover prices its computed spans at near-zero
191
+ // and deliberately leaves them out"), read here for position instead of
192
+ // for coverage. With neither, nothing is known and the old behaviour
193
+ // (forest[0].start) stands.
194
+ const primaryStart = primarySpans.length > 0
195
+ ? primarySpans.reduce((m, [s]) => Math.min(m, s), Infinity)
196
+ : forest[0].start;
165
197
  const pieces = [
166
- { start: forest[0].start, bytes: primary },
198
+ { start: primaryStart, bytes: primary },
167
199
  ];
168
200
  const qv = pre.guide; // once, not per root
169
201
  const rest = lonePromotes ? forest : forest.slice(1);
@@ -237,4 +269,4 @@ unclimbed = false) {
237
269
  }
238
270
  // (resonance.js is already a static dependency above — `bridge` — so the old
239
271
  // dynamic import of pivotInto guarded against a cycle that does not exist.)
240
- import { containsSpan } from "./mechanisms/extraction.js";
272
+ import { containsSpan } from "./match.js";
@@ -47,6 +47,8 @@ export function recognise(ctx, bytes) {
47
47
  const key = latin1Key(bytes);
48
48
  const hit = ctx.recogniseMemo.get(key);
49
49
  if (hit !== undefined) {
50
+ if (ctx.meter)
51
+ ctx.meter.recogniseHits++;
50
52
  ctx.trace?.step("recognise", [rItem(bytes, "query")], hit.sites.map((s) => rItem(bytes.subarray(s.start, s.end), "form", s.payload, [
51
53
  s.start,
52
54
  s.end,
@@ -61,11 +63,20 @@ export function recognise(ctx, bytes) {
61
63
  return recogniseImpl(ctx, bytes);
62
64
  }
63
65
  function recogniseImpl(ctx, bytes) {
66
+ if (ctx.meter) {
67
+ ctx.meter.recognitions++;
68
+ ctx.meter.recognisedBytes += bytes.length;
69
+ }
64
70
  const store = ctx.store;
65
71
  const sites = [];
66
72
  const leaves = [];
67
73
  const splits = new Set();
68
74
  const starts = new Set();
75
+ // The same cuts in ASCENDING order. The post-order walk below visits
76
+ // leaf-parents left to right, so appending as they are added keeps this
77
+ // sorted with no comparison — which is what lets the composite search find
78
+ // its candidates by binary search instead of rescanning the whole set.
79
+ const startList = [];
69
80
  if (bytes.length === 0)
70
81
  return { sites, leaves, splits, starts };
71
82
  // Span-resolve memo for THIS call: the structural pass (sub-runs inside
@@ -105,6 +116,33 @@ function recogniseImpl(ctx, bytes) {
105
116
  const emit = (start, end, id) => {
106
117
  if (id < 0 && atomsAreHubs)
107
118
  return;
119
+ // A SITE MUST SPAN ONE RIVER WINDOW. Below W, byte overlap is chance,
120
+ // not evidence — the principle identityBar already states ("below one
121
+ // river window, byte overlap is chance") and the bridge's attestedQ
122
+ // already applies ("spans shorter than W carry no window of their own").
123
+ // No new constant.
124
+ //
125
+ // This REPLACES the false premise it used to share with fuse() and
126
+ // tryChain: those gates asked "does this offset sit on a fold boundary?"
127
+ // and read the answer from `starts`, which is exactly {0, W, 2W, …}
128
+ // because riverFold groups fixed-arity — arithmetic, not evidence.
129
+ //
130
+ // Measured on the 17.9M-node store, over the sites of 7 probes (1 good,
131
+ // 11 junk by hand-labelling, corrected for whole-query forms):
132
+ // len >= W rejects "hi"(2) "of"(2) "is"(2) "di"(2) "the"(3),
133
+ // admits "Eiffel Tower"(12) and both whole-query forms
134
+ // len >= W-1 admits "the" — W-1 is the write side's straddle
135
+ // neighbour for RETRIEVAL, never a claim about units
136
+ // §2.7 saturation admits 11/11 junk: edgeAncestors on a site node
137
+ // reaches 1..48 contexts, so dominates(ctx, N) needs
138
+ // ctx > 162805 and never fires; every site reads DISC
139
+ // rarity does not separate: "hi" has 1 container, "the" 572
140
+ //
141
+ // A span covering the WHOLE query is exempt: then it is not a fragment of
142
+ // something longer, it is the question ("hi" asked on its own).
143
+ if (atomsAreHubs && end - start < ctx.space.maxGroup &&
144
+ !(start === 0 && end === bytes.length))
145
+ return;
108
146
  const key = start + "," + end + "," + id;
109
147
  if (seen.has(key))
110
148
  return;
@@ -115,6 +153,7 @@ function recogniseImpl(ctx, bytes) {
115
153
  };
116
154
  // ── structural: the query's own perceived tree ──────────────────────
117
155
  starts.add(0);
156
+ startList.push(0);
118
157
  foldTree(ctx, perceive(ctx, bytes), 0, (n, start, end, node) => {
119
158
  if (n.kids === null) {
120
159
  leaves.push({ start, end, bytes: n.leaf ?? new Uint8Array(0), node });
@@ -190,26 +229,50 @@ function recogniseImpl(ctx, bytes) {
190
229
  // "And " prepended to a follow-up turn — not boundary noise, actual
191
230
  // content the injected canonicalizer has no equivalence for) shows
192
231
  // up as a canon-miss too big for the chunk-scale search above: the
193
- // turn is its OWN segment (stable-prefix folded independently see
194
- // mind.ts's _growContext), so it can be turn/segment-scale, not
195
- // chunk-scale. Widening the size bound itself reopens the root-
196
- // scale false-positive this module already fixed once (test/46);
197
- // widening the SEARCH instead trying every position up to W
198
- // chunk-widths from the left edge that the query's OWN fold treats
199
- // as a chunk boundary (`starts`, the same set the canonical pass
200
- // privileges with full chain reach) does not: `starts.has(p)` is
201
- // fold EVIDENCE the query produced on its own (a leaf-parent chunk
202
- // like "And " is visited, and its start added to `starts`, before
203
- // this composite ever runs foldTree is post-order), never a blind
204
- // guess. Bounded to W candidates, each an O(1) set lookup before
205
- // paying for the real canonResolve fold canonResolve, not
206
- // resolve()/findBranch, because the gap here is often exactly the
207
- // kind of equivalence (case, in the live trace) canon exists for,
208
- // not just an exact-content coincidence.
209
- for (let k = 1; k <= W; k++) {
210
- const p = start + k * W;
211
- if (p >= end - 1 || !starts.has(p))
212
- continue;
232
+ // turn is its OWN segment, so it can be turn/segment-scale, not
233
+ // chunk-scale. Widening the size bound itself reopens the root-scale
234
+ // false-positive this module already fixed once (test/46); widening the
235
+ // SEARCH instead does not, because every candidate is a cut the query's
236
+ // OWN fold drew (`starts`, the same set the canonical pass privileges
237
+ // with full chain reach) fold EVIDENCE, never a blind guess.
238
+ //
239
+ // The candidates are the fold's own segment starts inside this span, in
240
+ // order. They used to be probed at `start + k*W`, which assumed cuts
241
+ // land on multiples of W; content-defined cuts do not, so that stride
242
+ // tested offsets no segment ever began at and this search silently
243
+ // never fired (test/44 pins it). Still bounded to W candidates, each
244
+ // one O(1) from the sorted cut list before paying for a real
245
+ // canonResolve fold — canonResolve, not resolve()/findBranch, because
246
+ // the gap here is often exactly the kind of equivalence (case, in the
247
+ // live trace) canon exists for, not an exact-content coincidence.
248
+ // A deposit's ROOT is a whole-stream node, and a stream's ends are not
249
+ // content cuts so an embedded occurrence of a trained form reproduces
250
+ // its SEGMENTS (which are offset-free) but never its root. What is
251
+ // being looked for is therefore a suffix of this span that happens to be
252
+ // a whole trained form, and its left edge can only be a cut the fold
253
+ // itself drew. Candidates are taken from the RIGHT, nearest the end
254
+ // first: the form ends where this node ends, so its start is near it.
255
+ // Left-to-right was wrong — in test/44 the target's start is the 6th cut
256
+ // from the end but the 12th from the beginning.
257
+ //
258
+ // `starts` is still filling (this runs inside the post-order walk), but
259
+ // post-order guarantees every chunk BELOW this span is already in it —
260
+ // exactly the set wanted. Bounded to chainReach(W) candidates, the same
261
+ // reach the canonical pass trusts, so cost stays O(reach · span).
262
+ let hi = startList.length; // first index past the last usable cut
263
+ let lo = 0;
264
+ while (lo < hi) {
265
+ const mid = (lo + hi) >> 1;
266
+ if (startList[mid] < end - 1)
267
+ lo = mid + 1;
268
+ else
269
+ hi = mid;
270
+ }
271
+ const reach = chainReach(W);
272
+ for (let k = 0; k < reach; k++) {
273
+ const p = startList[lo - 1 - k];
274
+ if (p === undefined || p <= start)
275
+ break;
213
276
  const cid = canonResolve(ctx, bytes.subarray(p, end));
214
277
  if (cid !== null)
215
278
  emit(p, end, cid);
@@ -218,6 +281,8 @@ function recogniseImpl(ctx, bytes) {
218
281
  }
219
282
  if (isChunk(n)) {
220
283
  starts.add(start);
284
+ if (startList[startList.length - 1] !== start)
285
+ startList.push(start);
221
286
  // Try every sub-span within this leaf-parent.
222
287
  const leafOffsets = [];
223
288
  let off = start;
@@ -290,6 +355,14 @@ function recogniseImpl(ctx, bytes) {
290
355
  // hub-scale atoms, chained at an offset nothing in the query's own fold
291
356
  // selected). Chains that start ON a boundary carry the fold's own
292
357
  // evidence instead and are exempt.
358
+ //
359
+ // NOTE (2026-07-24): that last sentence is FALSE — `starts` is exactly
360
+ // {0, W, 2W, …} (riverFold groups fixed-arity), so the exemption is
361
+ // arithmetic, not evidence. Removing it wholesale was measured and
362
+ // REVERTED: it also drops legitimate multi-byte chains (the 12-byte
363
+ // "Eiffel Tower" site vanished with it). The premise is wrong but the
364
+ // trust it stood in for is real; a replacement signal is still open work.
365
+ // See bench/README.md.
293
366
  const tryChain = (p, maxIds, boundary) => {
294
367
  const first = leafFrom(p);
295
368
  if (!first)
@@ -1,5 +1,16 @@
1
1
  import { Vec } from "../vec.js";
2
2
  import type { AncestorReach, MindContext } from "./types.js";
3
+ /** The reach memo this response should use — see the note above.
4
+ *
5
+ * A TRACED response always gets a fresh, empty one. `AncestorReach`'s
6
+ * `visited`/`maxDepth`/`saturation` fields are populated only when a trace
7
+ * is attached, so an entry deposited by an untraced earlier turn would
8
+ * silently black out the reach detail of a later traced one; and the trace's
9
+ * reach payload is serialised by ITERATING this map, which must therefore
10
+ * hold what THIS climb consulted, not the whole conversation's history.
11
+ * Consistent with AGENTS §2.11: a traced response is a different machine —
12
+ * never benchmark with a trace attached. */
13
+ export declare function sharedReachMemo(ctx: MindContext): Map<number, AncestorReach>;
3
14
  /** Climb the structural DAG from a node to its edge-bearing ancestor contexts.
4
15
  * Ascent stops at hub nodes (parents > √N) — their reach is non-discriminative.
5
16
  * When the start node has no structural parents, climbs from containment parents
@@ -8,6 +8,49 @@
8
8
  import { cosine } from "../vec.js";
9
9
  import { gistOf, read } from "./primitives.js";
10
10
  const structCaches = new WeakMap();
11
+ // ── The shared ancestor-reach memo ──────────────────────────────────────
12
+ //
13
+ // `edgeAncestors` is a pure function of (node, N) over a read-only store —
14
+ // asking never writes — so its result is reusable for as long as the store
15
+ // is not written to. There used to be TWO memos and they never met: the
16
+ // climb built a private one per call (computeAttention), while
17
+ // `Precomputed.reachMemo` — documented as "one response-scoped memo serves
18
+ // every mechanism that prices commonality" — was reached only by confluence.
19
+ // The climb is by far the biggest consumer.
20
+ //
21
+ // Keyed off `ctx.climbMemo`'s OBJECT IDENTITY, exactly like the struct cache
22
+ // above, which buys the right lifetime for free: a plain respond() has a
23
+ // fresh climbMemo, so the memo is response-scoped; a conversation turn has
24
+ // the conversation's persistent one, so it is conversation-scoped. That
25
+ // matters — the stable-prefix fold makes each turn's subtree independent of
26
+ // what follows, so 59–70% of a later turn's climb regions are byte-identical
27
+ // repeats of an earlier turn's (measured on a 4-turn session), and every one
28
+ // of them used to re-climb from cold.
29
+ //
30
+ // Budgeted, not unbounded (AGENTS §2.12): past the cap the whole map is
31
+ // dropped and re-derived, costing a cold climb and never a wrong answer.
32
+ const REACH_MEMO_MAX = 100_000;
33
+ const reachCaches = new WeakMap();
34
+ /** The reach memo this response should use — see the note above.
35
+ *
36
+ * A TRACED response always gets a fresh, empty one. `AncestorReach`'s
37
+ * `visited`/`maxDepth`/`saturation` fields are populated only when a trace
38
+ * is attached, so an entry deposited by an untraced earlier turn would
39
+ * silently black out the reach detail of a later traced one; and the trace's
40
+ * reach payload is serialised by ITERATING this map, which must therefore
41
+ * hold what THIS climb consulted, not the whole conversation's history.
42
+ * Consistent with AGENTS §2.11: a traced response is a different machine —
43
+ * never benchmark with a trace attached. */
44
+ export function sharedReachMemo(ctx) {
45
+ if (ctx.trace !== null || ctx.climbMemo === null)
46
+ return new Map();
47
+ let m = reachCaches.get(ctx.climbMemo);
48
+ if (m === undefined)
49
+ reachCaches.set(ctx.climbMemo, m = new Map());
50
+ else if (m.size >= REACH_MEMO_MAX)
51
+ m.clear();
52
+ return m;
53
+ }
11
54
  function getStructCache(ctx) {
12
55
  if (ctx.climbMemo === null)
13
56
  return null;
@@ -89,7 +132,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
89
132
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
90
133
  // climbs exactly as before, so single-letter facts keep working.
91
134
  if (id < 0 && atomIsHub(ctx, contextCount)) {
92
- const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
135
+ const bound0 = boundFor(contextCount);
93
136
  const reach = {
94
137
  roots: [],
95
138
  contextsReached: 0,
@@ -102,13 +145,15 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
102
145
  observed: atomReach(ctx, contextCount),
103
146
  limit: bound0,
104
147
  },
148
+ visited: 0,
149
+ maxDepth: 0,
105
150
  }
106
151
  : {}),
107
152
  };
108
153
  memo?.set(id, reach);
109
154
  return reach;
110
155
  }
111
- const bound = Math.ceil(Math.sqrt(contextCount));
156
+ const bound = boundFor(contextCount);
112
157
  const roots = [];
113
158
  const seen = new Set([id]);
114
159
  const ctxSeen = new Set();
@@ -151,7 +196,22 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
151
196
  // ~20K distinct nodes per climb family, >95% unique — not memoisable)
152
197
  // while the context account never decided.
153
198
  let lateral = 0;
199
+ // CLIMB READ-OUT (pure instrumentation, same contract as satStop): the
200
+ // parallel `depths` stack mirrors every push/pop of `stack`, so a node's
201
+ // ascent distance is known at its pop. Allocated only when a trace is
202
+ // requested; the climb itself never reads any of these back.
203
+ const depths = ctx.trace ? [] : null;
204
+ let curDepth = 0;
205
+ let visitedCount = 0;
206
+ let maxDepth = 0;
154
207
  const visit = (x) => {
208
+ if (ctx.meter)
209
+ ctx.meter.ancestorVisits++;
210
+ if (depths) {
211
+ visitedCount++;
212
+ if (curDepth > maxDepth)
213
+ maxDepth = curDepth;
214
+ }
155
215
  const hasNx = cachedHasNext(ctx, x, structCache);
156
216
  const pc = cachedPrevCount(ctx, x, structCache);
157
217
  if (hasNx || pc > 0) {
@@ -203,6 +263,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
203
263
  if (!seen.has(p)) {
204
264
  seen.add(p);
205
265
  stack.push(p);
266
+ depths?.push(curDepth + 1);
206
267
  fresh++;
207
268
  }
208
269
  }
@@ -225,8 +286,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
225
286
  };
226
287
  const stack = [];
227
288
  const containment = !cachedHasParents(ctx, id, structCache);
228
- if (!containment)
289
+ if (!containment) {
229
290
  stack.push(id);
291
+ depths?.push(0);
292
+ }
230
293
  // The containment seed is STREAMED in pages of √N: a distinctive window's
231
294
  // containers (which converge on one or two contexts, however many chunks
232
295
  // of one deposit repeat it) are walked IN FULL — exact — while a common
@@ -247,17 +310,22 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
247
310
  if (!seen.has(c)) {
248
311
  seen.add(c);
249
312
  stack.push(c);
313
+ depths?.push(1);
250
314
  }
251
315
  }
252
316
  if (stack.length === 0) {
253
- if (containerOff === 0)
317
+ if (containerOff === 0) {
254
318
  stack.push(id); // no containers at all
319
+ depths?.push(0);
320
+ }
255
321
  else
256
322
  break;
257
323
  }
258
324
  }
259
325
  while (stack.length > 0) {
260
326
  let x = stack.pop();
327
+ if (depths)
328
+ curDepth = depths.pop();
261
329
  // TRANSPARENT-CHAIN HOP: a node with no edges in or out and exactly one
262
330
  // parent contributes nothing here — no root, no context, no lateral
263
331
  // entry — so the run to its first non-transparent ancestor is skipped
@@ -277,6 +345,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
277
345
  if (dup)
278
346
  continue;
279
347
  x = top;
348
+ // The chain's interior hops are part of the terminal's ascent
349
+ // distance — count them exactly as a node-at-a-time ascent would.
350
+ if (depths)
351
+ curDepth += run.length - 1;
280
352
  }
281
353
  if (!visit(x)) {
282
354
  saturated = true;
@@ -289,6 +361,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
289
361
  contextsReached: ctxSeen.size,
290
362
  saturated,
291
363
  ...(saturated && satStop ? { saturation: satStop } : {}),
364
+ ...(depths ? { visited: visitedCount, maxDepth } : {}),
292
365
  };
293
366
  memo?.set(id, reach);
294
367
  return reach;
@@ -316,8 +389,7 @@ export function atomReach(ctx, contextCount) {
316
389
  * atom votes and is recognised exactly as any stored form; above it the
317
390
  * alphabet is scaffolding everywhere and abstains. */
318
391
  export function atomIsHub(ctx, contextCount) {
319
- return atomReach(ctx, contextCount) >
320
- Math.ceil(Math.sqrt(Math.max(2, contextCount)));
392
+ return atomReach(ctx, contextCount) > boundFor(contextCount);
321
393
  }
322
394
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
323
395
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
@@ -358,7 +430,16 @@ export function corpusN(ctx) {
358
430
  * materialised list. {@link hubCap} is the list-side reading of the same
359
431
  * convention. */
360
432
  export function hubBound(ctx) {
361
- return Math.ceil(Math.sqrt(corpusN(ctx)));
433
+ return boundFor(corpusN(ctx));
434
+ }
435
+ /** √N for an EXPLICIT context count — the ctx-free reading of {@link
436
+ * hubBound}, for the callers inside this module that are handed a count
437
+ * rather than a context ({@link edgeAncestors}, {@link atomIsHub}). The
438
+ * floor at 2 matches {@link corpusN}'s, so both readings agree for every
439
+ * input: the two used to be spelled out inline, once WITH the floor and
440
+ * once without, in the same function. */
441
+ function boundFor(contextCount) {
442
+ return Math.ceil(Math.sqrt(Math.max(2, contextCount)));
362
443
  }
363
444
  /** Cap a candidate list at the hub bound √N (insertion order) — the ONE
364
445
  * fan-out convention every walk and disambiguation uses (see HOW_IT_WORKS
@@ -4,17 +4,16 @@ import type { BoundedMap, Store } from "../store.js";
4
4
  import type { Space } from "../sema.js";
5
5
  import type { Alphabet } from "../alphabet.js";
6
6
  import type { MindConfig } from "../config.js";
7
+ import type { Meter } from "../meter.js";
7
8
  import type { GraphSearch, Leaf, Seg, Site } from "./graph-search.js";
8
9
  import type { Rationale } from "./rationale.js";
9
- import type { FoldPyramid, Grid, StableFold } from "../geometry.js";
10
+ import type { Grid, StableFold } from "../geometry.js";
10
11
  /** One {@link MindContext._depositTrees} entry — see that field's doc. */
11
12
  export interface DepositCacheEntry {
12
13
  /** Turn boundaries accumulated over this content's deposit chain —
13
14
  * strictly increasing proper offsets, each a previously-deposited
14
15
  * whole-context length. Empty for a first-seen (single-turn) input. */
15
16
  boundaries: number[];
16
- /** Plain-fold pyramid (first-seen inputs only). */
17
- pyramid?: FoldPyramid;
18
17
  /** Stable-prefix segment folds (grown-context inputs only). */
19
18
  stable?: StableFold;
20
19
  /** The continuation bytes this ctxInput was paired with in ingestPair, if
@@ -29,6 +28,10 @@ export type Input = string | Uint8Array | Grid | Grid[];
29
28
  /** The host capabilities GraphSearch consults during a cover. MindContext
30
29
  * extends this so the Mind can pass itself as the host. */
31
30
  export interface GraphSearchHost {
31
+ /** Work accumulator, or null/absent when nothing is profiling — see
32
+ * src/meter.ts. Declared here (not only on MindContext) so the graph
33
+ * search can report its chart effort without importing mind code. */
34
+ readonly meter?: Meter | null;
32
35
  resolve(bytes: Uint8Array): number | null;
33
36
  recogniseSpan?(bytes: Uint8Array): {
34
37
  sites: ReadonlyArray<Site>;
@@ -132,6 +135,19 @@ export interface RegionVote {
132
135
  * to "one region" just because it collapsed to one pooled axiom.
133
136
  * Defaults to 1 when absent. */
134
137
  absorbed?: number;
138
+ /** The SEPARATE query places this vote's evidence occupies, when that is
139
+ * more than the one contiguous run [start, end]. A cross-region junction
140
+ * vote is pooled as a single synthetic region spanning its endpoints and
141
+ * the gap between them, so `[start, end]` reads as ONE place — yet the
142
+ * vote exists precisely because two non-adjacent regions each voted and
143
+ * only their conjunction resolved. Cluster counting (Attention.clusters)
144
+ * asks "how many separate places in the query corroborate this?", and
145
+ * answering it from the merged span makes every joint binding look like a
146
+ * single local neighbourhood; fusion's dispersion gate then drops it
147
+ * unless it also explains most of the whole query, which a binding inside
148
+ * a MULTI-topic query structurally cannot. Absent for an ordinary
149
+ * per-region vote, where the merged span already is the truth. */
150
+ parts?: readonly (readonly [number, number])[];
135
151
  }
136
152
  /** The structural gate that first decided an {@link edgeAncestors} climb was
137
153
  * saturated (an abstention, not a discriminative conclusion) — pure
@@ -156,6 +172,17 @@ export interface AncestorReach {
156
172
  * a non-saturated reach, and absent (even when saturated) when no trace
157
173
  * was requested — instrumentation must not allocate when tracing is off. */
158
174
  saturation?: SaturationStop;
175
+ /** The number of nodes the climb actually PROCESSED (popped and examined
176
+ * by its visit step; a transparent chain counts as its one terminal).
177
+ * Present only when a trace was requested — same contract as
178
+ * {@link saturation}: instrumentation must not allocate when tracing is
179
+ * off. Purely a read-out; the climb never consults it. */
180
+ visited?: number;
181
+ /** The maximum structural ascent distance (in parent/containment hops,
182
+ * transparent-chain interiors counted) from the start node among the
183
+ * processed nodes. Present only when a trace was requested — see
184
+ * {@link visited}. */
185
+ maxDepth?: number;
159
186
  }
160
187
  /** Saturated-interval information for the noise-drop gate. */
161
188
  export interface SaturationInfo {
@@ -179,6 +206,12 @@ export type AItem = {
179
206
  };
180
207
  export interface MindContext extends GraphSearchHost {
181
208
  store: Store;
209
+ /** The work accumulator for the inference call in flight, or null when
210
+ * nothing is profiling — see src/meter.ts. WRITE-ONLY from the engine's
211
+ * point of view: no inference decision may read a counter, or the
212
+ * determinism contract (AGENTS §2.1) is gone. Every call site is
213
+ * `ctx.meter?.x++`, so an unprofiled response allocates nothing. */
214
+ meter: Meter | null;
182
215
  space: Space;
183
216
  alphabet: Alphabet;
184
217
  cfg: MindConfig;
@@ -239,8 +272,9 @@ export interface MindContext extends GraphSearchHost {
239
272
  * folds with the SAME stable-prefix fold query-time perception uses
240
273
  * (structural train/inference agreement, load-bearing for recall),
241
274
  * reusing every already-folded segment via `stable` (see StableFold) —
242
- * O(turn) per deposit instead of O(context). A first-seen input keeps
243
- * the plain fold and caches its `pyramid` (see FoldPyramid). Purely a
275
+ * O(turn) per deposit instead of O(context). A first-seen input takes the
276
+ * same fold with no boundaries at all, and caches the segments it produced
277
+ * so a later turn of the same conversation reuses them. Purely a
244
278
  * performance cache for the FOLD STATE; the boundaries are semantic but
245
279
  * derived only from the deposit sequence itself (an evicted chain falls
246
280
  * back to plain-fold behavior, exactly the pre-boundary shape). */
@@ -1,5 +1,6 @@
1
1
  import { Vec } from "./vec.js";
2
2
  import { type StoreConfig } from "./config.js";
3
+ import type { Meter } from "./meter.js";
3
4
  /** A node id: a dense, non-negative integer assigned in creation order. */
4
5
  export type NodeId = number;
5
6
  /** A node in the graph. Exactly one of `leaf` / `kids` is set. */
@@ -62,6 +63,13 @@ export declare class BoundedMap<K, V> {
62
63
  }
63
64
  export interface Store {
64
65
  readonly D: number;
66
+ /** The work accumulator for the inference call in flight, or null. The
67
+ * Mind attaches one per profiled response and detaches it after (see
68
+ * src/meter.ts). A store MUST only ever write to it — no read may reach
69
+ * a decision, or determinism is gone. An adapter that adds reads of its
70
+ * own should bump the matching counter; one that does not simply reports
71
+ * less, never wrongly. */
72
+ meter: Meter | null;
65
73
  /** Insert a leaf, returning its content id. Idempotent. */
66
74
  putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId>;
67
75
  /** Insert a branch over child ids, returning its content id. Idempotent. */
@@ -508,6 +516,10 @@ export declare abstract class AbstractStore implements Store {
508
516
  * outlive their records — the read degrades safely to empty bytes, this
509
517
  * counter is what keeps that degradation observable. */
510
518
  danglingReads: number;
519
+ /** {@link Store.meter} — the per-response work accumulator, or null when
520
+ * nothing is profiling. Every read below bumps it through `?.`, so an
521
+ * unprofiled store pays one null check per read and allocates nothing. */
522
+ meter: Meter | null;
511
523
  bytes(id: NodeId): Uint8Array;
512
524
  /** First `maxLen` bytes of a node. Walks only the leftmost branch,
513
525
  * stopping at `maxLen` — so a 1 MB document root costs the same as a
@@ -517,6 +529,9 @@ export declare abstract class AbstractStore implements Store {
517
529
  * may be shared with the byte cache and with other callers — treat them
518
530
  * as read-only. Mutating one would corrupt every subsequent read. */
519
531
  bytesPrefix(id: NodeId, maxLen: number): Uint8Array;
532
+ /** {@link bytesPrefix}'s recursive body — uncharged; see the metering
533
+ * boundary note there. */
534
+ private _prefix;
520
535
  contentLen(id: NodeId, cap?: number): number;
521
536
  findLeaf(bytes: Uint8Array): NodeId | null;
522
537
  findBranch(kids: NodeId[]): NodeId | null;