Skip to main content

llm_kernel/search/
federation.rs

1//! Cross-engine search federation over multiple vector backends.
2//!
3//! Federation queries several [`AsyncVectorIndex`](crate::embedding::AsyncVectorIndex)
4//! backends concurrently and merges their results with the existing fusion
5//! functions ([`rrf_fuse`],
6//! [`weighted_sum_fuse`]).
7//!
8//! # Why RRF is the default
9//!
10//! Heterogeneous backends score on incompatible scales: Qdrant (cosine
11//! distance) returns scores in `[0, 1]`; Elasticsearch knn `_score` is
12//! `(1 + cosine) / 2`, also in `[0, 1]` but a *different* monotonic transform;
13//! the in-memory `TurbovecIndex` returns raw cosine in `[-1, 1]`. Score-based
14//! fusion (weighted sum) over these raw values ranks documents incorrectly
15//! because a `0.3` from one backend is not comparable to a `0.3` from another.
16//!
17//! [`FusionStrategy::Rrf`] is **rank-based** (`1/(k + rank)`), so it is
18//! scale-invariant — it fuses heterogeneous backends correctly with **no**
19//! normalization. That is why it is the default. [`FusionStrategy::WeightedSum`]
20//! is opt-in: it normalizes each list with min-max first, which is only correct
21//! when every backend scores on a comparable scale, so it carries a caveat.
22
23use crate::search::SearchResult;
24use crate::search::fusion::{normalize_minmax, weighted_sum_fuse};
25use crate::search::rrf_fuse;
26
27/// How federated result lists are merged.
28///
29/// Defaults to [`FusionStrategy::Rrf`] (rank-based, scale-invariant). See the
30/// [module docs](self) for why this matters across heterogeneous backends.
31#[derive(Debug, Clone)]
32pub enum FusionStrategy {
33    /// Reciprocal Rank Fusion with constant `k` (typically 60). Rank-based, so
34    /// no score normalization is required across backends.
35    Rrf {
36        /// RRF smoothing constant (larger = flatter).
37        k: u32,
38    },
39    /// Weighted sum of min-max-normalized per-list scores. Each list is
40    /// normalized in isolation before summing, so this is only correct when
41    /// every backend scores on a comparable scale — otherwise prefer
42    /// [`FusionStrategy::Rrf`].
43    WeightedSum,
44}
45
46impl Default for FusionStrategy {
47    fn default() -> Self {
48        FusionStrategy::Rrf { k: 60 }
49    }
50}
51
52/// Fuse pre-fetched result lists with no I/O.
53///
54/// Lets a synchronous backend (e.g. the in-memory
55/// [`TurbovecIndex`](crate::embedding::TurbovecIndex)) participate in
56/// federation: the caller searches it directly, then folds its list in here
57/// alongside lists gathered from async backends (or any source). All backends
58/// contribute equally (weight `1.0`).
59///
60/// ```
61/// use llm_kernel::search::{SearchResult, federation::{federate_results, FusionStrategy}};
62///
63/// let qdrant = vec![SearchResult { id: "1".into(), score: 0.9, text: String::new() }];
64/// let es = vec![SearchResult { id: "1".into(), score: 0.97, text: String::new() }];
65/// let turbovec = vec![SearchResult { id: "1".into(), score: 0.3, text: String::new() }];
66///
67/// let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
68/// assert_eq!(merged.len(), 1); // shared id deduped, not tripled
69/// ```
70pub fn federate_results(
71    lists: &[Vec<SearchResult>],
72    strategy: &FusionStrategy,
73) -> Vec<SearchResult> {
74    match strategy {
75        FusionStrategy::Rrf { k } => rrf_fuse(lists, *k),
76        FusionStrategy::WeightedSum => {
77            let normed: Vec<Vec<SearchResult>> = lists
78                .iter()
79                .map(|l| {
80                    let mut c = l.clone();
81                    normalize_minmax(&mut c);
82                    c
83                })
84                .collect();
85            let weights = vec![1.0_f32; normed.len()];
86            weighted_sum_fuse(&normed, &weights)
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Async federation over AsyncVectorIndex backends (needs the `federation` feature).
93// ---------------------------------------------------------------------------
94
95#[cfg(feature = "federation")]
96mod federated {
97    use std::sync::Arc;
98    use std::time::Duration;
99
100    use futures_util::future::join_all;
101
102    use crate::embedding::{AsyncVectorIndex, SearchHit};
103    use crate::error::{KernelError, Result};
104    use crate::search::SearchResult;
105    use crate::search::fusion::{normalize_minmax, weighted_sum_fuse};
106    use crate::search::rrf_fuse;
107
108    use super::FusionStrategy;
109
110    /// One backend in a [`FederatedSearch`]: the index and its fusion weight.
111    struct Backend {
112        index: Arc<dyn AsyncVectorIndex>,
113        weight: f32,
114    }
115
116    /// Map u64-keyed [`SearchHit`]s into the String-id [`SearchResult`] shape the
117    /// fusion functions expect, canonicalizing the id so a shared document
118    /// merges across backends rather than appearing multiple times.
119    fn hits_to_results(hits: Vec<SearchHit>) -> Vec<SearchResult> {
120        hits.into_iter()
121            .map(|h| SearchResult {
122                id: h.id.to_string(),
123                score: h.score,
124                text: String::new(),
125            })
126            .collect()
127    }
128
129    /// Concurrent search over multiple [`AsyncVectorIndex`] backends.
130    ///
131    /// Queries every backend at once, applies a per-backend timeout so one slow
132    /// remote cannot stall the whole query, drops failing or timed-out backends
133    /// with an observable `tracing::warn!`, and merges the survivors with the
134    /// configured [`FusionStrategy`]. If **every** backend fails, returns
135    /// [`KernelError::Search`].
136    ///
137    /// Synchronous backends (e.g. `TurbovecIndex`) participate via
138    /// [`federate_results`](super::federate_results) instead — search them
139    /// directly and fold the list in.
140    pub struct FederatedSearch {
141        backends: Vec<Backend>,
142        strategy: FusionStrategy,
143        timeout: Duration,
144    }
145
146    impl Default for FederatedSearch {
147        fn default() -> Self {
148            Self {
149                backends: Vec::new(),
150                strategy: FusionStrategy::default(),
151                timeout: Duration::from_secs(5),
152            }
153        }
154    }
155
156    impl FederatedSearch {
157        /// Create an empty federated search (default strategy RRF k=60, 5s timeout).
158        pub fn new() -> Self {
159            Self::default()
160        }
161
162        /// Add a backend with a fusion weight (used only by
163        /// [`FusionStrategy::WeightedSum`]; ignored by RRF).
164        #[must_use]
165        pub fn with_backend(mut self, index: Arc<dyn AsyncVectorIndex>, weight: f32) -> Self {
166            self.backends.push(Backend { index, weight });
167            self
168        }
169
170        /// Set the fusion strategy (default [`FusionStrategy::Rrf`] k=60).
171        #[must_use]
172        pub fn strategy(mut self, strategy: FusionStrategy) -> Self {
173            self.strategy = strategy;
174            self
175        }
176
177        /// Set the per-backend query timeout (default 5s). A backend that
178        /// exceeds it is dropped with a warning rather than blocking the query.
179        #[must_use]
180        pub fn timeout(mut self, timeout: Duration) -> Self {
181            self.timeout = timeout;
182            self
183        }
184
185        /// Run `query` against every backend concurrently, merge survivors.
186        ///
187        /// Each backend is queried for `2 * k` results (over-fetch) so RRF
188        /// rank-credit is preserved for a document that ranks just below `k` in
189        /// one backend but near the top in another; the fused list is then
190        /// truncated to the requested `k`. A per-backend timeout drops slow or
191        /// failing backends with an observable `tracing::warn!` rather than
192        /// stalling the query.
193        ///
194        /// Returns the fused result list (at most `k` items).
195        /// [`KernelError::Search`] is returned only when *no* backend succeeded;
196        /// one or more survivors yield a partial (but non-empty) merged result.
197        pub async fn search(&self, query: &[f32], k_req: usize) -> Result<Vec<SearchResult>> {
198            if self.backends.is_empty() {
199                return Ok(Vec::new());
200            }
201
202            // Snapshot (index, weight) so each future is self-contained.
203            let entries: Vec<(Arc<dyn AsyncVectorIndex>, f32)> = self
204                .backends
205                .iter()
206                .map(|b| (b.index.clone(), b.weight))
207                .collect();
208            let timeout = self.timeout;
209
210            // Over-fetch each backend so RRF rank-credit is preserved for
211            // documents that rank just below k in one backend but appear near
212            // the top in another. Standard RRF practice: fetch ~2k, fuse, then
213            // truncate the merged list to the requested k (done after fusion).
214            // `saturating_mul` guards usize overflow and yields 0 for k == 0.
215            let fetch_k = k_req.saturating_mul(2);
216
217            let futs = entries.into_iter().map(|(index, weight)| {
218                let q = query.to_vec();
219                async move {
220                    match tokio::time::timeout(timeout, index.search(&q, fetch_k)).await {
221                        Ok(Ok(hits)) => Some((weight, hits)),
222                        Ok(Err(e)) => {
223                            tracing::warn!("federated backend errored; excluding: {e}");
224                            None
225                        }
226                        Err(_elapsed) => {
227                            tracing::warn!(
228                                "federated backend timed out after {:?}; excluding",
229                                timeout
230                            );
231                            None
232                        }
233                    }
234                }
235            });
236            let collected: Vec<Option<(f32, Vec<SearchHit>)>> = join_all(futs).await;
237
238            let ok: Vec<(f32, Vec<SearchHit>)> = collected.into_iter().flatten().collect();
239            if ok.is_empty() {
240                return Err(KernelError::Search(
241                    "all federated backends failed or timed out".into(),
242                ));
243            }
244
245            // Adapt u64-keyed hits into the String-id SearchResult shape fusion
246            // expects, canonicalizing the id so a shared document merges across
247            // backends rather than appearing multiple times. `ok` is consumed
248            // once: RRF needs only the lists, WeightedSum additionally needs the
249            // per-backend weights (collected inside that arm). Note: the RRF
250            // smoothing constant is named `k` by the `FusionStrategy::Rrf`
251            // variant, which is why the requested count is `k_req` here — the
252            // two must not be confused at the truncation step.
253            let mut fused = match self.strategy {
254                FusionStrategy::Rrf { k } => {
255                    let lists: Vec<Vec<SearchResult>> = ok
256                        .into_iter()
257                        .map(|(_w, hits)| hits_to_results(hits))
258                        .collect();
259                    rrf_fuse(&lists, k)
260                }
261                FusionStrategy::WeightedSum => {
262                    let mut lists: Vec<Vec<SearchResult>> = Vec::with_capacity(ok.len());
263                    let mut weights: Vec<f32> = Vec::with_capacity(ok.len());
264                    for (w, hits) in ok {
265                        let mut list = hits_to_results(hits);
266                        normalize_minmax(&mut list);
267                        lists.push(list);
268                        weights.push(w);
269                    }
270                    weighted_sum_fuse(&lists, &weights)
271                }
272            };
273            // The over-fetch produced lists longer than requested; trim the
274            // fused output back to exactly `k_req`. `truncate` is a no-op if
275            // the fused list is already shorter (e.g. a backend with fewer than
276            // `fetch_k` documents).
277            fused.truncate(k_req);
278            Ok(fused)
279        }
280    }
281}
282
283#[cfg(feature = "federation")]
284pub use federated::FederatedSearch;
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn hits(ids: &[(&str, f32)]) -> Vec<SearchResult> {
291        ids.iter()
292            .map(|(id, score)| SearchResult {
293                id: (*id).to_string(),
294                score: *score,
295                text: String::new(),
296            })
297            .collect()
298    }
299
300    /// AC5: RRF is scale-invariant — heterogeneous raw-score scales (Qdrant
301    /// cosine, ES `(1+cos)/2`, TurboVec raw cosine) fuse correctly under the
302    /// default strategy with no manual normalization. A document ranked #1 in
303    /// all three lists tops the merge regardless of wildly different scores.
304    #[test]
305    fn rrf_fuses_heterogeneous_scales_correctly() {
306        // Qdrant cosine [0,1]
307        let qdrant = hits(&[("shared", 0.90), ("a", 0.50)]);
308        // ES _score (1+cos)/2 [0,1] — note 0.97 here corresponds to cos≈0.94
309        let es = hits(&[("shared", 0.97), ("b", 0.70)]);
310        // TurboVec raw cosine [-1,1] — shared only scores 0.3 on this scale
311        let turbovec = hits(&[("shared", 0.30), ("c", -0.50)]);
312
313        let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
314
315        // "shared" is rank 0 in all three → highest RRF score.
316        assert_eq!(merged[0].id, "shared");
317    }
318
319    /// AC6: a shared id present in all backends is deduped (merged) once, and
320    /// accumulates rank-credit so it outranks any single-backend document.
321    #[test]
322    fn shared_id_is_deduped_and_boosted() {
323        let qdrant = hits(&[("shared", 1.0), ("only_q", 0.9)]);
324        let es = hits(&[("shared", 1.0)]);
325        let turbovec = hits(&[("shared", 1.0)]);
326
327        let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
328
329        // "shared" appears exactly once.
330        let shared_count = merged.iter().filter(|r| r.id == "shared").count();
331        assert_eq!(shared_count, 1);
332        assert_eq!(merged.len(), 2); // shared + only_q
333        // shared: rank 0 in three lists; only_q: rank 1 in one list.
334        let shared_score = merged.iter().find(|r| r.id == "shared").unwrap().score;
335        let only_q_score = merged.iter().find(|r| r.id == "only_q").unwrap().score;
336        assert!(shared_score > only_q_score);
337    }
338
339    #[test]
340    fn weighted_sum_strategy_runs() {
341        let a = hits(&[("x", 0.0), ("y", 1.0)]);
342        let b = hits(&[("y", 1.0), ("z", 0.4)]);
343        let merged = federate_results(&[a, b], &FusionStrategy::WeightedSum);
344        assert_eq!(merged.len(), 3);
345        assert_eq!(merged[0].id, "y");
346    }
347}
348
349#[cfg(all(test, feature = "federation"))]
350mod async_tests {
351    use std::sync::Arc;
352    use std::time::Duration;
353
354    use async_trait::async_trait;
355
356    use crate::embedding::{AsyncVectorIndex, SearchHit};
357    use crate::error::{KernelError, Result};
358    use crate::search::federation::{FederatedSearch, FusionStrategy};
359
360    /// Configurable stub backend: returns canned hits, optionally fails, or
361    /// delays past a timeout.
362    struct StubIndex {
363        hits: Vec<SearchHit>,
364        delay: Option<Duration>,
365        fail: bool,
366        dim: usize,
367    }
368
369    #[async_trait]
370    impl AsyncVectorIndex for StubIndex {
371        async fn add(&self, _vectors: &[Vec<f32>], _ids: &[u64]) -> Result<()> {
372            Ok(())
373        }
374        async fn remove(&self, _ids: &[u64]) -> Result<()> {
375            Ok(())
376        }
377        async fn search(&self, _query: &[f32], _k: usize) -> Result<Vec<SearchHit>> {
378            if let Some(d) = self.delay {
379                tokio::time::sleep(d).await;
380            }
381            if self.fail {
382                return Err(KernelError::Embedding("stub backend failure".into()));
383            }
384            Ok(self.hits.clone())
385        }
386        async fn search_filtered(
387            &self,
388            _query: &[f32],
389            _k: usize,
390            _allowlist: &[u64],
391        ) -> Result<Vec<SearchHit>> {
392            Ok(self.hits.clone())
393        }
394        async fn len(&self) -> Result<usize> {
395            Ok(self.hits.len())
396        }
397        fn dim(&self) -> usize {
398            self.dim
399        }
400    }
401
402    fn hit(id: u64, score: f32) -> SearchHit {
403        SearchHit { id, score }
404    }
405
406    /// AC4: a slow backend that exceeds the timeout is dropped without failing
407    /// the query; the fast backend's results still come through.
408    #[tokio::test]
409    async fn slow_backend_is_dropped_not_blocking() {
410        let fast = Arc::new(StubIndex {
411            hits: vec![hit(1, 0.9), hit(2, 0.5)],
412            delay: None,
413            fail: false,
414            dim: 4,
415        });
416        let slow = Arc::new(StubIndex {
417            hits: vec![hit(3, 1.0)],
418            delay: Some(Duration::from_millis(500)),
419            fail: false,
420            dim: 4,
421        });
422
423        let fed = FederatedSearch::new()
424            .with_backend(fast, 1.0)
425            .with_backend(slow, 1.0)
426            .timeout(Duration::from_millis(50));
427
428        let merged = fed.search(&[1.0, 0.0, 0.0, 0.0], 5).await.unwrap();
429        // Only the fast backend contributed; id 3 (from the timed-out backend)
430        // is absent, but the query still succeeded.
431        assert!(merged.iter().any(|r| r.id == "1"));
432        assert!(!merged.iter().any(|r| r.id == "3"));
433    }
434
435    /// AC4: a failing backend is excluded; survivors still return results.
436    #[tokio::test]
437    async fn failing_backend_is_excluded() {
438        let good = Arc::new(StubIndex {
439            hits: vec![hit(7, 0.8)],
440            delay: None,
441            fail: false,
442            dim: 4,
443        });
444        let bad = Arc::new(StubIndex {
445            hits: vec![],
446            delay: None,
447            fail: true,
448            dim: 4,
449        });
450
451        let merged = FederatedSearch::new()
452            .with_backend(good, 1.0)
453            .with_backend(bad, 1.0)
454            .search(&[0.0, 0.0, 0.0, 1.0], 3)
455            .await
456            .unwrap();
457        assert!(merged.iter().any(|r| r.id == "7"));
458    }
459
460    /// AC4: when *every* backend fails, `search` returns `Err`.
461    #[tokio::test]
462    async fn all_backends_failing_returns_err() {
463        let bad = Arc::new(StubIndex {
464            hits: vec![],
465            delay: None,
466            fail: true,
467            dim: 4,
468        });
469        let res = FederatedSearch::new()
470            .with_backend(bad.clone(), 1.0)
471            .with_backend(bad, 1.0)
472            .search(&[0.0; 4], 3)
473            .await;
474        assert!(res.is_err());
475    }
476
477    /// AC4/AC5: two healthy backends merge under the default RRF strategy.
478    #[tokio::test]
479    async fn two_backends_merge_via_rrf() {
480        let a = Arc::new(StubIndex {
481            hits: vec![hit(1, 0.99), hit(2, 0.4)],
482            delay: None,
483            fail: false,
484            dim: 4,
485        });
486        let b = Arc::new(StubIndex {
487            hits: vec![hit(2, 0.95), hit(3, 0.6)],
488            delay: None,
489            fail: false,
490            dim: 4,
491        });
492        let merged = FederatedSearch::new()
493            .with_backend(a, 1.0)
494            .with_backend(b, 1.0)
495            .search(&[1.0, 0.0, 0.0, 0.0], 5)
496            .await
497            .unwrap();
498        // id 2 is rank 0/1 across both → top.
499        assert_eq!(merged[0].id, "2");
500        assert_eq!(merged.len(), 3);
501        // Strategy default is RRF k=60.
502        assert!(matches!(
503            FusionStrategy::default(),
504            FusionStrategy::Rrf { k: 60 }
505        ));
506    }
507
508    /// Guards the refactored WeightedSum async arm (the weights-collection loop
509    /// moved inside that arm): two backends with distinct weights still merge,
510    /// with results from both backends present.
511    #[tokio::test]
512    async fn two_backends_merge_via_weighted_sum() {
513        let a = Arc::new(StubIndex {
514            hits: vec![hit(1, 1.0), hit(2, 0.2)],
515            delay: None,
516            fail: false,
517            dim: 4,
518        });
519        let b = Arc::new(StubIndex {
520            hits: vec![hit(2, 1.0), hit(3, 0.1)],
521            delay: None,
522            fail: false,
523            dim: 4,
524        });
525        let merged = FederatedSearch::new()
526            .with_backend(a, 0.75)
527            .with_backend(b, 0.25)
528            .strategy(FusionStrategy::WeightedSum)
529            .search(&[1.0, 0.0, 0.0, 0.0], 5)
530            .await
531            .unwrap();
532        // a's top (id 1, normalized weight 0.75) leads; both backends present.
533        assert!(!merged.is_empty());
534        assert_eq!(merged[0].id, "1");
535        assert!(merged.iter().any(|r| r.id == "2")); // shared, deduped
536        assert!(merged.iter().any(|r| r.id == "3")); // from b
537    }
538
539    /// No backends configured → empty result, no error.
540    #[tokio::test]
541    async fn no_backends_returns_empty() {
542        let merged = FederatedSearch::new().search(&[0.0; 4], 3).await.unwrap();
543        assert!(merged.is_empty());
544    }
545
546    // --- over-fetch / truncate (hardening) ---------------------------------
547    //
548    // `StubIndex` above ignores its `k` argument, so it cannot exercise the
549    // fetch-2k-then-truncate behavior. `RankAwareStub` honors `k` by returning
550    // only the first `k` of its canned list, letting us prove the over-fetch
551    // preserves RRF rank-credit and the output is truncated to the requested k.
552
553    /// Stub backend that honors the requested `k`: returns the first `k` of its
554    /// canned list (clamped to the list length). This mirrors how a real
555    /// `AsyncVectorIndex` returns at most `k` neighbors.
556    struct RankAwareStub {
557        hits: Vec<SearchHit>,
558        dim: usize,
559    }
560
561    #[async_trait]
562    impl AsyncVectorIndex for RankAwareStub {
563        async fn add(&self, _vectors: &[Vec<f32>], _ids: &[u64]) -> Result<()> {
564            Ok(())
565        }
566        async fn remove(&self, _ids: &[u64]) -> Result<()> {
567            Ok(())
568        }
569        async fn search(&self, _query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
570            Ok(self.hits.iter().take(k).cloned().collect())
571        }
572        async fn search_filtered(
573            &self,
574            _query: &[f32],
575            k: usize,
576            _allowlist: &[u64],
577        ) -> Result<Vec<SearchHit>> {
578            Ok(self.hits.iter().take(k).cloned().collect())
579        }
580        async fn len(&self) -> Result<usize> {
581            Ok(self.hits.len())
582        }
583        fn dim(&self) -> usize {
584            self.dim
585        }
586    }
587
588    /// Without over-fetch, a document that ranks just below `k` in one backend
589    /// but at the top in another loses its rank-credit: the first backend never
590    /// returns it (it asked for only `k`). Over-fetching `2 * k` per backend
591    /// lets that document enter both lists, so RRF credits it from both and it
592    /// survives the final truncate to `k`.
593    #[tokio::test]
594    async fn over_fetch_preserves_rank_credit_across_backends() {
595        // Backend A ranks `shared` at position 2 (rank index 2) — below k=2,
596        // so a bare `k` query would never return it. Backend B ranks `shared`
597        // first. With over-fetch (fetch_k = 4), A DOES return `shared`.
598        let a = Arc::new(RankAwareStub {
599            hits: vec![hit(101, 0.99), hit(102, 0.9), hit(7, 0.8), hit(8, 0.7)],
600            dim: 4,
601        });
602        let b = Arc::new(RankAwareStub {
603            hits: vec![hit(7, 1.0), hit(9, 0.6)],
604            dim: 4,
605        });
606
607        let merged = FederatedSearch::new()
608            .with_backend(a, 1.0)
609            .with_backend(b, 1.0)
610            // k = 2 → each backend is queried for 4; id 7 appears in BOTH
611            // lists (rank 2 in A, rank 0 in B) and accumulates rank-credit, so
612            // it outranks the single-backend filler docs and makes the top 2.
613            .search(&[1.0, 0.0, 0.0, 0.0], 2)
614            .await
615            .unwrap();
616
617        // Truncated to exactly k = 2.
618        assert_eq!(merged.len(), 2);
619        // id 7 is present (it appeared in both over-fetched lists). Without
620        // over-fetch it would have been dropped by backend A and could not
621        // accumulate cross-backend credit.
622        assert!(
623            merged.iter().any(|r| r.id == "7"),
624            "id 7 should survive via over-fetch rank-credit: {merged:?}"
625        );
626    }
627
628    /// The fused output is truncated to the requested `k`, never more, even
629    /// when every backend has far more than `k` documents.
630    #[tokio::test]
631    async fn fused_output_is_truncated_to_requested_k() {
632        let a = Arc::new(RankAwareStub {
633            hits: (1..=20).map(|i| hit(i, 1.0 - i as f32 * 0.01)).collect(),
634            dim: 4,
635        });
636        let b = Arc::new(RankAwareStub {
637            hits: (21..=40).map(|i| hit(i, 0.5 - i as f32 * 0.01)).collect(),
638            dim: 4,
639        });
640        let merged = FederatedSearch::new()
641            .with_backend(a, 1.0)
642            .with_backend(b, 1.0)
643            .search(&[1.0, 0.0, 0.0, 0.0], 5)
644            .await
645            .unwrap();
646        assert_eq!(merged.len(), 5, "fused output must be truncated to k");
647    }
648
649    /// `k == 0` with configured backends yields an empty (non-error) result:
650    /// fetch_k == 0 → each backend returns nothing → fused empty → truncate(0).
651    #[tokio::test]
652    async fn k_zero_with_backends_returns_empty_not_err() {
653        let a = Arc::new(RankAwareStub {
654            hits: vec![hit(1, 0.9)],
655            dim: 4,
656        });
657        let merged = FederatedSearch::new()
658            .with_backend(a, 1.0)
659            .search(&[1.0, 0.0, 0.0, 0.0], 0)
660            .await
661            .unwrap();
662        assert!(merged.is_empty());
663    }
664}