@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
@@ -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,
@@ -110,7 +153,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
110
153
  memo?.set(id, reach);
111
154
  return reach;
112
155
  }
113
- const bound = Math.ceil(Math.sqrt(contextCount));
156
+ const bound = boundFor(contextCount);
114
157
  const roots = [];
115
158
  const seen = new Set([id]);
116
159
  const ctxSeen = new Set();
@@ -162,6 +205,8 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
162
205
  let visitedCount = 0;
163
206
  let maxDepth = 0;
164
207
  const visit = (x) => {
208
+ if (ctx.meter)
209
+ ctx.meter.ancestorVisits++;
165
210
  if (depths) {
166
211
  visitedCount++;
167
212
  if (curDepth > maxDepth)
@@ -344,8 +389,7 @@ export function atomReach(ctx, contextCount) {
344
389
  * atom votes and is recognised exactly as any stored form; above it the
345
390
  * alphabet is scaffolding everywhere and abstains. */
346
391
  export function atomIsHub(ctx, contextCount) {
347
- return atomReach(ctx, contextCount) >
348
- Math.ceil(Math.sqrt(Math.max(2, contextCount)));
392
+ return atomReach(ctx, contextCount) > boundFor(contextCount);
349
393
  }
350
394
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
351
395
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
@@ -386,7 +430,16 @@ export function corpusN(ctx) {
386
430
  * materialised list. {@link hubCap} is the list-side reading of the same
387
431
  * convention. */
388
432
  export function hubBound(ctx) {
389
- 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)));
390
443
  }
391
444
  /** Cap a candidate list at the hub bound √N (insertion order) — the ONE
392
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
@@ -190,6 +206,12 @@ export type AItem = {
190
206
  };
191
207
  export interface MindContext extends GraphSearchHost {
192
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;
193
215
  space: Space;
194
216
  alphabet: Alphabet;
195
217
  cfg: MindConfig;
@@ -250,8 +272,9 @@ export interface MindContext extends GraphSearchHost {
250
272
  * folds with the SAME stable-prefix fold query-time perception uses
251
273
  * (structural train/inference agreement, load-bearing for recall),
252
274
  * reusing every already-folded segment via `stable` (see StableFold) —
253
- * O(turn) per deposit instead of O(context). A first-seen input keeps
254
- * 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
255
278
  * performance cache for the FOLD STATE; the boundaries are semantic but
256
279
  * derived only from the deposit sequence itself (an evicted chain falls
257
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;
package/dist/src/store.js CHANGED
@@ -516,6 +516,8 @@ export class AbstractStore {
516
516
  }
517
517
  // ── DAG traversal ──────────────────────────────────────────────────────
518
518
  get(id) {
519
+ if (this.meter)
520
+ this.meter.nodeRecords++;
519
521
  // Byte leaves are implicit — fabricate from the id.
520
522
  if (id < 0) {
521
523
  return { id, leaf: new Uint8Array([-(id + 1)]), kids: null };
@@ -536,7 +538,20 @@ export class AbstractStore {
536
538
  * outlive their records — the read degrades safely to empty bytes, this
537
539
  * counter is what keeps that degradation observable. */
538
540
  danglingReads = 0;
541
+ /** {@link Store.meter} — the per-response work accumulator, or null when
542
+ * nothing is profiling. Every read below bumps it through `?.`, so an
543
+ * unprofiled store pays one null check per read and allocates nothing. */
544
+ meter = null;
539
545
  bytes(id) {
546
+ if (this.meter) {
547
+ this.meter.byteReads++;
548
+ // Charged BEFORE the fast path returns, and from the cache entry when
549
+ // there is one: the volume a caller pulled through is the cost signal
550
+ // (an unbounded read is unbounded whether or not it was cached).
551
+ const c = this._bytesCache.get(id);
552
+ if (c)
553
+ this.meter.bytesRead += c.length;
554
+ }
540
555
  // Fast path.
541
556
  const hit = this._bytesCache.get(id);
542
557
  if (hit)
@@ -583,7 +598,10 @@ export class AbstractStore {
583
598
  const out = concat(kids.map((k) => cache.get(k)));
584
599
  cache.set(nid, out);
585
600
  }
586
- return cache.get(id) ?? _ZERO;
601
+ const out = cache.get(id) ?? _ZERO;
602
+ if (this.meter)
603
+ this.meter.bytesRead += out.length;
604
+ return out;
587
605
  }
588
606
  /** First `maxLen` bytes of a node. Walks only the leftmost branch,
589
607
  * stopping at `maxLen` — so a 1 MB document root costs the same as a
@@ -593,8 +611,6 @@ export class AbstractStore {
593
611
  * may be shared with the byte cache and with other callers — treat them
594
612
  * as read-only. Mutating one would corrupt every subsequent read. */
595
613
  bytesPrefix(id, maxLen) {
596
- if (maxLen <= 0)
597
- return _ZERO;
598
614
  // A FULL read (the ALL sentinel) routes through bytes(), whose
599
615
  // reconstruction enters the byte-budget cache. Without this, the mind's
600
616
  // read() — which always passes ALL — re-walked the DAG and re-concatenated
@@ -602,6 +618,23 @@ export class AbstractStore {
602
618
  // exists precisely for those reconstructions.
603
619
  if (maxLen >= 0x7fffffff)
604
620
  return this.bytes(id);
621
+ // METERING BOUNDARY: this public entry point is charged ONCE per logical
622
+ // read; the walk below recurses through `_prefix`, which is not charged.
623
+ // Counting the recursion instead made a single read of an N-byte branch
624
+ // report as N reads (one per node descended), so `byteReads` measured
625
+ // tree size rather than read requests — it read as ~1 byte per read.
626
+ if (this.meter)
627
+ this.meter.byteReads++;
628
+ const out = this._prefix(id, maxLen);
629
+ if (this.meter)
630
+ this.meter.bytesRead += out.length;
631
+ return out;
632
+ }
633
+ /** {@link bytesPrefix}'s recursive body — uncharged; see the metering
634
+ * boundary note there. */
635
+ _prefix(id, maxLen) {
636
+ if (maxLen <= 0)
637
+ return _ZERO;
605
638
  // Full-cache hit: bytes() already reconstructed the whole node.
606
639
  const full = this._bytesCache.get(id);
607
640
  if (full)
@@ -625,13 +658,15 @@ export class AbstractStore {
625
658
  for (const k of kids) {
626
659
  if (got >= maxLen)
627
660
  break;
628
- const child = this.bytesPrefix(k, maxLen - got);
661
+ const child = this._prefix(k, maxLen - got);
629
662
  parts.push(child);
630
663
  got += child.length;
631
664
  }
632
665
  return concat(parts);
633
666
  }
634
667
  contentLen(id, cap = Infinity) {
668
+ if (this.meter)
669
+ this.meter.lenReads++;
635
670
  if (id < 0)
636
671
  return 1; // implicit single-byte leaf
637
672
  const hit = this._lenCache.get(id);
@@ -661,6 +696,8 @@ export class AbstractStore {
661
696
  }
662
697
  // ── Content-addressed lookup ───────────────────────────────────────────
663
698
  findLeaf(bytes) {
699
+ if (this.meter)
700
+ this.meter.leafLookups++;
664
701
  if (bytes.length === 1)
665
702
  return -(bytes[0] + 1);
666
703
  const key = keyOf(bytes);
@@ -673,6 +710,8 @@ export class AbstractStore {
673
710
  return id;
674
711
  }
675
712
  findBranch(kids) {
713
+ if (this.meter)
714
+ this.meter.branchLookups++;
676
715
  const key = kids.join(",");
677
716
  const cached = this._branchKey.get(key);
678
717
  if (cached !== undefined)
@@ -693,15 +732,23 @@ export class AbstractStore {
693
732
  }
694
733
  // ── Structural parents ─────────────────────────────────────────────────
695
734
  parents(id) {
735
+ if (this.meter)
736
+ this.meter.parentReads++;
696
737
  return this._dbGetParents(id);
697
738
  }
698
739
  parentsFirst(id, limit) {
740
+ if (this.meter)
741
+ this.meter.parentReads++;
699
742
  return this._dbGetParentsFirst(id, limit);
700
743
  }
701
744
  hasParents(id) {
745
+ if (this.meter)
746
+ this.meter.parentProbes++;
702
747
  return this._dbGetParentsFirst(id, 1).length > 0;
703
748
  }
704
749
  chainRun(id) {
750
+ if (this.meter)
751
+ this.meter.chainRuns++;
705
752
  const hit = this._chainMemo.get(id);
706
753
  if (hit !== undefined)
707
754
  return hit;
@@ -736,12 +783,16 @@ export class AbstractStore {
736
783
  set.add(parent);
737
784
  }
738
785
  hasContainers(child) {
786
+ if (this.meter)
787
+ this.meter.containerProbes++;
739
788
  if (this._dbContainExists(child))
740
789
  return true;
741
790
  const buf = this._containBuf.get(child);
742
791
  return buf !== undefined && buf.size > 0;
743
792
  }
744
793
  containersSlice(child, offset, limit) {
794
+ if (this.meter)
795
+ this.meter.containerReads++;
745
796
  const out = this._dbGetContainParentsSlice(child, offset, limit);
746
797
  if (out.length >= limit)
747
798
  return out;
@@ -765,6 +816,8 @@ export class AbstractStore {
765
816
  return out;
766
817
  }
767
818
  containers(child) {
819
+ if (this.meter)
820
+ this.meter.containerReads++;
768
821
  const stored = this._dbGetContainParents(child);
769
822
  const buf = this._containBuf.get(child);
770
823
  if (stored.length === 0)
@@ -1152,9 +1205,14 @@ export class AbstractStore {
1152
1205
  const cache = this._resonateCache;
1153
1206
  if (cache) {
1154
1207
  const hit = cache.get(rk);
1155
- if (hit !== undefined)
1208
+ if (hit !== undefined) {
1209
+ if (this.meter)
1210
+ this.meter.annCacheHits++;
1156
1211
  return hit;
1212
+ }
1157
1213
  }
1214
+ if (this.meter)
1215
+ this.meter.annQueries++;
1158
1216
  const clusters = this._vecContentClusterCount();
1159
1217
  const results = this._vecContentQuery(normalize(copy(v)), k * this.overfetch,
1160
1218
  // Exhaustive: probe every cluster (ef ≥ 4·clusters guarantees the
@@ -1169,6 +1227,10 @@ export class AbstractStore {
1169
1227
  if (out.length >= k)
1170
1228
  break;
1171
1229
  }
1230
+ // Vectors the index actually scored for THIS descent — the counter that
1231
+ // exposes a query whose ANN cost is growing with the corpus.
1232
+ if (this.meter)
1233
+ this.meter.annVectorReads += this._vecContentLastReads();
1172
1234
  const rc = this._resonateCache ??= new Map();
1173
1235
  if (rc.size >= RESONATE_CACHE_MAX)
1174
1236
  rc.clear();
@@ -1390,24 +1452,36 @@ export class AbstractStore {
1390
1452
  this._dbInsertEdge(from, to);
1391
1453
  }
1392
1454
  next(id) {
1455
+ if (this.meter)
1456
+ this.meter.edgeReads++;
1393
1457
  return this._dbGetNextEdges(id);
1394
1458
  }
1395
1459
  /** {@link Store.hasNext} — one indexed point probe, never a range read. */
1396
1460
  hasNext(id) {
1461
+ if (this.meter)
1462
+ this.meter.edgeProbes++;
1397
1463
  return this._dbEdgeSrcExists(id);
1398
1464
  }
1399
1465
  prev(id) {
1466
+ if (this.meter)
1467
+ this.meter.prevReads++;
1400
1468
  return this._dbGetPrevEdges(id);
1401
1469
  }
1402
1470
  nextFirst(id, limit) {
1471
+ if (this.meter)
1472
+ this.meter.edgeReads++;
1403
1473
  return this._dbGetNextEdgesFirst(id, limit);
1404
1474
  }
1405
1475
  prevFirst(id, limit) {
1476
+ if (this.meter)
1477
+ this.meter.prevReads++;
1406
1478
  return this._dbGetPrevEdgesFirst(id, limit);
1407
1479
  }
1408
1480
  /** {@link Store.prevCount}. Subclasses with an indexed reverse-edge count
1409
1481
  * should override; this default materialises (correct, not optimal). */
1410
1482
  prevCount(id) {
1483
+ if (this.meter)
1484
+ this.meter.prevProbes++;
1411
1485
  return this._dbGetPrevEdges(id).length;
1412
1486
  }
1413
1487
  edgeSourceCount() {
@@ -1422,10 +1496,14 @@ export class AbstractStore {
1422
1496
  }
1423
1497
  // ── Halos ──────────────────────────────────────────────────────────────
1424
1498
  haloMass(id) {
1499
+ if (this.meter)
1500
+ this.meter.haloProbes++;
1425
1501
  const r = this._dbGetHalo(id);
1426
1502
  return r ? r.mass : 0;
1427
1503
  }
1428
1504
  halo(id) {
1505
+ if (this.meter)
1506
+ this.meter.haloReads++;
1429
1507
  const cached = this._haloNorm.get(id);
1430
1508
  if (cached !== undefined)
1431
1509
  return copy(cached);
@@ -1440,6 +1518,8 @@ export class AbstractStore {
1440
1518
  /** {@link Store.hasHalo} — MUST mirror {@link halo}'s null condition
1441
1519
  * exactly (row present AND mass ≥ minHaloMass), minus the decode. */
1442
1520
  hasHalo(id) {
1521
+ if (this.meter)
1522
+ this.meter.haloProbes++;
1443
1523
  const r = this._dbGetHalo(id);
1444
1524
  return r !== null && r.mass >= this.minHaloMass;
1445
1525
  }
@@ -1473,9 +1553,14 @@ export class AbstractStore {
1473
1553
  const cache = this._resonateHaloCache;
1474
1554
  if (cache) {
1475
1555
  const hit = cache.get(rk);
1476
- if (hit !== undefined)
1556
+ if (hit !== undefined) {
1557
+ if (this.meter)
1558
+ this.meter.annCacheHits++;
1477
1559
  return hit;
1560
+ }
1478
1561
  }
1562
+ if (this.meter)
1563
+ this.meter.haloQueries++;
1479
1564
  const results = this._vecHaloQuery(normalize(copy(v)), k * this.overfetch, this.efFor(this._vecHaloClusterCount()));
1480
1565
  const out = [];
1481
1566
  for (const r of results) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -128,12 +128,25 @@ export interface Derivation<I> {
128
128
  premises: Array<Derivation<I>>;
129
129
  }
130
130
 
131
+ /** Optional search-effort read-out, filled in place while the search runs.
132
+ * A plain structural record so the sublibrary stays firewalled (it imports
133
+ * nothing from Sema, and Sema copies these into its own accounting). Purely
134
+ * observational: the search never reads it back. */
135
+ export interface SearchStats {
136
+ /** Agenda entries popped, stale lazy-deletion entries included — the chart
137
+ * work actually done. */
138
+ pops: number;
139
+ /** Agenda entries pushed by a successful relaxation. */
140
+ pushes: number;
141
+ }
142
+
131
143
  /**
132
144
  * Find a lightest derivation of a goal item, or `null` if none exists.
133
145
  * `cost` on the returned root is the total derivation cost.
134
146
  */
135
147
  export function lightestDerivation<I>(
136
148
  system: DeductionSystem<I>,
149
+ stats?: SearchStats,
137
150
  ): Derivation<I> | null {
138
151
  const g = new Map<string, number>(); // best known cost per item
139
152
  const proof = new Map<string, Rule<I> | null>(); // producing rule per item
@@ -177,6 +190,7 @@ export function lightestDerivation<I>(
177
190
  g.set(key, cost);
178
191
  proof.set(key, rule);
179
192
  items.set(key, item);
193
+ if (stats) stats.pushes++;
180
194
  agenda.push(cost + h(item, key), { key, g: cost });
181
195
  }
182
196
  };
@@ -185,6 +199,7 @@ export function lightestDerivation<I>(
185
199
 
186
200
  while (agenda.size > 0) {
187
201
  const { value } = agenda.pop() as { value: { key: string; g: number } };
202
+ if (stats) stats.pops++;
188
203
  const key = value.key;
189
204
  // Lazy deletion: an entry is stale if a cheaper derivation has since been
190
205
  // recorded for the same item.
@@ -7,6 +7,7 @@
7
7
  // symbolic-rewriting mechanism. See ./../README.md for the design.
8
8
 
9
9
  export { lightestDerivation } from "./deduction.js";
10
+ export type { SearchStats } from "./deduction.js";
10
11
  export type {
11
12
  DeductionSystem,
12
13
  Derivation,