@gmickel/gno 0.3.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 (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Query expansion for hybrid search.
3
+ * Uses GenerationPort to generate query variants.
4
+ *
5
+ * @module src/pipeline/expansion
6
+ */
7
+
8
+ import { createHash } from 'node:crypto'; // No Bun alternative for hashing
9
+ import type { GenerationPort } from '../llm/types';
10
+ import type { StoreResult } from '../store/types';
11
+ import { ok } from '../store/types';
12
+ import type { ExpansionResult } from './types';
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Constants
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ const EXPANSION_PROMPT_VERSION = 'v1';
19
+ const DEFAULT_TIMEOUT_MS = 5000;
20
+ const JSON_EXTRACT_PATTERN = /\{[\s\S]*\}/;
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // Cache Key Generation
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Generate cache key for expansion results.
28
+ * Key = SHA256(promptVersion || modelUri || query || lang)
29
+ */
30
+ export function generateCacheKey(
31
+ modelUri: string,
32
+ query: string,
33
+ lang: string
34
+ ): string {
35
+ const data = [EXPANSION_PROMPT_VERSION, modelUri, query, lang].join('\0');
36
+ return createHash('sha256').update(data).digest('hex');
37
+ }
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // Prompt Templates
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ const EXPANSION_PROMPT_EN = `You are a query expansion assistant. Given a search query, generate alternative phrasings to improve search results.
44
+
45
+ Input query: "{query}"
46
+
47
+ Generate a JSON object with:
48
+ - "lexicalQueries": array of 2-3 keyword-based variations (for BM25 search)
49
+ - "vectorQueries": array of 2-3 semantic rephrasing (for embedding search)
50
+ - "hyde": a short hypothetical document passage that would answer the query (optional)
51
+
52
+ Respond ONLY with valid JSON, no explanation.
53
+
54
+ Example:
55
+ {
56
+ "lexicalQueries": ["deployment process", "how to deploy", "deploying application"],
57
+ "vectorQueries": ["steps to release software to production", "guide for application deployment"],
58
+ "hyde": "To deploy the application, first run the build command, then push to the staging environment..."
59
+ }`;
60
+
61
+ const EXPANSION_PROMPT_DE = `Du bist ein Query-Erweiterungs-Assistent. Generiere alternative Formulierungen für die Suchanfrage.
62
+
63
+ Suchanfrage: "{query}"
64
+
65
+ Generiere ein JSON-Objekt mit:
66
+ - "lexicalQueries": Array mit 2-3 Keyword-Variationen (für BM25-Suche)
67
+ - "vectorQueries": Array mit 2-3 semantischen Umformulierungen (für Vektor-Suche)
68
+ - "hyde": Ein kurzer hypothetischer Dokumentenausschnitt, der die Anfrage beantworten würde (optional)
69
+
70
+ Antworte NUR mit validem JSON, keine Erklärung.`;
71
+
72
+ const EXPANSION_PROMPT_MULTILINGUAL = `You are a query expansion assistant. Generate alternative phrasings for the search query in the same language as the query.
73
+
74
+ Input query: "{query}"
75
+
76
+ Generate a JSON object with:
77
+ - "lexicalQueries": array of 2-3 keyword-based variations
78
+ - "vectorQueries": array of 2-3 semantic rephrasing
79
+ - "hyde": a short hypothetical document passage (optional)
80
+
81
+ Respond ONLY with valid JSON.`;
82
+
83
+ /**
84
+ * Get prompt template for language.
85
+ */
86
+ function getPromptTemplate(lang?: string): string {
87
+ switch (lang?.toLowerCase()) {
88
+ case 'en':
89
+ case 'en-us':
90
+ case 'en-gb':
91
+ return EXPANSION_PROMPT_EN;
92
+ case 'de':
93
+ case 'de-de':
94
+ case 'de-at':
95
+ case 'de-ch':
96
+ return EXPANSION_PROMPT_DE;
97
+ default:
98
+ return EXPANSION_PROMPT_MULTILINGUAL;
99
+ }
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Schema Validation
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Validate and parse expansion result from LLM output.
108
+ */
109
+ function parseExpansionResult(output: string): ExpansionResult | null {
110
+ try {
111
+ // Try to extract JSON from output (model may include extra text)
112
+ const jsonMatch = output.match(JSON_EXTRACT_PATTERN);
113
+ if (!jsonMatch) {
114
+ return null;
115
+ }
116
+
117
+ const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
118
+
119
+ // Validate required fields
120
+ if (!Array.isArray(parsed.lexicalQueries)) {
121
+ return null;
122
+ }
123
+ if (!Array.isArray(parsed.vectorQueries)) {
124
+ return null;
125
+ }
126
+
127
+ // Validate array contents are strings
128
+ const lexicalQueries = parsed.lexicalQueries.filter(
129
+ (q): q is string => typeof q === 'string' && q.length > 0
130
+ );
131
+ const vectorQueries = parsed.vectorQueries.filter(
132
+ (q): q is string => typeof q === 'string' && q.length > 0
133
+ );
134
+
135
+ // Limit array sizes
136
+ const result: ExpansionResult = {
137
+ lexicalQueries: lexicalQueries.slice(0, 5),
138
+ vectorQueries: vectorQueries.slice(0, 5),
139
+ };
140
+
141
+ // Optional fields
142
+ if (typeof parsed.hyde === 'string' && parsed.hyde.length > 0) {
143
+ result.hyde = parsed.hyde;
144
+ }
145
+ if (typeof parsed.notes === 'string') {
146
+ result.notes = parsed.notes;
147
+ }
148
+
149
+ return result;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // ─────────────────────────────────────────────────────────────────────────────
156
+ // Expansion Function
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+
159
+ export interface ExpansionOptions {
160
+ /** Language hint for prompt selection */
161
+ lang?: string;
162
+ /** Timeout in milliseconds */
163
+ timeout?: number;
164
+ }
165
+
166
+ /**
167
+ * Expand query using generation model.
168
+ * Returns null on timeout or parse failure (graceful degradation).
169
+ */
170
+ export async function expandQuery(
171
+ genPort: GenerationPort,
172
+ query: string,
173
+ options: ExpansionOptions = {}
174
+ ): Promise<StoreResult<ExpansionResult | null>> {
175
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
176
+
177
+ // Build prompt
178
+ const template = getPromptTemplate(options.lang);
179
+ const prompt = template.replace('{query}', query);
180
+
181
+ // Run with timeout
182
+ const timeoutPromise = new Promise<null>((resolve) => {
183
+ setTimeout(() => resolve(null), timeout);
184
+ });
185
+
186
+ try {
187
+ const result = await Promise.race([
188
+ genPort.generate(prompt, {
189
+ temperature: 0,
190
+ seed: 42,
191
+ maxTokens: 512,
192
+ }),
193
+ timeoutPromise,
194
+ ]);
195
+
196
+ // Timeout
197
+ if (result === null) {
198
+ return ok(null);
199
+ }
200
+
201
+ // Generation failed
202
+ if (!result.ok) {
203
+ return ok(null); // Graceful degradation
204
+ }
205
+
206
+ // Parse result
207
+ const parsed = parseExpansionResult(result.value);
208
+ return ok(parsed);
209
+ } catch {
210
+ return ok(null); // Graceful degradation
211
+ }
212
+ }
213
+
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+ // Cached Expansion
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+
218
+ export interface CachedExpansionDeps {
219
+ genPort: GenerationPort;
220
+ getCache: (key: string) => Promise<string | null>;
221
+ setCache: (key: string, value: string) => Promise<void>;
222
+ }
223
+
224
+ /**
225
+ * Expand query with caching.
226
+ */
227
+ export async function expandQueryCached(
228
+ deps: CachedExpansionDeps,
229
+ query: string,
230
+ options: ExpansionOptions = {}
231
+ ): Promise<StoreResult<ExpansionResult | null>> {
232
+ const lang = options.lang ?? 'auto';
233
+ const cacheKey = generateCacheKey(deps.genPort.modelUri, query, lang);
234
+
235
+ // Check cache
236
+ const cached = await deps.getCache(cacheKey);
237
+ if (cached) {
238
+ const parsed = parseExpansionResult(cached);
239
+ if (parsed) {
240
+ return ok(parsed);
241
+ }
242
+ }
243
+
244
+ // Generate
245
+ const result = await expandQuery(deps.genPort, query, options);
246
+ if (!result.ok) {
247
+ return result;
248
+ }
249
+
250
+ // Cache result
251
+ if (result.value) {
252
+ await deps.setCache(cacheKey, JSON.stringify(result.value));
253
+ }
254
+
255
+ return result;
256
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Explainability formatter for search pipeline.
3
+ * Outputs pipeline details to stderr.
4
+ *
5
+ * @module src/pipeline/explain
6
+ */
7
+
8
+ import type {
9
+ ExpansionResult,
10
+ ExplainLine,
11
+ ExplainResult,
12
+ RerankedCandidate,
13
+ } from './types';
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Formatter
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Format explain lines for stderr output.
21
+ */
22
+ export function formatExplain(lines: ExplainLine[]): string {
23
+ return lines.map((l) => `[explain] ${l.stage}: ${l.message}`).join('\n');
24
+ }
25
+
26
+ /**
27
+ * Format result explain for --explain output.
28
+ */
29
+ export function formatResultExplain(results: ExplainResult[]): string {
30
+ const lines: string[] = [];
31
+ for (const r of results.slice(0, 10)) {
32
+ let msg = `score=${r.score.toFixed(2)}`;
33
+ if (r.bm25Score !== undefined) {
34
+ msg += ` (bm25=${r.bm25Score.toFixed(2)}`;
35
+ if (r.vecScore !== undefined) {
36
+ msg += `, vec=${r.vecScore.toFixed(2)}`;
37
+ }
38
+ if (r.rerankScore !== undefined) {
39
+ msg += `, rerank=${r.rerankScore.toFixed(2)}`;
40
+ }
41
+ msg += ')';
42
+ }
43
+ lines.push(`[explain] result ${r.rank}: ${r.docid} ${msg}`);
44
+ }
45
+ return lines.join('\n');
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // Explain Line Builders
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ export function explainExpansion(
53
+ enabled: boolean,
54
+ result: ExpansionResult | null
55
+ ): ExplainLine {
56
+ if (!enabled) {
57
+ return { stage: 'expansion', message: 'disabled' };
58
+ }
59
+ if (!result) {
60
+ return { stage: 'expansion', message: 'skipped (strong BM25 or timeout)' };
61
+ }
62
+ const lex = result.lexicalQueries.length;
63
+ const sem = result.vectorQueries.length;
64
+ const hyde = result.hyde ? ', 1 HyDE' : '';
65
+ return {
66
+ stage: 'expansion',
67
+ message: `enabled (${lex} lexical, ${sem} semantic variants${hyde})`,
68
+ };
69
+ }
70
+
71
+ export function explainBm25(count: number): ExplainLine {
72
+ return { stage: 'bm25', message: `${count} candidates` };
73
+ }
74
+
75
+ export function explainVector(count: number, available: boolean): ExplainLine {
76
+ if (!available) {
77
+ return { stage: 'vector', message: 'unavailable (sqlite-vec not loaded)' };
78
+ }
79
+ return { stage: 'vector', message: `${count} candidates` };
80
+ }
81
+
82
+ export function explainFusion(k: number, uniqueCount: number): ExplainLine {
83
+ return {
84
+ stage: 'fusion',
85
+ message: `RRF k=${k}, ${uniqueCount} unique candidates`,
86
+ };
87
+ }
88
+
89
+ export function explainRerank(enabled: boolean, count: number): ExplainLine {
90
+ if (!enabled) {
91
+ return { stage: 'rerank', message: 'disabled' };
92
+ }
93
+ return { stage: 'rerank', message: `top ${count} reranked` };
94
+ }
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ // Build ExplainResult from RerankedCandidate
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+
100
+ export function buildExplainResults(
101
+ candidates: RerankedCandidate[],
102
+ docidMap: Map<string, string>
103
+ ): ExplainResult[] {
104
+ return candidates.slice(0, 20).map((c, i) => {
105
+ const key = `${c.mirrorHash}:${c.seq}`;
106
+ return {
107
+ rank: i + 1,
108
+ docid: docidMap.get(key) ?? '#unknown',
109
+ score: c.blendedScore,
110
+ bm25Score: c.bm25Rank !== null ? 1 / (60 + c.bm25Rank) : undefined,
111
+ vecScore: c.vecRank !== null ? 1 / (60 + c.vecRank) : undefined,
112
+ rerankScore: c.rerankScore ?? undefined,
113
+ };
114
+ });
115
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * RRF (Reciprocal Rank Fusion) implementation.
3
+ * Combines BM25 and vector search results.
4
+ *
5
+ * @module src/pipeline/fusion
6
+ */
7
+
8
+ import type { FusionCandidate, FusionSource, RrfConfig } from './types';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ /** Input for fusion - ranked results from a single source */
15
+ export interface RankedInput {
16
+ source: FusionSource;
17
+ results: Array<{
18
+ mirrorHash: string;
19
+ seq: number;
20
+ rank: number; // 1-based rank
21
+ }>;
22
+ }
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // RRF Score Calculation
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Calculate RRF contribution for a single rank.
30
+ * Formula: weight / (k + rank)
31
+ */
32
+ function rrfContribution(rank: number, k: number, weight: number): number {
33
+ return weight / (k + rank);
34
+ }
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Fusion Implementation
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Fuse multiple ranked lists using RRF.
42
+ *
43
+ * @param inputs - Array of ranked inputs from different sources
44
+ * @param config - RRF configuration
45
+ * @returns Fused and sorted candidates
46
+ */
47
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: RRF fusion with deduplication and scoring
48
+ export function rrfFuse(
49
+ inputs: RankedInput[],
50
+ config: RrfConfig
51
+ ): FusionCandidate[] {
52
+ // Map: "mirrorHash:seq" -> FusionCandidate
53
+ const candidates = new Map<string, FusionCandidate>();
54
+
55
+ // Separate inputs by type for weight assignment
56
+ const bm25Inputs = inputs.filter(
57
+ (i) => i.source === 'bm25' || i.source === 'bm25_variant'
58
+ );
59
+ const vectorInputs = inputs.filter(
60
+ (i) =>
61
+ i.source === 'vector' ||
62
+ i.source === 'vector_variant' ||
63
+ i.source === 'hyde'
64
+ );
65
+
66
+ // Process BM25 sources
67
+ for (const input of bm25Inputs) {
68
+ const weight =
69
+ input.source === 'bm25' ? config.bm25Weight : config.bm25Weight * 0.5;
70
+
71
+ for (const result of input.results) {
72
+ const key = `${result.mirrorHash}:${result.seq}`;
73
+ let candidate = candidates.get(key);
74
+
75
+ if (!candidate) {
76
+ candidate = {
77
+ mirrorHash: result.mirrorHash,
78
+ seq: result.seq,
79
+ bm25Rank: null,
80
+ vecRank: null,
81
+ fusionScore: 0,
82
+ sources: [],
83
+ };
84
+ candidates.set(key, candidate);
85
+ }
86
+
87
+ // Track best BM25 rank
88
+ if (candidate.bm25Rank === null || result.rank < candidate.bm25Rank) {
89
+ candidate.bm25Rank = result.rank;
90
+ }
91
+
92
+ // Add RRF contribution
93
+ candidate.fusionScore += rrfContribution(result.rank, config.k, weight);
94
+ if (!candidate.sources.includes(input.source)) {
95
+ candidate.sources.push(input.source);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Process vector sources
101
+ for (const input of vectorInputs) {
102
+ let weight = config.vecWeight;
103
+ if (input.source === 'vector_variant') {
104
+ weight = config.vecWeight * 0.5;
105
+ } else if (input.source === 'hyde') {
106
+ weight = config.vecWeight * 0.7;
107
+ }
108
+
109
+ for (const result of input.results) {
110
+ const key = `${result.mirrorHash}:${result.seq}`;
111
+ let candidate = candidates.get(key);
112
+
113
+ if (!candidate) {
114
+ candidate = {
115
+ mirrorHash: result.mirrorHash,
116
+ seq: result.seq,
117
+ bm25Rank: null,
118
+ vecRank: null,
119
+ fusionScore: 0,
120
+ sources: [],
121
+ };
122
+ candidates.set(key, candidate);
123
+ }
124
+
125
+ // Track best vector rank
126
+ if (candidate.vecRank === null || result.rank < candidate.vecRank) {
127
+ candidate.vecRank = result.rank;
128
+ }
129
+
130
+ // Add RRF contribution
131
+ candidate.fusionScore += rrfContribution(result.rank, config.k, weight);
132
+ if (!candidate.sources.includes(input.source)) {
133
+ candidate.sources.push(input.source);
134
+ }
135
+ }
136
+ }
137
+
138
+ // Apply top-rank bonus
139
+ for (const candidate of candidates.values()) {
140
+ if (
141
+ candidate.bm25Rank !== null &&
142
+ candidate.bm25Rank <= config.topRankThreshold &&
143
+ candidate.vecRank !== null &&
144
+ candidate.vecRank <= config.topRankThreshold
145
+ ) {
146
+ candidate.fusionScore += config.topRankBonus;
147
+ }
148
+ }
149
+
150
+ // Sort by fusion score (descending), then by mirrorHash:seq for determinism
151
+ const sorted = Array.from(candidates.values()).sort((a, b) => {
152
+ const scoreDiff = b.fusionScore - a.fusionScore;
153
+ if (Math.abs(scoreDiff) > 1e-9) {
154
+ return scoreDiff;
155
+ }
156
+ // Deterministic tie-breaking
157
+ const aKey = `${a.mirrorHash}:${a.seq}`;
158
+ const bKey = `${b.mirrorHash}:${b.seq}`;
159
+ return aKey.localeCompare(bKey);
160
+ });
161
+
162
+ return sorted;
163
+ }
164
+
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+ // Helper: Convert search results to ranked input
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Convert array of (mirrorHash, seq) tuples to RankedInput.
171
+ * Results are assumed to be in rank order (first = rank 1).
172
+ */
173
+ export function toRankedInput(
174
+ source: FusionSource,
175
+ results: Array<{ mirrorHash: string; seq: number }>
176
+ ): RankedInput {
177
+ return {
178
+ source,
179
+ results: results.map((r, i) => ({
180
+ mirrorHash: r.mirrorHash,
181
+ seq: r.seq,
182
+ rank: i + 1,
183
+ })),
184
+ };
185
+ }