@seanhogg/builderforce-memory 2026.10.28 → 2026.10.29

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 (253) hide show
  1. package/dist/ingest/IngestionPipeline.d.ts +1 -8
  2. package/dist/ingest/IngestionPipeline.d.ts.map +1 -1
  3. package/dist/ingest/IngestionPipeline.js +3 -15
  4. package/dist/ingest/IngestionPipeline.js.map +1 -1
  5. package/dist/ingest/docsExtract.d.ts +30 -0
  6. package/dist/ingest/docsExtract.d.ts.map +1 -0
  7. package/dist/ingest/docsExtract.js +221 -0
  8. package/dist/ingest/docsExtract.js.map +1 -0
  9. package/dist/ingest/hash.d.ts +15 -0
  10. package/dist/ingest/hash.d.ts.map +1 -0
  11. package/dist/ingest/hash.js +22 -0
  12. package/dist/ingest/hash.js.map +1 -0
  13. package/dist/ingest/parsers.d.ts.map +1 -1
  14. package/dist/ingest/parsers.js +4 -0
  15. package/dist/ingest/parsers.js.map +1 -1
  16. package/dist/ingest/types.d.ts +7 -0
  17. package/dist/ingest/types.d.ts.map +1 -1
  18. package/dist/ingest/types.js.map +1 -1
  19. package/dist/knowledge/build.d.ts +24 -0
  20. package/dist/knowledge/build.d.ts.map +1 -0
  21. package/dist/knowledge/build.js +263 -0
  22. package/dist/knowledge/build.js.map +1 -0
  23. package/dist/knowledge/cards.d.ts +17 -0
  24. package/dist/knowledge/cards.d.ts.map +1 -0
  25. package/dist/knowledge/cards.js +118 -0
  26. package/dist/knowledge/cards.js.map +1 -0
  27. package/dist/knowledge/findings.d.ts +24 -0
  28. package/dist/knowledge/findings.d.ts.map +1 -0
  29. package/dist/knowledge/findings.js +152 -0
  30. package/dist/knowledge/findings.js.map +1 -0
  31. package/dist/knowledge/importance.d.ts +21 -0
  32. package/dist/knowledge/importance.d.ts.map +1 -0
  33. package/dist/knowledge/importance.js +70 -0
  34. package/dist/knowledge/importance.js.map +1 -0
  35. package/dist/knowledge/index.d.ts +31 -0
  36. package/dist/knowledge/index.d.ts.map +1 -0
  37. package/dist/knowledge/index.js +26 -0
  38. package/dist/knowledge/index.js.map +1 -0
  39. package/dist/knowledge/judge.d.ts +86 -0
  40. package/dist/knowledge/judge.d.ts.map +1 -0
  41. package/dist/knowledge/judge.js +213 -0
  42. package/dist/knowledge/judge.js.map +1 -0
  43. package/dist/knowledge/model.d.ts +333 -0
  44. package/dist/knowledge/model.d.ts.map +1 -0
  45. package/dist/knowledge/model.js +13 -0
  46. package/dist/knowledge/model.js.map +1 -0
  47. package/dist/knowledge/packs/docs.d.ts +17 -0
  48. package/dist/knowledge/packs/docs.d.ts.map +1 -0
  49. package/dist/knowledge/packs/docs.js +89 -0
  50. package/dist/knowledge/packs/docs.js.map +1 -0
  51. package/dist/knowledge/partitions.d.ts +31 -0
  52. package/dist/knowledge/partitions.d.ts.map +1 -0
  53. package/dist/knowledge/partitions.js +135 -0
  54. package/dist/knowledge/partitions.js.map +1 -0
  55. package/dist/knowledge/plan.d.ts +33 -0
  56. package/dist/knowledge/plan.d.ts.map +1 -0
  57. package/dist/knowledge/plan.js +89 -0
  58. package/dist/knowledge/plan.js.map +1 -0
  59. package/dist/knowledge/refs.d.ts +40 -0
  60. package/dist/knowledge/refs.d.ts.map +1 -0
  61. package/dist/knowledge/refs.js +102 -0
  62. package/dist/knowledge/refs.js.map +1 -0
  63. package/dist/knowledge/resolve.d.ts +35 -0
  64. package/dist/knowledge/resolve.d.ts.map +1 -0
  65. package/dist/knowledge/resolve.js +67 -0
  66. package/dist/knowledge/resolve.js.map +1 -0
  67. package/dist/knowledge/review.d.ts +23 -0
  68. package/dist/knowledge/review.d.ts.map +1 -0
  69. package/dist/knowledge/review.js +233 -0
  70. package/dist/knowledge/review.js.map +1 -0
  71. package/dist/knowledge/sources.d.ts +47 -0
  72. package/dist/knowledge/sources.d.ts.map +1 -0
  73. package/dist/knowledge/sources.js +136 -0
  74. package/dist/knowledge/sources.js.map +1 -0
  75. package/dist/knowledge/stores/localSnapshotStore.d.ts +50 -0
  76. package/dist/knowledge/stores/localSnapshotStore.d.ts.map +1 -0
  77. package/dist/knowledge/stores/localSnapshotStore.js +125 -0
  78. package/dist/knowledge/stores/localSnapshotStore.js.map +1 -0
  79. package/dist/knowledge/trace.d.ts +32 -0
  80. package/dist/knowledge/trace.d.ts.map +1 -0
  81. package/dist/knowledge/trace.js +102 -0
  82. package/dist/knowledge/trace.js.map +1 -0
  83. package/dist/knowledge/truncate.d.ts +24 -0
  84. package/dist/knowledge/truncate.d.ts.map +1 -0
  85. package/dist/knowledge/truncate.js +128 -0
  86. package/dist/knowledge/truncate.js.map +1 -0
  87. package/dist/memory/SemanticRecall.d.ts.map +1 -1
  88. package/dist/memory/SemanticRecall.js +3 -1
  89. package/dist/memory/SemanticRecall.js.map +1 -1
  90. package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
  91. package/dist/retrieval/HybridRetriever.js +3 -1
  92. package/dist/retrieval/HybridRetriever.js.map +1 -1
  93. package/dist/retrieval/bm25.d.ts +3 -2
  94. package/dist/retrieval/bm25.d.ts.map +1 -1
  95. package/dist/retrieval/bm25.js +5 -3
  96. package/dist/retrieval/bm25.js.map +1 -1
  97. package/dist/retrieval/fusion.d.ts +2 -1
  98. package/dist/retrieval/fusion.d.ts.map +1 -1
  99. package/dist/retrieval/fusion.js +4 -2
  100. package/dist/retrieval/fusion.js.map +1 -1
  101. package/dist/retrieval/hnsw.d.ts +49 -11
  102. package/dist/retrieval/hnsw.d.ts.map +1 -1
  103. package/dist/retrieval/hnsw.js +57 -29
  104. package/dist/retrieval/hnsw.js.map +1 -1
  105. package/dist/retrieval/index.d.ts +7 -3
  106. package/dist/retrieval/index.d.ts.map +1 -1
  107. package/dist/retrieval/index.js +5 -2
  108. package/dist/retrieval/index.js.map +1 -1
  109. package/dist/retrieval/knowledge-index/bytes.d.ts +43 -0
  110. package/dist/retrieval/knowledge-index/bytes.d.ts.map +1 -0
  111. package/dist/retrieval/knowledge-index/bytes.js +151 -0
  112. package/dist/retrieval/knowledge-index/bytes.js.map +1 -0
  113. package/dist/retrieval/knowledge-index/cascade.d.ts +37 -0
  114. package/dist/retrieval/knowledge-index/cascade.d.ts.map +1 -0
  115. package/dist/retrieval/knowledge-index/cascade.js +111 -0
  116. package/dist/retrieval/knowledge-index/cascade.js.map +1 -0
  117. package/dist/retrieval/knowledge-index/compact.d.ts +18 -0
  118. package/dist/retrieval/knowledge-index/compact.d.ts.map +1 -0
  119. package/dist/retrieval/knowledge-index/compact.js +64 -0
  120. package/dist/retrieval/knowledge-index/compact.js.map +1 -0
  121. package/dist/retrieval/knowledge-index/csr.d.ts +41 -0
  122. package/dist/retrieval/knowledge-index/csr.d.ts.map +1 -0
  123. package/dist/retrieval/knowledge-index/csr.js +76 -0
  124. package/dist/retrieval/knowledge-index/csr.js.map +1 -0
  125. package/dist/retrieval/knowledge-index/digest.d.ts +21 -0
  126. package/dist/retrieval/knowledge-index/digest.d.ts.map +1 -0
  127. package/dist/retrieval/knowledge-index/digest.js +50 -0
  128. package/dist/retrieval/knowledge-index/digest.js.map +1 -0
  129. package/dist/retrieval/knowledge-index/embeddings.d.ts +32 -0
  130. package/dist/retrieval/knowledge-index/embeddings.d.ts.map +1 -0
  131. package/dist/retrieval/knowledge-index/embeddings.js +78 -0
  132. package/dist/retrieval/knowledge-index/embeddings.js.map +1 -0
  133. package/dist/retrieval/knowledge-index/expand.d.ts +19 -0
  134. package/dist/retrieval/knowledge-index/expand.d.ts.map +1 -0
  135. package/dist/retrieval/knowledge-index/expand.js +49 -0
  136. package/dist/retrieval/knowledge-index/expand.js.map +1 -0
  137. package/dist/retrieval/knowledge-index/index.d.ts +40 -0
  138. package/dist/retrieval/knowledge-index/index.d.ts.map +1 -0
  139. package/dist/retrieval/knowledge-index/index.js +134 -0
  140. package/dist/retrieval/knowledge-index/index.js.map +1 -0
  141. package/dist/retrieval/knowledge-index/lexical.d.ts +26 -0
  142. package/dist/retrieval/knowledge-index/lexical.d.ts.map +1 -0
  143. package/dist/retrieval/knowledge-index/lexical.js +76 -0
  144. package/dist/retrieval/knowledge-index/lexical.js.map +1 -0
  145. package/dist/retrieval/knowledge-index/pairs.d.ts +22 -0
  146. package/dist/retrieval/knowledge-index/pairs.d.ts.map +1 -0
  147. package/dist/retrieval/knowledge-index/pairs.js +51 -0
  148. package/dist/retrieval/knowledge-index/pairs.js.map +1 -0
  149. package/dist/retrieval/knowledge-index/prune.d.ts +21 -0
  150. package/dist/retrieval/knowledge-index/prune.d.ts.map +1 -0
  151. package/dist/retrieval/knowledge-index/prune.js +43 -0
  152. package/dist/retrieval/knowledge-index/prune.js.map +1 -0
  153. package/dist/retrieval/knowledge-index/quantize.d.ts +37 -0
  154. package/dist/retrieval/knowledge-index/quantize.d.ts.map +1 -0
  155. package/dist/retrieval/knowledge-index/quantize.js +77 -0
  156. package/dist/retrieval/knowledge-index/quantize.js.map +1 -0
  157. package/dist/retrieval/knowledge-index/serialize.d.ts +18 -0
  158. package/dist/retrieval/knowledge-index/serialize.d.ts.map +1 -0
  159. package/dist/retrieval/knowledge-index/serialize.js +123 -0
  160. package/dist/retrieval/knowledge-index/serialize.js.map +1 -0
  161. package/dist/retrieval/knowledge-index/sha256.d.ts +30 -0
  162. package/dist/retrieval/knowledge-index/sha256.d.ts.map +1 -0
  163. package/dist/retrieval/knowledge-index/sha256.js +130 -0
  164. package/dist/retrieval/knowledge-index/sha256.js.map +1 -0
  165. package/dist/retrieval/knowledge-index/state.d.ts +55 -0
  166. package/dist/retrieval/knowledge-index/state.d.ts.map +1 -0
  167. package/dist/retrieval/knowledge-index/state.js +67 -0
  168. package/dist/retrieval/knowledge-index/state.js.map +1 -0
  169. package/dist/retrieval/knowledge-index/tokenize.d.ts +20 -0
  170. package/dist/retrieval/knowledge-index/tokenize.d.ts.map +1 -0
  171. package/dist/retrieval/knowledge-index/tokenize.js +42 -0
  172. package/dist/retrieval/knowledge-index/tokenize.js.map +1 -0
  173. package/dist/retrieval/knowledge-index/traverse.d.ts +26 -0
  174. package/dist/retrieval/knowledge-index/traverse.d.ts.map +1 -0
  175. package/dist/retrieval/knowledge-index/traverse.js +90 -0
  176. package/dist/retrieval/knowledge-index/traverse.js.map +1 -0
  177. package/dist/retrieval/knowledge-index/types.d.ts +85 -0
  178. package/dist/retrieval/knowledge-index/types.d.ts.map +1 -0
  179. package/dist/retrieval/knowledge-index/types.js +9 -0
  180. package/dist/retrieval/knowledge-index/types.js.map +1 -0
  181. package/dist/retrieval/mmr.d.ts +11 -4
  182. package/dist/retrieval/mmr.d.ts.map +1 -1
  183. package/dist/retrieval/mmr.js +28 -8
  184. package/dist/retrieval/mmr.js.map +1 -1
  185. package/dist/retrieval/order.d.ts +41 -0
  186. package/dist/retrieval/order.d.ts.map +1 -0
  187. package/dist/retrieval/order.js +54 -0
  188. package/dist/retrieval/order.js.map +1 -0
  189. package/dist/retrieval/weightedFusion.d.ts +2 -1
  190. package/dist/retrieval/weightedFusion.d.ts.map +1 -1
  191. package/dist/retrieval/weightedFusion.js +7 -5
  192. package/dist/retrieval/weightedFusion.js.map +1 -1
  193. package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -1
  194. package/dist/vectorstore/MemoryVectorStore.js +2 -1
  195. package/dist/vectorstore/MemoryVectorStore.js.map +1 -1
  196. package/package.json +8 -3
  197. package/src/ingest/IngestionPipeline.ts +3 -15
  198. package/src/ingest/docsExtract.ts +219 -0
  199. package/src/ingest/hash.ts +22 -0
  200. package/src/ingest/parsers.ts +4 -0
  201. package/src/ingest/types.ts +7 -0
  202. package/src/knowledge/build.test.ts +150 -0
  203. package/src/knowledge/build.ts +266 -0
  204. package/src/knowledge/cards.ts +130 -0
  205. package/src/knowledge/docs.test.ts +118 -0
  206. package/src/knowledge/findings.ts +159 -0
  207. package/src/knowledge/importance.ts +72 -0
  208. package/src/knowledge/index.ts +53 -0
  209. package/src/knowledge/judge.ts +231 -0
  210. package/src/knowledge/model.ts +312 -0
  211. package/src/knowledge/packs/docs.ts +91 -0
  212. package/src/knowledge/partitions.test.ts +98 -0
  213. package/src/knowledge/partitions.ts +119 -0
  214. package/src/knowledge/plan.ts +115 -0
  215. package/src/knowledge/refs.ts +108 -0
  216. package/src/knowledge/resolve.ts +78 -0
  217. package/src/knowledge/review.test.ts +180 -0
  218. package/src/knowledge/review.ts +235 -0
  219. package/src/knowledge/sources.ts +146 -0
  220. package/src/knowledge/stores/localSnapshotStore.test.ts +82 -0
  221. package/src/knowledge/stores/localSnapshotStore.ts +159 -0
  222. package/src/knowledge/testkit.test-util.ts +92 -0
  223. package/src/knowledge/trace.test.ts +91 -0
  224. package/src/knowledge/trace.ts +111 -0
  225. package/src/knowledge/truncate.ts +118 -0
  226. package/src/memory/SemanticRecall.ts +3 -1
  227. package/src/retrieval/HybridRetriever.ts +3 -1
  228. package/src/retrieval/bm25.ts +5 -3
  229. package/src/retrieval/fusion.ts +5 -2
  230. package/src/retrieval/hnsw.ts +82 -35
  231. package/src/retrieval/index.ts +17 -3
  232. package/src/retrieval/knowledge-index/bytes.ts +166 -0
  233. package/src/retrieval/knowledge-index/cascade.ts +125 -0
  234. package/src/retrieval/knowledge-index/compact.ts +68 -0
  235. package/src/retrieval/knowledge-index/csr.ts +102 -0
  236. package/src/retrieval/knowledge-index/digest.ts +54 -0
  237. package/src/retrieval/knowledge-index/embeddings.ts +85 -0
  238. package/src/retrieval/knowledge-index/expand.ts +55 -0
  239. package/src/retrieval/knowledge-index/index.ts +162 -0
  240. package/src/retrieval/knowledge-index/lexical.ts +78 -0
  241. package/src/retrieval/knowledge-index/pairs.ts +57 -0
  242. package/src/retrieval/knowledge-index/prune.ts +48 -0
  243. package/src/retrieval/knowledge-index/quantize.ts +78 -0
  244. package/src/retrieval/knowledge-index/serialize.ts +124 -0
  245. package/src/retrieval/knowledge-index/sha256.ts +122 -0
  246. package/src/retrieval/knowledge-index/state.ts +108 -0
  247. package/src/retrieval/knowledge-index/tokenize.ts +41 -0
  248. package/src/retrieval/knowledge-index/traverse.ts +95 -0
  249. package/src/retrieval/knowledge-index/types.ts +78 -0
  250. package/src/retrieval/mmr.ts +35 -10
  251. package/src/retrieval/order.ts +68 -0
  252. package/src/retrieval/weightedFusion.ts +8 -5
  253. package/src/vectorstore/MemoryVectorStore.ts +2 -1
@@ -10,11 +10,19 @@
10
10
  * above a threshold.
11
11
  *
12
12
  * Distance is cosine similarity (higher = closer); the graph stores `1 - cos`
13
- * as the metric so "nearer" is "smaller". Construction is deterministic given a
14
- * seed, so builds (and tests) reproduce exactly.
13
+ * as the metric so "nearer" is "smaller".
14
+ *
15
+ * Determinism: a node's level is {@link levelOf} its id — a geometric draw from
16
+ * the id's sha-256, never a sequential RNG — and every ordering (candidates,
17
+ * pruning, results) is {@link compareScoreThenId}: score, then id. So the graph
18
+ * depends only on the vectors and the insertion order, and
19
+ * {@link HnswIndex.canonicalBuild} (id-sorted inserts) removes the latter: the
20
+ * same set of (id, vector) gives the same graph whatever order it arrived in.
15
21
  */
16
22
 
17
23
  import { cosineSimilarity } from '../similarity/index.js';
24
+ import { compareScoreThenId, compareDenseHits, compareIds, DENSE_SCORE_QUANTUM } from './order.js';
25
+ import { sha256 } from './knowledge-index/sha256.js';
18
26
 
19
27
  export interface HnswOptions {
20
28
  /** Max neighbours per node on layers > 0. Default 16. */
@@ -23,7 +31,11 @@ export interface HnswOptions {
23
31
  efConstruction?: number;
24
32
  /** Default candidate-list size during search. Default 50. */
25
33
  efSearch?: number;
26
- /** Deterministic seed for layer assignment. Default 0x5eed. */
34
+ /**
35
+ * Retained for API compatibility. It no longer affects anything: levels come
36
+ * from each id's hash ({@link levelOf}), so a build does not depend on a seed
37
+ * or on how many inserts preceded a node.
38
+ */
27
39
  seed?: number;
28
40
  }
29
41
 
@@ -33,20 +45,38 @@ export interface SearchHit {
33
45
  score: number;
34
46
  }
35
47
 
48
+ /** Any numeric vector: a float embedding, or int8 codes (cosine over codes is exact). */
49
+ export type HnswVector = ArrayLike<number>;
50
+
36
51
  interface Node {
37
52
  id: string;
38
- vector: Float32Array;
53
+ vector: HnswVector;
39
54
  /** neighbours[layer] = list of node indices. */
40
55
  neighbours: number[][];
41
56
  }
42
57
 
43
- /** A small deterministic LCG → float in [0,1). Mirrors the engine's SeededRng. */
44
- function makeRng(seed: number): () => number {
45
- let s = (seed >>> 0) || 1;
46
- return () => {
47
- s = (Math.imul(1664525, s) + 1013904223) >>> 0;
48
- return s / 0x1_0000_0000;
49
- };
58
+ /** A read-only view of the built graph, for a consumer that stores it elsewhere (CSR). */
59
+ export interface HnswGraphView {
60
+ /** Node ids, by node index (insertion order). */
61
+ ids: readonly string[];
62
+ /** neighbours[node][layer] = node indices. */
63
+ neighbours: ReadonlyArray<ReadonlyArray<readonly number[]>>;
64
+ entry: number;
65
+ maxLayer: number;
66
+ }
67
+
68
+ /**
69
+ * The HNSW level of `id`: a geometric draw with P(level ≥ L) = M^-L, taken from the
70
+ * eight 32-bit words of sha-256(id) — each word below 2^32 / M is one more level.
71
+ * Integer-only, so it is the same on every platform and independent of insertion.
72
+ */
73
+ export function levelOf(id: string, M: number): number {
74
+ const h = sha256(id);
75
+ const view = new DataView(h.buffer, h.byteOffset, h.byteLength);
76
+ const threshold = 0x1_0000_0000 / M;
77
+ let level = 0;
78
+ while (level < 8 && view.getUint32(level * 4) < threshold) level++;
79
+ return level;
50
80
  }
51
81
 
52
82
  export class HnswIndex {
@@ -54,8 +84,6 @@ export class HnswIndex {
54
84
  private readonly _Mmax0: number;
55
85
  private readonly _efC: number;
56
86
  private readonly _efS: number;
57
- private readonly _mL: number;
58
- private readonly _rng: () => number;
59
87
 
60
88
  private readonly _nodes: Node[] = [];
61
89
  private readonly _idToIdx = new Map<string, number>();
@@ -67,8 +95,19 @@ export class HnswIndex {
67
95
  this._Mmax0 = this._M * 2;
68
96
  this._efC = Math.max(this._M, opts.efConstruction ?? 200);
69
97
  this._efS = Math.max(1, opts.efSearch ?? 50);
70
- this._mL = 1 / Math.log(this._M);
71
- this._rng = makeRng(opts.seed ?? 0x5eed);
98
+ }
99
+
100
+ /**
101
+ * Builds an index by inserting `items` in id order — the canonical build. The
102
+ * same (id, vector) set yields the same graph regardless of the input order.
103
+ * Ids are unique; for a duplicated id the first in input order wins.
104
+ */
105
+ static canonicalBuild(items: ReadonlyArray<{ id: string; vector: HnswVector }>, opts?: HnswOptions): HnswIndex {
106
+ const index = new HnswIndex(opts);
107
+ const sorted = items.map((it, i) => ({ it, i }))
108
+ .sort((a, b) => compareIds(a.it.id, b.it.id) || a.i - b.i);
109
+ for (const { it } of sorted) index.add(it.id, it.vector);
110
+ return index;
72
111
  }
73
112
 
74
113
  get size(): number {
@@ -79,18 +118,28 @@ export class HnswIndex {
79
118
  return this._idToIdx.has(id);
80
119
  }
81
120
 
82
- private _dist(a: Float32Array, b: Float32Array): number {
83
- return 1 - cosineSimilarity(a, b);
121
+ /** The built graph, by node index — what a CSR store and pruning read. */
122
+ graph(): HnswGraphView {
123
+ return {
124
+ ids: this._nodes.map((n) => n.id),
125
+ neighbours: this._nodes.map((n) => n.neighbours),
126
+ entry: this._entry,
127
+ maxLayer: this._maxLayer,
128
+ };
84
129
  }
85
130
 
86
- private _randomLayer(): number {
87
- return Math.floor(-Math.log(Math.max(this._rng(), 1e-12)) * this._mL);
131
+ private _dist(a: HnswVector, b: HnswVector): number {
132
+ return 1 - cosineSimilarity(a, b);
88
133
  }
89
134
 
135
+ /** Nearest first; equal (quantized) distances by id ascending. */
136
+ private readonly _byDist = (a: { idx: number; dist: number }, b: { idx: number; dist: number }): number =>
137
+ compareScoreThenId(1 - a.dist, this._nodes[a.idx]!.id, 1 - b.dist, this._nodes[b.idx]!.id, DENSE_SCORE_QUANTUM);
138
+
90
139
  /** Insert a vector. Ids are unique — re-adding a known id is ignored. */
91
- add(id: string, vector: Float32Array): void {
140
+ add(id: string, vector: HnswVector): void {
92
141
  if (this._idToIdx.has(id)) return;
93
- const layer = this._randomLayer();
142
+ const layer = levelOf(id, this._M);
94
143
  const node: Node = { id, vector, neighbours: Array.from({ length: layer + 1 }, () => []) };
95
144
  const idx = this._nodes.length;
96
145
  this._nodes.push(node);
@@ -130,8 +179,8 @@ export class HnswIndex {
130
179
  }
131
180
  }
132
181
 
133
- /** Top-k nearest by cosine similarity (descending). */
134
- search(query: Float32Array, k: number, ef = this._efS): SearchHit[] {
182
+ /** Top-k nearest by cosine similarity (descending; equal scores by id). */
183
+ search(query: HnswVector, k: number, ef = this._efS): SearchHit[] {
135
184
  if (this._entry === -1 || k <= 0) return [];
136
185
  let ep = this._entry;
137
186
  for (let lc = this._maxLayer; lc > 0; lc--) {
@@ -151,7 +200,7 @@ export class HnswIndex {
151
200
  // ── internals ──────────────────────────────────────────────────────────────
152
201
 
153
202
  /** Greedy hill-climb to the single closest node to `q` on layer `lc`. */
154
- private _greedyClosest(q: Float32Array, start: number, lc: number): number {
203
+ private _greedyClosest(q: HnswVector, start: number, lc: number): number {
155
204
  let best = start;
156
205
  let bestDist = this._dist(q, this._nodes[best]!.vector);
157
206
  let improved = true;
@@ -173,7 +222,7 @@ export class HnswIndex {
173
222
  * ef-search on a single layer: returns candidates sorted nearest-first.
174
223
  * A bounded best-first expansion (the standard HNSW layer search).
175
224
  */
176
- private _searchLayer(q: Float32Array, entry: number, ef: number, lc: number): Array<{ idx: number; dist: number }> {
225
+ private _searchLayer(q: HnswVector, entry: number, ef: number, lc: number): Array<{ idx: number; dist: number }> {
177
226
  const visited = new Set<number>([entry]);
178
227
  const entryDist = this._dist(q, this._nodes[entry]!.vector);
179
228
  // `candidates` is a min-heap-ish frontier; `results` keeps the best ef.
@@ -206,21 +255,21 @@ export class HnswIndex {
206
255
  }
207
256
  }
208
257
  }
209
- return results.sort((a, b) => a.dist - b.dist);
258
+ return results.sort(this._byDist);
210
259
  }
211
260
 
212
261
  /** Keep only the `Mmax` closest neighbours of `node` on layer `lc`. */
213
262
  private _prune(node: Node, lc: number, Mmax: number): void {
214
- const list = node.neighbours[lc]!;
215
- list.sort((a, b) => this._dist(node.vector, this._nodes[a]!.vector) - this._dist(node.vector, this._nodes[b]!.vector));
216
- node.neighbours[lc] = list.slice(0, Mmax);
263
+ const ranked = node.neighbours[lc]!.map((idx) => ({ idx, dist: this._dist(node.vector, this._nodes[idx]!.vector) }));
264
+ node.neighbours[lc] = ranked.sort(this._byDist).slice(0, Mmax).map((r) => r.idx);
217
265
  }
218
266
  }
219
267
 
220
268
  /**
221
269
  * Dense KNN with an exact-scan fast path: below `threshold` candidates a linear
222
- * cosine scan is simpler and faster; at/above it, build an HNSW index and query
223
- * it. Returned hits are cosine-similarity descending. Pure given its inputs.
270
+ * cosine scan is simpler and faster; at/above it, build an HNSW index (the
271
+ * canonical, id-ordered build) and query it. Returned hits are in
272
+ * {@link compareDenseHits} order. Pure given its inputs, and independent of their order.
224
273
  */
225
274
  export function denseSearch(
226
275
  query: Float32Array,
@@ -233,10 +282,8 @@ export function denseSearch(
233
282
  if (items.length < threshold) {
234
283
  return items
235
284
  .map((it) => ({ id: it.id, score: cosineSimilarity(query, it.vector) }))
236
- .sort((a, b) => b.score - a.score)
285
+ .sort(compareDenseHits)
237
286
  .slice(0, k);
238
287
  }
239
- const index = new HnswIndex(hnsw);
240
- for (const it of items) index.add(it.id, it.vector);
241
- return index.search(query, k);
288
+ return HnswIndex.canonicalBuild(items, hnsw).search(query, k);
242
289
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Retrieval layer — chunking, BM25, rank and score fusion, MMR, recency decay, the
3
- * similarity primitives they rest on, and the HybridRetriever.
3
+ * similarity primitives they rest on, the HybridRetriever, the ONE total order every
4
+ * ranker returns (`compareHits`), and the deterministic knowledge index.
4
5
  *
5
6
  * Zero-dependency and engine-free: this is the `@seanhogg/builderforce-memory/retrieval`
6
7
  * subpath, which a Cloudflare Worker, Node and the browser can all import without
@@ -41,5 +42,18 @@ export type {
41
42
  HybridHit,
42
43
  } from './HybridRetriever.js';
43
44
 
44
- export { HnswIndex, denseSearch } from './hnsw.js';
45
- export type { HnswOptions, SearchHit } from './hnsw.js';
45
+ export { HnswIndex, denseSearch, levelOf } from './hnsw.js';
46
+ export type { HnswOptions, SearchHit, HnswVector, HnswGraphView } from './hnsw.js';
47
+
48
+ export {
49
+ compareHits,
50
+ compareDenseHits,
51
+ compareScoreThenId,
52
+ compareIds,
53
+ quantizeScore,
54
+ DENSE_SCORE_QUANTUM,
55
+ LEXICAL_SCORE_QUANTUM,
56
+ } from './order.js';
57
+ export type { ScoredId } from './order.js';
58
+
59
+ export * from './knowledge-index/index.js';
@@ -0,0 +1,166 @@
1
+ /**
2
+ * bytes.ts — the little-endian byte writer/reader that `serialize` and `digest`
3
+ * share, so the persisted format and the hashed canonical state encode every
4
+ * field the same way. DataView only: no Node `Buffer`, no host endianness.
5
+ */
6
+
7
+ const encoder = new TextEncoder();
8
+ const decoder = new TextDecoder();
9
+
10
+ export class ByteWriter {
11
+ private _buf = new Uint8Array(1024);
12
+ private _view = new DataView(this._buf.buffer);
13
+ private _len = 0;
14
+
15
+ private _reserve(n: number): void {
16
+ if (this._len + n <= this._buf.length) return;
17
+ let size = this._buf.length * 2;
18
+ while (size < this._len + n) size *= 2;
19
+ const next = new Uint8Array(size);
20
+ next.set(this._buf.subarray(0, this._len));
21
+ this._buf = next;
22
+ this._view = new DataView(next.buffer);
23
+ }
24
+
25
+ u8(v: number): this {
26
+ this._reserve(1);
27
+ this._view.setUint8(this._len, v);
28
+ this._len += 1;
29
+ return this;
30
+ }
31
+
32
+ u32(v: number): this {
33
+ this._reserve(4);
34
+ this._view.setUint32(this._len, v >>> 0, true);
35
+ this._len += 4;
36
+ return this;
37
+ }
38
+
39
+ i32(v: number): this {
40
+ this._reserve(4);
41
+ this._view.setInt32(this._len, v, true);
42
+ this._len += 4;
43
+ return this;
44
+ }
45
+
46
+ f64(v: number): this {
47
+ this._reserve(8);
48
+ this._view.setFloat64(this._len, v, true);
49
+ this._len += 8;
50
+ return this;
51
+ }
52
+
53
+ /** Raw bytes, no length prefix. */
54
+ raw(bytes: Uint8Array): this {
55
+ this._reserve(bytes.length);
56
+ this._buf.set(bytes, this._len);
57
+ this._len += bytes.length;
58
+ return this;
59
+ }
60
+
61
+ /** Length-prefixed UTF-8. */
62
+ str(s: string): this {
63
+ const b = encoder.encode(s);
64
+ return this.u32(b.length).raw(b);
65
+ }
66
+
67
+ /** Count-prefixed u32 values. */
68
+ u32s(arr: ArrayLike<number>): this {
69
+ this.u32(arr.length);
70
+ this._reserve(arr.length * 4);
71
+ for (let i = 0; i < arr.length; i++) this._view.setUint32(this._len + i * 4, arr[i]! >>> 0, true);
72
+ this._len += arr.length * 4;
73
+ return this;
74
+ }
75
+
76
+ /** Count-prefixed f32 values. */
77
+ f32s(arr: ArrayLike<number>): this {
78
+ this.u32(arr.length);
79
+ this._reserve(arr.length * 4);
80
+ for (let i = 0; i < arr.length; i++) this._view.setFloat32(this._len + i * 4, arr[i]!, true);
81
+ this._len += arr.length * 4;
82
+ return this;
83
+ }
84
+
85
+ /** Count-prefixed bytes (an Int8Array's bytes are written as-is). */
86
+ bytes(arr: Uint8Array | Int8Array): this {
87
+ this.u32(arr.length);
88
+ return this.raw(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
89
+ }
90
+
91
+ finish(): Uint8Array {
92
+ return this._buf.slice(0, this._len);
93
+ }
94
+ }
95
+
96
+ export class ByteReader {
97
+ private readonly _view: DataView;
98
+ private _pos = 0;
99
+
100
+ constructor(private readonly _buf: Uint8Array) {
101
+ this._view = new DataView(_buf.buffer, _buf.byteOffset, _buf.byteLength);
102
+ }
103
+
104
+ private _need(n: number): void {
105
+ if (this._pos + n > this._buf.length) throw new Error('knowledge-index: truncated bytes');
106
+ }
107
+
108
+ u8(): number {
109
+ this._need(1);
110
+ return this._view.getUint8(this._pos++);
111
+ }
112
+
113
+ u32(): number {
114
+ this._need(4);
115
+ const v = this._view.getUint32(this._pos, true);
116
+ this._pos += 4;
117
+ return v;
118
+ }
119
+
120
+ i32(): number {
121
+ this._need(4);
122
+ const v = this._view.getInt32(this._pos, true);
123
+ this._pos += 4;
124
+ return v;
125
+ }
126
+
127
+ f64(): number {
128
+ this._need(8);
129
+ const v = this._view.getFloat64(this._pos, true);
130
+ this._pos += 8;
131
+ return v;
132
+ }
133
+
134
+ raw(n: number): Uint8Array {
135
+ this._need(n);
136
+ const out = this._buf.slice(this._pos, this._pos + n);
137
+ this._pos += n;
138
+ return out;
139
+ }
140
+
141
+ str(): string {
142
+ return decoder.decode(this.raw(this.u32()));
143
+ }
144
+
145
+ u32s(): Uint32Array {
146
+ const n = this.u32();
147
+ this._need(n * 4);
148
+ const out = new Uint32Array(n);
149
+ for (let i = 0; i < n; i++) out[i] = this._view.getUint32(this._pos + i * 4, true);
150
+ this._pos += n * 4;
151
+ return out;
152
+ }
153
+
154
+ f32s(): Float32Array {
155
+ const n = this.u32();
156
+ this._need(n * 4);
157
+ const out = new Float32Array(n);
158
+ for (let i = 0; i < n; i++) out[i] = this._view.getFloat32(this._pos + i * 4, true);
159
+ this._pos += n * 4;
160
+ return out;
161
+ }
162
+
163
+ bytes(): Uint8Array {
164
+ return this.raw(this.u32());
165
+ }
166
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * cascade.ts — the knowledge index's search: cheapest tier first.
3
+ *
4
+ * 1. exact — the folded `keys` map; a hit scores 1.0, and when the exact hits
5
+ * fill `k` nothing else runs;
6
+ * 2. lexical — BM25 over card text with the code-aware tokenizer;
7
+ * 3. dense — int8 graph search for `k × rerankFactor` candidates (plus the
8
+ * cards not yet compacted), then a float rerank over vectors
9
+ * recomputed through the hash-keyed LRU;
10
+ * 4. fuse — RRF over the lexical and dense lists; exact hits stay on top at
11
+ * 1.0; everything in {@link compareHits} order.
12
+ *
13
+ * Dense runs only with an embedder of the stored fingerprint — otherwise the tier
14
+ * is absent, never silently replaced.
15
+ */
16
+
17
+ import { reciprocalRankFusion } from '../fusion.js';
18
+ import { compareDenseHits, compareHits } from '../order.js';
19
+ import { cosineSimilarity } from '../../similarity/index.js';
20
+ import type { EmbedderPort, IndexHit, IndexTier } from './types.js';
21
+ import { availableTiers, graphNode, type DocEntry, type IndexState } from './state.js';
22
+ import type { EmbeddingCache } from './embeddings.js';
23
+ import { dotInt8, quantize } from './quantize.js';
24
+ import { searchGraph } from './traverse.js';
25
+ import { foldKey } from './tokenize.js';
26
+
27
+ export interface SearchContext {
28
+ state: IndexState;
29
+ embedder?: EmbedderPort;
30
+ /** The embedder's recompute cache; present whenever `embedder` is. */
31
+ cache?: EmbeddingCache;
32
+ }
33
+
34
+ export interface SearchOptions {
35
+ k?: number;
36
+ tiers?: IndexTier[];
37
+ }
38
+
39
+ /** Exact-tier ids for `q`, ascending. */
40
+ function exactIds(state: IndexState, q: string): string[] {
41
+ const ids = state.keys.get(foldKey(q));
42
+ return ids ? [...ids].sort() : [];
43
+ }
44
+
45
+ /**
46
+ * Dense candidates before the float rerank: the best `n` live graph nodes by
47
+ * integer dot product, plus the best `n` cards not yet in the graph. Ids, unordered
48
+ * between the two sources (the rerank orders them).
49
+ */
50
+ export async function denseCandidates(ctx: SearchContext, qv: Float32Array, n: number): Promise<string[]> {
51
+ const { state, cache } = ctx;
52
+ const out = new Set<string>();
53
+ const c = state.compacted;
54
+ if (c && state.scale > 0) {
55
+ const qc = quantize(qv, state.scale);
56
+ const recomputed = new Map<number, Int8Array>();
57
+ const codeOf = (node: number) => c.codes[node] ?? recomputed.get(node);
58
+ const found = await searchGraph(c.graph, {
59
+ score: (node) => {
60
+ const code = codeOf(node);
61
+ return code ? dotInt8(qc, code) : -Infinity;
62
+ },
63
+ ensure: cache && state.params.lean ? (nodes) => {
64
+ const live = nodes.filter((node) => !codeOf(node) && graphNode(state, c.ids[node]!) === node);
65
+ if (live.length === 0) return undefined;
66
+ return cache.vectors(live.map((node) => state.docs.get(c.ids[node]!)!)).then((vecs) => {
67
+ live.forEach((node, i) => recomputed.set(node, quantize(vecs[i]!, state.scale)));
68
+ });
69
+ } : undefined,
70
+ }, Math.max(state.params.efSearch, n));
71
+ for (const hit of found) {
72
+ if (out.size >= n) break;
73
+ if (hit.score !== -Infinity && graphNode(state, c.ids[hit.node]!) === hit.node) out.add(c.ids[hit.node]!);
74
+ }
75
+ }
76
+ const pending: Array<{ id: string; score: number }> = [];
77
+ for (const doc of state.docs.values()) {
78
+ if (doc.vec && graphNode(state, doc.id) < 0) pending.push({ id: doc.id, score: cosineSimilarity(qv, doc.vec) });
79
+ }
80
+ for (const hit of pending.sort(compareDenseHits).slice(0, n)) out.add(hit.id);
81
+ return [...out];
82
+ }
83
+
84
+ /** The dense tier: candidates, then float recompute + rerank, best first. */
85
+ async function denseRank(ctx: SearchContext, q: string, n: number): Promise<Array<{ id: string; score: number }>> {
86
+ const cache = ctx.cache!;
87
+ const qv = await cache.query(q);
88
+ const ids = await denseCandidates(ctx, qv, n);
89
+ const docs = ids.map((id) => ctx.state.docs.get(id)!);
90
+ const needVec = docs.filter((d) => !d.vec);
91
+ const fresh = await cache.vectors(needVec);
92
+ const vecOf = new Map<DocEntry, Float32Array>(needVec.map((d, i) => [d, fresh[i]!]));
93
+ return docs
94
+ .map((d) => ({ id: d.id, score: cosineSimilarity(qv, d.vec ?? vecOf.get(d)!) }))
95
+ .sort(compareDenseHits)
96
+ .slice(0, n);
97
+ }
98
+
99
+ /** Runs the cascade. A pure function of the state (its digest), `q` and `opts`. */
100
+ export async function cascadeSearch(ctx: SearchContext, q: string, opts: SearchOptions = {}): Promise<IndexHit[]> {
101
+ const { state } = ctx;
102
+ const k = Math.max(0, opts.k ?? 10);
103
+ if (k === 0 || state.docs.size === 0) return [];
104
+ const available = availableTiers(state, ctx.embedder);
105
+ const run = new Set((opts.tiers ?? available).filter((t) => available.includes(t)));
106
+
107
+ const exact: IndexHit[] = run.has('exact')
108
+ ? exactIds(state, q).map((id) => ({ id, score: 1, tier: 'exact' as const }))
109
+ : [];
110
+ if (exact.length >= k) return exact.slice(0, k);
111
+ const exactSet = new Set(exact.map((h) => h.id));
112
+
113
+ const n = Math.max(k, k * state.params.rerankFactor);
114
+ const lexical = run.has('lexical')
115
+ ? state.lexical.search(q, n + exact.length).filter((h) => !exactSet.has(h.id)).map((h) => h.id)
116
+ : [];
117
+ const dense = run.has('dense')
118
+ ? (await denseRank(ctx, q, n + exact.length)).filter((h) => !exactSet.has(h.id)).map((h) => h.id)
119
+ : [];
120
+
121
+ const lexicalSet = new Set(lexical);
122
+ const fused = reciprocalRankFusion([{ ids: lexical }, { ids: dense }].filter((l) => l.ids.length > 0), state.params.rrfK)
123
+ .map((h): IndexHit => ({ id: h.id, score: h.score, tier: lexicalSet.has(h.id) ? 'lexical' : 'dense' }));
124
+ return [...exact, ...fused].sort(compareHits).slice(0, k);
125
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * compact.ts — the canonical rebuild.
3
+ *
4
+ * Every live card, in id order, is coded (an unchanged node keeps its code; a new
5
+ * or changed card is quantized from its float vector) and the graph is rebuilt
6
+ * from those codes. Since levels come from id hashes, inserts are id-ordered and
7
+ * every tie breaks on id, the same card set and fingerprint give the same bytes
8
+ * whatever order the upserts arrived in.
9
+ *
10
+ * The int8 scale is fitted at the first compaction and fixed after it. An embedder
11
+ * whose fingerprint differs from the stored one is never mixed in: compaction
12
+ * drops every stored vector and code and re-embeds all cards under it.
13
+ */
14
+
15
+ import { compareIds } from '../order.js';
16
+ import type { EmbedderPort } from './types.js';
17
+ import { graphNode, type DocEntry, type IndexState } from './state.js';
18
+ import type { EmbeddingCache } from './embeddings.js';
19
+ import { fitScale, quantize } from './quantize.js';
20
+ import { buildGraph } from './csr.js';
21
+
22
+ /** Float vectors for the docs that have neither a reusable code nor a pending vector. */
23
+ async function vectorsFor(cache: EmbeddingCache, docs: readonly DocEntry[]): Promise<Map<string, Float32Array>> {
24
+ const vecs = await cache.vectors(docs);
25
+ return new Map(docs.map((d, i) => [d.id, vecs[i]!]));
26
+ }
27
+
28
+ export async function compactState(state: IndexState, embedder: EmbedderPort | undefined, cache: EmbeddingCache | undefined): Promise<void> {
29
+ // Nothing to code with: exact and lexical need no compaction, and a stored
30
+ // graph of another fingerprint is kept for an embedder that matches it.
31
+ if (!embedder || !cache) return;
32
+ if (embedder.fingerprint !== state.fingerprint || embedder.dims !== state.dims) {
33
+ state.fingerprint = embedder.fingerprint;
34
+ state.dims = embedder.dims;
35
+ state.scale = 0;
36
+ state.compacted = null;
37
+ for (const doc of state.docs.values()) doc.vec = undefined;
38
+ }
39
+
40
+ const ids = [...state.docs.keys()].sort(compareIds);
41
+ const docs = ids.map((id) => state.docs.get(id)!);
42
+ if (docs.length === 0) {
43
+ state.compacted = null;
44
+ return;
45
+ }
46
+
47
+ const old = state.compacted;
48
+ const reused = docs.map((d) => {
49
+ const node = graphNode(state, d.id);
50
+ return node >= 0 && state.scale > 0 ? old!.codes[node] : undefined;
51
+ });
52
+ const missing = await vectorsFor(cache, docs.filter((d, i) => !reused[i] && !d.vec));
53
+ const floatOf = (d: DocEntry) => d.vec ?? missing.get(d.id)!;
54
+
55
+ if (state.scale === 0) state.scale = fitScale(docs.map(floatOf));
56
+ const codes = docs.map((d, i) => reused[i] ?? quantize(floatOf(d), state.scale));
57
+
58
+ const { graph, hubs } = buildGraph(ids, codes, state.params);
59
+ state.compacted = {
60
+ ids,
61
+ hashes: docs.map((d) => d.hash),
62
+ codes: state.params.lean ? codes.map((code, i) => (hubs[i] ? code : undefined)) : codes,
63
+ hubs,
64
+ graph,
65
+ nodeOf: new Map(ids.map((id, i) => [id, i])),
66
+ };
67
+ for (const doc of docs) doc.vec = undefined;
68
+ }