@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
@@ -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,
@@ -147,6 +191,8 @@ export function edgeAncestors(
147
191
  observed: atomReach(ctx, contextCount),
148
192
  limit: bound0,
149
193
  },
194
+ visited: 0,
195
+ maxDepth: 0,
150
196
  }
151
197
  : {}),
152
198
  };
@@ -154,7 +200,7 @@ export function edgeAncestors(
154
200
  return reach;
155
201
  }
156
202
 
157
- const bound = Math.ceil(Math.sqrt(contextCount));
203
+ const bound = boundFor(contextCount);
158
204
  const roots: number[] = [];
159
205
  const seen = new Set<number>([id]);
160
206
  const ctxSeen = new Set<number>();
@@ -201,7 +247,21 @@ export function edgeAncestors(
201
247
  // while the context account never decided.
202
248
  let lateral = 0;
203
249
 
250
+ // CLIMB READ-OUT (pure instrumentation, same contract as satStop): the
251
+ // parallel `depths` stack mirrors every push/pop of `stack`, so a node's
252
+ // ascent distance is known at its pop. Allocated only when a trace is
253
+ // requested; the climb itself never reads any of these back.
254
+ const depths: number[] | null = ctx.trace ? [] : null;
255
+ let curDepth = 0;
256
+ let visitedCount = 0;
257
+ let maxDepth = 0;
258
+
204
259
  const visit = (x: number): boolean => {
260
+ if (ctx.meter) ctx.meter.ancestorVisits++;
261
+ if (depths) {
262
+ visitedCount++;
263
+ if (curDepth > maxDepth) maxDepth = curDepth;
264
+ }
205
265
  const hasNx = cachedHasNext(ctx, x, structCache);
206
266
  const pc = cachedPrevCount(ctx, x, structCache);
207
267
  if (hasNx || pc > 0) {
@@ -251,6 +311,7 @@ export function edgeAncestors(
251
311
  if (!seen.has(p)) {
252
312
  seen.add(p);
253
313
  stack.push(p);
314
+ depths?.push(curDepth + 1);
254
315
  fresh++;
255
316
  }
256
317
  }
@@ -274,7 +335,10 @@ export function edgeAncestors(
274
335
 
275
336
  const stack: number[] = [];
276
337
  const containment = !cachedHasParents(ctx, id, structCache);
277
- if (!containment) stack.push(id);
338
+ if (!containment) {
339
+ stack.push(id);
340
+ depths?.push(0);
341
+ }
278
342
 
279
343
  // The containment seed is STREAMED in pages of √N: a distinctive window's
280
344
  // containers (which converge on one or two contexts, however many chunks
@@ -295,15 +359,19 @@ export function edgeAncestors(
295
359
  if (!seen.has(c)) {
296
360
  seen.add(c);
297
361
  stack.push(c);
362
+ depths?.push(1);
298
363
  }
299
364
  }
300
365
  if (stack.length === 0) {
301
- if (containerOff === 0) stack.push(id); // no containers at all
302
- else break;
366
+ if (containerOff === 0) {
367
+ stack.push(id); // no containers at all
368
+ depths?.push(0);
369
+ } else break;
303
370
  }
304
371
  }
305
372
  while (stack.length > 0) {
306
373
  let x = stack.pop()!;
374
+ if (depths) curDepth = depths.pop()!;
307
375
  // TRANSPARENT-CHAIN HOP: a node with no edges in or out and exactly one
308
376
  // parent contributes nothing here — no root, no context, no lateral
309
377
  // entry — so the run to its first non-transparent ancestor is skipped
@@ -321,6 +389,9 @@ export function edgeAncestors(
321
389
  for (let i = 1; i < run.length; i++) seen.add(run[i]);
322
390
  if (dup) continue;
323
391
  x = top;
392
+ // The chain's interior hops are part of the terminal's ascent
393
+ // distance — count them exactly as a node-at-a-time ascent would.
394
+ if (depths) curDepth += run.length - 1;
324
395
  }
325
396
  if (!visit(x)) {
326
397
  saturated = true;
@@ -334,6 +405,7 @@ export function edgeAncestors(
334
405
  contextsReached: ctxSeen.size,
335
406
  saturated,
336
407
  ...(saturated && satStop ? { saturation: satStop } : {}),
408
+ ...(depths ? { visited: visitedCount, maxDepth } : {}),
337
409
  };
338
410
  memo?.set(id, reach);
339
411
  return reach;
@@ -368,8 +440,7 @@ export function atomReach(ctx: MindContext, contextCount: number): number {
368
440
  * atom votes and is recognised exactly as any stored form; above it the
369
441
  * alphabet is scaffolding everywhere and abstains. */
370
442
  export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
371
- return atomReach(ctx, contextCount) >
372
- Math.ceil(Math.sqrt(Math.max(2, contextCount)));
443
+ return atomReach(ctx, contextCount) > boundFor(contextCount);
373
444
  }
374
445
 
375
446
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
@@ -417,7 +488,17 @@ export function corpusN(ctx: MindContext): number {
417
488
  * materialised list. {@link hubCap} is the list-side reading of the same
418
489
  * convention. */
419
490
  export function hubBound(ctx: MindContext): number {
420
- 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)));
421
502
  }
422
503
 
423
504
  /** Cap a candidate list at the hub bound √N (insertion order) — the ONE
package/src/mind/types.ts CHANGED
@@ -9,6 +9,7 @@ import type { BoundedMap, Store } from "../store.js";
9
9
  import type { Space } from "../sema.js";
10
10
  import type { Alphabet } from "../alphabet.js";
11
11
  import type { MindConfig } from "../config.js";
12
+ import type { Meter } from "../meter.js";
12
13
  import type {
13
14
  ComputedResult,
14
15
  DerivationItem,
@@ -19,7 +20,7 @@ import type {
19
20
  Site,
20
21
  } from "./graph-search.js";
21
22
  import type { Rationale } from "./rationale.js";
22
- import type { FoldPyramid, Grid, StableFold } from "../geometry.js";
23
+ import type { Grid, StableFold } from "../geometry.js";
23
24
 
24
25
  /** One {@link MindContext._depositTrees} entry — see that field's doc. */
25
26
  export interface DepositCacheEntry {
@@ -27,8 +28,6 @@ export interface DepositCacheEntry {
27
28
  * strictly increasing proper offsets, each a previously-deposited
28
29
  * whole-context length. Empty for a first-seen (single-turn) input. */
29
30
  boundaries: number[];
30
- /** Plain-fold pyramid (first-seen inputs only). */
31
- pyramid?: FoldPyramid;
32
31
  /** Stable-prefix segment folds (grown-context inputs only). */
33
32
  stable?: StableFold;
34
33
  /** The continuation bytes this ctxInput was paired with in ingestPair, if
@@ -59,6 +58,10 @@ export type Input = string | Uint8Array | Grid | Grid[];
59
58
  /** The host capabilities GraphSearch consults during a cover. MindContext
60
59
  * extends this so the Mind can pass itself as the host. */
61
60
  export interface GraphSearchHost {
61
+ /** Work accumulator, or null/absent when nothing is profiling — see
62
+ * src/meter.ts. Declared here (not only on MindContext) so the graph
63
+ * search can report its chart effort without importing mind code. */
64
+ readonly meter?: Meter | null;
62
65
  resolve(bytes: Uint8Array): number | null;
63
66
  recogniseSpan?(bytes: Uint8Array): {
64
67
  sites: ReadonlyArray<Site>;
@@ -173,6 +176,19 @@ export interface RegionVote {
173
176
  * to "one region" just because it collapsed to one pooled axiom.
174
177
  * Defaults to 1 when absent. */
175
178
  absorbed?: number;
179
+ /** The SEPARATE query places this vote's evidence occupies, when that is
180
+ * more than the one contiguous run [start, end]. A cross-region junction
181
+ * vote is pooled as a single synthetic region spanning its endpoints and
182
+ * the gap between them, so `[start, end]` reads as ONE place — yet the
183
+ * vote exists precisely because two non-adjacent regions each voted and
184
+ * only their conjunction resolved. Cluster counting (Attention.clusters)
185
+ * asks "how many separate places in the query corroborate this?", and
186
+ * answering it from the merged span makes every joint binding look like a
187
+ * single local neighbourhood; fusion's dispersion gate then drops it
188
+ * unless it also explains most of the whole query, which a binding inside
189
+ * a MULTI-topic query structurally cannot. Absent for an ordinary
190
+ * per-region vote, where the merged span already is the truth. */
191
+ parts?: readonly (readonly [number, number])[];
176
192
  }
177
193
 
178
194
  /** The structural gate that first decided an {@link edgeAncestors} climb was
@@ -205,6 +221,17 @@ export interface AncestorReach {
205
221
  * a non-saturated reach, and absent (even when saturated) when no trace
206
222
  * was requested — instrumentation must not allocate when tracing is off. */
207
223
  saturation?: SaturationStop;
224
+ /** The number of nodes the climb actually PROCESSED (popped and examined
225
+ * by its visit step; a transparent chain counts as its one terminal).
226
+ * Present only when a trace was requested — same contract as
227
+ * {@link saturation}: instrumentation must not allocate when tracing is
228
+ * off. Purely a read-out; the climb never consults it. */
229
+ visited?: number;
230
+ /** The maximum structural ascent distance (in parent/containment hops,
231
+ * transparent-chain interiors counted) from the start node among the
232
+ * processed nodes. Present only when a trace was requested — see
233
+ * {@link visited}. */
234
+ maxDepth?: number;
208
235
  }
209
236
 
210
237
  /** Saturated-interval information for the noise-drop gate. */
@@ -226,6 +253,12 @@ export type AItem =
226
253
 
227
254
  export interface MindContext extends GraphSearchHost {
228
255
  store: Store;
256
+ /** The work accumulator for the inference call in flight, or null when
257
+ * nothing is profiling — see src/meter.ts. WRITE-ONLY from the engine's
258
+ * point of view: no inference decision may read a counter, or the
259
+ * determinism contract (AGENTS §2.1) is gone. Every call site is
260
+ * `ctx.meter?.x++`, so an unprofiled response allocates nothing. */
261
+ meter: Meter | null;
229
262
  space: Space;
230
263
  alphabet: Alphabet;
231
264
  cfg: MindConfig;
@@ -283,8 +316,9 @@ export interface MindContext extends GraphSearchHost {
283
316
  * folds with the SAME stable-prefix fold query-time perception uses
284
317
  * (structural train/inference agreement, load-bearing for recall),
285
318
  * reusing every already-folded segment via `stable` (see StableFold) —
286
- * O(turn) per deposit instead of O(context). A first-seen input keeps
287
- * the plain fold and caches its `pyramid` (see FoldPyramid). Purely a
319
+ * O(turn) per deposit instead of O(context). A first-seen input takes the
320
+ * same fold with no boundaries at all, and caches the segments it produced
321
+ * so a later turn of the same conversation reuses them. Purely a
288
322
  * performance cache for the FOLD STATE; the boundaries are semantic but
289
323
  * derived only from the deposit sequence itself (an evicted chain falls
290
324
  * back to plain-fold behavior, exactly the pre-boundary shape). */
package/src/store.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  import { addInto, copy, dot, normalize, Vec } from "./vec.js";
32
32
  import { DEFAULT_CONFIG, type StoreConfig } from "./config.js";
33
33
  import { identityBar } from "./geometry.js";
34
+ import type { Meter } from "./meter.js";
34
35
 
35
36
  /** A node id: a dense, non-negative integer assigned in creation order. */
36
37
  export type NodeId = number;
@@ -226,6 +227,14 @@ export class BoundedMap<K, V> {
226
227
  export interface Store {
227
228
  readonly D: number;
228
229
 
230
+ /** The work accumulator for the inference call in flight, or null. The
231
+ * Mind attaches one per profiled response and detaches it after (see
232
+ * src/meter.ts). A store MUST only ever write to it — no read may reach
233
+ * a decision, or determinism is gone. An adapter that adds reads of its
234
+ * own should bump the matching counter; one that does not simply reports
235
+ * less, never wrongly. */
236
+ meter: Meter | null;
237
+
229
238
  // ── the DAG (hash-consed) ──────────────────────────────────────────────
230
239
  /** Insert a leaf, returning its content id. Idempotent. */
231
240
  putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId>;
@@ -1010,6 +1019,7 @@ export abstract class AbstractStore implements Store {
1010
1019
  // ── DAG traversal ──────────────────────────────────────────────────────
1011
1020
 
1012
1021
  get(id: NodeId): NodeRec | null {
1022
+ if (this.meter) this.meter.nodeRecords++;
1013
1023
  // Byte leaves are implicit — fabricate from the id.
1014
1024
  if (id < 0) {
1015
1025
  return { id, leaf: new Uint8Array([-(id + 1)]), kids: null };
@@ -1030,7 +1040,20 @@ export abstract class AbstractStore implements Store {
1030
1040
  * counter is what keeps that degradation observable. */
1031
1041
  danglingReads = 0;
1032
1042
 
1043
+ /** {@link Store.meter} — the per-response work accumulator, or null when
1044
+ * nothing is profiling. Every read below bumps it through `?.`, so an
1045
+ * unprofiled store pays one null check per read and allocates nothing. */
1046
+ meter: Meter | null = null;
1047
+
1033
1048
  bytes(id: NodeId): Uint8Array {
1049
+ if (this.meter) {
1050
+ this.meter.byteReads++;
1051
+ // Charged BEFORE the fast path returns, and from the cache entry when
1052
+ // there is one: the volume a caller pulled through is the cost signal
1053
+ // (an unbounded read is unbounded whether or not it was cached).
1054
+ const c = this._bytesCache.get(id);
1055
+ if (c) this.meter.bytesRead += c.length;
1056
+ }
1034
1057
  // Fast path.
1035
1058
  const hit = this._bytesCache.get(id);
1036
1059
  if (hit) return hit;
@@ -1082,7 +1105,9 @@ export abstract class AbstractStore implements Store {
1082
1105
  cache.set(nid, out);
1083
1106
  }
1084
1107
 
1085
- return cache.get(id) ?? _ZERO;
1108
+ const out = cache.get(id) ?? _ZERO;
1109
+ if (this.meter) this.meter.bytesRead += out.length;
1110
+ return out;
1086
1111
  }
1087
1112
 
1088
1113
  /** First `maxLen` bytes of a node. Walks only the leftmost branch,
@@ -1093,14 +1118,27 @@ export abstract class AbstractStore implements Store {
1093
1118
  * may be shared with the byte cache and with other callers — treat them
1094
1119
  * as read-only. Mutating one would corrupt every subsequent read. */
1095
1120
  bytesPrefix(id: NodeId, maxLen: number): Uint8Array {
1096
- if (maxLen <= 0) return _ZERO;
1097
-
1098
1121
  // A FULL read (the ALL sentinel) routes through bytes(), whose
1099
1122
  // reconstruction enters the byte-budget cache. Without this, the mind's
1100
1123
  // read() — which always passes ALL — re-walked the DAG and re-concatenated
1101
1124
  // on EVERY repeated read of an uncached branch, bypassing the cache that
1102
1125
  // exists precisely for those reconstructions.
1103
1126
  if (maxLen >= 0x7fffffff) return this.bytes(id);
1127
+ // METERING BOUNDARY: this public entry point is charged ONCE per logical
1128
+ // read; the walk below recurses through `_prefix`, which is not charged.
1129
+ // Counting the recursion instead made a single read of an N-byte branch
1130
+ // report as N reads (one per node descended), so `byteReads` measured
1131
+ // tree size rather than read requests — it read as ~1 byte per read.
1132
+ if (this.meter) this.meter.byteReads++;
1133
+ const out = this._prefix(id, maxLen);
1134
+ if (this.meter) this.meter.bytesRead += out.length;
1135
+ return out;
1136
+ }
1137
+
1138
+ /** {@link bytesPrefix}'s recursive body — uncharged; see the metering
1139
+ * boundary note there. */
1140
+ private _prefix(id: NodeId, maxLen: number): Uint8Array {
1141
+ if (maxLen <= 0) return _ZERO;
1104
1142
 
1105
1143
  // Full-cache hit: bytes() already reconstructed the whole node.
1106
1144
  const full = this._bytesCache.get(id);
@@ -1125,7 +1163,7 @@ export abstract class AbstractStore implements Store {
1125
1163
  let got = 0;
1126
1164
  for (const k of kids) {
1127
1165
  if (got >= maxLen) break;
1128
- const child = this.bytesPrefix(k, maxLen - got);
1166
+ const child = this._prefix(k, maxLen - got);
1129
1167
  parts.push(child);
1130
1168
  got += child.length;
1131
1169
  }
@@ -1133,6 +1171,7 @@ export abstract class AbstractStore implements Store {
1133
1171
  }
1134
1172
 
1135
1173
  contentLen(id: NodeId, cap = Infinity): number {
1174
+ if (this.meter) this.meter.lenReads++;
1136
1175
  if (id < 0) return 1; // implicit single-byte leaf
1137
1176
  const hit = this._lenCache.get(id);
1138
1177
  if (hit !== undefined) return hit; // exact — valid under any cap
@@ -1159,6 +1198,7 @@ export abstract class AbstractStore implements Store {
1159
1198
  // ── Content-addressed lookup ───────────────────────────────────────────
1160
1199
 
1161
1200
  findLeaf(bytes: Uint8Array): NodeId | null {
1201
+ if (this.meter) this.meter.leafLookups++;
1162
1202
  if (bytes.length === 1) return -(bytes[0] + 1);
1163
1203
  const key = keyOf(bytes);
1164
1204
  const cached = this._leafKey.get(key);
@@ -1169,6 +1209,7 @@ export abstract class AbstractStore implements Store {
1169
1209
  }
1170
1210
 
1171
1211
  findBranch(kids: NodeId[]): NodeId | null {
1212
+ if (this.meter) this.meter.branchLookups++;
1172
1213
  const key = kids.join(",");
1173
1214
  const cached = this._branchKey.get(key);
1174
1215
  if (cached !== undefined) return cached;
@@ -1189,18 +1230,22 @@ export abstract class AbstractStore implements Store {
1189
1230
  // ── Structural parents ─────────────────────────────────────────────────
1190
1231
 
1191
1232
  parents(id: NodeId): NodeId[] {
1233
+ if (this.meter) this.meter.parentReads++;
1192
1234
  return this._dbGetParents(id);
1193
1235
  }
1194
1236
 
1195
1237
  parentsFirst(id: NodeId, limit: number): NodeId[] {
1238
+ if (this.meter) this.meter.parentReads++;
1196
1239
  return this._dbGetParentsFirst(id, limit);
1197
1240
  }
1198
1241
 
1199
1242
  hasParents(id: NodeId): boolean {
1243
+ if (this.meter) this.meter.parentProbes++;
1200
1244
  return this._dbGetParentsFirst(id, 1).length > 0;
1201
1245
  }
1202
1246
 
1203
1247
  chainRun(id: NodeId): readonly NodeId[] {
1248
+ if (this.meter) this.meter.chainRuns++;
1204
1249
  const hit = this._chainMemo.get(id);
1205
1250
  if (hit !== undefined) return hit;
1206
1251
  const run = this._chainWalk(id, CHAIN_DEPTH_CAP);
@@ -1236,12 +1281,14 @@ export abstract class AbstractStore implements Store {
1236
1281
  }
1237
1282
 
1238
1283
  hasContainers(child: NodeId): boolean {
1284
+ if (this.meter) this.meter.containerProbes++;
1239
1285
  if (this._dbContainExists(child)) return true;
1240
1286
  const buf = this._containBuf.get(child);
1241
1287
  return buf !== undefined && buf.size > 0;
1242
1288
  }
1243
1289
 
1244
1290
  containersSlice(child: NodeId, offset: number, limit: number): NodeId[] {
1291
+ if (this.meter) this.meter.containerReads++;
1245
1292
  const out = this._dbGetContainParentsSlice(child, offset, limit);
1246
1293
  if (out.length >= limit) return out;
1247
1294
  // Buffered adds page in AFTER the stored ones. A buffered parent that is
@@ -1262,6 +1309,7 @@ export abstract class AbstractStore implements Store {
1262
1309
  }
1263
1310
 
1264
1311
  containers(child: NodeId): NodeId[] {
1312
+ if (this.meter) this.meter.containerReads++;
1265
1313
  const stored = this._dbGetContainParents(child);
1266
1314
  const buf = this._containBuf.get(child);
1267
1315
  if (stored.length === 0) return buf ? [...buf] : [];
@@ -1662,9 +1710,13 @@ export abstract class AbstractStore implements Store {
1662
1710
  const cache = this._resonateCache;
1663
1711
  if (cache) {
1664
1712
  const hit = cache.get(rk);
1665
- if (hit !== undefined) return hit;
1713
+ if (hit !== undefined) {
1714
+ if (this.meter) this.meter.annCacheHits++;
1715
+ return hit;
1716
+ }
1666
1717
  }
1667
1718
 
1719
+ if (this.meter) this.meter.annQueries++;
1668
1720
  const clusters = this._vecContentClusterCount();
1669
1721
  const results = this._vecContentQuery(
1670
1722
  normalize(copy(v)),
@@ -1682,6 +1734,10 @@ export abstract class AbstractStore implements Store {
1682
1734
  if (out.length >= k) break;
1683
1735
  }
1684
1736
 
1737
+ // Vectors the index actually scored for THIS descent — the counter that
1738
+ // exposes a query whose ANN cost is growing with the corpus.
1739
+ if (this.meter) this.meter.annVectorReads += this._vecContentLastReads();
1740
+
1685
1741
  const rc = this._resonateCache ??= new Map();
1686
1742
  if (rc.size >= RESONATE_CACHE_MAX) rc.clear();
1687
1743
  rc.set(rk, out);
@@ -1924,29 +1980,35 @@ export abstract class AbstractStore implements Store {
1924
1980
  }
1925
1981
 
1926
1982
  next(id: NodeId): NodeId[] {
1983
+ if (this.meter) this.meter.edgeReads++;
1927
1984
  return this._dbGetNextEdges(id);
1928
1985
  }
1929
1986
 
1930
1987
  /** {@link Store.hasNext} — one indexed point probe, never a range read. */
1931
1988
  hasNext(id: NodeId): boolean {
1989
+ if (this.meter) this.meter.edgeProbes++;
1932
1990
  return this._dbEdgeSrcExists(id);
1933
1991
  }
1934
1992
 
1935
1993
  prev(id: NodeId): NodeId[] {
1994
+ if (this.meter) this.meter.prevReads++;
1936
1995
  return this._dbGetPrevEdges(id);
1937
1996
  }
1938
1997
 
1939
1998
  nextFirst(id: NodeId, limit: number): NodeId[] {
1999
+ if (this.meter) this.meter.edgeReads++;
1940
2000
  return this._dbGetNextEdgesFirst(id, limit);
1941
2001
  }
1942
2002
 
1943
2003
  prevFirst(id: NodeId, limit: number): NodeId[] {
2004
+ if (this.meter) this.meter.prevReads++;
1944
2005
  return this._dbGetPrevEdgesFirst(id, limit);
1945
2006
  }
1946
2007
 
1947
2008
  /** {@link Store.prevCount}. Subclasses with an indexed reverse-edge count
1948
2009
  * should override; this default materialises (correct, not optimal). */
1949
2010
  prevCount(id: NodeId): number {
2011
+ if (this.meter) this.meter.prevProbes++;
1950
2012
  return this._dbGetPrevEdges(id).length;
1951
2013
  }
1952
2014
 
@@ -1964,11 +2026,13 @@ export abstract class AbstractStore implements Store {
1964
2026
  // ── Halos ──────────────────────────────────────────────────────────────
1965
2027
 
1966
2028
  haloMass(id: NodeId): number {
2029
+ if (this.meter) this.meter.haloProbes++;
1967
2030
  const r = this._dbGetHalo(id);
1968
2031
  return r ? r.mass : 0;
1969
2032
  }
1970
2033
 
1971
2034
  halo(id: NodeId): Vec | null {
2035
+ if (this.meter) this.meter.haloReads++;
1972
2036
  const cached = this._haloNorm.get(id);
1973
2037
  if (cached !== undefined) return copy(cached);
1974
2038
  const r = this._dbGetHalo(id);
@@ -1982,6 +2046,7 @@ export abstract class AbstractStore implements Store {
1982
2046
  /** {@link Store.hasHalo} — MUST mirror {@link halo}'s null condition
1983
2047
  * exactly (row present AND mass ≥ minHaloMass), minus the decode. */
1984
2048
  hasHalo(id: NodeId): boolean {
2049
+ if (this.meter) this.meter.haloProbes++;
1985
2050
  const r = this._dbGetHalo(id);
1986
2051
  return r !== null && r.mass >= this.minHaloMass;
1987
2052
  }
@@ -2018,9 +2083,13 @@ export abstract class AbstractStore implements Store {
2018
2083
  const cache = this._resonateHaloCache;
2019
2084
  if (cache) {
2020
2085
  const hit = cache.get(rk);
2021
- if (hit !== undefined) return hit;
2086
+ if (hit !== undefined) {
2087
+ if (this.meter) this.meter.annCacheHits++;
2088
+ return hit;
2089
+ }
2022
2090
  }
2023
2091
 
2092
+ if (this.meter) this.meter.haloQueries++;
2024
2093
  const results = this._vecHaloQuery(
2025
2094
  normalize(copy(v)),
2026
2095
  k * this.overfetch,
@@ -376,16 +376,26 @@ test("inference: input is processed at a roughly constant KB/s (linear, not quad
376
376
 
377
377
  await mind.respond(queries[queries.length - 1]); // warm up
378
378
 
379
- const times = [];
380
- for (const q of queries) {
381
- const samples = [];
382
- for (let r = 0; r < 5; r++) {
379
+ // INTERLEAVED sampling — round-robin across sizes, not size-at-a-time.
380
+ // The inputs and the store are deterministic, so the WORK per query is
381
+ // fixed; only wall clock varies. Sampling one size to completion before
382
+ // moving on let a single GC/JIT episode land on one size's ENTIRE sample
383
+ // block, so even `fastest` (min of 5) came back ~2.7x high for that size
384
+ // alone — a bimodal reading that made the throughput-band ratio below trip
385
+ // intermittently (observed on both the 1KB point and, more weakly, before
386
+ // any of this file's callers changed). Round-robin spreads every episode
387
+ // across all sizes, so `fastest` sees at least one clean sample each.
388
+ // This does not weaken the assertion — it removes the sampling artefact
389
+ // the assertion was reacting to.
390
+ const samples = queries.map(() => []);
391
+ for (let r = 0; r < 5; r++) {
392
+ for (let i = 0; i < queries.length; i++) {
383
393
  const t0 = performance.now();
384
- await mind.respond(q);
385
- samples.push(performance.now() - t0);
394
+ await mind.respond(queries[i]);
395
+ samples[i].push(performance.now() - t0);
386
396
  }
387
- times.push(fastest(samples));
388
397
  }
398
+ const times = samples.map(fastest);
389
399
  await mind.store.close();
390
400
 
391
401
  const rates = bytes.map((b, i) => (b / 1024) / (times[i] / 1000)); // KB/s
@@ -235,8 +235,11 @@ test("H2: the ALU meaning-memo does not change answers across repeat responds",
235
235
  // ═══════════════════════════════════════════════════════════════════════
236
236
 
237
237
  test("I1: containsSpan rejects a sparse subsequence that isSpanShaped accepts", async () => {
238
+ // The span-shape family lives in the shared match/project family
239
+ // (match.ts), not inside a mechanism: pipeline-mechanism.ts and
240
+ // reasoning.ts both consume it, so a mechanism may not own it.
238
241
  const { containsSpan, isSpanShaped } = await import(
239
- "../dist/src/mind/mechanisms/extraction.js"
242
+ "../dist/src/mind/match.js"
240
243
  );
241
244
  const m = newMind();
242
245
  await m.ingest([["what is ice?", "ice is frozen water"]]);
@@ -142,7 +142,19 @@ test("3 — a near-tie between the winner and runner-up emits narrowDecision", a
142
142
  ["steel is hard so steel is strong", "strong"],
143
143
  ["water is frigid so water is freezing", "freezing"],
144
144
  ]);
145
- const { steps } = await trace(m, "steel is frigid so steel is ???");
145
+ // The near-tie has to be REAL: two candidates whose accounted spans are the
146
+ // same, separated by a single move. `steel is hard so steel is` is that —
147
+ // extraction and recall both explain the whole prefix (grades 21011 and
148
+ // 21010, margin 1).
149
+ //
150
+ // The old fixture, `steel is frigid so steel is ???`, was a near-tie only
151
+ // while CAST could not fire on it: the counterfactual subject and the seat
152
+ // it fills sit on opposite sides of one recurring structure, and CAST's
153
+ // subject gate used to reject any point whose alignment continued PAST the
154
+ // seat — which that very recurrence guarantees. With CAST firing, it leaves
155
+ // only "???" unexplained and wins by 22008; a decisive win is the opposite
156
+ // of what this test pins, and test 3b already covers it.
157
+ const { steps } = await trace(m, "steel is hard so steel is");
146
158
  const narrow = stepsNamed(steps, "narrowDecision");
147
159
  assert.equal(narrow.length, 1, "expected exactly one narrowDecision step");
148
160
  assert.ok(/margin \d+ grade-unit/.test(narrow[0].note), narrow[0].note);
@@ -69,9 +69,16 @@ test("fuseAttention: a root whose continuation is already answered in the query
69
69
  // topic with no such embedded answer.
70
70
  const q = enc("greet reply-greet then red then circle");
71
71
  const roots = await m.climbAttention(q, 24);
72
- const greet = roots.find((r) => r.start === 0);
73
- const redCircle = roots.find((r) => r.start !== 0);
74
- assert.ok(greet, "expected the 'greet' root at query offset 0");
72
+ // Roots are identified by the ANCHOR they climbed to, never by the query
73
+ // offset of the region that got there: which region wins the climb is a
74
+ // property of the evidence, and the fold's cuts are content-defined, so an
75
+ // offset test would be asserting where a cut happened to fall. (It did:
76
+ // this once read `r.start === 0`, and passed only while the region reaching
77
+ // `greet` began at byte 0 — it now begins at 6, on the same anchor.)
78
+ const anchorOf = (r) => dec(m.store.bytesPrefix(r.anchor, 1e9));
79
+ const greet = roots.find((r) => anchorOf(r) === "greet");
80
+ const redCircle = roots.find((r) => anchorOf(r) === "red circle");
81
+ assert.ok(greet, "expected a root anchored on 'greet'");
75
82
  assert.ok(redCircle, "expected the 'red circle' binding root");
76
83
 
77
84
  const guide = gistOf(m, q);
@@ -2,9 +2,9 @@
2
2
  // must recover a trained form whose edge misalignment is MORE than one byte
3
3
  // (the shipped ±1 fix only covers a single stray edge byte).
4
4
  //
5
- // bytesToTree's plain fold is RADIX-ALIGNED: a chunk's own boundary is a
6
- // multiple of the geometry's group width W from the LOCAL start of whatever
7
- // it was folded from (see geometry.ts's FoldPyramid comment). A query whose
5
+ // bytesToTree's fold is CONTENT-DEFINED: a chunk's own boundary is a cut the
6
+ // bytes chose (see geometry.ts's contentBoundaries), so it moves with the
7
+ // content rather than with an absolute offset. A query whose
8
8
  // recognised span sits at a different local offset than the trained deposit
9
9
  // did — e.g. extra leading whitespace, which canon deliberately preserves
10
10
  // verbatim at the edges, only collapsing INTERIOR whitespace — shifts every