Skip to main content

ipfrs_semantic/
similarity_graph.rs

1//! # Semantic Similarity Graph
2//!
3//! A graph where nodes are semantic embeddings and edges represent similarity above a threshold.
4//! Supports community detection, shortest-path traversal, subgraph extraction, and graph statistics.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8// ---------------------------------------------------------------------------
9// Core types
10// ---------------------------------------------------------------------------
11
12/// A node in the semantic similarity graph.
13#[derive(Debug, Clone)]
14pub struct SgNode {
15    /// Unique identifier for this node.
16    pub id: String,
17    /// Embedding vector.
18    pub embedding: Vec<f64>,
19    /// Optional human-readable label.
20    pub label: Option<String>,
21    /// Arbitrary key-value metadata.
22    pub metadata: HashMap<String, String>,
23}
24
25impl SgNode {
26    /// Create a new node with the given id and embedding.
27    pub fn new(id: impl Into<String>, embedding: Vec<f64>) -> Self {
28        Self {
29            id: id.into(),
30            embedding,
31            label: None,
32            metadata: HashMap::new(),
33        }
34    }
35
36    /// Builder: set label.
37    pub fn with_label(mut self, label: impl Into<String>) -> Self {
38        self.label = Some(label.into());
39        self
40    }
41
42    /// Builder: insert a metadata entry.
43    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
44        self.metadata.insert(key.into(), value.into());
45        self
46    }
47}
48
49/// An undirected edge between two nodes, keyed by `min_id:max_id`.
50#[derive(Debug, Clone)]
51pub struct SgEdge {
52    /// ID of the first endpoint (lexicographically smaller).
53    pub from_id: String,
54    /// ID of the second endpoint (lexicographically larger).
55    pub to_id: String,
56    /// Cosine similarity in [−1, 1].
57    pub similarity: f64,
58    /// Unix-epoch milliseconds at creation time.
59    pub created_at: u64,
60}
61
62impl SgEdge {
63    /// Canonical edge key: `min_id:max_id`.
64    pub fn key(a: &str, b: &str) -> String {
65        if a <= b {
66            format!("{}:{}", a, b)
67        } else {
68            format!("{}:{}", b, a)
69        }
70    }
71}
72
73/// A community (cluster) of nodes.
74#[derive(Debug, Clone)]
75pub struct SgCommunity {
76    /// Unique index of this community.
77    pub id: usize,
78    /// Node IDs that belong to this community.
79    pub members: Vec<String>,
80    /// Mean embedding of all member nodes (centroid).
81    pub centroid: Vec<f64>,
82    /// Mean pairwise cosine similarity of members (1.0 if single member).
83    pub cohesion: f64,
84}
85
86/// Configuration for `SemanticSimilarityGraph`.
87#[derive(Debug, Clone)]
88pub struct GraphConfig {
89    /// Minimum cosine similarity for an edge to be created.
90    pub similarity_threshold: f64,
91    /// Maximum number of neighbors per node.
92    pub max_edges_per_node: usize,
93    /// If `true`, prune lowest-similarity edge when a node exceeds `max_edges_per_node`.
94    pub auto_prune: bool,
95}
96
97impl Default for GraphConfig {
98    fn default() -> Self {
99        Self {
100            similarity_threshold: 0.7,
101            max_edges_per_node: 50,
102            auto_prune: true,
103        }
104    }
105}
106
107/// Graph-level statistics snapshot.
108#[derive(Debug, Clone)]
109pub struct SgStats {
110    /// Total number of nodes.
111    pub node_count: usize,
112    /// Total number of edges.
113    pub edge_count: usize,
114    /// Graph density: `edges / (n*(n-1)/2)`.
115    pub density: f64,
116    /// Mean degree.
117    pub avg_degree: f64,
118    /// Mean edge similarity across all edges.
119    pub avg_similarity: f64,
120    /// Number of nodes with no neighbors.
121    pub isolated_nodes: usize,
122}
123
124// ---------------------------------------------------------------------------
125// Main graph structure
126// ---------------------------------------------------------------------------
127
128/// A graph of semantic embeddings connected by cosine similarity.
129#[derive(Debug, Clone)]
130pub struct SemanticSimilarityGraph {
131    /// Graph configuration.
132    pub config: GraphConfig,
133    /// All nodes, keyed by node ID.
134    pub nodes: HashMap<String, SgNode>,
135    /// All edges, keyed by `min_id:max_id`.
136    pub edges: HashMap<String, SgEdge>,
137    /// Adjacency list: node ID → list of neighbour IDs.
138    pub adjacency: HashMap<String, Vec<String>>,
139}
140
141impl SemanticSimilarityGraph {
142    // -----------------------------------------------------------------------
143    // Construction
144    // -----------------------------------------------------------------------
145
146    /// Create an empty graph with the given configuration.
147    pub fn new(config: GraphConfig) -> Self {
148        Self {
149            config,
150            nodes: HashMap::new(),
151            edges: HashMap::new(),
152            adjacency: HashMap::new(),
153        }
154    }
155
156    /// Create a graph with default configuration.
157    pub fn with_defaults() -> Self {
158        Self::new(GraphConfig::default())
159    }
160
161    // -----------------------------------------------------------------------
162    // Cosine similarity
163    // -----------------------------------------------------------------------
164
165    /// Compute cosine similarity between two embedding vectors.
166    ///
167    /// Returns `0.0` if either vector is all-zero, empty, or dimensions differ.
168    pub fn similarity(a: &[f64], b: &[f64]) -> f64 {
169        if a.len() != b.len() || a.is_empty() {
170            return 0.0;
171        }
172        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
173        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
174        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
175        if norm_a == 0.0 || norm_b == 0.0 {
176            return 0.0;
177        }
178        (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
179    }
180
181    // -----------------------------------------------------------------------
182    // Mutation
183    // -----------------------------------------------------------------------
184
185    /// Add a node to the graph.
186    ///
187    /// Computes similarity with every existing node and adds edges where the
188    /// similarity is ≥ `config.similarity_threshold`.  When `auto_prune` is
189    /// enabled the lowest-similarity edge of each over-full neighbour list is
190    /// dropped.
191    pub fn add_node(&mut self, node: SgNode) {
192        let node_id = node.id.clone();
193
194        // Collect similarity scores against all existing nodes first, before
195        // we mutate `self.nodes`.
196        let existing: Vec<(String, f64)> = self
197            .nodes
198            .iter()
199            .map(|(id, existing_node)| {
200                let sim = Self::similarity(&node.embedding, &existing_node.embedding);
201                (id.clone(), sim)
202            })
203            .collect();
204
205        // Insert the new node and initialise its adjacency list.
206        self.nodes.insert(node_id.clone(), node);
207        self.adjacency.entry(node_id.clone()).or_default();
208
209        // Create edges where similarity meets the threshold.
210        let threshold = self.config.similarity_threshold;
211        let now = Self::unix_millis();
212
213        for (other_id, sim) in existing {
214            if sim < threshold {
215                continue;
216            }
217            self.insert_edge_raw(&node_id, &other_id, sim, now);
218        }
219    }
220
221    /// Remove a node and all edges connected to it.
222    ///
223    /// Returns `true` if the node existed.
224    pub fn remove_node(&mut self, node_id: &str) -> bool {
225        if self.nodes.remove(node_id).is_none() {
226            return false;
227        }
228
229        // Collect neighbour list before mutating adjacency.
230        let neighbours: Vec<String> = self.adjacency.remove(node_id).unwrap_or_default();
231
232        for neighbour_id in &neighbours {
233            // Remove the edge record.
234            let key = SgEdge::key(node_id, neighbour_id);
235            self.edges.remove(&key);
236
237            // Remove from the neighbour's adjacency list.
238            if let Some(adj) = self.adjacency.get_mut(neighbour_id.as_str()) {
239                adj.retain(|id| id != node_id);
240            }
241        }
242
243        true
244    }
245
246    // -----------------------------------------------------------------------
247    // Queries
248    // -----------------------------------------------------------------------
249
250    /// Look up a node by ID.
251    pub fn get_node(&self, node_id: &str) -> Option<&SgNode> {
252        self.nodes.get(node_id)
253    }
254
255    /// Return all neighbours of `node_id`, sorted by similarity descending.
256    pub fn neighbors(&self, node_id: &str) -> Vec<(&SgNode, f64)> {
257        let adj = match self.adjacency.get(node_id) {
258            Some(v) => v,
259            None => return Vec::new(),
260        };
261
262        let mut result: Vec<(&SgNode, f64)> = adj
263            .iter()
264            .filter_map(|other_id| {
265                let key = SgEdge::key(node_id, other_id);
266                let edge = self.edges.get(&key)?;
267                let node = self.nodes.get(other_id.as_str())?;
268                Some((node, edge.similarity))
269            })
270            .collect();
271
272        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
273        result
274    }
275
276    /// Return the top-`n` most similar neighbours of `node_id`.
277    pub fn most_similar(&self, node_id: &str, n: usize) -> Vec<(&SgNode, f64)> {
278        let all = self.neighbors(node_id);
279        all.into_iter().take(n).collect()
280    }
281
282    // -----------------------------------------------------------------------
283    // Community detection
284    // -----------------------------------------------------------------------
285
286    /// Detect communities using BFS connected components, filtered by
287    /// `min_community_size`.  Centroid and cohesion are computed for each
288    /// returned community.
289    pub fn find_communities(&self, min_community_size: usize) -> Vec<SgCommunity> {
290        let mut visited: HashSet<&str> = HashSet::new();
291        let mut communities: Vec<SgCommunity> = Vec::new();
292        let mut community_id = 0usize;
293
294        for start_id in self.nodes.keys() {
295            if visited.contains(start_id.as_str()) {
296                continue;
297            }
298
299            // BFS
300            let mut queue: VecDeque<&str> = VecDeque::new();
301            let mut component: Vec<String> = Vec::new();
302
303            queue.push_back(start_id.as_str());
304            visited.insert(start_id.as_str());
305
306            while let Some(current) = queue.pop_front() {
307                component.push(current.to_owned());
308
309                if let Some(adj) = self.adjacency.get(current) {
310                    for neighbour in adj {
311                        if !visited.contains(neighbour.as_str()) {
312                            visited.insert(neighbour.as_str());
313                            queue.push_back(neighbour.as_str());
314                        }
315                    }
316                }
317            }
318
319            if component.len() < min_community_size {
320                continue;
321            }
322
323            let centroid = self.compute_centroid(&component);
324            let cohesion = self.compute_cohesion(&component);
325
326            communities.push(SgCommunity {
327                id: community_id,
328                members: component,
329                centroid,
330                cohesion,
331            });
332            community_id += 1;
333        }
334
335        communities
336    }
337
338    /// Return the community ID that contains `node_id`, if any.
339    pub fn community_of(node_id: &str, communities: &[SgCommunity]) -> Option<usize> {
340        for community in communities {
341            if community.members.iter().any(|m| m == node_id) {
342                return Some(community.id);
343            }
344        }
345        None
346    }
347
348    // -----------------------------------------------------------------------
349    // Path finding
350    // -----------------------------------------------------------------------
351
352    /// BFS shortest path from `from` to `to`.
353    ///
354    /// Returns `None` if either node does not exist or no path exists.
355    pub fn path_between(&self, from: &str, to: &str) -> Option<Vec<String>> {
356        if !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
357            return None;
358        }
359        if from == to {
360            return Some(vec![from.to_owned()]);
361        }
362
363        let mut visited: HashSet<&str> = HashSet::new();
364        let mut queue: VecDeque<Vec<&str>> = VecDeque::new();
365
366        visited.insert(from);
367        queue.push_back(vec![from]);
368
369        while let Some(path) = queue.pop_front() {
370            let current = *path.last()?;
371
372            if let Some(adj) = self.adjacency.get(current) {
373                for neighbour in adj {
374                    if neighbour == to {
375                        let mut result: Vec<String> = path.iter().map(|s| s.to_string()).collect();
376                        result.push(to.to_owned());
377                        return Some(result);
378                    }
379                    if !visited.contains(neighbour.as_str()) {
380                        visited.insert(neighbour.as_str());
381                        let mut new_path = path.clone();
382                        new_path.push(neighbour.as_str());
383                        queue.push_back(new_path);
384                    }
385                }
386            }
387        }
388
389        None
390    }
391
392    // -----------------------------------------------------------------------
393    // Subgraph extraction
394    // -----------------------------------------------------------------------
395
396    /// Induce a subgraph from the given node IDs, including only edges whose
397    /// both endpoints are in the set.
398    pub fn subgraph(&self, node_ids: &[&str]) -> SemanticSimilarityGraph {
399        let id_set: HashSet<&str> = node_ids.iter().copied().collect();
400
401        let mut sub = SemanticSimilarityGraph::new(self.config.clone());
402
403        for &nid in &id_set {
404            if let Some(node) = self.nodes.get(nid) {
405                sub.nodes.insert(nid.to_owned(), node.clone());
406                sub.adjacency.entry(nid.to_owned()).or_default();
407            }
408        }
409
410        for (key, edge) in &self.edges {
411            if id_set.contains(edge.from_id.as_str()) && id_set.contains(edge.to_id.as_str()) {
412                sub.edges.insert(key.clone(), edge.clone());
413                sub.adjacency
414                    .entry(edge.from_id.clone())
415                    .or_default()
416                    .push(edge.to_id.clone());
417                sub.adjacency
418                    .entry(edge.to_id.clone())
419                    .or_default()
420                    .push(edge.from_id.clone());
421            }
422        }
423
424        sub
425    }
426
427    // -----------------------------------------------------------------------
428    // Graph metrics
429    // -----------------------------------------------------------------------
430
431    /// Graph density: `|E| / (n*(n-1)/2)`.  Returns `0.0` for fewer than 2
432    /// nodes.
433    pub fn density(&self) -> f64 {
434        let n = self.nodes.len();
435        if n < 2 {
436            return 0.0;
437        }
438        let max_edges = n * (n - 1) / 2;
439        self.edges.len() as f64 / max_edges as f64
440    }
441
442    /// Mean number of neighbours per node.  Returns `0.0` if empty.
443    pub fn avg_degree(&self) -> f64 {
444        if self.nodes.is_empty() {
445            return 0.0;
446        }
447        let total: usize = self.adjacency.values().map(|v| v.len()).sum();
448        total as f64 / self.nodes.len() as f64
449    }
450
451    /// Number of nodes.
452    pub fn node_count(&self) -> usize {
453        self.nodes.len()
454    }
455
456    /// Number of edges.
457    pub fn edge_count(&self) -> usize {
458        self.edges.len()
459    }
460
461    /// Compute aggregate graph statistics.
462    pub fn stats(&self) -> SgStats {
463        let node_count = self.node_count();
464        let edge_count = self.edge_count();
465        let density = self.density();
466        let avg_degree = self.avg_degree();
467
468        let avg_similarity = if edge_count == 0 {
469            0.0
470        } else {
471            let total: f64 = self.edges.values().map(|e| e.similarity).sum();
472            total / edge_count as f64
473        };
474
475        let isolated_nodes = self.adjacency.values().filter(|v| v.is_empty()).count();
476
477        SgStats {
478            node_count,
479            edge_count,
480            density,
481            avg_degree,
482            avg_similarity,
483            isolated_nodes,
484        }
485    }
486
487    // -----------------------------------------------------------------------
488    // Private helpers
489    // -----------------------------------------------------------------------
490
491    /// Insert a single undirected edge, respecting `auto_prune` / `max_edges_per_node`.
492    fn insert_edge_raw(&mut self, a: &str, b: &str, sim: f64, now: u64) {
493        let key = SgEdge::key(a, b);
494
495        // Avoid duplicate edges.
496        if self.edges.contains_key(&key) {
497            return;
498        }
499
500        let (from_id, to_id) = if a <= b {
501            (a.to_owned(), b.to_owned())
502        } else {
503            (b.to_owned(), a.to_owned())
504        };
505
506        let edge = SgEdge {
507            from_id: from_id.clone(),
508            to_id: to_id.clone(),
509            similarity: sim,
510            created_at: now,
511        };
512
513        self.edges.insert(key, edge);
514
515        self.adjacency
516            .entry(a.to_owned())
517            .or_default()
518            .push(b.to_owned());
519        self.adjacency
520            .entry(b.to_owned())
521            .or_default()
522            .push(a.to_owned());
523
524        // Auto-prune for node `a`.
525        self.maybe_prune(a);
526        // Auto-prune for node `b`.
527        self.maybe_prune(b);
528    }
529
530    /// If `node_id` exceeds `max_edges_per_node`, remove the edge with the
531    /// lowest similarity.
532    fn maybe_prune(&mut self, node_id: &str) {
533        if !self.config.auto_prune {
534            return;
535        }
536        let max = self.config.max_edges_per_node;
537        let adj_len = self.adjacency.get(node_id).map(|v| v.len()).unwrap_or(0);
538        if adj_len <= max {
539            return;
540        }
541
542        // Find the weakest neighbour.
543        let weakest: Option<(String, f64)> = self.adjacency.get(node_id).and_then(|adj| {
544            adj.iter()
545                .filter_map(|nid| {
546                    let key = SgEdge::key(node_id, nid);
547                    let sim = self.edges.get(&key).map(|e| e.similarity)?;
548                    Some((nid.clone(), sim))
549                })
550                .min_by(|x, y| x.1.partial_cmp(&y.1).unwrap_or(std::cmp::Ordering::Equal))
551        });
552
553        if let Some((weak_id, _)) = weakest {
554            let key = SgEdge::key(node_id, &weak_id);
555            self.edges.remove(&key);
556
557            if let Some(adj) = self.adjacency.get_mut(node_id) {
558                adj.retain(|id| id != &weak_id);
559            }
560            if let Some(adj) = self.adjacency.get_mut(weak_id.as_str()) {
561                adj.retain(|id| id != node_id);
562            }
563        }
564    }
565
566    /// Compute the centroid (mean embedding) for a set of node IDs.
567    fn compute_centroid(&self, members: &[String]) -> Vec<f64> {
568        if members.is_empty() {
569            return Vec::new();
570        }
571
572        // Determine dimension from first member that exists.
573        let dim = members
574            .iter()
575            .find_map(|id| self.nodes.get(id.as_str()).map(|n| n.embedding.len()))
576            .unwrap_or(0);
577
578        if dim == 0 {
579            return Vec::new();
580        }
581
582        let mut centroid = vec![0.0f64; dim];
583        let mut count = 0usize;
584
585        for id in members {
586            if let Some(node) = self.nodes.get(id.as_str()) {
587                if node.embedding.len() == dim {
588                    for (c, v) in centroid.iter_mut().zip(node.embedding.iter()) {
589                        *c += v;
590                    }
591                    count += 1;
592                }
593            }
594        }
595
596        if count > 0 {
597            for c in &mut centroid {
598                *c /= count as f64;
599            }
600        }
601
602        centroid
603    }
604
605    /// Compute mean pairwise cosine similarity for a set of node IDs.
606    /// Returns `1.0` if fewer than 2 members.
607    fn compute_cohesion(&self, members: &[String]) -> f64 {
608        if members.len() < 2 {
609            return 1.0;
610        }
611
612        let embeddings: Vec<&[f64]> = members
613            .iter()
614            .filter_map(|id| self.nodes.get(id.as_str()).map(|n| n.embedding.as_slice()))
615            .collect();
616
617        let n = embeddings.len();
618        if n < 2 {
619            return 1.0;
620        }
621
622        let mut sum = 0.0f64;
623        let mut pairs = 0usize;
624
625        for i in 0..n {
626            for j in (i + 1)..n {
627                sum += Self::similarity(embeddings[i], embeddings[j]);
628                pairs += 1;
629            }
630        }
631
632        if pairs == 0 {
633            1.0
634        } else {
635            sum / pairs as f64
636        }
637    }
638
639    /// Current time as Unix-epoch milliseconds.
640    fn unix_millis() -> u64 {
641        use std::time::{SystemTime, UNIX_EPOCH};
642        SystemTime::now()
643            .duration_since(UNIX_EPOCH)
644            .map(|d| d.as_millis() as u64)
645            .unwrap_or(0)
646    }
647}
648
649// ---------------------------------------------------------------------------
650// Tests
651// ---------------------------------------------------------------------------
652
653#[cfg(test)]
654mod tests {
655    use std::collections::HashMap;
656
657    use crate::similarity_graph::{
658        GraphConfig, SemanticSimilarityGraph, SgCommunity, SgEdge, SgNode,
659    };
660
661    // ---- helpers -----------------------------------------------------------
662
663    fn unit(v: &[f64]) -> Vec<f64> {
664        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
665        if norm == 0.0 {
666            return v.to_vec();
667        }
668        v.iter().map(|x| x / norm).collect()
669    }
670
671    fn node(id: &str, emb: Vec<f64>) -> SgNode {
672        SgNode::new(id, emb)
673    }
674
675    fn default_graph() -> SemanticSimilarityGraph {
676        SemanticSimilarityGraph::with_defaults()
677    }
678
679    fn graph_with_threshold(t: f64) -> SemanticSimilarityGraph {
680        SemanticSimilarityGraph::new(GraphConfig {
681            similarity_threshold: t,
682            max_edges_per_node: 50,
683            auto_prune: true,
684        })
685    }
686
687    // ---- similarity --------------------------------------------------------
688
689    #[test]
690    fn test_similarity_identical_vectors() {
691        let v = vec![1.0, 0.0, 0.0];
692        let s = SemanticSimilarityGraph::similarity(&v, &v);
693        assert!((s - 1.0).abs() < 1e-10);
694    }
695
696    #[test]
697    fn test_similarity_orthogonal_vectors() {
698        let a = vec![1.0, 0.0, 0.0];
699        let b = vec![0.0, 1.0, 0.0];
700        let s = SemanticSimilarityGraph::similarity(&a, &b);
701        assert!(s.abs() < 1e-10);
702    }
703
704    #[test]
705    fn test_similarity_opposite_vectors() {
706        let a = vec![1.0, 0.0, 0.0];
707        let b = vec![-1.0, 0.0, 0.0];
708        let s = SemanticSimilarityGraph::similarity(&a, &b);
709        assert!((s + 1.0).abs() < 1e-10);
710    }
711
712    #[test]
713    fn test_similarity_zero_vector_returns_zero() {
714        let a = vec![0.0, 0.0, 0.0];
715        let b = vec![1.0, 2.0, 3.0];
716        assert_eq!(SemanticSimilarityGraph::similarity(&a, &b), 0.0);
717    }
718
719    #[test]
720    fn test_similarity_mismatched_dims_returns_zero() {
721        let a = vec![1.0, 0.0];
722        let b = vec![1.0, 0.0, 0.0];
723        assert_eq!(SemanticSimilarityGraph::similarity(&a, &b), 0.0);
724    }
725
726    #[test]
727    fn test_similarity_empty_returns_zero() {
728        assert_eq!(SemanticSimilarityGraph::similarity(&[], &[]), 0.0);
729    }
730
731    #[test]
732    fn test_similarity_known_value() {
733        // 45 degree angle → cos(45°) ≈ 0.7071
734        let a = unit(&[1.0, 1.0]);
735        let b = unit(&[1.0, 0.0]);
736        let s = SemanticSimilarityGraph::similarity(&a, &b);
737        assert!((s - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-9);
738    }
739
740    // ---- node CRUD ---------------------------------------------------------
741
742    #[test]
743    fn test_add_single_node() {
744        let mut g = default_graph();
745        g.add_node(node("a", vec![1.0, 0.0]));
746        assert_eq!(g.node_count(), 1);
747        assert_eq!(g.edge_count(), 0);
748    }
749
750    #[test]
751    fn test_get_node_exists() {
752        let mut g = default_graph();
753        g.add_node(node("a", vec![1.0, 0.0]));
754        assert!(g.get_node("a").is_some());
755    }
756
757    #[test]
758    fn test_get_node_missing() {
759        let g = default_graph();
760        assert!(g.get_node("x").is_none());
761    }
762
763    #[test]
764    fn test_remove_existing_node_returns_true() {
765        let mut g = default_graph();
766        g.add_node(node("a", vec![1.0, 0.0]));
767        assert!(g.remove_node("a"));
768        assert_eq!(g.node_count(), 0);
769    }
770
771    #[test]
772    fn test_remove_missing_node_returns_false() {
773        let mut g = default_graph();
774        assert!(!g.remove_node("ghost"));
775    }
776
777    #[test]
778    fn test_remove_node_cleans_edges() {
779        let mut g = graph_with_threshold(0.0); // threshold=0 → always add edges
780        g.add_node(node("a", vec![1.0, 0.0]));
781        g.add_node(node("b", vec![0.0, 1.0]));
782        assert_eq!(g.edge_count(), 1);
783        g.remove_node("a");
784        assert_eq!(g.edge_count(), 0);
785        assert!(g.adjacency.get("b").map(|v| v.is_empty()).unwrap_or(true));
786    }
787
788    // ---- edge creation -----------------------------------------------------
789
790    #[test]
791    fn test_edge_created_above_threshold() {
792        let mut g = graph_with_threshold(0.5);
793        // highly similar embeddings
794        g.add_node(node("a", unit(&[1.0, 0.1])));
795        g.add_node(node("b", unit(&[1.0, 0.2])));
796        assert_eq!(g.edge_count(), 1);
797    }
798
799    #[test]
800    fn test_no_edge_below_threshold() {
801        let mut g = graph_with_threshold(0.99);
802        g.add_node(node("a", vec![1.0, 0.0]));
803        g.add_node(node("b", vec![0.0, 1.0])); // orthogonal → sim=0
804        assert_eq!(g.edge_count(), 0);
805    }
806
807    #[test]
808    fn test_edge_key_canonical_order() {
809        assert_eq!(SgEdge::key("b", "a"), "a:b");
810        assert_eq!(SgEdge::key("a", "b"), "a:b");
811        assert_eq!(SgEdge::key("z", "a"), "a:z");
812    }
813
814    // ---- neighbors / most_similar ------------------------------------------
815
816    #[test]
817    fn test_neighbors_sorted_by_similarity_desc() {
818        let mut g = graph_with_threshold(0.0);
819        g.add_node(node("origin", unit(&[1.0, 0.0, 0.0])));
820        g.add_node(node("close", unit(&[1.0, 0.1, 0.0])));
821        g.add_node(node("far", unit(&[1.0, 1.0, 0.0])));
822
823        let nbrs = g.neighbors("origin");
824        assert_eq!(nbrs.len(), 2);
825        assert!(nbrs[0].1 >= nbrs[1].1);
826    }
827
828    #[test]
829    fn test_neighbors_unknown_node_empty() {
830        let g = default_graph();
831        assert!(g.neighbors("ghost").is_empty());
832    }
833
834    #[test]
835    fn test_most_similar_respects_n() {
836        let mut g = graph_with_threshold(0.0);
837        for i in 0..10 {
838            let v = unit(&[i as f64 + 1.0, 1.0]);
839            g.add_node(node(&format!("n{}", i), v));
840        }
841        let top3 = g.most_similar("n0", 3);
842        assert_eq!(top3.len(), 3);
843    }
844
845    #[test]
846    fn test_most_similar_fewer_than_n() {
847        let mut g = graph_with_threshold(0.0);
848        g.add_node(node("a", unit(&[1.0, 0.0])));
849        g.add_node(node("b", unit(&[1.0, 0.1])));
850        let top10 = g.most_similar("a", 10);
851        assert_eq!(top10.len(), 1);
852    }
853
854    // ---- auto-prune --------------------------------------------------------
855
856    #[test]
857    fn test_auto_prune_keeps_max_edges() {
858        let config = GraphConfig {
859            similarity_threshold: 0.0,
860            max_edges_per_node: 3,
861            auto_prune: true,
862        };
863        let mut g = SemanticSimilarityGraph::new(config);
864        // Add 5 nodes; the center node should never exceed 3 edges.
865        let center = unit(&[1.0, 0.0, 0.0]);
866        g.add_node(node("center", center));
867        for i in 0..5 {
868            let v = unit(&[1.0, (i as f64 + 1.0) * 0.01, 0.0]);
869            g.add_node(node(&format!("n{}", i), v));
870        }
871        let degree = g.adjacency.get("center").map(|v| v.len()).unwrap_or(0);
872        assert!(degree <= 3, "degree={}", degree);
873    }
874
875    #[test]
876    fn test_auto_prune_disabled_allows_many_edges() {
877        let config = GraphConfig {
878            similarity_threshold: 0.0,
879            max_edges_per_node: 3,
880            auto_prune: false,
881        };
882        let mut g = SemanticSimilarityGraph::new(config);
883        g.add_node(node("center", unit(&[1.0, 0.0, 0.0])));
884        for i in 0..5 {
885            let v = unit(&[1.0, (i as f64 + 1.0) * 0.01, 0.0]);
886            g.add_node(node(&format!("n{}", i), v));
887        }
888        let degree = g.adjacency.get("center").map(|v| v.len()).unwrap_or(0);
889        assert_eq!(degree, 5);
890    }
891
892    // ---- path_between ------------------------------------------------------
893
894    #[test]
895    fn test_path_between_direct_neighbors() {
896        let mut g = graph_with_threshold(0.0);
897        g.add_node(node("a", unit(&[1.0, 0.0])));
898        g.add_node(node("b", unit(&[1.0, 0.1])));
899        let path = g.path_between("a", "b");
900        assert_eq!(path, Some(vec!["a".to_owned(), "b".to_owned()]));
901    }
902
903    #[test]
904    fn test_path_between_same_node() {
905        let mut g = default_graph();
906        g.add_node(node("a", vec![1.0]));
907        let path = g.path_between("a", "a");
908        assert_eq!(path, Some(vec!["a".to_owned()]));
909    }
910
911    #[test]
912    fn test_path_between_no_path() {
913        let mut g = graph_with_threshold(0.99);
914        g.add_node(node("a", vec![1.0, 0.0]));
915        g.add_node(node("b", vec![0.0, 1.0]));
916        assert!(g.path_between("a", "b").is_none());
917    }
918
919    #[test]
920    fn test_path_between_missing_node() {
921        let g = default_graph();
922        assert!(g.path_between("x", "y").is_none());
923    }
924
925    #[test]
926    fn test_path_between_multi_hop() {
927        let mut g = graph_with_threshold(0.0);
928        g.add_node(node("a", unit(&[1.0, 0.0, 0.0])));
929        g.add_node(node("b", unit(&[0.0, 1.0, 0.0])));
930        g.add_node(node("c", unit(&[0.0, 0.0, 1.0])));
931        // Connect a-b and b-c manually by using threshold=0 so all nodes
932        // with non-zero similarity connect. Since a,b,c are orthogonal they
933        // won't connect. Force connections by using similar vectors.
934        let mut g2 = graph_with_threshold(0.0);
935        g2.add_node(node("a", unit(&[1.0, 0.0, 0.0])));
936        // b is close to a
937        g2.add_node(node("b", unit(&[1.0, 0.5, 0.0])));
938        // c is close to b but not necessarily a
939        g2.add_node(node("c", unit(&[0.0, 1.0, 0.5])));
940        // d is close to c
941        g2.add_node(node("d", unit(&[0.0, 0.5, 1.0])));
942
943        let path = g2.path_between("a", "d");
944        assert!(path.is_some());
945        let p = path.expect("test: path_between returned None");
946        assert_eq!(p.first().map(|s| s.as_str()), Some("a"));
947        assert_eq!(p.last().map(|s| s.as_str()), Some("d"));
948    }
949
950    // ---- subgraph ----------------------------------------------------------
951
952    #[test]
953    fn test_subgraph_contains_only_selected_nodes() {
954        let mut g = graph_with_threshold(0.0);
955        g.add_node(node("a", unit(&[1.0, 0.0])));
956        g.add_node(node("b", unit(&[1.0, 0.1])));
957        g.add_node(node("c", unit(&[0.0, 1.0])));
958        let sub = g.subgraph(&["a", "b"]);
959        assert_eq!(sub.node_count(), 2);
960        assert!(sub.get_node("c").is_none());
961    }
962
963    #[test]
964    fn test_subgraph_preserves_inter_edges() {
965        let mut g = graph_with_threshold(0.0);
966        g.add_node(node("a", unit(&[1.0, 0.0])));
967        g.add_node(node("b", unit(&[1.0, 0.1])));
968        g.add_node(node("c", unit(&[0.0, 1.0])));
969        let sub = g.subgraph(&["a", "b"]);
970        assert_eq!(sub.edge_count(), g.subgraph(&["a", "b"]).edge_count());
971    }
972
973    #[test]
974    fn test_subgraph_excludes_cross_edges() {
975        let mut g = graph_with_threshold(0.0);
976        g.add_node(node("a", unit(&[1.0, 0.0])));
977        g.add_node(node("b", unit(&[1.0, 0.1])));
978        g.add_node(node("c", unit(&[1.0, 0.2])));
979        // All three connect at threshold 0.
980        let sub = g.subgraph(&["a", "b"]); // exclude c
981                                           // The a-c edge should not appear in subgraph.
982        assert!(!sub.edges.contains_key(&SgEdge::key("a", "c")));
983    }
984
985    // ---- density / avg_degree / stats --------------------------------------
986
987    #[test]
988    fn test_density_empty_graph() {
989        assert_eq!(default_graph().density(), 0.0);
990    }
991
992    #[test]
993    fn test_density_single_node() {
994        let mut g = default_graph();
995        g.add_node(node("a", vec![1.0]));
996        assert_eq!(g.density(), 0.0);
997    }
998
999    #[test]
1000    fn test_density_complete_graph() {
1001        let mut g = graph_with_threshold(0.0);
1002        g.add_node(node("a", unit(&[1.0, 0.0])));
1003        g.add_node(node("b", unit(&[1.0, 0.1])));
1004        g.add_node(node("c", unit(&[1.0, 0.2])));
1005        // 3 edges out of max 3 → density = 1.0
1006        assert!((g.density() - 1.0).abs() < 1e-9);
1007    }
1008
1009    #[test]
1010    fn test_avg_degree_empty() {
1011        assert_eq!(default_graph().avg_degree(), 0.0);
1012    }
1013
1014    #[test]
1015    fn test_avg_degree_single() {
1016        let mut g = default_graph();
1017        g.add_node(node("a", vec![1.0]));
1018        assert_eq!(g.avg_degree(), 0.0);
1019    }
1020
1021    #[test]
1022    fn test_stats_fields() {
1023        let mut g = graph_with_threshold(0.0);
1024        g.add_node(node("a", unit(&[1.0, 0.0])));
1025        g.add_node(node("b", unit(&[1.0, 0.1])));
1026        let s = g.stats();
1027        assert_eq!(s.node_count, 2);
1028        assert_eq!(s.edge_count, 1);
1029        assert!(s.avg_similarity > 0.0);
1030        assert_eq!(s.isolated_nodes, 0);
1031    }
1032
1033    // ---- community detection -----------------------------------------------
1034
1035    #[test]
1036    fn test_find_communities_single_component() {
1037        let mut g = graph_with_threshold(0.0);
1038        g.add_node(node("a", unit(&[1.0, 0.0])));
1039        g.add_node(node("b", unit(&[1.0, 0.1])));
1040        g.add_node(node("c", unit(&[1.0, 0.2])));
1041        let communities = g.find_communities(1);
1042        assert_eq!(communities.len(), 1);
1043        assert_eq!(communities[0].members.len(), 3);
1044    }
1045
1046    #[test]
1047    fn test_find_communities_two_isolated() {
1048        let mut g = graph_with_threshold(0.99);
1049        g.add_node(node("a", vec![1.0, 0.0]));
1050        g.add_node(node("b", vec![0.0, 1.0]));
1051        // No edges → two singletons.
1052        let communities = g.find_communities(1);
1053        assert_eq!(communities.len(), 2);
1054    }
1055
1056    #[test]
1057    fn test_find_communities_min_size_filter() {
1058        let mut g = graph_with_threshold(0.99);
1059        g.add_node(node("a", vec![1.0, 0.0]));
1060        g.add_node(node("b", vec![0.0, 1.0]));
1061        // Both communities are singletons; min_size=2 → empty result.
1062        let communities = g.find_communities(2);
1063        assert!(communities.is_empty());
1064    }
1065
1066    #[test]
1067    fn test_community_cohesion_single_member() {
1068        let mut g = default_graph();
1069        g.add_node(node("a", vec![1.0, 0.0]));
1070        let communities = g.find_communities(1);
1071        assert_eq!(communities[0].cohesion, 1.0);
1072    }
1073
1074    #[test]
1075    fn test_community_of_returns_correct_id() {
1076        let mut g = graph_with_threshold(0.0);
1077        g.add_node(node("a", unit(&[1.0, 0.0])));
1078        g.add_node(node("b", unit(&[1.0, 0.1])));
1079        let communities = g.find_communities(1);
1080        let cid = SemanticSimilarityGraph::community_of("a", &communities);
1081        assert!(cid.is_some());
1082    }
1083
1084    #[test]
1085    fn test_community_of_missing_node_returns_none() {
1086        let communities: Vec<SgCommunity> = vec![];
1087        assert!(SemanticSimilarityGraph::community_of("ghost", &communities).is_none());
1088    }
1089
1090    // ---- SgNode builder ----------------------------------------------------
1091
1092    #[test]
1093    fn test_node_builder_label() {
1094        let n = SgNode::new("x", vec![1.0]).with_label("hello");
1095        assert_eq!(n.label.as_deref(), Some("hello"));
1096    }
1097
1098    #[test]
1099    fn test_node_builder_metadata() {
1100        let n = SgNode::new("x", vec![1.0]).with_meta("k", "v");
1101        assert_eq!(n.metadata.get("k").map(|s| s.as_str()), Some("v"));
1102    }
1103
1104    #[test]
1105    fn test_node_default_no_label() {
1106        let n = SgNode::new("x", vec![1.0]);
1107        assert!(n.label.is_none());
1108    }
1109
1110    #[test]
1111    fn test_node_default_empty_metadata() {
1112        let n = SgNode::new("x", vec![1.0]);
1113        assert!(n.metadata.is_empty());
1114    }
1115
1116    // ---- GraphConfig default -----------------------------------------------
1117
1118    #[test]
1119    fn test_graph_config_defaults() {
1120        let cfg = GraphConfig::default();
1121        assert_eq!(cfg.similarity_threshold, 0.7);
1122        assert_eq!(cfg.max_edges_per_node, 50);
1123        assert!(cfg.auto_prune);
1124    }
1125
1126    // ---- edge key ordering -------------------------------------------------
1127
1128    #[test]
1129    fn test_edge_key_same_regardless_of_order() {
1130        assert_eq!(SgEdge::key("foo", "bar"), SgEdge::key("bar", "foo"));
1131    }
1132
1133    // ---- community centroid ------------------------------------------------
1134
1135    #[test]
1136    fn test_community_centroid_not_empty() {
1137        let mut g = graph_with_threshold(0.0);
1138        g.add_node(node("a", unit(&[1.0, 0.0])));
1139        g.add_node(node("b", unit(&[0.5, 0.5])));
1140        let communities = g.find_communities(1);
1141        assert!(!communities[0].centroid.is_empty());
1142    }
1143
1144    // ---- re-add node -------------------------------------------------------
1145
1146    #[test]
1147    fn test_add_node_overwrites_existing() {
1148        let mut g = default_graph();
1149        g.add_node(node("a", vec![1.0, 0.0]));
1150        g.add_node(node("a", vec![0.0, 1.0])); // same id, different embedding
1151                                               // The node should have been replaced.
1152        let n = g.get_node("a").expect("node must exist");
1153        assert_eq!(n.embedding, vec![0.0, 1.0]);
1154    }
1155
1156    // ---- stats avg_similarity edge-less graph ------------------------------
1157
1158    #[test]
1159    fn test_stats_no_edges_avg_similarity_zero() {
1160        let mut g = graph_with_threshold(0.99);
1161        g.add_node(node("a", vec![1.0, 0.0]));
1162        g.add_node(node("b", vec![0.0, 1.0]));
1163        let s = g.stats();
1164        assert_eq!(s.avg_similarity, 0.0);
1165    }
1166
1167    // ---- isolated node count -----------------------------------------------
1168
1169    #[test]
1170    fn test_stats_isolated_nodes_counted() {
1171        let mut g = graph_with_threshold(0.99);
1172        g.add_node(node("a", vec![1.0, 0.0]));
1173        g.add_node(node("b", vec![0.0, 1.0]));
1174        let s = g.stats();
1175        assert_eq!(s.isolated_nodes, 2);
1176    }
1177
1178    // ---- SgNode metadata with HashMap --------------------------------------
1179
1180    #[test]
1181    fn test_node_with_multiple_metadata() {
1182        let mut meta = HashMap::new();
1183        meta.insert("key1".to_owned(), "val1".to_owned());
1184        meta.insert("key2".to_owned(), "val2".to_owned());
1185        let n = SgNode {
1186            id: "x".to_owned(),
1187            embedding: vec![1.0],
1188            label: None,
1189            metadata: meta,
1190        };
1191        assert_eq!(n.metadata.len(), 2);
1192    }
1193}