@graphorin/memory 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +24 -7
  3. package/dist/conflict/index.js +0 -1
  4. package/dist/conflict/pipeline.js +0 -1
  5. package/dist/conflict/pipeline.js.map +1 -1
  6. package/dist/conflict/stages/stage3-heuristic-regex.js +0 -1
  7. package/dist/conflict/stages/stage3-heuristic-regex.js.map +1 -1
  8. package/dist/conflict/types.d.ts +1 -1
  9. package/dist/conflict/types.js.map +1 -1
  10. package/dist/consolidator/index.js +2 -0
  11. package/dist/consolidator/index.js.map +1 -1
  12. package/dist/consolidator/lock.js +7 -1
  13. package/dist/consolidator/lock.js.map +1 -1
  14. package/dist/consolidator/phases/deep.js +19 -12
  15. package/dist/consolidator/phases/deep.js.map +1 -1
  16. package/dist/consolidator/phases/induce.d.ts.map +1 -1
  17. package/dist/consolidator/phases/induce.js +2 -3
  18. package/dist/consolidator/phases/induce.js.map +1 -1
  19. package/dist/consolidator/phases/reflect.js +35 -8
  20. package/dist/consolidator/phases/reflect.js.map +1 -1
  21. package/dist/consolidator/phases/standard.js +199 -28
  22. package/dist/consolidator/phases/standard.js.map +1 -1
  23. package/dist/consolidator/reconcile.js +16 -15
  24. package/dist/consolidator/reconcile.js.map +1 -1
  25. package/dist/consolidator/runtime.d.ts.map +1 -1
  26. package/dist/consolidator/runtime.js +129 -47
  27. package/dist/consolidator/runtime.js.map +1 -1
  28. package/dist/consolidator/scheduler.d.ts +17 -3
  29. package/dist/consolidator/scheduler.d.ts.map +1 -1
  30. package/dist/consolidator/scheduler.js.map +1 -1
  31. package/dist/consolidator/types.d.ts +30 -1
  32. package/dist/consolidator/types.d.ts.map +1 -1
  33. package/dist/consolidator/types.js +10 -0
  34. package/dist/consolidator/types.js.map +1 -1
  35. package/dist/context-engine/compaction/clear-tool-results.d.ts +1 -1
  36. package/dist/context-engine/compaction/clear-tool-results.js +1 -1
  37. package/dist/context-engine/compaction/clear-tool-results.js.map +1 -1
  38. package/dist/context-engine/compaction/compactor.d.ts +1 -0
  39. package/dist/context-engine/compaction/compactor.d.ts.map +1 -1
  40. package/dist/context-engine/compaction/compactor.js +32 -8
  41. package/dist/context-engine/compaction/compactor.js.map +1 -1
  42. package/dist/context-engine/compaction/hooks/types.d.ts +1 -1
  43. package/dist/context-engine/compaction/index.d.ts +1 -0
  44. package/dist/context-engine/compaction/templates/marker.d.ts +33 -0
  45. package/dist/context-engine/compaction/templates/marker.d.ts.map +1 -0
  46. package/dist/context-engine/compaction/templates/marker.js +34 -0
  47. package/dist/context-engine/compaction/templates/marker.js.map +1 -0
  48. package/dist/context-engine/compaction/templates/summary-9-section.d.ts +1 -1
  49. package/dist/context-engine/compaction/templates/summary-9-section.d.ts.map +1 -1
  50. package/dist/context-engine/compaction/templates/summary-9-section.js +4 -3
  51. package/dist/context-engine/compaction/templates/summary-9-section.js.map +1 -1
  52. package/dist/context-engine/engine.d.ts +7 -176
  53. package/dist/context-engine/engine.d.ts.map +1 -1
  54. package/dist/context-engine/engine.js +0 -2
  55. package/dist/context-engine/engine.js.map +1 -1
  56. package/dist/context-engine/index.d.ts +8 -49
  57. package/dist/context-engine/io-types.d.ts +144 -0
  58. package/dist/context-engine/io-types.d.ts.map +1 -0
  59. package/dist/context-engine/locale-packs/types.d.ts +1 -1
  60. package/dist/context-engine/locale-packs/types.js.map +1 -1
  61. package/dist/context-engine/privacy-filter.d.ts +1 -2
  62. package/dist/context-engine/privacy-filter.d.ts.map +1 -1
  63. package/dist/context-engine/privacy-filter.js +1 -2
  64. package/dist/context-engine/privacy-filter.js.map +1 -1
  65. package/dist/errors/index.d.ts +2 -2
  66. package/dist/errors/index.js +2 -2
  67. package/dist/errors/index.js.map +1 -1
  68. package/dist/facade.d.ts +43 -52
  69. package/dist/facade.d.ts.map +1 -1
  70. package/dist/facade.js +5 -3
  71. package/dist/facade.js.map +1 -1
  72. package/dist/graph/entity-resolver.js +1 -1
  73. package/dist/graph/entity-resolver.js.map +1 -1
  74. package/dist/index.d.ts +17 -15
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -7
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal/envelope.js +57 -0
  79. package/dist/internal/envelope.js.map +1 -0
  80. package/dist/internal/injection-heuristics.js +0 -0
  81. package/dist/internal/injection-heuristics.js.map +1 -1
  82. package/dist/internal/llm-json.js +40 -0
  83. package/dist/internal/llm-json.js.map +1 -0
  84. package/dist/internal/storage-adapter.d.ts +23 -10
  85. package/dist/internal/storage-adapter.d.ts.map +1 -1
  86. package/dist/memory-interface.d.ts +131 -0
  87. package/dist/memory-interface.d.ts.map +1 -0
  88. package/dist/package.js +6 -0
  89. package/dist/package.js.map +1 -0
  90. package/dist/search/iterative.d.ts.map +1 -1
  91. package/dist/search/iterative.js +2 -3
  92. package/dist/search/iterative.js.map +1 -1
  93. package/dist/search/query-transform.d.ts.map +1 -1
  94. package/dist/search/query-transform.js +2 -3
  95. package/dist/search/query-transform.js.map +1 -1
  96. package/dist/tiers/episodic-memory.js +3 -3
  97. package/dist/tiers/episodic-memory.js.map +1 -1
  98. package/dist/tiers/index.d.ts +2 -2
  99. package/dist/tiers/insight-memory.js +1 -1
  100. package/dist/tiers/insight-memory.js.map +1 -1
  101. package/dist/tiers/procedural-memory.d.ts +1 -1
  102. package/dist/tiers/procedural-memory.js +2 -2
  103. package/dist/tiers/procedural-memory.js.map +1 -1
  104. package/dist/tiers/semantic-memory.d.ts +58 -4
  105. package/dist/tiers/semantic-memory.d.ts.map +1 -1
  106. package/dist/tiers/semantic-memory.js +103 -20
  107. package/dist/tiers/semantic-memory.js.map +1 -1
  108. package/dist/tools/block-tools.d.ts +19 -57
  109. package/dist/tools/block-tools.d.ts.map +1 -1
  110. package/dist/tools/block-tools.js.map +1 -1
  111. package/dist/tools/fact-tools.d.ts +53 -214
  112. package/dist/tools/fact-tools.d.ts.map +1 -1
  113. package/dist/tools/fact-tools.js +3 -2
  114. package/dist/tools/fact-tools.js.map +1 -1
  115. package/dist/tools/recall-tools.d.ts +34 -175
  116. package/dist/tools/recall-tools.d.ts.map +1 -1
  117. package/dist/tools/recall-tools.js +5 -4
  118. package/dist/tools/recall-tools.js.map +1 -1
  119. package/dist/tools/runbook-tools.d.ts +15 -58
  120. package/dist/tools/runbook-tools.d.ts.map +1 -1
  121. package/dist/tools/runbook-tools.js.map +1 -1
  122. package/dist/tools/types.d.ts +1 -1
  123. package/package.json +18 -17
  124. package/src/conflict/index.ts +29 -0
  125. package/src/conflict/locale-packs/en.ts +142 -0
  126. package/src/conflict/locale-packs/index.ts +17 -0
  127. package/src/conflict/locale-packs/types.ts +138 -0
  128. package/src/conflict/pipeline.ts +371 -0
  129. package/src/conflict/stages/helpers.ts +65 -0
  130. package/src/conflict/stages/stage1-exact-dedup.ts +40 -0
  131. package/src/conflict/stages/stage2-embedding-three-zone.ts +54 -0
  132. package/src/conflict/stages/stage3-heuristic-regex.ts +51 -0
  133. package/src/conflict/stages/stage4-subject-predicate.ts +46 -0
  134. package/src/conflict/stages/stage5-defer-to-deep.ts +34 -0
  135. package/src/conflict/types.ts +263 -0
  136. package/src/consolidator/budget.ts +312 -0
  137. package/src/consolidator/decay.ts +246 -0
  138. package/src/consolidator/dlq.ts +61 -0
  139. package/src/consolidator/errors.ts +87 -0
  140. package/src/consolidator/idempotency.ts +27 -0
  141. package/src/consolidator/index.ts +269 -0
  142. package/src/consolidator/lock.ts +141 -0
  143. package/src/consolidator/noise-filter.ts +235 -0
  144. package/src/consolidator/phases/deep.ts +308 -0
  145. package/src/consolidator/phases/induce.ts +423 -0
  146. package/src/consolidator/phases/learned-context.ts +233 -0
  147. package/src/consolidator/phases/light.ts +166 -0
  148. package/src/consolidator/phases/reflect.ts +504 -0
  149. package/src/consolidator/phases/standard.ts +1002 -0
  150. package/src/consolidator/reconcile.ts +273 -0
  151. package/src/consolidator/runtime.ts +1049 -0
  152. package/src/consolidator/scheduler.ts +182 -0
  153. package/src/consolidator/triggers.ts +134 -0
  154. package/src/consolidator/types.ts +622 -0
  155. package/src/context-engine/annotations.ts +161 -0
  156. package/src/context-engine/auto-recall.ts +92 -0
  157. package/src/context-engine/compaction/clear-tool-results.ts +232 -0
  158. package/src/context-engine/compaction/compactor.ts +403 -0
  159. package/src/context-engine/compaction/hooks/reanchor-persona-block.ts +55 -0
  160. package/src/context-engine/compaction/hooks/reanchor-pinned-facts.ts +66 -0
  161. package/src/context-engine/compaction/hooks/reanchor-project-rules.ts +57 -0
  162. package/src/context-engine/compaction/hooks/reanchor-recent-results.ts +116 -0
  163. package/src/context-engine/compaction/hooks/types.ts +68 -0
  164. package/src/context-engine/compaction/index.ts +60 -0
  165. package/src/context-engine/compaction/templates/marker.ts +33 -0
  166. package/src/context-engine/compaction/templates/summary-9-section.ts +223 -0
  167. package/src/context-engine/compaction/thresholds.ts +107 -0
  168. package/src/context-engine/compaction/types.ts +255 -0
  169. package/src/context-engine/engine.ts +970 -0
  170. package/src/context-engine/index.ts +118 -0
  171. package/src/context-engine/io-types.ts +162 -0
  172. package/src/context-engine/locale-packs/en.ts +107 -0
  173. package/src/context-engine/locale-packs/index.ts +19 -0
  174. package/src/context-engine/locale-packs/resolver.ts +197 -0
  175. package/src/context-engine/locale-packs/types.ts +177 -0
  176. package/src/context-engine/metadata.ts +114 -0
  177. package/src/context-engine/preambles/inbound-en.ts +19 -0
  178. package/src/context-engine/privacy-filter.ts +190 -0
  179. package/src/context-engine/templates/base-en.ts +19 -0
  180. package/src/context-engine/templates/composer.ts +91 -0
  181. package/src/context-engine/token-budget.ts +319 -0
  182. package/src/context-engine/token-counter.ts +157 -0
  183. package/src/errors/index.ts +273 -0
  184. package/src/facade.ts +750 -0
  185. package/src/graph/entity-resolver.ts +364 -0
  186. package/src/graph/index.ts +24 -0
  187. package/src/index.ts +259 -0
  188. package/src/internal/contextualize.ts +173 -0
  189. package/src/internal/embedder-binding.ts +53 -0
  190. package/src/internal/envelope.ts +70 -0
  191. package/src/internal/id.ts +23 -0
  192. package/src/internal/injection-heuristics.ts +0 -0
  193. package/src/internal/llm-json.ts +38 -0
  194. package/src/internal/spans.ts +35 -0
  195. package/src/internal/storage-adapter.ts +963 -0
  196. package/src/memory-interface.ts +158 -0
  197. package/src/migration/embedder-migration.ts +237 -0
  198. package/src/migration/index.ts +13 -0
  199. package/src/search/explain.ts +123 -0
  200. package/src/search/fit-weights.ts +109 -0
  201. package/src/search/graph-ppr.ts +38 -0
  202. package/src/search/index.ts +55 -0
  203. package/src/search/iterative.ts +658 -0
  204. package/src/search/query-transform.ts +285 -0
  205. package/src/search/rrf.ts +225 -0
  206. package/src/search/trust.ts +55 -0
  207. package/src/search/types.ts +50 -0
  208. package/src/tiers/episodic-memory.ts +463 -0
  209. package/src/tiers/index.ts +50 -0
  210. package/src/tiers/insight-memory.ts +208 -0
  211. package/src/tiers/procedural-memory.ts +482 -0
  212. package/src/tiers/semantic-memory.ts +1729 -0
  213. package/src/tiers/session-memory.ts +271 -0
  214. package/src/tiers/shared-memory.ts +62 -0
  215. package/src/tiers/working-memory.ts +368 -0
  216. package/src/tools/block-tools.ts +172 -0
  217. package/src/tools/fact-tools.ts +473 -0
  218. package/src/tools/index.ts +109 -0
  219. package/src/tools/recall-tools.ts +303 -0
  220. package/src/tools/runbook-tools.ts +107 -0
  221. package/src/tools/taint.ts +39 -0
  222. package/src/tools/types.ts +37 -0
  223. package/dist/conflict/locale-packs/index.js +0 -4
  224. package/dist/context-engine/compaction/index.js +0 -10
  225. package/dist/context-engine/index.d.ts.map +0 -1
  226. package/dist/context-engine/index.js +0 -25
  227. package/dist/context-engine/locale-packs/index.js +0 -4
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Query transformation for hybrid retrieval (P2-3) - **multi-query /
3
+ * RAG-Fusion** and optional **HyDE**.
4
+ *
5
+ * Auto-recall is a regex trigger and the default search is single-shot:
6
+ * the user's phrasing rarely matches how a memory was stored, so a
7
+ * relevant fact is missed at the retrieval step. This module fans a
8
+ * query into a few reworded variants (one cheap LLM call) and/or writes
9
+ * a hypothetical answer passage to embed (HyDE, arXiv:2212.10496); the
10
+ * caller retrieves each and fuses the lists through the existing RRF
11
+ * reranker. Variant generation is the only thing that touches a
12
+ * provider, so it is **opt-in**: with no transformer configured the
13
+ * default search path stays fully offline and single-shot.
14
+ *
15
+ * The module is provider-agnostic - it imports only `@graphorin/core`
16
+ * types and never performs I/O itself. The concrete `Provider` is
17
+ * injected by the caller via {@link createProviderQueryTransformer},
18
+ * which is resilient: any provider error or unparseable output degrades
19
+ * to "no variants" / "no hypothetical" rather than throwing into the
20
+ * search hot path.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ import type { Provider, ProviderRequest } from '@graphorin/core';
26
+ import { stripFence } from '../internal/llm-json.js';
27
+
28
+ /** Default ceiling on reworded variants a provider-backed transformer requests. */
29
+ export const DEFAULT_MAX_QUERY_VARIANTS = 5;
30
+
31
+ /** Default output-token ceiling for a single transform call. */
32
+ const DEFAULT_TRANSFORM_MAX_TOKENS = 256;
33
+
34
+ /**
35
+ * Pluggable query-transformation seam consumed by
36
+ * `SemanticMemory.search(..., { multiQuery, hyde })`. The built-in
37
+ * provider-backed implementation lives in
38
+ * {@link createProviderQueryTransformer}; advanced callers can supply a
39
+ * bespoke one (e.g. a deterministic synonym expander) to
40
+ * `new SemanticMemory({ queryTransformer })`.
41
+ *
42
+ * Implementations MUST degrade gracefully - return `[]` / `null` rather
43
+ * than throw - so a transform failure never breaks recall.
44
+ *
45
+ * @stable
46
+ */
47
+ export interface QueryTransformer {
48
+ /**
49
+ * Rewrite a query into up to `count` **additional** reworded variants
50
+ * (the original query is retained by the caller). Returns `[]` when
51
+ * transformation is unavailable or the model returns nothing usable.
52
+ */
53
+ expand(
54
+ query: string,
55
+ count: number,
56
+ options?: QueryTransformOptions,
57
+ ): Promise<ReadonlyArray<string>>;
58
+ /**
59
+ * Generate a single short hypothetical-answer passage to embed (HyDE),
60
+ * or `null` when unavailable. The caller embeds the passage and fuses
61
+ * its vector neighbours into the result.
62
+ */
63
+ hypothetical(query: string, options?: QueryTransformOptions): Promise<string | null>;
64
+ }
65
+
66
+ /**
67
+ * Per-call options for a {@link QueryTransformer}.
68
+ *
69
+ * @stable
70
+ */
71
+ export interface QueryTransformOptions {
72
+ /** Cancellation signal forwarded to the underlying provider call. */
73
+ readonly signal?: AbortSignal;
74
+ }
75
+
76
+ /**
77
+ * System prompt for multi-query variant generation. Asks for a bare
78
+ * JSON array of standalone rephrasings that preserve the original
79
+ * intent - {@link parseQueryVariants} also tolerates a chatty model.
80
+ *
81
+ * @internal
82
+ */
83
+ export const QUERY_EXPANSION_SYSTEM_PROMPT =
84
+ 'You rewrite a search query into alternative phrasings to improve recall over a ' +
85
+ 'personal memory store. Preserve the original intent; vary vocabulary, specificity, ' +
86
+ 'and perspective so different wordings of the same stored memory are matched. ' +
87
+ 'Return ONLY a JSON array of strings - each a standalone query, no numbering, no ' +
88
+ 'commentary, no duplicates of the original.';
89
+
90
+ /**
91
+ * System prompt for HyDE. Asks for a short, plausible hypothetical
92
+ * answer (not a question rephrase) whose embedding sits near the
93
+ * passage that would actually answer the query.
94
+ *
95
+ * @internal
96
+ */
97
+ export const HYDE_SYSTEM_PROMPT =
98
+ 'Write a short, plausible hypothetical answer to the question, phrased as a ' +
99
+ 'first-person statement of fact as it might be recorded in a memory store. One to ' +
100
+ 'three sentences. Output only the passage - no preamble, no caveats, no markdown.';
101
+
102
+ /**
103
+ * Build the multi-query expansion request. Pure - no I/O. A higher
104
+ * temperature is used deliberately so the variants diverge (the
105
+ * downstream retrieval + RRF fusion stays deterministic).
106
+ *
107
+ * @stable
108
+ */
109
+ export function buildExpansionRequest(
110
+ query: string,
111
+ count: number,
112
+ options: { readonly maxTokens?: number; readonly signal?: AbortSignal } = {},
113
+ ): ProviderRequest {
114
+ return {
115
+ messages: [
116
+ {
117
+ role: 'user',
118
+ content: `Original query: ${query}\nReturn up to ${count} alternative phrasings as a JSON array of strings.`,
119
+ },
120
+ ],
121
+ systemMessage: QUERY_EXPANSION_SYSTEM_PROMPT,
122
+ temperature: 0.7,
123
+ maxTokens: options.maxTokens ?? DEFAULT_TRANSFORM_MAX_TOKENS,
124
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
125
+ outputType: { kind: 'structured' },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Build the HyDE pseudo-document request. Pure - no I/O.
131
+ *
132
+ * @stable
133
+ */
134
+ export function buildHydeRequest(
135
+ query: string,
136
+ options: { readonly maxTokens?: number; readonly signal?: AbortSignal } = {},
137
+ ): ProviderRequest {
138
+ return {
139
+ messages: [{ role: 'user', content: query }],
140
+ systemMessage: HYDE_SYSTEM_PROMPT,
141
+ temperature: 0.3,
142
+ maxTokens: options.maxTokens ?? DEFAULT_TRANSFORM_MAX_TOKENS,
143
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Parse the variant-generation model output into a deduped, capped list
149
+ * of reworded queries. Tolerates a JSON array, a `{ "variants": [...] }`
150
+ * / `{ "queries": [...] }` wrapper, fenced blocks, and (as a last
151
+ * resort) a newline / numbered list - so a chatty model never breaks
152
+ * recall. Empty strings and case-insensitive duplicates are dropped;
153
+ * the result is capped at `max`.
154
+ *
155
+ * @stable
156
+ */
157
+ export function parseQueryVariants(text: string | undefined, max: number): ReadonlyArray<string> {
158
+ if (max <= 0 || text === undefined) return [];
159
+ const candidate = stripFence(text).trim();
160
+ if (candidate.length === 0) return [];
161
+ const raw = extractVariantStrings(candidate);
162
+ const seen = new Set<string>();
163
+ const out: string[] = [];
164
+ for (const item of raw) {
165
+ const trimmed = item.trim();
166
+ if (trimmed.length === 0) continue;
167
+ const key = trimmed.toLowerCase();
168
+ if (seen.has(key)) continue;
169
+ seen.add(key);
170
+ out.push(trimmed);
171
+ if (out.length >= max) break;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Parse the HyDE model output into a single passage, or `null` when the
178
+ * model returned nothing usable. Strips a fenced block and trims.
179
+ *
180
+ * @stable
181
+ */
182
+ export function parseHypothetical(text: string | undefined): string | null {
183
+ if (text === undefined) return null;
184
+ const trimmed = stripFence(text).trim();
185
+ return trimmed.length === 0 ? null : trimmed;
186
+ }
187
+
188
+ /**
189
+ * Wrap a {@link Provider} as a {@link QueryTransformer}. Both methods
190
+ * are **resilient**: a provider error or unparseable response degrades
191
+ * to `[]` / `null` so transformation never throws into `search`. The
192
+ * `maxVariants` ceiling caps the variants requested regardless of the
193
+ * caller's `multiQuery` value (a latency guardrail).
194
+ *
195
+ * @stable
196
+ */
197
+ export function createProviderQueryTransformer(
198
+ provider: Provider,
199
+ options: { readonly maxVariants?: number; readonly maxTokens?: number } = {},
200
+ ): QueryTransformer {
201
+ const maxVariants = Math.max(0, options.maxVariants ?? DEFAULT_MAX_QUERY_VARIANTS);
202
+ const maxTokens = options.maxTokens ?? DEFAULT_TRANSFORM_MAX_TOKENS;
203
+ return {
204
+ async expand(query, count, opts = {}): Promise<ReadonlyArray<string>> {
205
+ const want = Math.min(Math.max(0, count), maxVariants);
206
+ if (want === 0 || query.trim().length === 0) return [];
207
+ try {
208
+ const request = buildExpansionRequest(query, want, {
209
+ maxTokens,
210
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
211
+ });
212
+ const response = await provider.generate(request);
213
+ return parseQueryVariants(response.text, want);
214
+ } catch {
215
+ return [];
216
+ }
217
+ },
218
+ async hypothetical(query, opts = {}): Promise<string | null> {
219
+ if (query.trim().length === 0) return null;
220
+ try {
221
+ const request = buildHydeRequest(query, {
222
+ maxTokens,
223
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
224
+ });
225
+ const response = await provider.generate(request);
226
+ return parseHypothetical(response.text);
227
+ } catch {
228
+ return null;
229
+ }
230
+ },
231
+ };
232
+ }
233
+
234
+ /** Extract the raw (pre-dedup) variant strings from tolerant model output. */
235
+ function extractVariantStrings(candidate: string): ReadonlyArray<string> {
236
+ const parsed = tryParseJson(candidate);
237
+ if (parsed !== undefined) {
238
+ const arr = Array.isArray(parsed) ? parsed : isRecord(parsed) ? pickArray(parsed) : null;
239
+ if (arr !== null) {
240
+ return arr.filter((v): v is string => typeof v === 'string');
241
+ }
242
+ }
243
+ // Fallback - split a newline / numbered list and strip list markers.
244
+ return candidate.split('\n').map((line) =>
245
+ line
246
+ .replace(/^\s*(?:[-*•]|\d+[.)])\s*/u, '')
247
+ .replace(/^["']|["']$/gu, '')
248
+ .trim(),
249
+ );
250
+ }
251
+
252
+ function pickArray(record: Record<string, unknown>): ReadonlyArray<unknown> | null {
253
+ if (Array.isArray(record.variants)) return record.variants;
254
+ if (Array.isArray(record.queries)) return record.queries;
255
+ return null;
256
+ }
257
+
258
+ function tryParseJson(text: string): unknown {
259
+ try {
260
+ return JSON.parse(text);
261
+ } catch {
262
+ const slice = sliceJson(text);
263
+ if (slice === null) return undefined;
264
+ try {
265
+ return JSON.parse(slice);
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Slice the widest `[...]` array or `{...}` object out of chatty text. */
273
+ function sliceJson(text: string): string | null {
274
+ const arrStart = text.indexOf('[');
275
+ const arrEnd = text.lastIndexOf(']');
276
+ if (arrStart >= 0 && arrEnd > arrStart) return text.slice(arrStart, arrEnd + 1);
277
+ const objStart = text.indexOf('{');
278
+ const objEnd = text.lastIndexOf('}');
279
+ if (objStart >= 0 && objEnd > objStart) return text.slice(objStart, objEnd + 1);
280
+ return null;
281
+ }
282
+
283
+ function isRecord(value: unknown): value is Record<string, unknown> {
284
+ return value !== null && typeof value === 'object';
285
+ }
@@ -0,0 +1,225 @@
1
+ import type { MemoryHit, MemoryRecord } from '@graphorin/core';
2
+ import type { ReRanker, ReRankOptions } from './types.js';
3
+
4
+ /**
5
+ * Industry-standard Reciprocal Rank Fusion constant. Lower `k` makes
6
+ * top-ranked items dominate; higher `k` smooths the contribution of
7
+ * lower-ranked items. `k = 60` is the value popularised by Cormack,
8
+ * Clarke, and Büttcher (2009) and is the framework default.
9
+ *
10
+ * @stable
11
+ */
12
+ export const RRF_DEFAULT_K = 60;
13
+
14
+ /**
15
+ * Built-in Reciprocal Rank Fusion reranker. Combines multiple ranked
16
+ * lists (vector hits, FTS5 hits, optional entity boost) into a single
17
+ * fused ranking by summing `1 / (k + rank)` per list each item appears
18
+ * in.
19
+ *
20
+ * Properties (verified by the property-based test suite):
21
+ *
22
+ * - **Deterministic.** Identical input lists yield identical output.
23
+ * - **Stable under permutation of input lists.** The fusion is
24
+ * independent of the order in which the input lists are passed in.
25
+ * - **Tie-broken by stable record id.** Records with equal RRF
26
+ * scores keep their first-seen order.
27
+ *
28
+ * @stable
29
+ */
30
+ export class RRFReranker implements ReRanker {
31
+ readonly id = 'rrf' as const;
32
+ readonly k: number;
33
+
34
+ constructor(k: number = RRF_DEFAULT_K) {
35
+ if (!Number.isFinite(k) || k <= 0) {
36
+ throw new TypeError(
37
+ `[graphorin/memory] RRFReranker: 'k' must be a positive finite number, got ${String(k)}.`,
38
+ );
39
+ }
40
+ this.k = k;
41
+ }
42
+
43
+ async rerank<TRecord extends MemoryRecord>(
44
+ query: string,
45
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
46
+ options: ReRankOptions = {},
47
+ ): Promise<ReadonlyArray<MemoryHit<TRecord>>> {
48
+ void query;
49
+ if (options.signal?.aborted === true) {
50
+ throw new DOMException('RRFReranker aborted', 'AbortError');
51
+ }
52
+ const fused = fuseRrf(lists, this.k, options.labels);
53
+ const topK = options.topK ?? 10;
54
+ return fused.slice(0, Math.max(0, topK));
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Weighted-RRF reranker (X-2). Fuses parallel ranked lists through
60
+ * {@link fuseWeighted}, applying a per-list `weights[i]` so a caller who
61
+ * has calibrated list reliability against labels (the P0-1 eval harness)
62
+ * can trust one retriever over another - e.g. up-weight dense vector
63
+ * hits over lexical FTS hits. `weights` is **positional**: aligned to the
64
+ * input `lists` order (the built-in hybrid search passes FTS first, then
65
+ * vector). At equal weights it is identical to {@link RRFReranker}; RRF
66
+ * stays the framework default.
67
+ *
68
+ * Like the RRF reranker it is deterministic and tie-broken by stable
69
+ * record id, and it preserves the upstream `signals` on each hit.
70
+ *
71
+ * @stable
72
+ */
73
+ export class WeightedRRFReranker implements ReRanker {
74
+ readonly id = 'weighted-rrf' as const;
75
+ readonly k: number;
76
+ readonly weights: ReadonlyArray<number>;
77
+
78
+ constructor(args: { weights: ReadonlyArray<number>; k?: number }) {
79
+ const k = args.k ?? RRF_DEFAULT_K;
80
+ if (!Number.isFinite(k) || k <= 0) {
81
+ throw new TypeError(
82
+ `[graphorin/memory] WeightedRRFReranker: 'k' must be a positive finite number, got ${String(k)}.`,
83
+ );
84
+ }
85
+ for (const w of args.weights) {
86
+ if (!Number.isFinite(w) || w < 0) {
87
+ throw new TypeError(
88
+ `[graphorin/memory] WeightedRRFReranker: every weight must be a non-negative finite number, got ${String(w)}.`,
89
+ );
90
+ }
91
+ }
92
+ this.k = k;
93
+ this.weights = args.weights;
94
+ }
95
+
96
+ async rerank<TRecord extends MemoryRecord>(
97
+ query: string,
98
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
99
+ options: ReRankOptions = {},
100
+ ): Promise<ReadonlyArray<MemoryHit<TRecord>>> {
101
+ void query;
102
+ if (options.signal?.aborted === true) {
103
+ throw new DOMException('WeightedRRFReranker aborted', 'AbortError');
104
+ }
105
+ const fused = fuseWeighted(lists, this.weights, this.k, options.labels);
106
+ const topK = options.topK ?? 10;
107
+ return fused.slice(0, Math.max(0, topK));
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Pure functional core of the RRF reranker - the equal-weight case of
113
+ * {@link fuseWeighted}. Exported separately so the test suite (and the
114
+ * property-based fuzzer) can exercise the math without the `Promise<…>`
115
+ * wrapping of the public surface.
116
+ *
117
+ * @stable
118
+ */
119
+ export function fuseRrf<TRecord extends MemoryRecord>(
120
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
121
+ k: number,
122
+ labels?: ReadonlyArray<string>,
123
+ ): ReadonlyArray<MemoryHit<TRecord>> {
124
+ return fuseWeighted(lists, undefined, k, labels);
125
+ }
126
+
127
+ /**
128
+ * Weighted / convex generalization of {@link fuseRrf} (X-2). Each input
129
+ * list `i` contributes `weights[i] · 1 / (k + rank)` to a record's fused
130
+ * score instead of the flat `1 / (k + rank)`, so a caller who has
131
+ * calibrated list reliability against labels (the P0-1 eval harness) can
132
+ * trust one retriever over another. RRF stays the zero-tuning default: a
133
+ * `weights` of `undefined` (or all-`1`) is byte-for-byte identical to
134
+ * {@link fuseRrf}. A missing, non-finite, or negative entry falls back to
135
+ * the neutral weight `1`, so a partial / malformed `weights` never throws
136
+ * or poisons the ranking.
137
+ *
138
+ * Like {@link fuseRrf} the fusion is deterministic, tie-broken by
139
+ * first-seen order, and preserves any upstream `signals` (FTS `bm25`,
140
+ * vector similarity) the hits carried in - the `rrf` / `rrf.list_N`
141
+ * signals it records are the *weighted* contributions, so the fused
142
+ * `score` still equals their sum. Note that - unlike RRF - the result
143
+ * depends on input list *order*, because each weight is bound to a list
144
+ * position.
145
+ *
146
+ * @stable
147
+ */
148
+ export function fuseWeighted<TRecord extends MemoryRecord>(
149
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
150
+ weights: ReadonlyArray<number> | undefined,
151
+ k: number,
152
+ labels?: ReadonlyArray<string>,
153
+ ): ReadonlyArray<MemoryHit<TRecord>> {
154
+ type Aggregate = {
155
+ record: TRecord;
156
+ score: number;
157
+ signals: Record<string, number>;
158
+ firstSeenOrder: number;
159
+ };
160
+ const aggregates = new Map<string, Aggregate>();
161
+ let nextOrder = 0;
162
+ for (let listIdx = 0; listIdx < lists.length; listIdx++) {
163
+ const list = lists[listIdx] ?? [];
164
+ const weight = resolveWeight(weights, listIdx);
165
+ // MRET-13: explanation signals key by the caller's retriever-kind
166
+ // label when supplied - `rrf.list_N` is an ephemeral position that
167
+ // changes meaning whenever a leg (vector / HyDE / graph) is
168
+ // conditionally absent from the fan-out.
169
+ const signalKey = `rrf.${labels?.[listIdx] ?? `list_${listIdx}`}`;
170
+ for (let i = 0; i < list.length; i++) {
171
+ const hit = list[i];
172
+ if (hit === undefined) continue;
173
+ const rank = i + 1;
174
+ const contribution = weight / (k + rank);
175
+ const id = hit.record.id;
176
+ const existing = aggregates.get(id);
177
+ if (existing === undefined) {
178
+ const signals: Record<string, number> = {
179
+ ...(hit.signals ?? {}),
180
+ rrf: contribution,
181
+ [signalKey]: contribution,
182
+ };
183
+ aggregates.set(id, {
184
+ record: hit.record,
185
+ score: contribution,
186
+ signals,
187
+ firstSeenOrder: nextOrder++,
188
+ });
189
+ } else {
190
+ existing.score += contribution;
191
+ existing.signals[signalKey] = contribution;
192
+ existing.signals.rrf = (existing.signals.rrf ?? 0) + contribution;
193
+ for (const [key, value] of Object.entries(hit.signals ?? {})) {
194
+ if (key in existing.signals) continue;
195
+ existing.signals[key] = value;
196
+ }
197
+ }
198
+ }
199
+ }
200
+ const fused: MemoryHit<TRecord>[] = [];
201
+ for (const a of aggregates.values()) {
202
+ fused.push({
203
+ record: a.record,
204
+ score: a.score,
205
+ signals: Object.freeze({ ...a.signals }),
206
+ });
207
+ }
208
+ fused.sort((a, b) => {
209
+ if (b.score !== a.score) return b.score - a.score;
210
+ const orderA = aggregates.get(a.record.id)?.firstSeenOrder ?? 0;
211
+ const orderB = aggregates.get(b.record.id)?.firstSeenOrder ?? 0;
212
+ return orderA - orderB;
213
+ });
214
+ return fused;
215
+ }
216
+
217
+ /**
218
+ * Resolve list `index`'s fusion weight, defaulting a missing / non-finite
219
+ * / negative entry to the neutral `1` (so an absent or malformed
220
+ * `weights` degrades to plain RRF rather than poisoning the ranking).
221
+ */
222
+ function resolveWeight(weights: ReadonlyArray<number> | undefined, index: number): number {
223
+ const raw = weights?.[index];
224
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? raw : 1;
225
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * C5: trust-aware retrieval ranking (pairs memory-retrieval-01's ranking
3
+ * leg / security-04). The provenance + quarantine discounts that the
4
+ * eviction-time `salience()` composite already applies now also apply at
5
+ * search SCORING time, so a poisoned or foreign-origin memory cannot
6
+ * outrank the user's own words purely on lexical/vector similarity - the
7
+ * direct defense against MINJA-class memory poisoning (query-only
8
+ * injection >95% ASR without rank-time trust).
9
+ *
10
+ * The factor reuses the {@link SalienceWeights} semantics verbatim:
11
+ * quarantined -> `1 - weights.quarantine` (default 0.3x), foreign
12
+ * provenance -> `1 - weights.foreignProvenance` (default 0.8x), first
13
+ * party -> `1` (neutral: default rankings of ordinary user/extraction
14
+ * facts are byte-identical).
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { DEFAULT_SALIENCE_WEIGHTS, type SalienceWeights } from '../consolidator/decay.js';
20
+
21
+ /**
22
+ * `true` for provenance that did not originate first-party (P1-4) -
23
+ * mirrors the eviction-path classification: `null` (legacy / direct
24
+ * write), `'user'`, and `'extraction'` (the consolidator distilling the
25
+ * user's own session) are first-party; `'tool'`, `'imported'`,
26
+ * `'reflection'`, and `'induction'` are foreign.
27
+ *
28
+ * @stable
29
+ */
30
+ export function isForeignProvenance(provenance: string | null | undefined): boolean {
31
+ return (
32
+ provenance !== null &&
33
+ provenance !== undefined &&
34
+ provenance !== 'user' &&
35
+ provenance !== 'extraction'
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Rank-time trust multiplier for one record. `1` for first-party active
41
+ * facts (the common case - zero ranking change), `1 - quarantine` for
42
+ * quarantined-but-included rows, `1 - foreignProvenance` for foreign
43
+ * origin. Quarantine wins when both apply (same branch order as
44
+ * `salience()`).
45
+ *
46
+ * @stable
47
+ */
48
+ export function trustDiscount(
49
+ record: { readonly status?: string; readonly provenance?: string | null },
50
+ weights: SalienceWeights = DEFAULT_SALIENCE_WEIGHTS,
51
+ ): number {
52
+ if (record.status === 'quarantined') return Math.max(0, 1 - weights.quarantine);
53
+ if (isForeignProvenance(record.provenance)) return Math.max(0, 1 - weights.foreignProvenance);
54
+ return 1;
55
+ }
@@ -0,0 +1,50 @@
1
+ import type { MemoryHit, MemoryRecord } from '@graphorin/core';
2
+
3
+ /**
4
+ * Pluggable reranker contract. Concrete implementations live in
5
+ * `@graphorin/memory/search` (the built-in `RRFReranker`) and the
6
+ * Phase 16 optional packages (`@graphorin/reranker-transformersjs`,
7
+ * `@graphorin/reranker-llm`).
8
+ *
9
+ * The reranker accepts one or more parallel ranked lists (vector +
10
+ * FTS5 + optional entity boost) and produces a single fused ranking.
11
+ * Implementations MUST be pure (no I/O outside `signal`-aware
12
+ * dependencies) so the agent runtime can call them mid-stream without
13
+ * deadlocking the main loop.
14
+ *
15
+ * @stable
16
+ */
17
+ export interface ReRanker {
18
+ /** Stable lowercase identifier surfaced on every span. */
19
+ readonly id: string;
20
+ /**
21
+ * Rerank one or more parallel ranked lists and return the fused
22
+ * top-K (default `topK = 10`). Each input list must already be
23
+ * sorted by `score` descending.
24
+ */
25
+ rerank<TRecord extends MemoryRecord>(
26
+ query: string,
27
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
28
+ options?: ReRankOptions,
29
+ ): Promise<ReadonlyArray<MemoryHit<TRecord>>>;
30
+ }
31
+
32
+ /**
33
+ * Per-call reranker options. `topK` defaults to `10`; `signal` is
34
+ * propagated to any async work the reranker performs.
35
+ *
36
+ * @stable
37
+ */
38
+ export interface ReRankOptions {
39
+ readonly topK?: number;
40
+ readonly signal?: AbortSignal;
41
+ /**
42
+ * Stable per-list labels for the explanation signals (MRET-13) -
43
+ * `rrf.<label>` instead of the ephemeral `rrf.list_<index>`. Callers
44
+ * that fan lists out conditionally (multi-query, HyDE, graph) pass
45
+ * retriever-kind labels so X-3 consumers can identify which leg a
46
+ * contribution came from across calls. Missing entries fall back to
47
+ * the positional key.
48
+ */
49
+ readonly labels?: ReadonlyArray<string>;
50
+ }