Skip to main content

ipfrs_semantic/
federated_search.rs

1//! Federated Search Coordinator — Cross-Node Vector Similarity Search
2//!
3//! This module enables cross-node vector similarity search by fanning out queries to
4//! multiple registered peer nodes and merging the ranked results into a single
5//! deduplicated, re-ranked response.
6//!
7//! # Design
8//!
9//! - Peers are managed through a `RwLock<Vec<SearchPeer>>` for concurrent read access.
10//! - Results are cached keyed by `QueryKey` (FNV-1a hash of query vector, top_k, ef).
11//! - Merged result lists are deduplicated by CID and re-ranked by similarity score.
12//! - Atomic stats counters track query counts, cache hits, peers queried, etc.
13
14use std::collections::HashMap;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex, RwLock};
17use std::time::{Duration, Instant};
18
19// ---------------------------------------------------------------------------
20// QueryKey
21// ---------------------------------------------------------------------------
22
23/// Cache key derived from the query vector (FNV-1a hash), top_k, and ef.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct QueryKey {
26    /// FNV-1a hash of the raw bytes of the query vector.
27    pub query_hash: u64,
28    /// Number of top results requested.
29    pub top_k: usize,
30    /// ef parameter for HNSW search.
31    pub ef: usize,
32}
33
34impl QueryKey {
35    /// Construct a new `QueryKey` by hashing `query_vec` with FNV-1a.
36    pub fn new(query_vec: &[f32], top_k: usize, ef: usize) -> Self {
37        let query_hash = fnv1a_hash_f32_slice(query_vec);
38        Self {
39            query_hash,
40            top_k,
41            ef,
42        }
43    }
44}
45
46/// FNV-1a hash over the raw bytes of a `&[f32]` slice.
47fn fnv1a_hash_f32_slice(values: &[f32]) -> u64 {
48    const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
49    const FNV_PRIME: u64 = 1_099_511_628_211;
50
51    let mut hash = FNV_OFFSET_BASIS;
52    for &v in values {
53        for byte in v.to_le_bytes() {
54            hash ^= u64::from(byte);
55            hash = hash.wrapping_mul(FNV_PRIME);
56        }
57    }
58    hash
59}
60
61// ---------------------------------------------------------------------------
62// SearchResult
63// ---------------------------------------------------------------------------
64
65/// A single result returned from a peer node's vector index.
66#[derive(Debug, Clone)]
67pub struct SearchResult {
68    /// Content identifier of the indexed item.
69    pub cid: String,
70    /// Similarity score — higher means more similar.
71    pub score: f32,
72    /// The peer node that produced this result.
73    pub peer_id: String,
74    /// Arbitrary metadata attached to the result.
75    pub metadata: HashMap<String, String>,
76}
77
78// ---------------------------------------------------------------------------
79// SearchPeer
80// ---------------------------------------------------------------------------
81
82/// A registered remote (or local) peer that holds a vector index.
83#[derive(Debug, Clone)]
84pub struct SearchPeer {
85    /// Unique identifier for this peer.
86    pub peer_id: String,
87    /// Multiaddr string (e.g. `/ip4/127.0.0.1/tcp/4001`).
88    pub multiaddr: String,
89    /// Estimated round-trip latency to this peer in milliseconds.
90    pub estimated_latency_ms: u64,
91    /// Number of vectors indexed by this peer.
92    pub index_size: u64,
93    /// Last time this peer was observed as alive.
94    pub last_seen: Instant,
95}
96
97impl SearchPeer {
98    /// Create a new `SearchPeer` with sensible defaults.
99    pub fn new(peer_id: impl Into<String>, multiaddr: impl Into<String>) -> Self {
100        Self {
101            peer_id: peer_id.into(),
102            multiaddr: multiaddr.into(),
103            estimated_latency_ms: 50,
104            index_size: 0,
105            last_seen: Instant::now(),
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// CachedSearchResult
112// ---------------------------------------------------------------------------
113
114/// A cached response to a query.
115#[derive(Debug, Clone)]
116pub struct CachedSearchResult {
117    /// The cached result list.
118    pub results: Vec<SearchResult>,
119    /// Wall-clock time when the entry was stored.
120    pub cached_at: Instant,
121    /// How long the cached entry remains valid.
122    pub ttl: Duration,
123}
124
125impl CachedSearchResult {
126    /// Returns `true` when the entry has outlived its TTL.
127    pub fn is_expired(&self, now: Instant) -> bool {
128        now.duration_since(self.cached_at) > self.ttl
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Stats
134// ---------------------------------------------------------------------------
135
136/// Atomic statistics counters for the coordinator.
137pub struct FederatedSearchStats {
138    /// Total number of searches dispatched (cache misses included).
139    pub total_queries: AtomicU64,
140    /// Number of queries that were served from cache.
141    pub total_cache_hits: AtomicU64,
142    /// Cumulative number of peer node queries issued.
143    pub total_peers_queried: AtomicU64,
144    /// Total number of `SearchResult` entries merged across all queries.
145    pub total_results_merged: AtomicU64,
146    /// Total number of duplicate entries removed during merges.
147    pub total_deduped: AtomicU64,
148}
149
150impl FederatedSearchStats {
151    fn new() -> Self {
152        Self {
153            total_queries: AtomicU64::new(0),
154            total_cache_hits: AtomicU64::new(0),
155            total_peers_queried: AtomicU64::new(0),
156            total_results_merged: AtomicU64::new(0),
157            total_deduped: AtomicU64::new(0),
158        }
159    }
160
161    /// Take a consistent snapshot of the current counters.
162    pub fn snapshot(&self) -> FederatedSearchStatsSnapshot {
163        FederatedSearchStatsSnapshot {
164            total_queries: self.total_queries.load(Ordering::Relaxed),
165            total_cache_hits: self.total_cache_hits.load(Ordering::Relaxed),
166            total_peers_queried: self.total_peers_queried.load(Ordering::Relaxed),
167            total_results_merged: self.total_results_merged.load(Ordering::Relaxed),
168            total_deduped: self.total_deduped.load(Ordering::Relaxed),
169        }
170    }
171}
172
173impl std::fmt::Debug for FederatedSearchStats {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_struct("FederatedSearchStats")
176            .field("total_queries", &self.total_queries.load(Ordering::Relaxed))
177            .field(
178                "total_cache_hits",
179                &self.total_cache_hits.load(Ordering::Relaxed),
180            )
181            .field(
182                "total_peers_queried",
183                &self.total_peers_queried.load(Ordering::Relaxed),
184            )
185            .field(
186                "total_results_merged",
187                &self.total_results_merged.load(Ordering::Relaxed),
188            )
189            .field("total_deduped", &self.total_deduped.load(Ordering::Relaxed))
190            .finish()
191    }
192}
193
194/// Non-atomic copy of the coordinator stats for external consumption.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct FederatedSearchStatsSnapshot {
197    pub total_queries: u64,
198    pub total_cache_hits: u64,
199    pub total_peers_queried: u64,
200    pub total_results_merged: u64,
201    pub total_deduped: u64,
202}
203
204// ---------------------------------------------------------------------------
205// FederatedSearchCoordinator
206// ---------------------------------------------------------------------------
207
208/// Coordinates cross-node vector similarity searches by fanning out queries to
209/// multiple registered [`SearchPeer`]s and merging the returned ranked lists.
210pub struct FederatedSearchCoordinator {
211    /// All known peer nodes.
212    known_peers: RwLock<Vec<SearchPeer>>,
213    /// LRU-style result cache keyed by [`QueryKey`].
214    result_cache: Mutex<HashMap<QueryKey, CachedSearchResult>>,
215    /// How long a cached entry remains valid.
216    pub cache_ttl: Duration,
217    /// Upper bound on the number of peers queried per search.
218    pub max_peers_per_query: usize,
219    /// Atomic operational statistics.
220    stats: Arc<FederatedSearchStats>,
221}
222
223impl FederatedSearchCoordinator {
224    /// Construct a new coordinator with default settings.
225    ///
226    /// - `cache_ttl` = 30 seconds
227    /// - `max_peers_per_query` = 8
228    pub fn new() -> Self {
229        Self {
230            known_peers: RwLock::new(Vec::new()),
231            result_cache: Mutex::new(HashMap::new()),
232            cache_ttl: Duration::from_secs(30),
233            max_peers_per_query: 8,
234            stats: Arc::new(FederatedSearchStats::new()),
235        }
236    }
237
238    /// Create a coordinator with custom TTL and max-peers settings.
239    pub fn with_config(cache_ttl: Duration, max_peers_per_query: usize) -> Self {
240        Self {
241            known_peers: RwLock::new(Vec::new()),
242            result_cache: Mutex::new(HashMap::new()),
243            cache_ttl,
244            max_peers_per_query,
245            stats: Arc::new(FederatedSearchStats::new()),
246        }
247    }
248
249    // ------------------------------------------------------------------
250    // Peer management
251    // ------------------------------------------------------------------
252
253    /// Register a peer node so it is eligible to receive fan-out queries.
254    pub fn register_peer(&self, peer: SearchPeer) {
255        let mut peers = match self.known_peers.write() {
256            Ok(g) => g,
257            Err(poisoned) => poisoned.into_inner(),
258        };
259        // Replace an existing entry with the same ID (upsert semantics).
260        if let Some(existing) = peers.iter_mut().find(|p| p.peer_id == peer.peer_id) {
261            *existing = peer;
262        } else {
263            peers.push(peer);
264        }
265    }
266
267    /// Remove a peer by its ID. Does nothing if the peer is not registered.
268    pub fn unregister_peer(&self, peer_id: &str) {
269        let mut peers = match self.known_peers.write() {
270            Ok(g) => g,
271            Err(poisoned) => poisoned.into_inner(),
272        };
273        peers.retain(|p| p.peer_id != peer_id);
274    }
275
276    /// Return the number of currently registered peers.
277    pub fn peer_count(&self) -> usize {
278        let peers = match self.known_peers.read() {
279            Ok(g) => g,
280            Err(poisoned) => poisoned.into_inner(),
281        };
282        peers.len()
283    }
284
285    // ------------------------------------------------------------------
286    // Peer selection
287    // ------------------------------------------------------------------
288
289    /// Select up to `max_peers_per_query` peers for a query, sorted by
290    /// ascending `estimated_latency_ms` (lowest latency first).
291    ///
292    /// `top_k` is accepted for future routing heuristics but is not currently
293    /// used to filter peers beyond the `max_peers_per_query` cap.
294    pub fn select_peers_for_query(&self, _top_k: usize) -> Vec<SearchPeer> {
295        let peers = match self.known_peers.read() {
296            Ok(g) => g,
297            Err(poisoned) => poisoned.into_inner(),
298        };
299
300        let mut selected: Vec<SearchPeer> = peers.clone();
301        selected.sort_by_key(|p| p.estimated_latency_ms);
302        selected.truncate(self.max_peers_per_query);
303        selected
304    }
305
306    // ------------------------------------------------------------------
307    // Result merging
308    // ------------------------------------------------------------------
309
310    /// Merge N per-peer result lists into a single deduplicated, sorted list.
311    ///
312    /// Deduplication is by CID; when duplicates exist, the entry with the
313    /// **highest** score is kept.  The final list is sorted by score descending
314    /// and truncated to `top_k`.
315    pub fn merge_results(
316        &self,
317        results: Vec<Vec<SearchResult>>,
318        top_k: usize,
319    ) -> Vec<SearchResult> {
320        // Flatten into a single map keyed by CID, keeping the best score.
321        let mut best: HashMap<String, SearchResult> = HashMap::new();
322        let mut total_before_dedup: u64 = 0;
323
324        for list in results {
325            for item in list {
326                total_before_dedup += 1;
327                match best.get(&item.cid) {
328                    Some(existing) if existing.score >= item.score => {
329                        // Keep the already-stored higher-score entry.
330                    }
331                    _ => {
332                        best.insert(item.cid.clone(), item);
333                    }
334                }
335            }
336        }
337
338        let total_after_dedup = best.len() as u64;
339        let deduped = total_before_dedup.saturating_sub(total_after_dedup);
340
341        self.stats
342            .total_results_merged
343            .fetch_add(total_before_dedup, Ordering::Relaxed);
344        self.stats
345            .total_deduped
346            .fetch_add(deduped, Ordering::Relaxed);
347
348        // Sort by score descending, then truncate to top_k.
349        let mut merged: Vec<SearchResult> = best.into_values().collect();
350        merged.sort_by(|a, b| {
351            b.score
352                .partial_cmp(&a.score)
353                .unwrap_or(std::cmp::Ordering::Equal)
354        });
355        merged.truncate(top_k);
356        merged
357    }
358
359    // ------------------------------------------------------------------
360    // Cache operations
361    // ------------------------------------------------------------------
362
363    /// Store a result list in the cache under `key`.
364    pub fn cache_result(&self, key: QueryKey, results: Vec<SearchResult>) {
365        let entry = CachedSearchResult {
366            results,
367            cached_at: Instant::now(),
368            ttl: self.cache_ttl,
369        };
370        let mut cache = match self.result_cache.lock() {
371            Ok(g) => g,
372            Err(poisoned) => poisoned.into_inner(),
373        };
374        cache.insert(key, entry);
375    }
376
377    /// Retrieve a cached result list, returning `None` if the key is absent
378    /// or the entry has expired.
379    pub fn get_cached(&self, key: &QueryKey) -> Option<Vec<SearchResult>> {
380        let now = Instant::now();
381        let cache = match self.result_cache.lock() {
382            Ok(g) => g,
383            Err(poisoned) => poisoned.into_inner(),
384        };
385        cache
386            .get(key)
387            .filter(|entry| !entry.is_expired(now))
388            .map(|entry| {
389                self.stats.total_cache_hits.fetch_add(1, Ordering::Relaxed);
390                entry.results.clone()
391            })
392    }
393
394    /// Remove all expired entries from the result cache.
395    pub fn evict_expired_cache(&self) {
396        let now = Instant::now();
397        let mut cache = match self.result_cache.lock() {
398            Ok(g) => g,
399            Err(poisoned) => poisoned.into_inner(),
400        };
401        cache.retain(|_key, entry| !entry.is_expired(now));
402    }
403
404    // ------------------------------------------------------------------
405    // High-level fan-out search (synchronous stub for library usage)
406    // ------------------------------------------------------------------
407
408    /// Fan out a query, merging results from selected peers.
409    ///
410    /// This method drives the coordinator's own bookkeeping (query counter,
411    /// cache checks, peer-query counter).  Actual network transport is the
412    /// caller's responsibility — `fetch_fn` is invoked once per selected peer
413    /// and must return the peer's result list.
414    ///
415    /// ```rust
416    /// # use ipfrs_semantic::federated_search::{FederatedSearchCoordinator, SearchPeer, SearchResult, QueryKey};
417    /// # use std::collections::HashMap;
418    /// # let coord = FederatedSearchCoordinator::new();
419    /// # let peer = SearchPeer::new("p1", "/ip4/127.0.0.1/tcp/4001");
420    /// # coord.register_peer(peer);
421    /// let query = vec![0.1_f32; 8];
422    /// let top_k = 5;
423    /// let ef    = 64;
424    /// let key   = QueryKey::new(&query, top_k, ef);
425    ///
426    /// let results = coord.search(
427    ///     &query, top_k, ef,
428    ///     |_peer| Vec::<SearchResult>::new(),
429    /// );
430    /// assert!(results.len() <= top_k);
431    /// ```
432    pub fn search<F>(
433        &self,
434        query: &[f32],
435        top_k: usize,
436        ef: usize,
437        fetch_fn: F,
438    ) -> Vec<SearchResult>
439    where
440        F: Fn(&SearchPeer) -> Vec<SearchResult>,
441    {
442        self.stats.total_queries.fetch_add(1, Ordering::Relaxed);
443
444        let key = QueryKey::new(query, top_k, ef);
445
446        // Cache check.
447        if let Some(cached) = self.get_cached(&key) {
448            return cached;
449        }
450
451        let peers = self.select_peers_for_query(top_k);
452        self.stats
453            .total_peers_queried
454            .fetch_add(peers.len() as u64, Ordering::Relaxed);
455
456        let per_peer_results: Vec<Vec<SearchResult>> = peers.iter().map(fetch_fn).collect();
457
458        let merged = self.merge_results(per_peer_results, top_k);
459        self.cache_result(key, merged.clone());
460        merged
461    }
462
463    // ------------------------------------------------------------------
464    // Stats
465    // ------------------------------------------------------------------
466
467    /// Return an immutable snapshot of the coordinator's statistics.
468    pub fn stats(&self) -> FederatedSearchStatsSnapshot {
469        self.stats.snapshot()
470    }
471}
472
473impl Default for FederatedSearchCoordinator {
474    fn default() -> Self {
475        Self::new()
476    }
477}
478
479impl std::fmt::Debug for FederatedSearchCoordinator {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        let peer_count = self.peer_count();
482        let cache_len = self.result_cache.lock().map(|g| g.len()).unwrap_or(0);
483        f.debug_struct("FederatedSearchCoordinator")
484            .field("peer_count", &peer_count)
485            .field("cache_entries", &cache_len)
486            .field("cache_ttl", &self.cache_ttl)
487            .field("max_peers_per_query", &self.max_peers_per_query)
488            .field("stats", &self.stats)
489            .finish()
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Tests
495// ---------------------------------------------------------------------------
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use std::time::Duration;
501
502    // ------------------------------------------------------------------
503    // Helpers
504    // ------------------------------------------------------------------
505
506    fn make_peer(id: &str, latency: u64) -> SearchPeer {
507        let mut p = SearchPeer::new(id, format!("/ip4/127.0.0.1/tcp/{}", 4000 + latency));
508        p.estimated_latency_ms = latency;
509        p
510    }
511
512    fn make_result(cid: &str, score: f32, peer_id: &str) -> SearchResult {
513        SearchResult {
514            cid: cid.to_string(),
515            score,
516            peer_id: peer_id.to_string(),
517            metadata: HashMap::new(),
518        }
519    }
520
521    // ------------------------------------------------------------------
522    // 1. Peer registration
523    // ------------------------------------------------------------------
524
525    #[test]
526    fn test_register_peer_increases_count() {
527        let coord = FederatedSearchCoordinator::new();
528        assert_eq!(coord.peer_count(), 0);
529        coord.register_peer(make_peer("p1", 10));
530        assert_eq!(coord.peer_count(), 1);
531        coord.register_peer(make_peer("p2", 20));
532        assert_eq!(coord.peer_count(), 2);
533    }
534
535    // ------------------------------------------------------------------
536    // 2. Peer unregistration
537    // ------------------------------------------------------------------
538
539    #[test]
540    fn test_unregister_peer_decreases_count() {
541        let coord = FederatedSearchCoordinator::new();
542        coord.register_peer(make_peer("p1", 10));
543        coord.register_peer(make_peer("p2", 20));
544        coord.unregister_peer("p1");
545        assert_eq!(coord.peer_count(), 1);
546    }
547
548    #[test]
549    fn test_unregister_nonexistent_peer_noop() {
550        let coord = FederatedSearchCoordinator::new();
551        coord.register_peer(make_peer("p1", 10));
552        coord.unregister_peer("does_not_exist");
553        assert_eq!(coord.peer_count(), 1);
554    }
555
556    // ------------------------------------------------------------------
557    // 3. peer_count reflects register/unregister
558    // ------------------------------------------------------------------
559
560    #[test]
561    fn test_peer_count_reflects_all_operations() {
562        let coord = FederatedSearchCoordinator::new();
563        for i in 0..5u64 {
564            coord.register_peer(make_peer(&format!("p{}", i), i * 5));
565        }
566        assert_eq!(coord.peer_count(), 5);
567        coord.unregister_peer("p2");
568        coord.unregister_peer("p4");
569        assert_eq!(coord.peer_count(), 3);
570    }
571
572    // ------------------------------------------------------------------
573    // 4. select_peers_for_query respects max_peers_per_query
574    // ------------------------------------------------------------------
575
576    #[test]
577    fn test_select_peers_respects_max() {
578        let coord = FederatedSearchCoordinator::with_config(Duration::from_secs(30), 3);
579        for i in 0..10u64 {
580            coord.register_peer(make_peer(&format!("p{}", i), i * 5));
581        }
582        let selected = coord.select_peers_for_query(10);
583        assert_eq!(selected.len(), 3);
584    }
585
586    // ------------------------------------------------------------------
587    // 5. select_peers_for_query sorts by latency ascending
588    // ------------------------------------------------------------------
589
590    #[test]
591    fn test_select_peers_sorted_by_latency() {
592        let coord = FederatedSearchCoordinator::with_config(Duration::from_secs(30), 5);
593        // Register in reverse latency order.
594        coord.register_peer(make_peer("slow", 200));
595        coord.register_peer(make_peer("medium", 100));
596        coord.register_peer(make_peer("fast", 10));
597
598        let selected = coord.select_peers_for_query(5);
599        assert_eq!(selected[0].peer_id, "fast");
600        assert_eq!(selected[1].peer_id, "medium");
601        assert_eq!(selected[2].peer_id, "slow");
602    }
603
604    // ------------------------------------------------------------------
605    // 6. merge_results deduplicates by CID
606    // ------------------------------------------------------------------
607
608    #[test]
609    fn test_merge_results_deduplicates_by_cid() {
610        let coord = FederatedSearchCoordinator::new();
611        let list_a = vec![
612            make_result("cid1", 0.9, "p1"),
613            make_result("cid2", 0.7, "p1"),
614        ];
615        let list_b = vec![
616            make_result("cid1", 0.8, "p2"), // duplicate cid1 with lower score
617            make_result("cid3", 0.6, "p2"),
618        ];
619        let merged = coord.merge_results(vec![list_a, list_b], 10);
620        let cid1_results: Vec<_> = merged.iter().filter(|r| r.cid == "cid1").collect();
621        // Exactly one entry for cid1.
622        assert_eq!(cid1_results.len(), 1);
623        // The higher-score entry is kept.
624        assert!((cid1_results[0].score - 0.9).abs() < f32::EPSILON);
625    }
626
627    // ------------------------------------------------------------------
628    // 7. merge_results returns top_k sorted by score descending
629    // ------------------------------------------------------------------
630
631    #[test]
632    fn test_merge_results_top_k_sorted_descending() {
633        let coord = FederatedSearchCoordinator::new();
634        let list = vec![
635            make_result("c1", 0.3, "p1"),
636            make_result("c2", 0.9, "p1"),
637            make_result("c3", 0.6, "p1"),
638            make_result("c4", 0.1, "p1"),
639            make_result("c5", 0.8, "p1"),
640        ];
641        let merged = coord.merge_results(vec![list], 3);
642        assert_eq!(merged.len(), 3);
643        assert!(merged[0].score >= merged[1].score);
644        assert!(merged[1].score >= merged[2].score);
645        // Top result must be c2 (0.9).
646        assert_eq!(merged[0].cid, "c2");
647    }
648
649    // ------------------------------------------------------------------
650    // 8. merge_results with empty input list
651    // ------------------------------------------------------------------
652
653    #[test]
654    fn test_merge_results_empty_input() {
655        let coord = FederatedSearchCoordinator::new();
656        let merged = coord.merge_results(vec![], 10);
657        assert!(merged.is_empty());
658    }
659
660    // ------------------------------------------------------------------
661    // 9. merge_results with all empty per-peer lists
662    // ------------------------------------------------------------------
663
664    #[test]
665    fn test_merge_results_all_empty_lists() {
666        let coord = FederatedSearchCoordinator::new();
667        let merged = coord.merge_results(vec![vec![], vec![], vec![]], 10);
668        assert!(merged.is_empty());
669    }
670
671    // ------------------------------------------------------------------
672    // 10. QueryKey hash stability (same input → same hash)
673    // ------------------------------------------------------------------
674
675    #[test]
676    fn test_query_key_hash_stability() {
677        let vec_a = vec![0.1_f32, 0.2, 0.3, 0.4];
678        let key1 = QueryKey::new(&vec_a, 5, 64);
679        let key2 = QueryKey::new(&vec_a, 5, 64);
680        assert_eq!(key1, key2);
681        assert_eq!(key1.query_hash, key2.query_hash);
682    }
683
684    #[test]
685    fn test_query_key_different_inputs_differ() {
686        let vec_a = vec![0.1_f32, 0.2, 0.3];
687        let vec_b = vec![0.9_f32, 0.8, 0.7];
688        let key_a = QueryKey::new(&vec_a, 5, 64);
689        let key_b = QueryKey::new(&vec_b, 5, 64);
690        assert_ne!(key_a.query_hash, key_b.query_hash);
691    }
692
693    // ------------------------------------------------------------------
694    // 11. Cache hit on repeated query
695    // ------------------------------------------------------------------
696
697    #[test]
698    fn test_cache_hit_on_repeated_query() {
699        let coord = FederatedSearchCoordinator::new();
700        let results = vec![make_result("cid1", 0.95, "p1")];
701        let key = QueryKey::new(&[0.5_f32; 4], 5, 64);
702
703        assert!(coord.get_cached(&key).is_none());
704        coord.cache_result(key.clone(), results);
705        let cached = coord.get_cached(&key);
706        assert!(cached.is_some());
707        let list = cached.expect("cache entry must exist");
708        assert_eq!(list.len(), 1);
709        assert_eq!(list[0].cid, "cid1");
710    }
711
712    // ------------------------------------------------------------------
713    // 12. Cache eviction on TTL expiry
714    // ------------------------------------------------------------------
715
716    #[test]
717    fn test_cache_eviction_on_ttl_expiry() {
718        // TTL of 0 — effectively expired immediately.
719        let coord = FederatedSearchCoordinator::with_config(Duration::from_nanos(1), 8);
720        let key = QueryKey::new(&[0.1_f32; 4], 5, 64);
721        coord.cache_result(key.clone(), vec![make_result("cid_expired", 0.5, "p1")]);
722
723        // Spin until at least 1ns has passed.
724        let deadline = std::time::Instant::now() + Duration::from_millis(10);
725        while std::time::Instant::now() < deadline {}
726
727        // A get_cached on an expired entry should return None.
728        assert!(coord.get_cached(&key).is_none());
729    }
730
731    #[test]
732    fn test_evict_expired_cache_removes_stale_entries() {
733        let coord = FederatedSearchCoordinator::with_config(Duration::from_nanos(1), 8);
734        let key1 = QueryKey::new(&[0.1_f32; 4], 5, 64);
735        let key2 = QueryKey::new(&[0.2_f32; 4], 5, 64);
736        coord.cache_result(key1.clone(), vec![make_result("c1", 0.5, "p1")]);
737        coord.cache_result(key2.clone(), vec![make_result("c2", 0.6, "p1")]);
738
739        // Wait for TTL to elapse.
740        let deadline = std::time::Instant::now() + Duration::from_millis(10);
741        while std::time::Instant::now() < deadline {}
742
743        coord.evict_expired_cache();
744
745        let cache_len = coord
746            .result_cache
747            .lock()
748            .map(|g| g.len())
749            .unwrap_or(usize::MAX);
750        assert_eq!(cache_len, 0);
751    }
752
753    // ------------------------------------------------------------------
754    // 13. Stats accumulation via search()
755    // ------------------------------------------------------------------
756
757    #[test]
758    fn test_stats_accumulation() {
759        let coord = FederatedSearchCoordinator::new();
760        coord.register_peer(make_peer("p1", 10));
761        coord.register_peer(make_peer("p2", 20));
762
763        let query = vec![0.5_f32; 4];
764
765        // First search — cache miss, both peers queried.
766        coord.search(&query, 3, 64, |_peer| {
767            vec![make_result("c1", 0.9, "p1"), make_result("c2", 0.5, "p2")]
768        });
769
770        // Second search — same query key → cache hit.
771        coord.search(&query, 3, 64, |_peer| vec![]);
772
773        let snap = coord.stats();
774        assert_eq!(snap.total_queries, 2);
775        assert_eq!(snap.total_cache_hits, 1);
776        // First search queried 2 peers.
777        assert_eq!(snap.total_peers_queried, 2);
778        // First search merged 4 results (2 peers × 2 results).
779        assert_eq!(snap.total_results_merged, 4);
780    }
781
782    // ------------------------------------------------------------------
783    // 14. Upsert semantics on register_peer
784    // ------------------------------------------------------------------
785
786    #[test]
787    fn test_register_peer_upsert_does_not_duplicate() {
788        let coord = FederatedSearchCoordinator::new();
789        coord.register_peer(make_peer("p1", 50));
790        // Re-register the same peer with updated latency.
791        let mut updated = make_peer("p1", 10);
792        updated.index_size = 9999;
793        coord.register_peer(updated);
794        // Still only one peer.
795        assert_eq!(coord.peer_count(), 1);
796        // And the updated peer is stored.
797        let selected = coord.select_peers_for_query(1);
798        assert_eq!(selected[0].estimated_latency_ms, 10);
799        assert_eq!(selected[0].index_size, 9999);
800    }
801
802    // ------------------------------------------------------------------
803    // 15. select_peers_for_query returns fewer than max when not enough peers
804    // ------------------------------------------------------------------
805
806    #[test]
807    fn test_select_peers_fewer_than_max() {
808        let coord = FederatedSearchCoordinator::with_config(Duration::from_secs(30), 8);
809        coord.register_peer(make_peer("only_peer", 15));
810        let selected = coord.select_peers_for_query(10);
811        assert_eq!(selected.len(), 1);
812    }
813
814    // ------------------------------------------------------------------
815    // 16. CachedSearchResult::is_expired logic
816    // ------------------------------------------------------------------
817
818    #[test]
819    fn test_cached_result_is_expired() {
820        let entry = CachedSearchResult {
821            results: vec![],
822            cached_at: Instant::now() - Duration::from_secs(60),
823            ttl: Duration::from_secs(30),
824        };
825        assert!(entry.is_expired(Instant::now()));
826
827        let fresh = CachedSearchResult {
828            results: vec![],
829            cached_at: Instant::now(),
830            ttl: Duration::from_secs(30),
831        };
832        assert!(!fresh.is_expired(Instant::now()));
833    }
834}