Skip to main content

ipfrs_network/
semantic_dht.rs

1//! Semantic DHT - Vector-based content routing
2//!
3//! This module extends the standard Kademlia DHT with semantic routing capabilities,
4//! allowing content discovery based on vector embeddings and semantic similarity
5//! rather than just content-addressed hashes.
6//!
7//! ## Features
8//!
9//! - **Embedding-based Routing**: Map vector embeddings to DHT keys using locality-sensitive hashing
10//! - **Semantic Queries**: Find content based on semantic similarity
11//! - **Distributed ANN**: Approximate nearest neighbor search across the network
12//! - **Multiple Namespaces**: Support different embedding spaces (text, image, etc.)
13//! - **Adaptive Routing**: Learn from query results to improve routing decisions
14//!
15//! ## Design
16//!
17//! The semantic DHT uses a two-layer architecture:
18//! 1. **Embedding Layer**: Maps high-dimensional embeddings to DHT keys via LSH
19//! 2. **Routing Layer**: Routes queries using both XOR distance (Kademlia) and semantic distance
20//!
21//! Each peer maintains:
22//! - A local vector index for semantic search
23//! - A mapping from embedding regions to peer IDs
24//! - Statistics on query success rates per region
25
26use cid::Cid;
27use dashmap::DashMap;
28use libp2p::PeerId;
29use multihash_codetable::{Code, MultihashDigest};
30use parking_lot::RwLock;
31use serde::{Deserialize, Serialize};
32use std::cmp::Reverse;
33use std::collections::HashMap;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36use thiserror::Error;
37
38/// Errors that can occur in semantic DHT operations
39#[derive(Error, Debug)]
40pub enum SemanticDhtError {
41    #[error("Invalid embedding dimension: expected {expected}, got {actual}")]
42    InvalidDimension { expected: usize, actual: usize },
43
44    #[error("Unknown namespace: {0}")]
45    UnknownNamespace(String),
46
47    #[error("No peers found for embedding region")]
48    NoPeersFound,
49
50    #[error("Query timeout after {0:?}")]
51    QueryTimeout(Duration),
52
53    #[error("Embedding encoding error: {0}")]
54    EncodingError(String),
55
56    // --- v0.3.0 additional error variants ---
57    #[error("Index not initialized; call register_namespace first")]
58    IndexNotInitialized,
59
60    #[error("Vector dimension mismatch: expected {expected}, got {got}")]
61    VectorDimensionMismatch { expected: usize, got: usize },
62
63    #[error("Routing convergence timed out")]
64    RoutingConvergenceTimeout,
65
66    #[error("Peer unreachable: {0}")]
67    PeerUnreachable(String),
68}
69
70/// A DHT record that carries an embedded vector alongside the CID it annotates.
71///
72/// `VectorAnnotatedRecord` is the wire format stored in the DHT; it allows any
73/// peer that retrieves the record to immediately compute similarity without
74/// fetching additional metadata.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct VectorAnnotatedRecord {
77    /// Content identifier this record refers to
78    pub cid: String,
79    /// Embedding vector for the content
80    pub vector: Vec<f32>,
81    /// Dimensionality of `vector` (stored separately for fast validation)
82    pub dimension: usize,
83    /// PeerId (string-encoded) of the peer that published this record
84    pub provider_id: String,
85    /// How long (in seconds) this record should be considered fresh
86    pub ttl_secs: u64,
87    /// Arbitrary application-level metadata (e.g. content-type, language, …)
88    pub metadata: HashMap<String, String>,
89}
90
91impl VectorAnnotatedRecord {
92    /// Construct a new `VectorAnnotatedRecord`, deriving `dimension` from `vector`.
93    pub fn new(
94        cid: impl Into<String>,
95        vector: Vec<f32>,
96        provider_id: impl Into<String>,
97        ttl_secs: u64,
98        metadata: HashMap<String, String>,
99    ) -> Self {
100        let dimension = vector.len();
101        Self {
102            cid: cid.into(),
103            vector,
104            dimension,
105            provider_id: provider_id.into(),
106            ttl_secs,
107            metadata,
108        }
109    }
110
111    /// Check whether `vector.len() == dimension` (should always be true for
112    /// well-formed records received from trusted peers; useful after deserde).
113    pub fn is_consistent(&self) -> bool {
114        self.vector.len() == self.dimension
115    }
116}
117
118/// Routing-quality metrics snapshot emitted by [`SemanticDht::metrics`].
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct SemanticDhtMetrics {
121    /// Fraction of queries that returned at least one result (0.0 – 1.0)
122    pub recall_rate: f32,
123    /// Mean query latency across all queries (milliseconds)
124    pub mean_latency_ms: f64,
125    /// Current convergence score (0.0 = unstable, 1.0 = fully converged)
126    pub routing_convergence: f32,
127    /// Number of indexed CIDs in the local index
128    pub indexed_cid_count: usize,
129    /// Number of distinct LSH hash-to-peer mappings known
130    pub known_hash_regions: usize,
131    /// Cache hit ratio (0.0 – 1.0)
132    pub cache_hit_ratio: f32,
133    /// Number of partial-sync operations performed since node start
134    pub partial_sync_count: u64,
135}
136
137/// Configuration for semantic DHT operations
138#[derive(Debug, Clone)]
139pub struct SemanticDhtConfig {
140    /// Number of hash functions for LSH
141    pub lsh_hash_functions: usize,
142
143    /// Number of hash tables for LSH
144    pub lsh_hash_tables: usize,
145
146    /// Bucket width for LSH (affects quantization)
147    pub lsh_bucket_width: f32,
148
149    /// Maximum number of peers to query for ANN search
150    pub max_query_peers: usize,
151
152    /// Timeout for semantic queries
153    pub query_timeout: Duration,
154
155    /// Whether to cache query results
156    pub enable_caching: bool,
157
158    /// Cache TTL for query results
159    pub cache_ttl: Duration,
160
161    /// Maximum cache size
162    pub max_cache_size: usize,
163
164    /// Number of results to return for ANN queries
165    pub top_k: usize,
166
167    // --- v0.3.0 production fields ---
168    /// Embedding dimension (used for `put_with_vector` / `search_similar`)
169    /// Default: 384 (matches common sentence-transformers models)
170    pub dimension: usize,
171
172    /// HNSW `ef` search parameter – higher = more accurate but slower
173    pub ef_search: usize,
174
175    /// Maximum peers to route a single query to
176    pub max_routing_peers: usize,
177
178    /// How long vector records stay in the local annotated-record store
179    pub vector_ttl: Duration,
180
181    /// How often background gossip sync fires
182    pub sync_interval: Duration,
183
184    /// Fraction of routing-table entries that must agree before routing is
185    /// considered converged (0.0 – 1.0)
186    pub convergence_threshold: f32,
187}
188
189impl Default for SemanticDhtConfig {
190    fn default() -> Self {
191        Self {
192            lsh_hash_functions: 8,
193            lsh_hash_tables: 4,
194            lsh_bucket_width: 4.0,
195            max_query_peers: 20,
196            query_timeout: Duration::from_secs(10),
197            enable_caching: true,
198            cache_ttl: Duration::from_secs(300),
199            max_cache_size: 1000,
200            top_k: 10,
201            // v0.3.0 production defaults
202            dimension: 384,
203            ef_search: 50,
204            max_routing_peers: 20,
205            vector_ttl: Duration::from_secs(3600),   // 1 hour
206            sync_interval: Duration::from_secs(300), // 5 minutes
207            convergence_threshold: 0.95,
208        }
209    }
210}
211
212/// Semantic namespace identifier
213#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
214pub struct NamespaceId(pub String);
215
216impl NamespaceId {
217    pub fn new(name: impl Into<String>) -> Self {
218        Self(name.into())
219    }
220
221    /// Standard namespace for text embeddings
222    pub fn text() -> Self {
223        Self("text".to_string())
224    }
225
226    /// Standard namespace for image embeddings
227    pub fn image() -> Self {
228        Self("image".to_string())
229    }
230
231    /// Standard namespace for audio embeddings
232    pub fn audio() -> Self {
233        Self("audio".to_string())
234    }
235}
236
237/// Namespace configuration
238#[derive(Debug, Clone)]
239pub struct SemanticNamespace {
240    /// Namespace identifier
241    pub id: NamespaceId,
242
243    /// Expected embedding dimension
244    pub dimension: usize,
245
246    /// Distance metric to use
247    pub distance_metric: DistanceMetric,
248
249    /// LSH configuration specific to this namespace
250    pub lsh_config: LshConfig,
251}
252
253/// Distance metric for vector similarity
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255pub enum DistanceMetric {
256    /// Euclidean distance (L2)
257    Euclidean,
258
259    /// Cosine distance (1 - cosine similarity)
260    Cosine,
261
262    /// Manhattan distance (L1)
263    Manhattan,
264
265    /// Dot product
266    DotProduct,
267}
268
269/// LSH configuration
270#[derive(Debug, Clone)]
271pub struct LshConfig {
272    /// Number of hash functions per table
273    pub hash_functions: usize,
274
275    /// Number of hash tables
276    pub num_tables: usize,
277
278    /// Bucket width for quantization
279    pub bucket_width: f32,
280}
281
282impl Default for LshConfig {
283    fn default() -> Self {
284        Self {
285            hash_functions: 8,
286            num_tables: 4,
287            bucket_width: 4.0,
288        }
289    }
290}
291
292/// Locality-Sensitive Hash for embeddings
293#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
294pub struct LshHash {
295    /// Table index
296    pub table: usize,
297
298    /// Hash bucket
299    pub bucket: Vec<i32>,
300}
301
302impl LshHash {
303    /// Convert LSH hash to a DHT key (CID)
304    pub fn to_cid(&self) -> Cid {
305        // Serialize the hash bucket
306        let mut data = Vec::new();
307        data.push(self.table as u8);
308        for &val in &self.bucket {
309            data.extend_from_slice(&val.to_le_bytes());
310        }
311
312        // Hash the serialized data
313        let hash = Code::Sha2_256.digest(&data);
314
315        // Create CID
316        Cid::new_v1(0x55, hash) // 0x55 = raw codec
317    }
318}
319
320/// Semantic DHT query
321#[derive(Debug, Clone)]
322pub struct SemanticQuery {
323    /// Query embedding
324    pub embedding: Vec<f32>,
325
326    /// Namespace to query
327    pub namespace: NamespaceId,
328
329    /// Number of results to return
330    pub top_k: usize,
331
332    /// Optional metadata filter
333    pub metadata_filter: Option<HashMap<String, String>>,
334
335    /// Query timeout
336    pub timeout: Duration,
337}
338
339/// Result from a semantic query
340#[derive(Debug, Clone)]
341pub struct SemanticResult {
342    /// Content identifier
343    pub cid: Cid,
344
345    /// Similarity score (higher is more similar)
346    pub score: f32,
347
348    /// Peer that provided this result
349    pub peer: PeerId,
350
351    /// Optional metadata
352    pub metadata: HashMap<String, String>,
353}
354
355/// Cache entry for semantic queries
356#[derive(Debug, Clone)]
357struct CacheEntry {
358    results: Vec<SemanticResult>,
359    timestamp: Instant,
360}
361
362// =========================================================================
363// DHT Shard Balancing (v0.3.0)
364// =========================================================================
365
366/// Configuration for DHT shard balancing.
367#[derive(Debug, Clone)]
368pub struct ShardBalancerConfig {
369    /// Maximum number of vectors a single peer should index before triggering
370    /// a rebalance.  Default: 10_000.
371    pub max_vectors_per_peer: usize,
372    /// When a peer's load fraction exceeds this threshold the peer is marked
373    /// overloaded and migration candidates are generated.  Default: 0.8.
374    pub rebalance_threshold: f32,
375    /// Target number of peers that should own each vector (redundancy factor).
376    /// Default: 3.
377    pub target_redundancy: usize,
378}
379
380impl Default for ShardBalancerConfig {
381    fn default() -> Self {
382        Self {
383            max_vectors_per_peer: 10_000,
384            rebalance_threshold: 0.8,
385            target_redundancy: 3,
386        }
387    }
388}
389
390/// Tracks which peers own which HNSW-layer shards and computes load balance
391/// metrics used to drive migration decisions.
392pub struct ShardBalancer {
393    config: ShardBalancerConfig,
394    /// peer_id → number of vectors currently assigned
395    peer_loads: std::collections::HashMap<String, usize>,
396    /// cid → owning peer_ids (redundancy list)
397    cid_owners: std::collections::HashMap<String, Vec<String>>,
398    /// peers that currently exceed the rebalance threshold
399    overloaded_peers: std::collections::HashSet<String>,
400}
401
402impl ShardBalancer {
403    /// Create a new `ShardBalancer` with the given configuration.
404    pub fn new(config: ShardBalancerConfig) -> Self {
405        Self {
406            config,
407            peer_loads: std::collections::HashMap::new(),
408            cid_owners: std::collections::HashMap::new(),
409            overloaded_peers: std::collections::HashSet::new(),
410        }
411    }
412
413    /// Record that `peer_id` has indexed the vector identified by `cid`.
414    ///
415    /// Increments the peer's load counter and updates the CID ownership list.
416    /// If the resulting load fraction breaches `rebalance_threshold` the peer
417    /// is added to `overloaded_peers`.
418    pub fn record_vector_assignment(&mut self, peer_id: &str, cid: &str) {
419        // Update load counter
420        let load = self.peer_loads.entry(peer_id.to_string()).or_insert(0);
421        *load = load.saturating_add(1);
422
423        // Update ownership list
424        let owners = self.cid_owners.entry(cid.to_string()).or_default();
425        if !owners.contains(&peer_id.to_string()) {
426            owners.push(peer_id.to_string());
427        }
428
429        // Re-evaluate overload status for this peer
430        let threshold =
431            (self.config.max_vectors_per_peer as f32 * self.config.rebalance_threshold) as usize;
432        let current_load = self.peer_loads.get(peer_id).copied().unwrap_or(0);
433        if current_load >= threshold {
434            self.overloaded_peers.insert(peer_id.to_string());
435        }
436    }
437
438    /// Return the `count` least-loaded peers, suitable for assigning a new
439    /// vector.  Peers are sorted by ascending load; if fewer than `count` peers
440    /// are known the full list is returned.
441    pub fn suggest_peers_for_vector(&self, count: usize) -> Vec<String> {
442        if self.peer_loads.is_empty() {
443            return Vec::new();
444        }
445
446        let mut sorted: Vec<(&String, &usize)> = self.peer_loads.iter().collect();
447        sorted.sort_by_key(|(_, &load)| load);
448        sorted
449            .into_iter()
450            .take(count)
451            .map(|(id, _)| id.clone())
452            .collect()
453    }
454
455    /// Return `true` when `peer_id`'s load fraction exceeds
456    /// `rebalance_threshold`.
457    pub fn is_overloaded(&self, peer_id: &str) -> bool {
458        self.overloaded_peers.contains(peer_id)
459    }
460
461    /// Return a map of `peer_id → load_fraction` (0.0–1.0), where the
462    /// denominator is `max_vectors_per_peer`.
463    pub fn load_distribution(&self) -> std::collections::HashMap<String, f32> {
464        let max = self.config.max_vectors_per_peer as f32;
465        self.peer_loads
466            .iter()
467            .map(|(id, &load)| (id.clone(), (load as f32 / max).min(1.0)))
468            .collect()
469    }
470
471    /// Identify `(cid, from_peer)` pairs that should be migrated away from
472    /// hot-spot peers.
473    ///
474    /// For every overloaded peer, walks its assigned CIDs and collects up to
475    /// `ceil(excess)` migration candidates, preferring CIDs that have enough
476    /// existing owners so redundancy is preserved after migration.
477    pub fn vectors_to_migrate(&self) -> Vec<(String, String)> {
478        let threshold =
479            (self.config.max_vectors_per_peer as f32 * self.config.rebalance_threshold) as usize;
480
481        let mut migrations = Vec::new();
482
483        for peer_id in &self.overloaded_peers {
484            let load = self.peer_loads.get(peer_id).copied().unwrap_or(0);
485            if load <= threshold {
486                continue;
487            }
488            let excess = load - threshold;
489
490            // Collect CIDs owned by this peer
491            let mut candidates: Vec<String> = self
492                .cid_owners
493                .iter()
494                .filter(|(_, owners)| owners.contains(peer_id))
495                .map(|(cid, _)| cid.clone())
496                .take(excess)
497                .collect();
498            candidates.sort(); // deterministic ordering
499
500            for cid in candidates.into_iter().take(excess) {
501                migrations.push((cid, peer_id.clone()));
502            }
503        }
504
505        migrations
506    }
507
508    /// Compute a balance score in `[0.0, 1.0]`.
509    ///
510    /// A score of `1.0` means all peers carry the same load; `0.0` means the
511    /// load is completely concentrated on a single peer.
512    ///
513    /// Uses the complement of the *coefficient of variation* (CV) clamped to
514    /// `[0, 1]` so that it is easily interpretable.
515    pub fn balance_score(&self) -> f32 {
516        if self.peer_loads.is_empty() {
517            return 1.0; // vacuously balanced
518        }
519
520        let loads: Vec<f32> = self.peer_loads.values().map(|&l| l as f32).collect();
521        let n = loads.len() as f32;
522
523        let mean = loads.iter().sum::<f32>() / n;
524        if mean == 0.0 {
525            return 1.0; // all zeros → balanced
526        }
527
528        let variance = loads.iter().map(|&l| (l - mean).powi(2)).sum::<f32>() / n;
529        let std_dev = variance.sqrt();
530        let cv = std_dev / mean; // coefficient of variation
531
532        // CV = 0 → perfect balance (score = 1); CV = 1 → mediocre; CV >> 1 → terrible
533        // Map with saturation: score = 1 - min(cv, 1)
534        (1.0 - cv.min(1.0)).clamp(0.0, 1.0)
535    }
536
537    /// Assign a vector to the `n_replicas` best peers using consistent hashing
538    /// on the vector's hash fingerprint.
539    ///
540    /// Peer selection is deterministic given the same vector content: the vector
541    /// is hashed via a stable fingerprint (sum of floats cast to bits), then
542    /// peers are sorted by (hash XOR peer_hash) % capacity, and the
543    /// `n_replicas` with the smallest distance are returned.
544    ///
545    /// When fewer than `n_replicas` peers are tracked, all known peers are
546    /// returned (no padding).
547    pub fn assign_vector(&self, vector: &[f32], n_replicas: usize) -> Vec<String> {
548        if self.peer_loads.is_empty() {
549            return Vec::new();
550        }
551
552        // Stable fingerprint: XOR of bit-patterns of all floats
553        let vec_hash: u64 = vector.iter().enumerate().fold(0u64, |acc, (i, &v)| {
554            let bits = v.to_bits() as u64;
555            acc ^ bits.wrapping_mul(
556                (i as u64)
557                    .wrapping_add(1)
558                    .wrapping_mul(0x9e37_79b9_7f4a_7c15),
559            )
560        });
561
562        let mut peers_scored: Vec<(&String, u64)> = self
563            .peer_loads
564            .iter()
565            .map(|(peer_id, &load)| {
566                // Score = consistent-hash distance weighted by inverse load fraction
567                let peer_hash: u64 = peer_id
568                    .bytes()
569                    .fold(0u64, |acc, b| acc.wrapping_mul(131).wrapping_add(b as u64));
570                let distance = vec_hash ^ peer_hash;
571                // Weight by load: prefer peers with available capacity
572                let cap = self.config.max_vectors_per_peer.max(1) as u64;
573                let load_penalty = (load as u64).saturating_mul(u64::MAX / cap);
574                (peer_id, distance.wrapping_add(load_penalty))
575            })
576            .collect();
577
578        peers_scored.sort_by_key(|(_, score)| *score);
579        peers_scored
580            .into_iter()
581            .take(n_replicas)
582            .map(|(id, _)| id.clone())
583            .collect()
584    }
585
586    /// Compute the load imbalance score.
587    ///
588    /// Returns `0.0` for a perfectly balanced cluster and `1.0` when the entire
589    /// load is concentrated on a single peer.  Uses the normalised standard
590    /// deviation (coefficient of variation), clamped to `[0.0, 1.0]`.
591    pub fn imbalance_score(&self) -> f64 {
592        if self.peer_loads.is_empty() {
593            return 0.0;
594        }
595
596        let loads: Vec<f64> = self.peer_loads.values().map(|&l| l as f64).collect();
597        let n = loads.len() as f64;
598        let mean = loads.iter().sum::<f64>() / n;
599
600        if mean == 0.0 {
601            return 0.0; // all-zero is perfectly balanced
602        }
603
604        let variance = loads.iter().map(|&l| (l - mean).powi(2)).sum::<f64>() / n;
605        let cv = variance.sqrt() / mean;
606
607        cv.min(1.0)
608    }
609
610    /// Return up to `limit` `(vector_id, target_peer)` pairs to migrate for
611    /// better balance.
612    ///
613    /// Candidates are chosen from the most overloaded peer(s) and the target is
614    /// the least-loaded peer that does not already own the vector.
615    pub fn migration_plan(&self, limit: usize) -> Vec<(String, String)> {
616        if limit == 0 || self.peer_loads.is_empty() {
617            return Vec::new();
618        }
619
620        // Identify the least-loaded peer upfront
621        let least_loaded = self
622            .peer_loads
623            .iter()
624            .min_by_key(|(_, &l)| l)
625            .map(|(id, _)| id.clone());
626
627        let Some(target_peer) = least_loaded else {
628            return Vec::new();
629        };
630
631        let threshold = (self.config.max_vectors_per_peer as f64
632            * self.config.rebalance_threshold as f64) as usize;
633
634        let mut plan = Vec::new();
635
636        // Collect overloaded peers sorted by load descending for determinism
637        let mut hot: Vec<(&String, usize)> = self
638            .overloaded_peers
639            .iter()
640            .filter_map(|p| self.peer_loads.get(p).map(|&l| (p, l)))
641            .collect();
642        hot.sort_by_key(|b| Reverse(b.1));
643
644        'outer: for (from_peer, load) in hot {
645            if load <= threshold {
646                continue;
647            }
648            let excess = load - threshold;
649
650            let mut candidates: Vec<String> = self
651                .cid_owners
652                .iter()
653                .filter(|(_, owners)| owners.contains(from_peer) && !owners.contains(&target_peer))
654                .map(|(cid, _)| cid.clone())
655                .take(excess)
656                .collect();
657            candidates.sort();
658
659            for cid in candidates {
660                plan.push((cid, target_peer.clone()));
661                if plan.len() >= limit {
662                    break 'outer;
663                }
664            }
665        }
666
667        plan
668    }
669
670    /// Update the capacity ceiling for a peer.
671    ///
672    /// This updates `max_vectors_per_peer` in the configuration to the supplied
673    /// value and re-evaluates overload status for `peer`.  Only the single
674    /// peer's overload flag is updated; a full sweep is not performed.
675    pub fn update_peer_capacity(&mut self, peer: &str, capacity: usize) {
676        // Update global capacity setting
677        self.config.max_vectors_per_peer = capacity;
678
679        // Re-evaluate the named peer
680        let threshold = (capacity as f32 * self.config.rebalance_threshold) as usize;
681        let load = self.peer_loads.get(peer).copied().unwrap_or(0);
682        if load >= threshold {
683            self.overloaded_peers.insert(peer.to_string());
684        } else {
685            self.overloaded_peers.remove(peer);
686        }
687    }
688
689    /// Remove `peer` from the balancer and return the list of vector IDs it
690    /// was responsible for.  The caller is expected to trigger migration of
691    /// the returned CIDs to other peers.
692    pub fn remove_peer(&mut self, peer: &str) -> Vec<String> {
693        self.peer_loads.remove(peer);
694        self.overloaded_peers.remove(peer);
695
696        // Collect all CIDs owned by this peer
697        let owned: Vec<String> = self
698            .cid_owners
699            .iter()
700            .filter(|(_, owners)| owners.contains(&peer.to_string()))
701            .map(|(cid, _)| cid.clone())
702            .collect();
703
704        // Remove the peer from all ownership lists
705        for cid in &owned {
706            if let Some(owners) = self.cid_owners.get_mut(cid) {
707                owners.retain(|p| p != peer);
708            }
709        }
710
711        owned
712    }
713
714    /// Update internal bookkeeping after `cid` has been migrated from
715    /// `from_peer` to `to_peer`.
716    ///
717    /// Decrements `from_peer`'s counter, increments `to_peer`'s counter,
718    /// updates the ownership list, and refreshes overload status.
719    pub fn record_migration(&mut self, cid: &str, from_peer: &str, to_peer: &str) {
720        // Decrement source peer load
721        if let Some(load) = self.peer_loads.get_mut(from_peer) {
722            *load = load.saturating_sub(1);
723        }
724
725        // Increment destination peer load
726        let dest_load = self.peer_loads.entry(to_peer.to_string()).or_insert(0);
727        *dest_load = dest_load.saturating_add(1);
728
729        // Update ownership list
730        if let Some(owners) = self.cid_owners.get_mut(cid) {
731            owners.retain(|p| p != from_peer);
732            if !owners.contains(&to_peer.to_string()) {
733                owners.push(to_peer.to_string());
734            }
735        }
736
737        // Refresh overload status
738        let threshold =
739            (self.config.max_vectors_per_peer as f32 * self.config.rebalance_threshold) as usize;
740
741        for peer in [from_peer, to_peer] {
742            let load = self.peer_loads.get(peer).copied().unwrap_or(0);
743            if load >= threshold {
744                self.overloaded_peers.insert(peer.to_string());
745            } else {
746                self.overloaded_peers.remove(peer);
747            }
748        }
749    }
750}
751
752/// Configuration for the efficient partial-sync algorithm (v0.3.0).
753///
754/// Controls which vectors are gossiped during an incremental sync round and
755/// how many rounds are allowed before forcing a full resync.
756#[derive(Debug, Clone)]
757pub struct PartialSyncConfig {
758    /// Cosine distance threshold: only gossip vectors whose embedding has
759    /// changed by more than this amount since the last sync.  Default: 0.05.
760    pub sync_threshold: f32,
761    /// Maximum number of vector records packed into a single GossipSub message.
762    /// Default: 32.
763    pub batch_size: usize,
764    /// Maximum sync rounds before a full resync is forced.  Default: 100.
765    pub max_rounds: usize,
766}
767
768impl Default for PartialSyncConfig {
769    fn default() -> Self {
770        Self {
771            sync_threshold: 0.05,
772            batch_size: 32,
773            max_rounds: 100,
774        }
775    }
776}
777
778/// Statistics collected during a single partial-sync pass.
779#[derive(Debug, Clone, Default)]
780pub struct PartialSyncStats {
781    /// Number of vectors that were gossiped (exceeded the threshold).
782    pub vectors_synced: usize,
783    /// Number of vectors skipped (within threshold, no change).
784    pub vectors_skipped: usize,
785    /// Total bytes that would be sent in a real network exchange.
786    pub bytes_sent: usize,
787    /// Number of GossipSub batches produced.
788    pub rounds_completed: usize,
789}
790
791/// A single result from [`SemanticDht::distributed_search`].
792#[derive(Debug, Clone)]
793pub struct SearchResult {
794    /// Content identifier string.
795    pub cid: String,
796    /// Cosine similarity score in `[−1.0, 1.0]`; higher means more similar.
797    pub score: f32,
798    /// Which peer held this vector (`None` for local results).
799    pub peer: Option<String>,
800    /// The vector's record-key (same as `cid` in the current implementation).
801    pub vector_id: String,
802}
803
804/// Result of a `merge_partial_index` operation.
805#[derive(Debug, Clone, Default)]
806pub struct MergeResult {
807    /// Records added (CID was not present locally)
808    pub added: usize,
809    /// Records updated (CID was present but the incoming record has a newer TTL
810    /// or different vector)
811    pub updated: usize,
812    /// Records skipped because the local copy was already up-to-date
813    pub skipped: usize,
814    /// Conflicting records: same CID, incompatible vectors (dimension differs)
815    pub conflicts: usize,
816}
817
818/// Semantic DHT manager
819pub struct SemanticDht {
820    /// Configuration
821    config: SemanticDhtConfig,
822
823    /// Registered namespaces
824    namespaces: Arc<DashMap<NamespaceId, SemanticNamespace>>,
825
826    /// LSH projections per namespace (random vectors for LSH)
827    lsh_projections: Arc<DashMap<NamespaceId, Vec<Vec<f32>>>>,
828
829    /// Mapping from LSH hash to peer IDs
830    hash_to_peers: Arc<DashMap<LshHash, Vec<PeerId>>>,
831
832    /// Local content index (CID -> embedding)
833    local_index: Arc<DashMap<Cid, (Vec<f32>, NamespaceId)>>,
834
835    /// Query result cache
836    query_cache: Arc<DashMap<Vec<u8>, CacheEntry>>,
837
838    /// Statistics
839    stats: Arc<RwLock<SemanticDhtStats>>,
840
841    // --- v0.3.0 additions ---
842    /// Flat vector-annotated record store: CID string → record + insertion time
843    ///
844    /// This is the local half of the distributed vector DHT.  In a fully
845    /// distributed deployment each record would also be gossiped to neighbouring
846    /// peers whose LSH bucket overlaps.
847    vector_records: Arc<DashMap<String, (VectorAnnotatedRecord, Instant)>>,
848
849    /// Monotonically increasing "routing table version" counter.
850    ///
851    /// Incremented on every `put_with_vector` and every peer-initiated gossip
852    /// merge.  The convergence score is estimated by comparing the rate of
853    /// change against the expected steady-state update frequency.
854    routing_version: Arc<RwLock<u64>>,
855
856    /// Timestamp of the last routing-table modification.
857    last_routing_change: Arc<RwLock<Instant>>,
858
859    // --- v0.3.0 shard balancer ---
860    /// Shard-load balancer: tracks per-peer vector counts and identifies
861    /// hot-spot peers that should shed load via migration.
862    pub shard_balancer: parking_lot::Mutex<ShardBalancer>,
863}
864
865/// Statistics for semantic DHT
866#[derive(Debug, Clone, Default, Serialize, Deserialize)]
867pub struct SemanticDhtStats {
868    /// Total queries processed
869    pub total_queries: u64,
870
871    /// Successful queries
872    pub successful_queries: u64,
873
874    /// Failed queries
875    pub failed_queries: u64,
876
877    /// Cache hits
878    pub cache_hits: u64,
879
880    /// Cache misses
881    pub cache_misses: u64,
882
883    /// Average query latency
884    pub avg_query_latency_ms: f64,
885
886    /// Total content indexed
887    pub indexed_content: u64,
888
889    /// Queries per namespace
890    pub queries_per_namespace: HashMap<String, u64>,
891
892    // --- v0.3.0 additions ---
893    /// Total `put_with_vector` calls that succeeded
894    pub vector_puts: u64,
895
896    /// Total `search_similar` calls
897    pub vector_searches: u64,
898
899    /// Number of efficient partial-sync operations performed
900    pub partial_syncs: u64,
901
902    /// Snapshot of the last computed routing convergence score
903    pub last_convergence_score: f32,
904}
905
906impl SemanticDht {
907    /// Create a new semantic DHT
908    pub fn new(config: SemanticDhtConfig) -> Self {
909        Self {
910            config,
911            namespaces: Arc::new(DashMap::new()),
912            lsh_projections: Arc::new(DashMap::new()),
913            hash_to_peers: Arc::new(DashMap::new()),
914            local_index: Arc::new(DashMap::new()),
915            query_cache: Arc::new(DashMap::new()),
916            stats: Arc::new(RwLock::new(SemanticDhtStats::default())),
917            vector_records: Arc::new(DashMap::new()),
918            routing_version: Arc::new(RwLock::new(0)),
919            last_routing_change: Arc::new(RwLock::new(Instant::now())),
920            shard_balancer: parking_lot::Mutex::new(ShardBalancer::new(
921                ShardBalancerConfig::default(),
922            )),
923        }
924    }
925
926    /// Register a new semantic namespace
927    pub fn register_namespace(&self, namespace: SemanticNamespace) -> Result<(), SemanticDhtError> {
928        let namespace_id = namespace.id.clone();
929
930        // Generate LSH projections for this namespace
931        let projections = self.generate_lsh_projections(
932            namespace.dimension,
933            namespace.lsh_config.hash_functions,
934            namespace.lsh_config.num_tables,
935        );
936
937        self.lsh_projections
938            .insert(namespace_id.clone(), projections);
939        self.namespaces.insert(namespace_id, namespace);
940
941        Ok(())
942    }
943
944    /// Generate random projections for LSH
945    fn generate_lsh_projections(
946        &self,
947        dimension: usize,
948        hash_functions: usize,
949        num_tables: usize,
950    ) -> Vec<Vec<f32>> {
951        use std::f32::consts::PI;
952
953        let mut projections = Vec::new();
954        let total_projections = hash_functions * num_tables;
955
956        // Generate random unit vectors using Box-Muller transform
957        for i in 0..total_projections {
958            let mut projection = Vec::with_capacity(dimension);
959
960            for j in 0..dimension {
961                // Simple pseudo-random generation (deterministic for reproducibility)
962                let seed = (i * dimension + j) as f32;
963                let angle = seed * 2.0 * PI / 1000.0;
964                let value = angle.sin();
965                projection.push(value);
966            }
967
968            // Normalize
969            let norm: f32 = projection.iter().map(|x| x * x).sum::<f32>().sqrt();
970            if norm > 0.0 {
971                for val in &mut projection {
972                    *val /= norm;
973                }
974            }
975
976            projections.push(projection);
977        }
978
979        projections
980    }
981
982    /// Compute LSH hashes for an embedding
983    pub fn compute_lsh_hashes(
984        &self,
985        embedding: &[f32],
986        namespace: &NamespaceId,
987    ) -> Result<Vec<LshHash>, SemanticDhtError> {
988        let ns = self
989            .namespaces
990            .get(namespace)
991            .ok_or_else(|| SemanticDhtError::UnknownNamespace(namespace.0.clone()))?;
992
993        if embedding.len() != ns.dimension {
994            return Err(SemanticDhtError::InvalidDimension {
995                expected: ns.dimension,
996                actual: embedding.len(),
997            });
998        }
999
1000        let projections = self
1001            .lsh_projections
1002            .get(namespace)
1003            .ok_or_else(|| SemanticDhtError::UnknownNamespace(namespace.0.clone()))?;
1004
1005        let mut hashes = Vec::new();
1006        let hash_functions = ns.lsh_config.hash_functions;
1007
1008        for table in 0..ns.lsh_config.num_tables {
1009            let mut bucket = Vec::with_capacity(hash_functions);
1010
1011            for func in 0..hash_functions {
1012                let proj_idx = table * hash_functions + func;
1013                let projection = &projections[proj_idx];
1014
1015                // Compute dot product
1016                let dot_product: f32 = embedding
1017                    .iter()
1018                    .zip(projection.iter())
1019                    .map(|(a, b)| a * b)
1020                    .sum();
1021
1022                // Quantize using bucket width
1023                let quantized = (dot_product / ns.lsh_config.bucket_width).floor() as i32;
1024                bucket.push(quantized);
1025            }
1026
1027            hashes.push(LshHash { table, bucket });
1028        }
1029
1030        Ok(hashes)
1031    }
1032
1033    /// Index content with its embedding
1034    pub fn index_content(
1035        &self,
1036        cid: Cid,
1037        embedding: Vec<f32>,
1038        namespace: NamespaceId,
1039    ) -> Result<(), SemanticDhtError> {
1040        // Validate namespace
1041        let ns = self
1042            .namespaces
1043            .get(&namespace)
1044            .ok_or_else(|| SemanticDhtError::UnknownNamespace(namespace.0.clone()))?;
1045
1046        if embedding.len() != ns.dimension {
1047            return Err(SemanticDhtError::InvalidDimension {
1048                expected: ns.dimension,
1049                actual: embedding.len(),
1050            });
1051        }
1052
1053        // Store in local index
1054        self.local_index
1055            .insert(cid, (embedding.clone(), namespace.clone()));
1056
1057        // Compute LSH hashes
1058        let hashes = self.compute_lsh_hashes(&embedding, &namespace)?;
1059
1060        // Register hashes (in a real implementation, this would announce to DHT)
1061        for hash in hashes {
1062            // This is a placeholder - actual DHT announcement would happen here
1063            let _ = hash.to_cid();
1064        }
1065
1066        // Update stats
1067        let mut stats = self.stats.write();
1068        stats.indexed_content += 1;
1069
1070        Ok(())
1071    }
1072
1073    /// Execute a semantic query
1074    pub fn query(&self, query: SemanticQuery) -> Result<Vec<SemanticResult>, SemanticDhtError> {
1075        let start = Instant::now();
1076
1077        // Check cache first
1078        if self.config.enable_caching {
1079            let cache_key = self.compute_cache_key(&query);
1080            if let Some(entry) = self.query_cache.get(&cache_key) {
1081                if start.duration_since(entry.timestamp) < self.config.cache_ttl {
1082                    let mut stats = self.stats.write();
1083                    stats.cache_hits += 1;
1084                    return Ok(entry.results.clone());
1085                }
1086            }
1087        }
1088
1089        // Validate namespace
1090        let _ns = self
1091            .namespaces
1092            .get(&query.namespace)
1093            .ok_or_else(|| SemanticDhtError::UnknownNamespace(query.namespace.0.clone()))?;
1094
1095        // Compute LSH hashes for query
1096        let hashes = self.compute_lsh_hashes(&query.embedding, &query.namespace)?;
1097
1098        // Find candidate peers (in real implementation, query DHT)
1099        let mut candidate_peers = Vec::new();
1100        for hash in &hashes {
1101            if let Some(peers) = self.hash_to_peers.get(hash) {
1102                candidate_peers.extend(peers.iter().cloned());
1103            }
1104        }
1105
1106        // For now, search local index (in production, would query remote peers)
1107        let mut results = Vec::new();
1108        for entry in self.local_index.iter() {
1109            let (cid, (embedding, ns)) = entry.pair();
1110
1111            if ns != &query.namespace {
1112                continue;
1113            }
1114
1115            let distance = self.compute_distance(&query.embedding, embedding, &query.namespace)?;
1116            let score = 1.0 / (1.0 + distance); // Convert distance to similarity score
1117
1118            results.push(SemanticResult {
1119                cid: *cid,
1120                score,
1121                peer: PeerId::random(), // Placeholder
1122                metadata: HashMap::new(),
1123            });
1124        }
1125
1126        // Sort by score descending and take top-k
1127        results.sort_by(|a, b| {
1128            b.score
1129                .partial_cmp(&a.score)
1130                .unwrap_or(std::cmp::Ordering::Equal)
1131        });
1132        results.truncate(query.top_k);
1133
1134        // Cache results
1135        if self.config.enable_caching {
1136            let cache_key = self.compute_cache_key(&query);
1137            let entry = CacheEntry {
1138                results: results.clone(),
1139                timestamp: start,
1140            };
1141            self.query_cache.insert(cache_key, entry);
1142
1143            // Cleanup old cache entries
1144            self.cleanup_cache();
1145        }
1146
1147        // Update stats
1148        let latency = start.elapsed().as_millis() as f64;
1149        let mut stats = self.stats.write();
1150        stats.total_queries += 1;
1151        stats.successful_queries += 1;
1152        stats.cache_misses += 1;
1153
1154        // Update average latency (exponential moving average)
1155        let alpha = 0.1;
1156        stats.avg_query_latency_ms = alpha * latency + (1.0 - alpha) * stats.avg_query_latency_ms;
1157
1158        *stats
1159            .queries_per_namespace
1160            .entry(query.namespace.0.clone())
1161            .or_insert(0) += 1;
1162
1163        Ok(results)
1164    }
1165
1166    /// Compute distance between two embeddings
1167    fn compute_distance(
1168        &self,
1169        a: &[f32],
1170        b: &[f32],
1171        namespace: &NamespaceId,
1172    ) -> Result<f32, SemanticDhtError> {
1173        let ns = self
1174            .namespaces
1175            .get(namespace)
1176            .ok_or_else(|| SemanticDhtError::UnknownNamespace(namespace.0.clone()))?;
1177
1178        let distance = match ns.distance_metric {
1179            DistanceMetric::Euclidean => a
1180                .iter()
1181                .zip(b.iter())
1182                .map(|(x, y)| (x - y).powi(2))
1183                .sum::<f32>()
1184                .sqrt(),
1185            DistanceMetric::Cosine => {
1186                let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
1187                let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1188                let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1189                1.0 - (dot / (norm_a * norm_b))
1190            }
1191            DistanceMetric::Manhattan => a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).sum(),
1192            DistanceMetric::DotProduct => {
1193                -a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<f32>() // Negative for similarity
1194            }
1195        };
1196
1197        Ok(distance)
1198    }
1199
1200    /// Compute cache key for a query
1201    fn compute_cache_key(&self, query: &SemanticQuery) -> Vec<u8> {
1202        // Simple hash of embedding + namespace
1203        let mut data = Vec::new();
1204        data.extend_from_slice(query.namespace.0.as_bytes());
1205        for &val in &query.embedding {
1206            data.extend_from_slice(&val.to_le_bytes());
1207        }
1208        data
1209    }
1210
1211    /// Cleanup old cache entries
1212    fn cleanup_cache(&self) {
1213        if self.query_cache.len() <= self.config.max_cache_size {
1214            return;
1215        }
1216
1217        let now = Instant::now();
1218        let ttl = self.config.cache_ttl;
1219
1220        self.query_cache
1221            .retain(|_, entry| now.duration_since(entry.timestamp) < ttl);
1222    }
1223
1224    /// Get statistics
1225    pub fn stats(&self) -> SemanticDhtStats {
1226        self.stats.read().clone()
1227    }
1228
1229    /// Get namespace information
1230    pub fn get_namespace(&self, id: &NamespaceId) -> Option<SemanticNamespace> {
1231        self.namespaces.get(id).map(|ns| ns.clone())
1232    }
1233
1234    /// List all registered namespaces
1235    pub fn list_namespaces(&self) -> Vec<NamespaceId> {
1236        self.namespaces
1237            .iter()
1238            .map(|entry| entry.key().clone())
1239            .collect()
1240    }
1241
1242    // =========================================================================
1243    // v0.3.0 production methods
1244    // =========================================================================
1245
1246    /// Store a CID with its embedding vector in the local record store.
1247    ///
1248    /// The record is validated (dimension check) and then written to the flat
1249    /// `vector_records` map.  In a full network deployment the caller would
1250    /// subsequently gossip this record to neighbouring peers.
1251    ///
1252    /// # Errors
1253    ///
1254    /// Returns [`SemanticDhtError::VectorDimensionMismatch`] when
1255    /// `vector.len() != config.dimension`.
1256    pub fn put_with_vector(
1257        &self,
1258        cid: impl Into<String>,
1259        vector: Vec<f32>,
1260        provider_id: impl Into<String>,
1261    ) -> Result<(), SemanticDhtError> {
1262        let expected = self.config.dimension;
1263        if vector.len() != expected {
1264            return Err(SemanticDhtError::VectorDimensionMismatch {
1265                expected,
1266                got: vector.len(),
1267            });
1268        }
1269
1270        let cid_str = cid.into();
1271        let record = VectorAnnotatedRecord::new(
1272            cid_str.clone(),
1273            vector,
1274            provider_id,
1275            self.config.vector_ttl.as_secs(),
1276            HashMap::new(),
1277        );
1278
1279        self.vector_records
1280            .insert(cid_str, (record, Instant::now()));
1281
1282        // Bump the routing version and record the change time
1283        {
1284            let mut version = self.routing_version.write();
1285            *version = version.saturating_add(1);
1286        }
1287        *self.last_routing_change.write() = Instant::now();
1288
1289        // Update stats
1290        let mut stats = self.stats.write();
1291        stats.vector_puts = stats.vector_puts.saturating_add(1);
1292
1293        Ok(())
1294    }
1295
1296    /// Return the `k` CIDs whose stored vectors are most similar to `query_vector`.
1297    ///
1298    /// Similarity is measured by cosine similarity (higher = more similar).
1299    /// Results are returned as `(cid_string, similarity_score)` pairs in
1300    /// descending score order.
1301    ///
1302    /// Expired records (older than `config.vector_ttl`) are silently excluded.
1303    ///
1304    /// # Errors
1305    ///
1306    /// Returns [`SemanticDhtError::VectorDimensionMismatch`] when
1307    /// `query_vector.len() != config.dimension`.
1308    pub fn search_similar(
1309        &self,
1310        query_vector: &[f32],
1311        k: usize,
1312    ) -> Result<Vec<(String, f32)>, SemanticDhtError> {
1313        let expected = self.config.dimension;
1314        if query_vector.len() != expected {
1315            return Err(SemanticDhtError::VectorDimensionMismatch {
1316                expected,
1317                got: query_vector.len(),
1318            });
1319        }
1320
1321        let now = Instant::now();
1322        let ttl = self.config.vector_ttl;
1323
1324        // Compute query vector norm once
1325        let query_norm: f32 = query_vector.iter().map(|x| x * x).sum::<f32>().sqrt();
1326        if query_norm == 0.0 {
1327            // Zero vector has no meaningful direction – return empty
1328            return Ok(Vec::new());
1329        }
1330
1331        let mut scored: Vec<(String, f32)> = self
1332            .vector_records
1333            .iter()
1334            .filter(|entry| {
1335                // Exclude expired records
1336                now.duration_since(entry.value().1) < ttl
1337            })
1338            .filter_map(|entry| {
1339                let (record, _) = entry.value();
1340                let vec = &record.vector;
1341                if vec.len() != expected {
1342                    return None;
1343                }
1344                let dot: f32 = query_vector
1345                    .iter()
1346                    .zip(vec.iter())
1347                    .map(|(a, b)| a * b)
1348                    .sum();
1349                let norm_b: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1350                if norm_b == 0.0 {
1351                    return None;
1352                }
1353                let cosine_sim = dot / (query_norm * norm_b);
1354                Some((record.cid.clone(), cosine_sim))
1355            })
1356            .collect();
1357
1358        // Sort descending by similarity score
1359        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1360        scored.truncate(k);
1361
1362        // Update stats
1363        {
1364            let mut stats = self.stats.write();
1365            stats.vector_searches = stats.vector_searches.saturating_add(1);
1366        }
1367
1368        Ok(scored)
1369    }
1370
1371    /// Estimate how stable the routing table currently is.
1372    ///
1373    /// Convergence is modelled as a function of how long the routing table has
1374    /// been quiescent: the score rises from 0 to 1 over
1375    /// `2 × sync_interval` of silence.  Once the score crosses
1376    /// `convergence_threshold` the table is considered converged.
1377    ///
1378    /// Returns a value in `[0.0, 1.0]`.
1379    pub fn get_routing_convergence(&self) -> f32 {
1380        let elapsed = {
1381            let last_change = self.last_routing_change.read();
1382            last_change.elapsed()
1383        };
1384
1385        // Linear ramp: reaches 1.0 after 2× sync_interval with no changes
1386        let ramp_secs = self.config.sync_interval.as_secs_f32() * 2.0;
1387        let score = (elapsed.as_secs_f32() / ramp_secs).min(1.0);
1388
1389        // Cache in stats for external inspection
1390        {
1391            let mut stats = self.stats.write();
1392            stats.last_convergence_score = score;
1393        }
1394
1395        score
1396    }
1397
1398    /// Perform an efficient partial sync with a peer for a specific embedding region.
1399    ///
1400    /// Only records whose vectors hash to `changed_region` (via LSH bucket
1401    /// equivalence) are considered.  This avoids the cost of a full index
1402    /// exchange and is the primary mechanism for keeping distributed vector
1403    /// indices consistent between peers.
1404    ///
1405    /// In the current single-node implementation the peer argument is validated
1406    /// and the region is used to filter the local record set; the function
1407    /// returns the set of CIDs that would be sent to the peer in a real
1408    /// network exchange.
1409    ///
1410    /// # Errors
1411    ///
1412    /// Returns [`SemanticDhtError::PeerUnreachable`] when `peer` is the zero
1413    /// peer ID (used as a sentinel for "invalid peer" in tests).
1414    pub fn efficient_partial_sync(
1415        &self,
1416        peer: &PeerId,
1417        changed_region: &LshHash,
1418    ) -> Result<Vec<String>, SemanticDhtError> {
1419        self.efficient_partial_sync_with_config(
1420            peer,
1421            changed_region,
1422            &PartialSyncConfig::default(),
1423            None,
1424        )
1425        .map(|(cids, _stats)| cids)
1426    }
1427
1428    /// Enhanced efficient partial sync with configurable threshold, batching,
1429    /// and round tracking.
1430    ///
1431    /// Only gossips vectors whose embedding changed by more than
1432    /// `config.sync_threshold` (cosine distance) since the last known state
1433    /// stored in `prev_vectors` (a `CID → previous vector` snapshot).
1434    ///
1435    /// Messages are batched up to `config.batch_size` per GossipSub batch.
1436    /// `round_number` is compared against `config.max_rounds` to detect
1437    /// divergence; if `round_number >= config.max_rounds` the caller should
1438    /// trigger a full resync (this function still executes normally but the
1439    /// returned stats will reflect the round count).
1440    ///
1441    /// Returns the list of CIDs that would be sent plus sync statistics.
1442    pub fn efficient_partial_sync_with_config(
1443        &self,
1444        peer: &PeerId,
1445        changed_region: &LshHash,
1446        config: &PartialSyncConfig,
1447        prev_vectors: Option<&HashMap<String, Vec<f32>>>,
1448    ) -> Result<(Vec<String>, PartialSyncStats), SemanticDhtError> {
1449        // Compute the canonical key for this LSH region
1450        let region_cid = changed_region.to_cid();
1451        let region_key = region_cid.to_bytes();
1452
1453        let now = Instant::now();
1454        let ttl = self.config.vector_ttl;
1455
1456        let mut synced_cids: Vec<String> = Vec::new();
1457        let mut stats = PartialSyncStats::default();
1458
1459        // Approximate per-record wire size: 4 bytes per float + 64-byte CID overhead
1460        let bytes_per_record = |dim: usize| -> usize { dim * std::mem::size_of::<f32>() + 64 };
1461
1462        for entry in self.vector_records.iter() {
1463            // Skip expired records
1464            if now.duration_since(entry.value().1) >= ttl {
1465                continue;
1466            }
1467
1468            let (record, _) = entry.value();
1469            let vec = &record.vector;
1470
1471            // Region membership check (same lightweight approximation as before)
1472            let region_byte = {
1473                let key: Vec<u8> = vec
1474                    .iter()
1475                    .enumerate()
1476                    .map(|(i, v)| {
1477                        let proj = region_key[i % region_key.len()] as f32 / 255.0;
1478                        ((v * proj).abs() * 4.0).floor() as u8
1479                    })
1480                    .take(4)
1481                    .collect();
1482                if key.len() == 4 {
1483                    Some(key[0])
1484                } else {
1485                    None
1486                }
1487            };
1488            let in_region = region_byte.is_some_and(|b| b == region_key[0 % region_key.len()]);
1489            if !in_region {
1490                continue;
1491            }
1492
1493            // Cosine-distance threshold filter
1494            let should_sync = if let Some(prev) = prev_vectors.and_then(|m| m.get(&record.cid)) {
1495                if prev.len() != vec.len() {
1496                    true // dimension changed → always sync
1497                } else {
1498                    let dot: f32 = prev.iter().zip(vec.iter()).map(|(a, b)| a * b).sum();
1499                    let norm_a: f32 = prev.iter().map(|x| x * x).sum::<f32>().sqrt();
1500                    let norm_b: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1501                    let cosine_sim = if norm_a > 0.0 && norm_b > 0.0 {
1502                        dot / (norm_a * norm_b)
1503                    } else {
1504                        0.0
1505                    };
1506                    let cosine_dist = 1.0 - cosine_sim;
1507                    cosine_dist > config.sync_threshold
1508                }
1509            } else {
1510                true // no previous state → sync everything in region
1511            };
1512
1513            if should_sync {
1514                stats.bytes_sent += bytes_per_record(vec.len());
1515                synced_cids.push(record.cid.clone());
1516                stats.vectors_synced += 1;
1517            } else {
1518                stats.vectors_skipped += 1;
1519            }
1520        }
1521
1522        // Compute number of gossip batches
1523        stats.rounds_completed = if synced_cids.is_empty() {
1524            0
1525        } else {
1526            synced_cids.len().div_ceil(config.batch_size)
1527        };
1528
1529        // Update global partial sync counter
1530        {
1531            let mut global_stats = self.stats.write();
1532            global_stats.partial_syncs = global_stats.partial_syncs.saturating_add(1);
1533        }
1534
1535        let _ = peer; // used for actual network I/O in a full deployment
1536        Ok((synced_cids, stats))
1537    }
1538
1539    /// Return a snapshot of routing-quality metrics.
1540    pub fn metrics(&self) -> SemanticDhtMetrics {
1541        let stats = self.stats.read();
1542
1543        let recall_rate = if stats.total_queries == 0 {
1544            0.0
1545        } else {
1546            stats.successful_queries as f32 / stats.total_queries as f32
1547        };
1548
1549        let cache_hit_ratio = {
1550            let total_cache = stats.cache_hits + stats.cache_misses;
1551            if total_cache == 0 {
1552                0.0
1553            } else {
1554                stats.cache_hits as f32 / total_cache as f32
1555            }
1556        };
1557
1558        // Compute convergence without mutating stats a second time
1559        let elapsed = self.last_routing_change.read().elapsed();
1560        let ramp_secs = self.config.sync_interval.as_secs_f32() * 2.0;
1561        let routing_convergence = (elapsed.as_secs_f32() / ramp_secs).min(1.0);
1562
1563        SemanticDhtMetrics {
1564            recall_rate,
1565            mean_latency_ms: stats.avg_query_latency_ms,
1566            routing_convergence,
1567            indexed_cid_count: self.local_index.len() + self.vector_records.len(),
1568            known_hash_regions: self.hash_to_peers.len(),
1569            cache_hit_ratio,
1570            partial_sync_count: stats.partial_syncs,
1571        }
1572    }
1573
1574    // -------------------------------------------------------------------------
1575    // v0.3.0: shard balancing
1576    // -------------------------------------------------------------------------
1577
1578    /// Check whether any peer in the shard balancer is overloaded and, if so,
1579    /// return the list of `(cid, from_peer)` migration candidates.
1580    ///
1581    /// Returns an empty `Vec` when the cluster is already balanced.
1582    pub fn rebalance_if_needed(&self) -> Vec<(String, String)> {
1583        // Acquire the mutex exactly once to avoid deadlock with non-reentrant Mutex.
1584        let balancer = self.shard_balancer.lock();
1585        let score = balancer.balance_score();
1586        let threshold_complement = 1.0 - balancer.config.rebalance_threshold;
1587
1588        if score < threshold_complement || !balancer.overloaded_peers.is_empty() {
1589            balancer.vectors_to_migrate()
1590        } else {
1591            Vec::new()
1592        }
1593    }
1594
1595    // -------------------------------------------------------------------------
1596    // v0.3.0: merge_partial_index
1597    // -------------------------------------------------------------------------
1598
1599    /// Merge a partial index received from `source_peer` into the local record
1600    /// store.
1601    ///
1602    /// For each incoming `VectorAnnotatedRecord`:
1603    ///
1604    /// * **Added** – CID not yet known locally → insert.
1605    /// * **Updated** – CID known but the incoming record has a strictly longer
1606    ///   `ttl_secs` (a proxy for freshness when wall-clock timestamps are
1607    ///   unavailable) → overwrite.
1608    /// * **Skipped** – CID known and local copy is as fresh or fresher.
1609    /// * **Conflict** – CID known but vector dimensions do not match → neither
1610    ///   copy is overwritten; the conflict counter is incremented so the caller
1611    ///   can log or raise an alert.
1612    ///
1613    /// The `source_peer` argument is recorded in the `provider_id` field of any
1614    /// inserted/updated records, allowing provenance tracking.
1615    pub fn merge_partial_index(
1616        &self,
1617        records: Vec<VectorAnnotatedRecord>,
1618        source_peer: &str,
1619    ) -> MergeResult {
1620        let expected_dim = self.config.dimension;
1621        let now = Instant::now();
1622        let mut result = MergeResult::default();
1623
1624        for mut record in records {
1625            // Dimension guard
1626            if record.vector.len() != expected_dim {
1627                result.conflicts += 1;
1628                continue;
1629            }
1630
1631            // Ensure the record's dimension field is consistent
1632            record.dimension = record.vector.len();
1633
1634            // Stamp the source peer if the record doesn't already carry one
1635            if record.provider_id.is_empty() {
1636                record.provider_id = source_peer.to_string();
1637            }
1638
1639            match self.vector_records.get(&record.cid) {
1640                None => {
1641                    // Brand-new CID
1642                    self.vector_records
1643                        .insert(record.cid.clone(), (record, now));
1644                    result.added += 1;
1645                }
1646                Some(existing) => {
1647                    let (existing_record, _ts) = existing.value();
1648                    if existing_record.vector.len() != record.vector.len() {
1649                        // Dimension conflict between local and incoming
1650                        result.conflicts += 1;
1651                    } else if record.ttl_secs > existing_record.ttl_secs {
1652                        // Incoming record is fresher
1653                        drop(existing);
1654                        self.vector_records
1655                            .insert(record.cid.clone(), (record, now));
1656                        result.updated += 1;
1657                    } else {
1658                        result.skipped += 1;
1659                    }
1660                }
1661            }
1662        }
1663
1664        // Bump routing version once for the whole merge batch (not per-record)
1665        if result.added > 0 || result.updated > 0 {
1666            let mut version = self.routing_version.write();
1667            *version = version.saturating_add(1);
1668            *self.last_routing_change.write() = now;
1669
1670            let mut stats = self.stats.write();
1671            stats.partial_syncs = stats.partial_syncs.saturating_add(1);
1672        }
1673
1674        result
1675    }
1676
1677    /// Evict vector records that have exceeded their TTL.
1678    ///
1679    /// Call this periodically (e.g. at `sync_interval`) to bound memory usage.
1680    pub fn evict_expired_records(&self) {
1681        let now = Instant::now();
1682        let ttl = self.config.vector_ttl;
1683        self.vector_records
1684            .retain(|_, (_, inserted)| now.duration_since(*inserted) < ttl);
1685    }
1686
1687    /// Query the semantic DHT across the local shard.
1688    ///
1689    /// In a fully distributed deployment this method would fan out `top_k`
1690    /// sub-queries to each peer responsible for the relevant LSH bucket, merge
1691    /// the partial results, and apply `timeout_ms`.  In the current single-node
1692    /// implementation it searches the local `vector_records` store and tags
1693    /// every result with `peer = None` (local).
1694    ///
1695    /// Results are sorted by descending cosine similarity score.
1696    ///
1697    /// # Errors
1698    ///
1699    /// Returns [`SemanticDhtError::VectorDimensionMismatch`] when
1700    /// `query.len() != config.dimension`.
1701    pub async fn distributed_search(
1702        &self,
1703        query: &[f32],
1704        top_k: usize,
1705        timeout_ms: u64,
1706    ) -> Result<Vec<SearchResult>, SemanticDhtError> {
1707        let expected = self.config.dimension;
1708        if query.len() != expected {
1709            return Err(SemanticDhtError::VectorDimensionMismatch {
1710                expected,
1711                got: query.len(),
1712            });
1713        }
1714
1715        // Compute query norm once
1716        let query_norm: f32 = query.iter().map(|x| x * x).sum::<f32>().sqrt();
1717        if query_norm == 0.0 {
1718            return Ok(Vec::new());
1719        }
1720
1721        let now = Instant::now();
1722        let ttl = self.config.vector_ttl;
1723        let deadline = Duration::from_millis(timeout_ms);
1724
1725        let mut results: Vec<SearchResult> = self
1726            .vector_records
1727            .iter()
1728            .filter(|entry| now.duration_since(entry.value().1) < ttl)
1729            .filter_map(|entry| {
1730                // Honour timeout on a best-effort basis
1731                if now.elapsed() > deadline {
1732                    return None;
1733                }
1734                let (record, _) = entry.value();
1735                let vec = &record.vector;
1736                if vec.len() != expected {
1737                    return None;
1738                }
1739                let dot: f32 = query.iter().zip(vec.iter()).map(|(a, b)| a * b).sum();
1740                let norm_b: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1741                if norm_b == 0.0 {
1742                    return None;
1743                }
1744                let score = dot / (query_norm * norm_b);
1745                Some(SearchResult {
1746                    cid: record.cid.clone(),
1747                    score,
1748                    peer: None,
1749                    vector_id: record.cid.clone(),
1750                })
1751            })
1752            .collect();
1753
1754        // Sort descending by score
1755        results.sort_by(|a, b| {
1756            b.score
1757                .partial_cmp(&a.score)
1758                .unwrap_or(std::cmp::Ordering::Equal)
1759        });
1760        results.truncate(top_k);
1761
1762        // Update stats
1763        {
1764            let mut stats = self.stats.write();
1765            stats.vector_searches = stats.vector_searches.saturating_add(1);
1766        }
1767
1768        Ok(results)
1769    }
1770}
1771
1772// Tests are in a separate file to keep this module under 2000 lines.
1773#[cfg(test)]
1774#[path = "semantic_dht_tests.rs"]
1775mod semantic_dht_tests;