@hviana/sema 0.1.6 → 0.1.8

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 (50) hide show
  1. package/dist/example/train_base.js +42 -9
  2. package/dist/src/alu/src/parser.d.ts +9 -0
  3. package/dist/src/alu/src/parser.js +110 -2
  4. package/dist/src/canon.d.ts +26 -0
  5. package/dist/src/canon.js +57 -0
  6. package/dist/src/geometry.d.ts +13 -2
  7. package/dist/src/geometry.js +101 -2
  8. package/dist/src/index.d.ts +1 -0
  9. package/dist/src/index.js +1 -0
  10. package/dist/src/mind/attention.js +11 -7
  11. package/dist/src/mind/learning.js +33 -1
  12. package/dist/src/mind/match.d.ts +4 -2
  13. package/dist/src/mind/match.js +30 -6
  14. package/dist/src/mind/mechanisms/cast.js +18 -4
  15. package/dist/src/mind/mechanisms/confluence.js +13 -1
  16. package/dist/src/mind/mechanisms/recall.js +115 -31
  17. package/dist/src/mind/mind.d.ts +138 -12
  18. package/dist/src/mind/mind.js +284 -22
  19. package/dist/src/mind/primitives.d.ts +22 -2
  20. package/dist/src/mind/primitives.js +95 -6
  21. package/dist/src/mind/recognition.js +31 -8
  22. package/dist/src/mind/traverse.d.ts +13 -0
  23. package/dist/src/mind/traverse.js +41 -0
  24. package/dist/src/mind/types.d.ts +33 -21
  25. package/dist/src/store-sqlite.d.ts +10 -0
  26. package/dist/src/store-sqlite.js +58 -0
  27. package/dist/src/store.d.ts +23 -0
  28. package/dist/src/store.js +13 -2
  29. package/example/train_base.ts +49 -9
  30. package/index.html +65 -0
  31. package/package.json +1 -1
  32. package/src/alu/src/parser.ts +105 -4
  33. package/src/canon.ts +65 -0
  34. package/src/geometry.ts +105 -1
  35. package/src/index.ts +1 -0
  36. package/src/mind/attention.ts +11 -6
  37. package/src/mind/learning.ts +39 -1
  38. package/src/mind/match.ts +29 -6
  39. package/src/mind/mechanisms/cast.ts +21 -6
  40. package/src/mind/mechanisms/confluence.ts +15 -1
  41. package/src/mind/mechanisms/recall.ts +132 -41
  42. package/src/mind/mind.ts +396 -22
  43. package/src/mind/primitives.ts +109 -7
  44. package/src/mind/recognition.ts +36 -8
  45. package/src/mind/traverse.ts +47 -0
  46. package/src/mind/types.ts +30 -21
  47. package/src/store-sqlite.ts +76 -0
  48. package/src/store.ts +46 -2
  49. package/test/13-conversation.test.mjs +240 -20
  50. package/test/35-prefix-edge.test.mjs +86 -0
@@ -4,8 +4,7 @@
4
4
  // Read — node → bytes (read)
5
5
 
6
6
  import { Vec } from "../vec.js";
7
- import { Sema, Space } from "../sema.js";
8
- import { Alphabet } from "../alphabet.js";
7
+ import { Sema } from "../sema.js";
9
8
  import {
10
9
  bytesToTree,
11
10
  bytesToTreePyramid,
@@ -15,6 +14,8 @@ import {
15
14
  hilbertBytes,
16
15
  stackGrids,
17
16
  } from "../geometry.js";
17
+ import { canonHash } from "../canon.js";
18
+ import { bytesEqual } from "../bytes.js";
18
19
  import { ALL } from "./types.js";
19
20
  import type { Input, MindContext } from "./types.js";
20
21
 
@@ -24,7 +25,7 @@ import type { Input, MindContext } from "./types.js";
24
25
  * collision-free encoding. Spans on the perception path are query-scale
25
26
  * (windows, regions, candidate spans), so key construction is far cheaper
26
27
  * than the river fold it deduplicates. */
27
- function latin1Key(bytes: Uint8Array): string {
28
+ export function latin1Key(bytes: Uint8Array): string {
28
29
  // Batched String.fromCharCode — avoids the O(n²) cost of repeated += on
29
30
  // potentially-large query spans, and stays well under the ~65536 arg limit.
30
31
  const n = bytes.length;
@@ -36,12 +37,19 @@ function latin1Key(bytes: Uint8Array): string {
36
37
  }
37
38
 
38
39
  /** Perceive input into a content-defined tree (the river fold).
39
- * Deterministic — identical bytes always produce an identical tree. */
40
+ * Deterministic — identical bytes always produce an identical tree.
41
+ *
42
+ * `boundaries` is an optional sorted list of proper byte offsets where the
43
+ * fold must split so that each prefix segment folds identically to how it
44
+ * folded when it was learned (§10.3 stable-prefix contract). Only the
45
+ * CALLER — who assembled the multi-turn context — knows where those
46
+ * boundaries are; the geometry never guesses them from the bytes. */
40
47
  export function perceive(
41
48
  ctx: MindContext,
42
49
  input: Input,
43
50
  leafAt?: (i: number) => number | null,
44
51
  lookup?: (ids: number[]) => number | null,
52
+ boundaries?: readonly number[],
45
53
  ): Sema {
46
54
  if (typeof input === "string" || input instanceof Uint8Array) {
47
55
  const bytes = typeof input === "string"
@@ -57,11 +65,25 @@ export function perceive(
57
65
  const key = latin1Key(bytes);
58
66
  const hit = memo.get(key);
59
67
  if (hit !== undefined) return hit;
60
- const tree = bytesToTree(ctx.space, ctx.alphabet, bytes);
68
+ const tree = bytesToTree(
69
+ ctx.space,
70
+ ctx.alphabet,
71
+ bytes,
72
+ undefined,
73
+ undefined,
74
+ boundaries,
75
+ );
61
76
  memo.set(key, tree);
62
77
  return tree;
63
78
  }
64
- return bytesToTree(ctx.space, ctx.alphabet, bytes);
79
+ return bytesToTree(
80
+ ctx.space,
81
+ ctx.alphabet,
82
+ bytes,
83
+ undefined,
84
+ undefined,
85
+ boundaries,
86
+ );
65
87
  }
66
88
  return bytesToTree(ctx.space, ctx.alphabet, bytes, leafAt, lookup);
67
89
  }
@@ -136,11 +158,25 @@ export function foldTree(
136
158
  start: number,
137
159
  visit?: (n: Sema, start: number, end: number, node: number | null) => void,
138
160
  ): { end: number; node: number | null } {
161
+ // Fast path: subtree already resolved (from a previous conversation turn
162
+ // or an earlier recognition pass). The pyramid reuses prefix subtrees as
163
+ // identical Sema objects, so this cache turns foldTree into O(suffix)
164
+ // instead of O(context) for multi-turn recognition.
165
+ const cached = ctx._resolvedSubtrees?.get(n);
166
+ if (cached !== undefined) {
167
+ const end = start + cached.len;
168
+ visit?.(n, start, end, cached.id);
169
+ return { end, node: cached.id };
170
+ }
171
+
139
172
  if (n.kids === null) {
140
173
  const b = n.leaf ?? new Uint8Array(0);
141
174
  const end = start + b.length;
142
175
  const node = ctx.store.findLeaf(b);
143
176
  visit?.(n, start, end, node);
177
+ if (node !== null && ctx._resolvedSubtrees) {
178
+ ctx._resolvedSubtrees.set(n, { id: node, len: b.length });
179
+ }
144
180
  return { end, node };
145
181
  }
146
182
  let pos = start;
@@ -154,6 +190,9 @@ export function foldTree(
154
190
  }
155
191
  const node = known ? ctx.store.findBranch(kids) : null;
156
192
  visit?.(n, start, pos, node);
193
+ if (node !== null && ctx._resolvedSubtrees) {
194
+ ctx._resolvedSubtrees.set(n, { id: node, len: pos - start });
195
+ }
157
196
  return { end: pos, node };
158
197
  }
159
198
 
@@ -162,7 +201,70 @@ export function foldTree(
162
201
  * unknown. */
163
202
  export function resolve(ctx: MindContext, bytes: Uint8Array): number | null {
164
203
  if (bytes.length === 0) return null;
165
- return foldTree(ctx, perceive(ctx, bytes), 0).node;
204
+ const exact = foldTree(ctx, perceive(ctx, bytes), 0).node;
205
+ if (exact !== null) return exact;
206
+ return canonResolve(ctx, bytes);
207
+ }
208
+
209
+ /** Equivalence-class resolution: when the exact content-addressed lookup
210
+ * misses, find a stored node whose CANONICAL key equals the span's — the
211
+ * store's canon index proposes candidates by key hash, and each is verified
212
+ * by re-canonicalizing its bytes (hash-then-verify, like every content
213
+ * lookup). Among verified candidates, one that leads somewhere (has a
214
+ * continuation edge) is preferred; ties break to the lowest id — a corpus
215
+ * property, not a seed property. Null when the response carries no
216
+ * canonicalizer, the store has no canon index, or nothing verifies. */
217
+ export function canonResolve(
218
+ ctx: MindContext,
219
+ bytes: Uint8Array,
220
+ ): number | null {
221
+ const canon = ctx.canon;
222
+ const store = ctx.store;
223
+ if (canon === null || !store.canonFind) return null;
224
+ if (bytes.length < 2) return null;
225
+ const memo = ctx.canonMemo;
226
+ const memoKey = memo ? latin1Key(bytes) : "";
227
+ if (memo) {
228
+ const hit = memo.get(memoKey);
229
+ if (hit !== undefined) return hit;
230
+ }
231
+ const set = (v: number | null): number | null => {
232
+ memo?.set(memoKey, v);
233
+ return v;
234
+ };
235
+ const key = canon(bytes);
236
+ if (key.length === 0) return set(null);
237
+ // A stored form that IS canonical is not in the index (buildCanonIndex
238
+ // skips identity rows) — the exact content-addressed lookup of the
239
+ // canonical bytes finds it directly.
240
+ if (key.length !== bytes.length || !bytesEqual(key, bytes)) {
241
+ const direct = foldTree(ctx, perceive(ctx, key), 0).node;
242
+ if (direct !== null) return set(direct);
243
+ }
244
+ const candidates = store.canonFind(canonHash(key));
245
+ if (candidates.length === 0) return set(null);
246
+ let best: number | null = null;
247
+ let bestLeads = false;
248
+ for (const id of candidates) {
249
+ const bytesOf = read(ctx, id);
250
+ const stored = canon(bytesOf);
251
+ if (stored.length !== key.length || !bytesEqual(stored, key)) continue;
252
+ // The index stores FLAT content twins; the id the exact path would have
253
+ // resolved for these bytes is their FOLD — the deposit-shaped node that
254
+ // carries the edges and halos. Re-folding the candidate's bytes lands
255
+ // on exactly the node the canonical-case query would have found.
256
+ const folded = foldTree(ctx, perceive(ctx, bytesOf), 0).node;
257
+ const use = folded ?? id;
258
+ const leads = store.hasNext(use) || store.haloMass(use) > 0;
259
+ if (
260
+ best === null || (leads && !bestLeads) ||
261
+ (leads === bestLeads && use < best)
262
+ ) {
263
+ best = use;
264
+ bestLeads = leads;
265
+ }
266
+ }
267
+ return set(best);
166
268
  }
167
269
 
168
270
  /** Walk a perceived tree in POST-ORDER with byte offsets — children before
@@ -7,8 +7,15 @@
7
7
  import { rItem } from "./trace.js";
8
8
 
9
9
  import type { MindContext, Recognition, Segment } from "./types.js";
10
- import { foldTree, gistOf, perceive, resolve } from "./primitives.js";
11
- import { leadsSomewhere } from "./traverse.js";
10
+ import {
11
+ canonResolve,
12
+ foldTree,
13
+ gistOf,
14
+ latin1Key,
15
+ perceive,
16
+ resolve,
17
+ } from "./primitives.js";
18
+ import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
12
19
  import { chainReach, leafIdAt } from "./canonical.js";
13
20
  import { isChunk, type Sema } from "../sema.js";
14
21
  import type { Leaf, Site } from "./graph-search.js";
@@ -26,15 +33,15 @@ import type { Leaf, Site } from "./graph-search.js";
26
33
  *
27
34
  * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
28
35
  export function recognise(ctx: MindContext, bytes: Uint8Array): Recognition {
29
- // Per-response memo (see MindContext.recogniseMemo): think, articulate and
30
- // the pre-consume pass all recognise the same byte objects; each repeat is
31
- // O(n·maxGroup²) store probes. Skipped while tracing so every call still
32
- // emits its rationale step.
36
+ // Content-keyed memo works for both single-turn respond() and multi-turn
37
+ // respondTurn() (where the map persists across calls). Skipped while
38
+ // tracing so every call still emits its rationale step.
33
39
  if (ctx.recogniseMemo && !ctx.trace) {
34
- const hit = ctx.recogniseMemo.get(bytes);
40
+ const key = latin1Key(bytes);
41
+ const hit = ctx.recogniseMemo.get(key);
35
42
  if (hit !== undefined) return hit;
36
43
  const fresh = recogniseImpl(ctx, bytes);
37
- ctx.recogniseMemo.set(bytes, fresh);
44
+ ctx.recogniseMemo.set(key, fresh);
38
45
  return fresh;
39
46
  }
40
47
  return recogniseImpl(ctx, bytes);
@@ -64,7 +71,19 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
64
71
  return id;
65
72
  };
66
73
 
74
+ // Byte atoms (implicit negative-id single-byte leaves) are admitted as
75
+ // recognised sites only while atoms can still DISCRIMINATE at this corpus
76
+ // scale (see {@link atomIsHub}). On a small store a single-letter fact
77
+ // ("a" → "A") is genuine learnt content and its site is essential; on a
78
+ // large one every letter of every query would otherwise become a
79
+ // "recognised form" — the bridge then finds junction connectors between
80
+ // bare letters, cover follows edges hanging off them, and pure noise
81
+ // ("qq8f3kz9…") grounds to an arbitrary learnt sentence instead of
82
+ // silence. Atoms stay available as leaves (PASS-carried literals) and
83
+ // through exact tier-0 resolution regardless.
84
+ const atomsAreHubs = atomIsHub(ctx, corpusN(ctx));
67
85
  const emit = (start: number, end: number, id: number) => {
86
+ if (id < 0 && atomsAreHubs) return;
68
87
  if (leadsSomewhere(ctx, id)) {
69
88
  sites.push({ start, end, payload: id });
70
89
  }
@@ -78,6 +97,15 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
78
97
  leaves.push({ start, end, bytes: n.leaf ?? new Uint8Array(0), node });
79
98
  }
80
99
  if (node !== null) emit(start, end, node);
100
+ // Canonical fallback: a subtree whose exact content-addressed lookup
101
+ // missed may still be a stored form under the response's equivalence
102
+ // (case, width, whitespace — whatever the injected canonicalizer says).
103
+ // O(subtree bytes) per miss, memoised per response; a no-op when no
104
+ // canonicalizer was injected or the store has no canon index.
105
+ else if (end - start >= 2) {
106
+ const cid = canonResolve(ctx, bytes.subarray(start, end));
107
+ if (cid !== null) emit(start, end, cid);
108
+ }
81
109
  if (isChunk(n)) {
82
110
  starts.add(start);
83
111
  // Try every sub-span within this leaf-parent.
@@ -115,6 +115,30 @@ export function edgeAncestors(
115
115
  const hit = memo?.get(id);
116
116
  if (hit !== undefined) return hit;
117
117
 
118
+ // BYTE-ATOM COMMONALITY. A single-byte leaf (implicit negative id) has no
119
+ // structural parents BY CONSTRUCTION — atoms are never linked into the kid
120
+ // or contain tables — so this climb cannot observe its containment at all.
121
+ // The walk below would see only the atom's own direct edges and report
122
+ // contextsReached ≈ 1, turning the MOST common content in the store into
123
+ // the MOST discriminative voter (observed on a 325K-context store: every
124
+ // recognised single-letter site voted full ln N for the one fact whose
125
+ // continuation is that letter, and their pooled sum out-voted every
126
+ // genuine anchor). An unmeasurable containment must not default to
127
+ // "maximally rare": it is bounded below by the uniform expectation over
128
+ // the byte alphabet — N contexts, each at least one chunk of up to W of
129
+ // the 256 possible atoms, reach ≥ N·W/256 contexts per atom on average
130
+ // (see {@link atomReach}). When that floor itself exceeds the hub bound
131
+ // √N the atom is a hub at this corpus scale and the climb abstains
132
+ // (saturated) — the atom's own edges remain fully traversable (tier-0
133
+ // exact recall, chooseNext, project); only its say as a consensus voter
134
+ // is withdrawn. On a small store the floor stays ≤ √N and the atom
135
+ // climbs exactly as before, so single-letter facts keep working.
136
+ if (id < 0 && atomIsHub(ctx, contextCount)) {
137
+ const reach = { roots: [], contextsReached: 0, saturated: true };
138
+ memo?.set(id, reach);
139
+ return reach;
140
+ }
141
+
118
142
  const bound = Math.ceil(Math.sqrt(contextCount));
119
143
  const roots: number[] = [];
120
144
  const seen = new Set<number>([id]);
@@ -257,6 +281,29 @@ export function prevOf(ctx: MindContext, id: number): number[] {
257
281
  return ctx.store.prev(id);
258
282
  }
259
283
 
284
+ /** The uniform-expectation floor on a byte atom's corpus commonality: N
285
+ * learnt contexts, each at least one perception chunk of up to W of the 256
286
+ * possible byte values, contain a given atom in ≥ N·W/256 contexts on
287
+ * average. An atom's TRUE containment is unmeasurable (atoms carry no
288
+ * kid/contain links by construction), so this floor is the honest stand-in:
289
+ * derived entirely from the corpus scale N, the perception window W, and
290
+ * the alphabet size — never tuned. */
291
+ export function atomReach(ctx: MindContext, contextCount: number): number {
292
+ return Math.max(
293
+ 1,
294
+ Math.ceil((contextCount * ctx.space.maxGroup) / 256),
295
+ );
296
+ }
297
+
298
+ /** Whether a byte atom is a hub at this corpus scale — its commonality floor
299
+ * {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
300
+ * atom votes and is recognised exactly as any stored form; above it the
301
+ * alphabet is scaffolding everywhere and abstains. */
302
+ export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
303
+ return atomReach(ctx, contextCount) >
304
+ Math.ceil(Math.sqrt(Math.max(2, contextCount)));
305
+ }
306
+
260
307
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
261
308
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
262
309
  * §15.3): a form that leads nowhere contributes nothing to any derivation.
package/src/mind/types.ts CHANGED
@@ -144,28 +144,37 @@ export interface MindContext extends GraphSearchHost {
144
144
  cfg: MindConfig;
145
145
  search: GraphSearch;
146
146
  trace: Rationale | null;
147
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null;
148
- /** Per-response memo of {@link recognise} keyed by the byte-array OBJECT
149
- * (think, articulate, and the post-grounding pre-consume all recognise the
150
- * same query/answer objects). Valid because the store is read-only while
151
- * a response is in flight; bypassed when a trace is attached so every
152
- * recognise still emits its rationale step. Null outside respond(). */
153
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null;
154
- /** Per-response memo of {@link perceive} keyed by the byte-array OBJECT —
155
- * the GENERAL memo the result-level ones (recogniseMemo, climbMemo,
156
- * _gistCache) each partially compensate for: resolve(), gistOf(), and
157
- * every mechanism's re-perception of the same query/answer object hit it
158
- * (a reason hop used to fold the same answer three times). Valid because
159
- * the store is read-only while a response is in flight and perception is
160
- * a pure function of bytes; only inference-shaped calls (plain Uint8Array,
161
- * no leafAt/lookup capabilities) are memoised, so the deposit path never
162
- * sees it. Keyed by CONTENT (latin1 of the bytes), not object identity —
163
- * mechanisms materialise the same span in fresh subarrays constantly
164
- * (measured on a trained store: 46% of one response's perceptions were
165
- * byte-identical repeats an identity key missed). NOT bypassed under
166
- * trace perception emits no rationale steps, so there is nothing a memo
167
- * hit could swallow. Null outside respond(). */
147
+ /** The content canonicalizer for THIS response, or null — injected by the
148
+ * modality entry point (respondText passes the text canonicalizer; a
149
+ * binary respond passes none). Resolution uses it as a fallback: when
150
+ * the exact content-addressed lookup misses, the span's canonical key is
151
+ * probed against the store's canon index (see src/canon.ts). The core
152
+ * never inspects what the equivalence IS. */
153
+ canon: ((bytes: Uint8Array) => Uint8Array) | null;
154
+ /** Per-response memo of canonical-fallback resolutions, keyed by the
155
+ * span's latin1 content key. Null outside respond(). */
156
+ canonMemo: Map<string, number | null> | null;
157
+ /** Memo of the consensus climb content-keyed (latin1) so results
158
+ * persist across conversation turns where the same byte spans recur.
159
+ * Null outside respond(); during respondTurn() the conversation's
160
+ * persistent map is swapped in. */
161
+ climbMemo: Map<string, Map<string, AttentionRead>> | null;
162
+ /** Memo of {@link recognise} content-keyed (latin1) so recognised
163
+ * forms carry forward across conversation turns. Bypassed while a
164
+ * trace is attached. Null outside respond(). */
165
+ recogniseMemo: Map<string, Recognition> | null;
166
+ /** Memo of {@link perceive} content-keyed (latin1). The general
167
+ * cache the result-level memos each partially compensate for. NOT
168
+ * bypassed under trace — perception emits no rationale steps.
169
+ * Null outside respond(). */
168
170
  perceiveMemo: Map<string, Sema> | null;
171
+ /** Subtree-resolution cache: Sema node → its store id and byte length.
172
+ * Populated by {@link foldTree} during inference; checked before
173
+ * walking children. When a conversation's pyramid reuses prefix
174
+ * subtrees, this cache lets {@link recognise} skip them entirely —
175
+ * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
176
+ * the Sema objects the pyramid keeps alive). */
177
+ _resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
169
178
  _edgeGuide: Vec | null;
170
179
  _edgeChoice: Map<number, number>;
171
180
  _prevSeen: Set<number> | null;
@@ -134,6 +134,18 @@ CREATE TABLE IF NOT EXISTS contain (
134
134
  id INTEGER PRIMARY KEY,
135
135
  parents BLOB NOT NULL
136
136
  );
137
+ -- CANONICAL-FORM index (Store.canonAdd/canonFind): h is the 32-bit hash of a
138
+ -- node's CANONICAL content key (the modality's canonicalizer output — case-
139
+ -- folded, whitespace-collapsed text, etc.; the store never sees the key
140
+ -- itself, only its hash). Same hash-then-verify discipline as idx_node_h:
141
+ -- the caller re-canonicalizes each candidate's bytes before trusting it, so
142
+ -- a collision costs a read, never a wrong id. WITHOUT ROWID on (h, id):
143
+ -- the composite key IS the lookup index and gives free dedup.
144
+ CREATE TABLE IF NOT EXISTS canon (
145
+ h INTEGER NOT NULL,
146
+ id INTEGER NOT NULL,
147
+ PRIMARY KEY (h, id)
148
+ ) WITHOUT ROWID;
137
149
  CREATE TABLE IF NOT EXISTS snapshot (
138
150
  id INTEGER PRIMARY KEY CHECK (id = 1),
139
151
  data BLOB NOT NULL
@@ -241,6 +253,10 @@ export class SQliteStore extends AbstractStore implements Store {
241
253
  private _selPrev: any = null;
242
254
  private _setMeta: any = null;
243
255
  private _getMeta: any = null;
256
+ private _insCanon: any = null;
257
+ private _selCanon: any = null;
258
+ private _cntCanon: any = null;
259
+ private _selContentFrom: any = null;
244
260
  private _delMeta: any = null;
245
261
  private _insSnapshot: any = null;
246
262
  private _selSnapshot: any = null;
@@ -990,6 +1006,58 @@ export class SQliteStore extends AbstractStore implements Store {
990
1006
  this._delMeta.run(key);
991
1007
  }
992
1008
 
1009
+ // -- Canonical-form index (Store optional capability) --
1010
+
1011
+ canonAdd(h: number, id: number): void {
1012
+ if (!this._insCanon) {
1013
+ this._insCanon = this.sqlite!.prepare(
1014
+ "INSERT OR IGNORE INTO canon (h, id) VALUES (?, ?)",
1015
+ );
1016
+ }
1017
+ // Join the deferred write transaction (committed by flush/commit), so a
1018
+ // bulk index build coalesces instead of paying autocommit per row.
1019
+ this._dbBeginTx();
1020
+ this._insCanon.run(h, id);
1021
+ }
1022
+
1023
+ canonFind(h: number): number[] {
1024
+ if (!this._selCanon) {
1025
+ this._selCanon = this.sqlite!.prepare(
1026
+ "SELECT id FROM canon WHERE h = ?",
1027
+ );
1028
+ }
1029
+ return (this._selCanon.all(h) as Array<{ id: number }>).map((r) => r.id);
1030
+ }
1031
+
1032
+ canonCount(): number {
1033
+ if (!this._cntCanon) {
1034
+ this._cntCanon = this.sqlite!.prepare(
1035
+ "SELECT count(*) AS c FROM canon",
1036
+ );
1037
+ }
1038
+ return (this._cntCanon.get() as { c: number }).c;
1039
+ }
1040
+
1041
+ eachContent(
1042
+ cb: (id: number, bytes: Uint8Array) => void,
1043
+ fromId = 0,
1044
+ ): void {
1045
+ if (!this._selContentFrom) {
1046
+ // Content-bearing nodes are FLAT branches: leaf present, kids an
1047
+ // empty (zero-length) blob — the same population PART 2 of the
1048
+ // diagnostics calls "distinct content spans".
1049
+ this._selContentFrom = this.sqlite!.prepare(
1050
+ "SELECT id, leaf FROM node " +
1051
+ "WHERE id >= ? AND leaf IS NOT NULL " +
1052
+ "AND (kids IS NULL OR length(kids) = 0)",
1053
+ );
1054
+ }
1055
+ for (const row of this._selContentFrom.iterate(fromId)) {
1056
+ const r = row as { id: number; leaf: Uint8Array };
1057
+ cb(r.id, new Uint8Array(r.leaf));
1058
+ }
1059
+ }
1060
+
993
1061
  // -- Snapshot --
994
1062
 
995
1063
  protected _dbSaveSnapshot(bytes: Uint8Array): void {
@@ -1048,6 +1116,10 @@ export class SQliteStore extends AbstractStore implements Store {
1048
1116
  return this.content ? this.content.physicalSize : 0;
1049
1117
  }
1050
1118
 
1119
+ protected _vecContentClusterCount(): number {
1120
+ return this.content ? this.content.clusterCount : 0;
1121
+ }
1122
+
1051
1123
  protected _vecContentCompact(): void {
1052
1124
  this.content!.compact();
1053
1125
  }
@@ -1099,6 +1171,10 @@ export class SQliteStore extends AbstractStore implements Store {
1099
1171
  return this.halos ? this.halos.physicalSize : 0;
1100
1172
  }
1101
1173
 
1174
+ protected _vecHaloClusterCount(): number {
1175
+ return this.halos ? this.halos.clusterCount : 0;
1176
+ }
1177
+
1102
1178
  protected _vecHaloCompact(): void {
1103
1179
  this.halos!.compact();
1104
1180
  }
package/src/store.ts CHANGED
@@ -417,6 +417,33 @@ export interface Store {
417
417
  * the node has no halo row. */
418
418
  haloMass(id: NodeId): number;
419
419
 
420
+ // ── canonical-form index (optional capability) ─────────────────────────
421
+ // A small hash index from CANONICAL content keys to node ids, enabling
422
+ // equivalence-class resolution ("WHAT" finds the stored "What") without
423
+ // the store knowing what the equivalence IS: the canonicalizer lives with
424
+ // the modality (see src/canon.ts) and is applied by the CALLER — the store
425
+ // only maps 32-bit key hashes to candidate ids, and the caller verifies
426
+ // canon(stored bytes) === key before trusting any candidate (the same
427
+ // hash-then-verify discipline as the node table's own `h` index).
428
+ // Backends that do not implement the capability leave all three absent;
429
+ // resolution then simply has no canonical fallback.
430
+
431
+ /** Record that node `id`'s canonical key hashes to `h`. Idempotent. */
432
+ canonAdd?(h: number, id: NodeId): void;
433
+ /** All candidate node ids whose canonical key hashes to `h` (collisions
434
+ * included — the caller verifies). */
435
+ canonFind?(h: number): NodeId[];
436
+ /** Number of (h, id) rows in the canon index — 0 means never built. */
437
+ canonCount?(): number;
438
+ /** Visit every content-bearing node (flat branch: `leaf` present, no
439
+ * kids) — the population a canonical index is built over. `fromId`
440
+ * restricts the scan to ids ≥ fromId, so an index refresh after further
441
+ * training only visits the new rows. */
442
+ eachContent?(
443
+ cb: (id: NodeId, bytes: Uint8Array) => void,
444
+ fromId?: NodeId,
445
+ ): void;
446
+
420
447
  // ── lifecycle ──────────────────────────────────────────────────────────
421
448
  size(): Promise<number>;
422
449
  saveSnapshot(bytes: Uint8Array): Promise<void>;
@@ -724,6 +751,7 @@ export abstract class AbstractStore implements Store {
724
751
  protected abstract _vecContentSize(): number;
725
752
  protected abstract _vecContentLastReads(): number;
726
753
  protected abstract _vecContentPhysicalSize(): number;
754
+ protected abstract _vecContentClusterCount(): number;
727
755
  protected abstract _vecContentCompact(): void;
728
756
  /** Live content-index entries whose INTERNAL id is > `after`, as
729
757
  * {ext, internal} pairs in internal-id order. Internal ids are monotone
@@ -759,8 +787,24 @@ export abstract class AbstractStore implements Store {
759
787
  * the tombstone-ratio compaction trigger compares physical size against. */
760
788
  protected abstract _vecHaloSize(): number;
761
789
  protected abstract _vecHaloPhysicalSize(): number;
790
+ protected abstract _vecHaloClusterCount(): number;
762
791
  protected abstract _vecHaloCompact(): void;
763
792
 
793
+ /** Derived query breadth for a partitioned index of C clusters: probe √C
794
+ * of them (the same √-of-the-population convention as the hub bound √N).
795
+ * The IVF maps ef → nprobe as ceil(ef/4), so ef = 4·⌈√C⌉ probes exactly
796
+ * ⌈√C⌉ clusters. A FIXED efSearch stops scaling the moment the
797
+ * collection outgrows it: at 4,270 clusters the default 64 probed 16
798
+ * clusters (0.4%), and an exact stored match of a query routinely sat in
799
+ * an unprobed cluster — recall silently degraded as the store grew. The
800
+ * configured efSearch remains the floor for small collections. */
801
+ protected efFor(clusterCount: number): number {
802
+ return Math.max(
803
+ this.efSearch,
804
+ 4 * Math.ceil(Math.sqrt(Math.max(1, clusterCount))),
805
+ );
806
+ }
807
+
764
808
  // ── Config ─────────────────────────────────────────────────────────────
765
809
 
766
810
  protected _D: number;
@@ -1615,7 +1659,7 @@ export abstract class AbstractStore implements Store {
1615
1659
  const results = this._vecContentQuery(
1616
1660
  normalize(copy(v)),
1617
1661
  k * this.overfetch,
1618
- this.efSearch,
1662
+ this.efFor(this._vecContentClusterCount()),
1619
1663
  );
1620
1664
  const out: Hit[] = [];
1621
1665
  for (const r of results) {
@@ -1966,7 +2010,7 @@ export abstract class AbstractStore implements Store {
1966
2010
  const results = this._vecHaloQuery(
1967
2011
  normalize(copy(v)),
1968
2012
  k * this.overfetch,
1969
- this.efSearch,
2013
+ this.efFor(this._vecHaloClusterCount()),
1970
2014
  );
1971
2015
  const out: Hit[] = [];
1972
2016
  for (const r of results) {