@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
@@ -0,0 +1,159 @@
1
+ /**
2
+ * knowledge/stores/localSnapshotStore.ts — a {@link KnowledgeStore} over one JSON snapshot,
3
+ * for a folder not linked to a project (PRD 38 §4.2, §5.1).
4
+ *
5
+ * Persistence is a port (`read`/`write` of the whole snapshot string), so this module has
6
+ * no `fs`: the memory MCP host persists it beside its own snapshot through its shared JSON
7
+ * file, a browser host in IndexedDB. Upserts use the platform's unique keys — a fact by
8
+ * `key`, a relation by `(from, to, kind)`, a scope by `rootId` — so linking a folder later
9
+ * uploads the same rows idempotently. The snapshot is written canonically (sorted), so
10
+ * equal content is equal bytes.
11
+ */
12
+
13
+ import type {
14
+ Extracted, KnowledgeStore, NodeRef, Scope, ScopeRecord, StoredFact, StoredRelation,
15
+ } from '../model.js';
16
+ import { canonicalJson, compareStr, pathOfRef } from '../refs.js';
17
+
18
+ export interface SnapshotPersistence {
19
+ read(): Promise<string | undefined>;
20
+ write(snapshot: string): Promise<void>;
21
+ }
22
+
23
+ export interface KnowledgeSnapshot {
24
+ version: 1;
25
+ scopes: ScopeRecord[];
26
+ facts: Record<string, StoredFact[]>;
27
+ relations: Record<string, StoredRelation[]>;
28
+ extracted: Record<string, Extracted[]>;
29
+ }
30
+
31
+ export interface LocalSnapshotStoreOptions {
32
+ /** ISO clock for `staleAt`. Default `new Date().toISOString()`. */
33
+ now?(): string;
34
+ }
35
+
36
+ const relKey = (r: Pick<StoredRelation, 'from' | 'to' | 'kind'>) => `${r.from}\u0000${r.to}\u0000${r.kind}`;
37
+
38
+ function empty(): KnowledgeSnapshot {
39
+ return { version: 1, scopes: [], facts: {}, relations: {}, extracted: {} };
40
+ }
41
+
42
+ function parse(text: string | undefined): KnowledgeSnapshot {
43
+ if (!text) return empty();
44
+ try {
45
+ const s = JSON.parse(text) as Partial<KnowledgeSnapshot>;
46
+ return {
47
+ version: 1,
48
+ scopes: Array.isArray(s.scopes) ? s.scopes : [],
49
+ facts: s.facts && typeof s.facts === 'object' ? s.facts : {},
50
+ relations: s.relations && typeof s.relations === 'object' ? s.relations : {},
51
+ extracted: s.extracted && typeof s.extracted === 'object' ? s.extracted : {},
52
+ };
53
+ } catch {
54
+ // A corrupt snapshot is rebuilt by the next review rather than failing every read.
55
+ return empty();
56
+ }
57
+ }
58
+
59
+ export class LocalSnapshotStore implements KnowledgeStore {
60
+ private snapshot: KnowledgeSnapshot | undefined;
61
+ /** Serializes mutations so two writers never interleave a read-modify-write. */
62
+ private queue: Promise<unknown> = Promise.resolve();
63
+ private readonly now: () => string;
64
+
65
+ constructor(private readonly persistence: SnapshotPersistence, opts: LocalSnapshotStoreOptions = {}) {
66
+ this.now = opts.now ?? (() => new Date().toISOString());
67
+ }
68
+
69
+ private async load(): Promise<KnowledgeSnapshot> {
70
+ if (!this.snapshot) this.snapshot = parse(await this.persistence.read());
71
+ return this.snapshot;
72
+ }
73
+
74
+ private mutate(fn: (s: KnowledgeSnapshot) => void): Promise<void> {
75
+ const run = this.queue.then(async () => {
76
+ const s = await this.load();
77
+ fn(s);
78
+ await this.persistence.write(serialize(s));
79
+ });
80
+ this.queue = run.catch(() => undefined);
81
+ return run;
82
+ }
83
+
84
+ async scopes(): Promise<ScopeRecord[]> {
85
+ await this.queue;
86
+ return [...(await this.load()).scopes];
87
+ }
88
+
89
+ async facts(scope: Scope): Promise<StoredFact[]> {
90
+ await this.queue;
91
+ return [...((await this.load()).facts[scope.rootId] ?? [])];
92
+ }
93
+
94
+ async relations(scope: Scope): Promise<StoredRelation[]> {
95
+ await this.queue;
96
+ return [...((await this.load()).relations[scope.rootId] ?? [])];
97
+ }
98
+
99
+ write(batch: { scope: ScopeRecord; facts: StoredFact[]; relations: StoredRelation[] }): Promise<void> {
100
+ const bad = [...batch.facts, ...batch.relations].find((x) => (x.origin as string) === 'extracted');
101
+ if (bad) return Promise.reject(new Error('knowledge: extracted output is never stored'));
102
+ return this.mutate((s) => {
103
+ const rootId = batch.scope.rootId;
104
+ const prior = s.scopes.find((x) => x.rootId === rootId);
105
+ const merged: ScopeRecord = { ...(prior ?? {}), ...stripUndefined(batch.scope) } as ScopeRecord;
106
+ s.scopes = [...s.scopes.filter((x) => x.rootId !== rootId), merged];
107
+
108
+ const facts = new Map((s.facts[rootId] ?? []).map((f) => [f.key, f] as const));
109
+ for (const f of batch.facts) facts.set(f.key, { ...f });
110
+ s.facts[rootId] = [...facts.values()];
111
+
112
+ const relations = new Map((s.relations[rootId] ?? []).map((r) => [relKey(r), r] as const));
113
+ for (const r of batch.relations) relations.set(relKey(r), { ...r });
114
+ s.relations[rootId] = [...relations.values()];
115
+ });
116
+ }
117
+
118
+ markStale(scope: Scope, sourceRef: NodeRef, currentHash: string): Promise<void> {
119
+ const path = pathOfRef(sourceRef);
120
+ return this.mutate((s) => {
121
+ const at = this.now();
122
+ const onSource = (ref: NodeRef) => path !== undefined && pathOfRef(ref) === path;
123
+ s.relations[scope.rootId] = (s.relations[scope.rootId] ?? []).map((r) =>
124
+ r.origin === 'model' && !r.staleAt && r.sourceHash !== currentHash && (onSource(r.from) || onSource(r.to))
125
+ ? { ...r, staleAt: at }
126
+ : r);
127
+ });
128
+ }
129
+
130
+ async getExtracted(hash: string): Promise<Extracted[] | undefined> {
131
+ await this.queue;
132
+ return (await this.load()).extracted[hash];
133
+ }
134
+
135
+ putExtracted(hash: string, extracted: Extracted[]): Promise<void> {
136
+ return this.mutate((s) => { s.extracted[hash] = extracted; });
137
+ }
138
+ }
139
+
140
+ export function localSnapshotStore(persistence: SnapshotPersistence, opts?: LocalSnapshotStoreOptions): LocalSnapshotStore {
141
+ return new LocalSnapshotStore(persistence, opts);
142
+ }
143
+
144
+ function stripUndefined<T extends object>(o: T): Partial<T> {
145
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
146
+ }
147
+
148
+ /** Canonical: scopes by rootId, facts by key, relations by (from, to, kind), keys sorted. */
149
+ function serialize(s: KnowledgeSnapshot): string {
150
+ const sortRecord = <T>(rec: Record<string, T[]>, by: (a: T, b: T) => number) =>
151
+ Object.fromEntries(Object.keys(rec).sort(compareStr).map((k) => [k, [...rec[k]!].sort(by)]));
152
+ return canonicalJson({
153
+ version: 1,
154
+ scopes: [...s.scopes].sort((a, b) => compareStr(a.rootId, b.rootId)),
155
+ facts: sortRecord(s.facts, (a, b) => compareStr(a.key, b.key)),
156
+ relations: sortRecord(s.relations, (a, b) => compareStr(relKey(a), relKey(b))),
157
+ extracted: s.extracted,
158
+ });
159
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared fixtures for the knowledge tests: an in-memory DirectorySource that records its
3
+ * reads, an in-memory snapshot persistence, a fake JudgementPort and node/edge builders.
4
+ */
5
+
6
+ import { hashText } from '../ingest/hash.js';
7
+ import type {
8
+ DirectorySource, Extracted, JudgementPort, JudgementRequest, JudgementResult, KEdge, KNode, NodeRef,
9
+ } from './model.js';
10
+ import { LocalSnapshotStore, type SnapshotPersistence } from './stores/localSnapshotStore.js';
11
+ import { srcRef } from './refs.js';
12
+
13
+ export const ROOT = 'r';
14
+
15
+ export interface SpySource extends DirectorySource {
16
+ files: Record<string, string>;
17
+ reads: string[];
18
+ }
19
+
20
+ export function memSource(files: Record<string, string>, rootId = ROOT): SpySource {
21
+ const src: SpySource = {
22
+ rootId, title: 'Fixture', files, reads: [],
23
+ async *list() {
24
+ for (const path of Object.keys(src.files)) {
25
+ const text = src.files[path]!;
26
+ yield { path, hash: hashText(text), size: text.length };
27
+ }
28
+ },
29
+ async read(path: string) {
30
+ src.reads.push(path);
31
+ return { id: path, tenantId: 't', content: { kind: 'text', text: src.files[path]! } };
32
+ },
33
+ };
34
+ return src;
35
+ }
36
+
37
+ export function memPersistence(): SnapshotPersistence & { value?: string } {
38
+ const p: SnapshotPersistence & { value?: string } = {
39
+ async read() { return p.value; },
40
+ async write(s: string) { p.value = s; },
41
+ };
42
+ return p;
43
+ }
44
+
45
+ export function memStore(): LocalSnapshotStore {
46
+ return new LocalSnapshotStore(memPersistence(), { now: () => '2026-10-28T00:00:00.000Z' });
47
+ }
48
+
49
+ /** Answers every extract with one claim + one entity, every compare with `contradicts`. */
50
+ export function fakeJudge(
51
+ shape: (req: JudgementRequest) => JudgementResult = defaultJudgement,
52
+ ): JudgementPort & { requests: JudgementRequest[] } {
53
+ const port = {
54
+ requests: [] as JudgementRequest[],
55
+ async judge(req: JudgementRequest) {
56
+ port.requests.push(req);
57
+ return shape(req);
58
+ },
59
+ };
60
+ return port;
61
+ }
62
+
63
+ function defaultJudgement(req: JudgementRequest): JudgementResult {
64
+ if (req.kind === 'compare') {
65
+ return { compare: [{ a: req.items[0]!.ref, b: req.items[1]!.ref, relation: 'contradicts', confidence: 0.9 }], invalid: 0 };
66
+ }
67
+ return {
68
+ extract: req.items.map((it) => ({
69
+ ref: it.ref,
70
+ claims: [{ subject: 'Lease', predicate: 'ends', object: it.text.split(/\s+/)[0] ?? 'x', quote: it.text.slice(0, 20) }],
71
+ entities: [{ name: 'Lease', type: 'contract' }],
72
+ })),
73
+ invalid: 0,
74
+ };
75
+ }
76
+
77
+ export function fileNode(path: string, extra: Partial<KNode> = {}): KNode {
78
+ return {
79
+ ref: srcRef(ROOT, path), kind: 'file', label: path, pack: 'docs', importance: 0, origin: 'extracted',
80
+ sourceHash: `h:${path}`, ...extra,
81
+ };
82
+ }
83
+
84
+ export function edge(from: string, to: string, kind = 'links', extra: Partial<KEdge> = {}): KEdge {
85
+ return { from: srcRef(ROOT, from), to: srcRef(ROOT, to), kind, weight: 1, origin: 'extracted', ...extra };
86
+ }
87
+
88
+ export function extractedOf(node: KNode, edges: KEdge[] = []): Extracted {
89
+ return { sourceRef: node.ref, sourceHash: node.sourceHash ?? '', nodes: [node], edges, keys: [] };
90
+ }
91
+
92
+ export const ref = (path: string): NodeRef => srcRef(ROOT, path);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * src/knowledge/trace.test.ts
3
+ * traceGraph's order (weight, then ref; each node once; back/cycle steps) and the Mermaid
4
+ * sequence diagram drawn from it.
5
+ */
6
+
7
+ import { toMermaid, traceGraph } from './trace.js';
8
+ import type { KnowledgeGraph, NodeRef, PartitionRegistry } from './model.js';
9
+ import { edge, fileNode, ref, ROOT } from './testkit.test-util.js';
10
+
11
+ const REG: PartitionRegistry = {
12
+ partitions: [
13
+ { id: 'presentation', order: 0, match: ['ui/**'] },
14
+ { id: 'application', order: 1, match: ['app/**'] },
15
+ { id: 'domain', order: 2, match: ['dom/**'] },
16
+ { id: 'infrastructure', order: 3, match: ['infra/**'] },
17
+ ],
18
+ allow: {},
19
+ };
20
+
21
+ const graph: KnowledgeGraph = {
22
+ scope: { rootId: ROOT, packs: ['code'], digest: 'd' },
23
+ nodes: [
24
+ fileNode('ui/A.ts', { partition: 'presentation' }),
25
+ fileNode('app/B.ts', { partition: 'application' }),
26
+ fileNode('dom/C.ts', { partition: 'domain' }),
27
+ fileNode('infra/D.ts', { partition: 'infrastructure' }),
28
+ ],
29
+ edges: [
30
+ edge('ui/A.ts', 'dom/C.ts', 'references'),
31
+ edge('ui/A.ts', 'app/B.ts', 'references', { weight: 2 }),
32
+ edge('app/B.ts', 'infra/D.ts', 'references', { verdict: 'skips' }),
33
+ edge('app/B.ts', 'dom/C.ts', 'references'),
34
+ edge('dom/C.ts', 'ui/A.ts', 'references', { cycle: true }),
35
+ edge('ui/A.ts', 'infra/D.ts', 'contains'),
36
+ ],
37
+ findings: [],
38
+ stats: { sources: 4, nodes: 4, edges: 6, stale: 0, truncated: false },
39
+ };
40
+
41
+ const name = (r: NodeRef) => r.slice(r.lastIndexOf('/') + 1, -3);
42
+
43
+ describe('traceGraph', () => {
44
+ const t = traceGraph(graph, ref('ui/A.ts'), { edgeKinds: ['references'], registry: REG });
45
+
46
+ it('walks heaviest edge first, then by ref; each node once; marks back and cycle steps', () => {
47
+ expect(t.steps.map((s) => `${name(s.from)}>${name(s.to)}@${s.depth}${s.back ? ' back' : ''}${s.cycle ? ' cycle' : ''}`)).toEqual([
48
+ 'A>B@1',
49
+ 'B>C@2',
50
+ 'C>A@3 back cycle',
51
+ 'B>D@2',
52
+ 'A>C@1 back',
53
+ ]);
54
+ expect(t.truncated).toBe(false);
55
+ });
56
+
57
+ it('orders lanes by partition order', () => {
58
+ expect(t.lanes).toEqual(['presentation', 'application', 'domain', 'infrastructure']);
59
+ });
60
+
61
+ it('is stable across edge order', () => {
62
+ const shuffled = { ...graph, edges: [...graph.edges].reverse() };
63
+ expect(traceGraph(shuffled, ref('ui/A.ts'), { edgeKinds: ['references'], registry: REG })).toEqual(t);
64
+ });
65
+
66
+ it('stops at maxSteps and maxDepth', () => {
67
+ expect(traceGraph(graph, ref('ui/A.ts'), { edgeKinds: ['references'], maxSteps: 2 })).toMatchObject({ truncated: true });
68
+ const shallow = traceGraph(graph, ref('ui/A.ts'), { edgeKinds: ['references'], maxDepth: 1 });
69
+ expect(shallow.steps.every((s) => s.depth === 1)).toBe(true);
70
+ expect(shallow.truncated).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('toMermaid', () => {
75
+ it('draws lanes as participants and steps as messages', () => {
76
+ const t = traceGraph(graph, ref('ui/A.ts'), { edgeKinds: ['references'], registry: REG });
77
+ expect(toMermaid(t, name)).toBe([
78
+ 'sequenceDiagram',
79
+ ' participant P0 as presentation',
80
+ ' participant P1 as application',
81
+ ' participant P2 as domain',
82
+ ' participant P3 as infrastructure',
83
+ ' P0->>P1: A references B',
84
+ ' P1->>P2: B references C',
85
+ ' P2-->>P0: C references A [cycle]',
86
+ ' P1->>P3: B references D [skips]',
87
+ ' P0-->>P2: A references C',
88
+ '',
89
+ ].join('\n'));
90
+ });
91
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * knowledge/trace.ts — "how does this reach that" (PRD 38 §3.4).
3
+ *
4
+ * `traceGraph` walks the chosen edge kinds depth-first from one node: heaviest edge first,
5
+ * then by target ref; each node is entered once; reaching a visited node is a `back` step
6
+ * (also `cycle` when the edge carries the cycle flag). Lanes are the partitions the trace
7
+ * touches — code layers or doc folders — and `toMermaid` draws them as a sequence diagram.
8
+ */
9
+
10
+ import type { KEdge, KnowledgeGraph, NodeRef, PartitionRegistry, Trace, TraceStep } from './model.js';
11
+ import { partitionOrder } from './partitions.js';
12
+ import { compareStr } from './refs.js';
13
+
14
+ export const DEFAULT_TRACE_EDGE_KINDS: readonly string[] = ['references', 'links', 'mentions'];
15
+ export const DEFAULT_TRACE_DEPTH = 4;
16
+ export const DEFAULT_TRACE_STEPS = 60;
17
+ /** The lane of a node with no partition. */
18
+ export const UNPARTITIONED_LANE = '(none)';
19
+
20
+ /** A trace that also knows each ref's lane (what {@link toMermaid} draws participants from). */
21
+ export interface LanedTrace extends Trace {
22
+ laneOf: Record<string, string>;
23
+ }
24
+
25
+ export interface TraceOptions {
26
+ edgeKinds?: string[];
27
+ maxDepth?: number;
28
+ maxSteps?: number;
29
+ /** Orders lanes by partition order; without it lanes keep first-visit order. */
30
+ registry?: PartitionRegistry;
31
+ }
32
+
33
+ export function traceGraph(g: KnowledgeGraph, from: NodeRef, opts: TraceOptions = {}): LanedTrace {
34
+ const kinds = new Set(opts.edgeKinds ?? DEFAULT_TRACE_EDGE_KINDS);
35
+ const maxDepth = opts.maxDepth ?? DEFAULT_TRACE_DEPTH;
36
+ const maxSteps = opts.maxSteps ?? DEFAULT_TRACE_STEPS;
37
+ const partition = new Map(g.nodes.map((n) => [n.ref, n.partition] as const));
38
+ const out = new Map<NodeRef, KEdge[]>();
39
+ for (const e of g.edges) {
40
+ if (!kinds.has(e.kind)) continue;
41
+ const list = out.get(e.from) ?? [];
42
+ list.push(e);
43
+ out.set(e.from, list);
44
+ }
45
+ for (const list of out.values()) {
46
+ list.sort((a, b) => b.weight - a.weight || compareStr(a.to, b.to) || compareStr(a.kind, b.kind));
47
+ }
48
+
49
+ const steps: TraceStep[] = [];
50
+ const visited = new Set<NodeRef>([from]);
51
+ let truncated = false;
52
+ const walk = (node: NodeRef, depth: number): void => {
53
+ for (const e of out.get(node) ?? []) {
54
+ if (steps.length >= maxSteps) { truncated = true; return; }
55
+ const back = visited.has(e.to);
56
+ steps.push({
57
+ from: e.from, to: e.to, depth: depth + 1, kind: e.kind,
58
+ ...(e.verdict ? { verdict: e.verdict } : {}),
59
+ ...(back && e.cycle ? { cycle: true } : {}),
60
+ ...(back ? { back: true } : {}),
61
+ });
62
+ if (back) continue;
63
+ visited.add(e.to);
64
+ if (depth + 1 < maxDepth) walk(e.to, depth + 1);
65
+ else if ((out.get(e.to) ?? []).length > 0) truncated = true;
66
+ }
67
+ };
68
+ if (partition.has(from)) walk(from, 0);
69
+
70
+ const laneOf: Record<string, string> = {};
71
+ const lanes: string[] = [];
72
+ for (const ref of [from, ...steps.flatMap((s) => [s.from, s.to])]) {
73
+ if (ref in laneOf) continue;
74
+ const lane = partition.get(ref) ?? UNPARTITIONED_LANE;
75
+ laneOf[ref] = lane;
76
+ if (!lanes.includes(lane)) lanes.push(lane);
77
+ }
78
+ if (opts.registry) {
79
+ const reg = opts.registry;
80
+ const order = (l: string) => partitionOrder(l, reg) ?? Number.MAX_SAFE_INTEGER;
81
+ lanes.sort((a, b) => order(a) - order(b) || compareStr(a, b));
82
+ }
83
+ return { from, lanes, steps, truncated, laneOf };
84
+ }
85
+
86
+ /** Mermaid text is line- and statement-delimited; keep labels on one safe line. */
87
+ function safe(text: string): string {
88
+ return text.replace(/[\r\n]+/g, ' ').replace(/[;#:]/g, ' ').replace(/\s+/g, ' ').trim();
89
+ }
90
+
91
+ /**
92
+ * A `sequenceDiagram`: lanes are participants, steps are messages. A back step is dashed;
93
+ * an upward/skipping step and a cycle are named in the message.
94
+ */
95
+ export function toMermaid(t: Trace, label: (r: NodeRef) => string): string {
96
+ const laned = (t as Partial<LanedTrace>).laneOf;
97
+ // Without lanes every node is its own participant.
98
+ const laneOf = (ref: NodeRef): string => laned?.[ref] ?? ref;
99
+ const lanes = laned ? t.lanes : [...new Set([t.from, ...t.steps.flatMap((s) => [s.from, s.to])])];
100
+ const id = new Map(lanes.map((l, i) => [l, `P${i}`] as const));
101
+ const lines = ['sequenceDiagram'];
102
+ for (const lane of lanes) lines.push(` participant ${id.get(lane)} as ${safe(laned ? lane : label(lane as NodeRef))}`);
103
+ for (const s of t.steps) {
104
+ const arrow = s.back ? '-->>' : '->>';
105
+ const notes = [s.verdict === 'upward' || s.verdict === 'skips' ? s.verdict : '', s.cycle ? 'cycle' : '']
106
+ .filter(Boolean).map((n) => ` [${n}]`).join('');
107
+ const text = safe(`${label(s.from)} ${s.kind} ${label(s.to)}`) + notes;
108
+ lines.push(` ${id.get(laneOf(s.from))}${arrow}${id.get(laneOf(s.to))}: ${text}`);
109
+ }
110
+ return `${lines.join('\n')}\n`;
111
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * knowledge/truncate.ts — the graph opens bounded (PRD 38 §5.3).
3
+ *
4
+ * Always kept: focus nodes, every node named by a finding, changed nodes, and the 1-hop
5
+ * neighbours of all of those (up to the hard cap). The rest of the budget goes by
6
+ * importance (then ref). Everything else folds into `area` nodes; edges are re-pointed to
7
+ * the areas, merged, and self-loops dropped.
8
+ */
9
+
10
+ import type { EdgeVerdict, KEdge, KNode, KnowledgeGraph, NodeRef } from './model.js';
11
+ import { areaImportance, byImportance } from './importance.js';
12
+ import { compareStr, pathOfRef, srcRef } from './refs.js';
13
+
14
+ /** Hard cap on nodes, however many the keep rules ask for. */
15
+ export const TRUNCATION_HARD_CAP = 2000;
16
+ /** The area of folded nodes that are not under any source path (unanchored facts). */
17
+ export const UNANCHORED_AREA = '(memory)';
18
+
19
+ const VERDICT_RANK: Record<EdgeVerdict, number> = { same: 0, allowed: 1, skips: 2, upward: 3 };
20
+
21
+ export interface TruncateOptions {
22
+ maxNodes: number;
23
+ focus: readonly NodeRef[];
24
+ changed: readonly NodeRef[];
25
+ }
26
+
27
+ export function truncateGraph(g: KnowledgeGraph, opts: TruncateOptions): { nodes: KNode[]; edges: KEdge[]; truncated: boolean } {
28
+ if (g.nodes.length <= opts.maxNodes) return { nodes: g.nodes, edges: g.edges, truncated: false };
29
+ const byRef = new Map(g.nodes.map((n) => [n.ref, n] as const));
30
+ const neighbours = new Map<NodeRef, Set<NodeRef>>();
31
+ const link = (a: NodeRef, b: NodeRef) => {
32
+ let s = neighbours.get(a);
33
+ if (!s) neighbours.set(a, (s = new Set()));
34
+ s.add(b);
35
+ };
36
+ for (const e of g.edges) { link(e.from, e.to); link(e.to, e.from); }
37
+
38
+ const kept: NodeRef[] = [];
39
+ const keptSet = new Set<NodeRef>();
40
+ const keep = (ref: NodeRef) => {
41
+ if (keptSet.has(ref) || !byRef.has(ref) || kept.length >= TRUNCATION_HARD_CAP) return;
42
+ keptSet.add(ref);
43
+ kept.push(ref);
44
+ };
45
+ const findingRefs = [...g.findings].sort((a, b) => compareStr(a.id, b.id)).flatMap((f) => f.nodes);
46
+ const seeds = [...[...opts.focus].sort(compareStr), ...findingRefs, ...[...opts.changed].sort(compareStr)];
47
+ for (const s of seeds) keep(s);
48
+ const seeded = [...kept];
49
+ for (const s of seeded) {
50
+ const around = [...(neighbours.get(s) ?? [])].map((r) => byRef.get(r)!).filter(Boolean).sort(byImportance);
51
+ for (const n of around) keep(n.ref);
52
+ }
53
+ for (const n of [...g.nodes].sort(byImportance)) {
54
+ if (kept.length >= opts.maxNodes) break;
55
+ keep(n.ref);
56
+ }
57
+
58
+ // Fold the rest into areas.
59
+ const aboutTarget = new Map<NodeRef, NodeRef>();
60
+ for (const e of g.edges) if (e.kind === 'about' && !aboutTarget.has(e.from)) aboutTarget.set(e.from, e.to);
61
+ const rootId = g.scope.rootId;
62
+ const areaOf = (n: KNode): NodeRef => {
63
+ if (n.area) return n.area;
64
+ const target = aboutTarget.get(n.ref);
65
+ const area = target && byRef.get(target)?.area;
66
+ return area ?? (pathOfRef(n.ref) !== undefined ? n.ref : srcRef(rootId, UNANCHORED_AREA));
67
+ };
68
+ const fold = new Map<NodeRef, NodeRef>();
69
+ const members = new Map<NodeRef, KNode[]>();
70
+ for (const n of g.nodes) {
71
+ if (keptSet.has(n.ref)) continue;
72
+ const area = areaOf(n);
73
+ fold.set(n.ref, area);
74
+ let m = members.get(area);
75
+ if (!m) members.set(area, (m = []));
76
+ m.push(n);
77
+ }
78
+ const imp = areaImportance(new Map([...members].map(([a, ms]) => [a, ms.map((n) => n.importance)] as const)));
79
+ const areaNodes: KNode[] = [...members.keys()].filter((a) => !keptSet.has(a)).map((area) => {
80
+ const ms = members.get(area)!;
81
+ const partition = mostCommon(ms.map((n) => n.partition));
82
+ return {
83
+ ref: area, kind: 'area', label: pathOfRef(area) ?? area, pack: mostCommon(ms.map((n) => n.pack)) ?? 'memory',
84
+ ...(partition !== undefined ? { partition } : {}),
85
+ importance: imp.get(area) ?? 0, origin: 'extracted' as const,
86
+ badges: { members: ms.length },
87
+ };
88
+ });
89
+
90
+ const edges = new Map<string, KEdge>();
91
+ for (const e of g.edges) {
92
+ const from = fold.get(e.from) ?? e.from, to = fold.get(e.to) ?? e.to;
93
+ if (from === to) continue;
94
+ const id = `${from}\u0000${to}\u0000${e.kind}`;
95
+ const prior = edges.get(id);
96
+ if (!prior) { edges.set(id, { ...e, from, to }); continue; }
97
+ prior.weight += e.weight;
98
+ if (e.cycle) prior.cycle = true;
99
+ if (e.confidence !== undefined) prior.confidence = Math.max(prior.confidence ?? 0, e.confidence);
100
+ if (e.verdict && (!prior.verdict || VERDICT_RANK[e.verdict] > VERDICT_RANK[prior.verdict])) prior.verdict = e.verdict;
101
+ }
102
+ const nodes = [...g.nodes.filter((n) => keptSet.has(n.ref)), ...areaNodes].sort((a, b) => compareStr(a.ref, b.ref));
103
+ const outEdges = [...edges.values()]
104
+ .map((e) => ({ ...e, weight: Math.round(e.weight * 1e6) / 1e6 }))
105
+ .sort((a, b) => compareStr(a.from, b.from) || compareStr(a.to, b.to) || compareStr(a.kind, b.kind));
106
+ return { nodes, edges: outEdges, truncated: true };
107
+ }
108
+
109
+ /** The most frequent defined value; ties go to the smallest. */
110
+ function mostCommon(values: ReadonlyArray<string | undefined>): string | undefined {
111
+ const counts = new Map<string, number>();
112
+ for (const v of values) if (v !== undefined) counts.set(v, (counts.get(v) ?? 0) + 1);
113
+ let best: string | undefined;
114
+ for (const [v, c] of [...counts].sort((a, b) => compareStr(a[0], b[0]))) {
115
+ if (best === undefined || c > counts.get(best)!) best = v;
116
+ }
117
+ return best;
118
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { tokenize, jaccardSimilarity } from '../similarity/index.js';
16
16
  import { hybridRetrieve, denseSearch, HnswIndex, type RetrievalCandidate, type HybridRetrieveOptions } from '../retrieval/index.js';
17
+ import { compareScoreThenId } from '../retrieval/order.js';
17
18
  import type { MemoryEntry, RankedRecall, ScoredMemory, SSMRuntimeRef } from './types.js';
18
19
 
19
20
  export interface SemanticRecallOptions {
@@ -80,7 +81,8 @@ export class SemanticRecall {
80
81
  }
81
82
  const queryTokens = new Set(tokenize(query));
82
83
  const scored = all.map((entry) => ({ entry, score: jaccardSimilarity(queryTokens, new Set(tokenize(entry.content))) }));
83
- scored.sort((a, b) => b.score - a.score);
84
+ // Jaccard is an exact ratio: the lexical quantum, then the fact key.
85
+ scored.sort((a, b) => compareScoreThenId(a.score, a.entry.key, b.score, b.entry.key));
84
86
  return this._floor(scored).slice(0, topK);
85
87
  }
86
88
 
@@ -22,6 +22,7 @@ import { mmrSelect } from './mmr.js';
22
22
  import { denseSearch } from './hnsw.js';
23
23
  import { calculateTemporalDecayMultiplier } from './decay.js';
24
24
  import { cosineSimilarity } from '../similarity/index.js';
25
+ import { compareHits } from './order.js';
25
26
 
26
27
  export interface RetrievalCandidate {
27
28
  id: string;
@@ -146,7 +147,7 @@ export function hybridRetrieve(
146
147
  const factor = new Map(fused.map(f => [f.id, signalFactor(byId.get(f.id)!, opts, now)]));
147
148
  const weighted = fused
148
149
  .map(f => ({ id: f.id, score: f.score * factor.get(f.id)! }))
149
- .sort((a, b) => b.score - a.score);
150
+ .sort(compareHits);
150
151
 
151
152
  // ── Rerank (MMR over the weighted top, using whatever vectors we have) ────
152
153
  const rerank = opts.rerank ?? true;
@@ -166,6 +167,7 @@ export function hybridRetrieve(
166
167
  similarity: (a, b) => cosineSimilarity(a.vector, b.vector),
167
168
  lambda: opts.mmrLambda ?? 0.7,
168
169
  topK,
170
+ idOf: (c) => c.id,
169
171
  }).map(c => c.id);
170
172
  // MMR only ranks the vectored subset; append any remaining ids after.
171
173
  const seen = new Set(reranked);
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { tokenize } from '../similarity/index.js';
26
+ import { compareHits } from './order.js';
26
27
 
27
28
  /** Splits text into the terms BM25 scores over. */
28
29
  export type Bm25Tokenizer = (text: string) => string[];
@@ -91,8 +92,9 @@ export function bm25TermScore(frequency: number, idf: number, lengthNorm: number
91
92
  }
92
93
 
93
94
  /**
94
- * Scores every document against `query` with Okapi BM25, returning hits sorted by
95
- * descending score (documents with no query-term overlap score 0 and are dropped).
95
+ * Scores every document against `query` with Okapi BM25, returning hits in
96
+ * {@link compareHits} order — score descending, then id (documents with no
97
+ * query-term overlap score 0 and are dropped).
96
98
  * Builds the index inline — for a recall over a bounded candidate set (the memory
97
99
  * store / a vector pre-filter) this is O(N·terms) and needs no persistence.
98
100
  */
@@ -136,6 +138,6 @@ export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {
136
138
  if (score > 0) hits.push({ id: d.id, score });
137
139
  }
138
140
 
139
- hits.sort((a, b) => b.score - a.score);
141
+ hits.sort(compareHits);
140
142
  return hits;
141
143
  }
@@ -8,6 +8,8 @@
8
8
  * Pure and zero-dependency.
9
9
  */
10
10
 
11
+ import { compareHits } from './order.js';
12
+
11
13
  export interface RankedList {
12
14
  /** Ordered ids, most relevant first. */
13
15
  ids: string[];
@@ -35,7 +37,8 @@ export function rrfTerm(rank: number, k: number = RRF_DEFAULT_K): number {
35
37
  /**
36
38
  * Reciprocal Rank Fusion over any number of ranked lists.
37
39
  * score(d) = Σ_lists weight / (k + rank(d)). `k` (default 60) damps the
38
- * contribution of low-ranked items; the canonical TREC value.
40
+ * contribution of low-ranked items; the canonical TREC value. Ordered by
41
+ * {@link compareHits}: fused score descending, then id.
39
42
  */
40
43
  export function reciprocalRankFusion(lists: RankedList[], k = RRF_DEFAULT_K): FusedHit[] {
41
44
  const acc = new Map<string, number>();
@@ -47,5 +50,5 @@ export function reciprocalRankFusion(lists: RankedList[], k = RRF_DEFAULT_K): Fu
47
50
  }
48
51
  return [...acc.entries()]
49
52
  .map(([id, score]) => ({ id, score }))
50
- .sort((a, b) => b.score - a.score);
53
+ .sort(compareHits);
51
54
  }