1use std::collections::{HashMap, HashSet, VecDeque};
7
8#[derive(Debug, Clone)]
14pub struct SgNode {
15 pub id: String,
17 pub embedding: Vec<f64>,
19 pub label: Option<String>,
21 pub metadata: HashMap<String, String>,
23}
24
25impl SgNode {
26 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 pub fn with_label(mut self, label: impl Into<String>) -> Self {
38 self.label = Some(label.into());
39 self
40 }
41
42 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#[derive(Debug, Clone)]
51pub struct SgEdge {
52 pub from_id: String,
54 pub to_id: String,
56 pub similarity: f64,
58 pub created_at: u64,
60}
61
62impl SgEdge {
63 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#[derive(Debug, Clone)]
75pub struct SgCommunity {
76 pub id: usize,
78 pub members: Vec<String>,
80 pub centroid: Vec<f64>,
82 pub cohesion: f64,
84}
85
86#[derive(Debug, Clone)]
88pub struct GraphConfig {
89 pub similarity_threshold: f64,
91 pub max_edges_per_node: usize,
93 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#[derive(Debug, Clone)]
109pub struct SgStats {
110 pub node_count: usize,
112 pub edge_count: usize,
114 pub density: f64,
116 pub avg_degree: f64,
118 pub avg_similarity: f64,
120 pub isolated_nodes: usize,
122}
123
124#[derive(Debug, Clone)]
130pub struct SemanticSimilarityGraph {
131 pub config: GraphConfig,
133 pub nodes: HashMap<String, SgNode>,
135 pub edges: HashMap<String, SgEdge>,
137 pub adjacency: HashMap<String, Vec<String>>,
139}
140
141impl SemanticSimilarityGraph {
142 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 pub fn with_defaults() -> Self {
158 Self::new(GraphConfig::default())
159 }
160
161 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 pub fn add_node(&mut self, node: SgNode) {
192 let node_id = node.id.clone();
193
194 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 self.nodes.insert(node_id.clone(), node);
207 self.adjacency.entry(node_id.clone()).or_default();
208
209 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 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 let neighbours: Vec<String> = self.adjacency.remove(node_id).unwrap_or_default();
231
232 for neighbour_id in &neighbours {
233 let key = SgEdge::key(node_id, neighbour_id);
235 self.edges.remove(&key);
236
237 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 pub fn get_node(&self, node_id: &str) -> Option<&SgNode> {
252 self.nodes.get(node_id)
253 }
254
255 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 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 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 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 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 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 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 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 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 pub fn node_count(&self) -> usize {
453 self.nodes.len()
454 }
455
456 pub fn edge_count(&self) -> usize {
458 self.edges.len()
459 }
460
461 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 fn insert_edge_raw(&mut self, a: &str, b: &str, sim: f64, now: u64) {
493 let key = SgEdge::key(a, b);
494
495 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 self.maybe_prune(a);
526 self.maybe_prune(b);
528 }
529
530 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 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 fn compute_centroid(&self, members: &[String]) -> Vec<f64> {
568 if members.is_empty() {
569 return Vec::new();
570 }
571
572 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 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 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#[cfg(test)]
654mod tests {
655 use std::collections::HashMap;
656
657 use crate::similarity_graph::{
658 GraphConfig, SemanticSimilarityGraph, SgCommunity, SgEdge, SgNode,
659 };
660
661 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 #[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 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 #[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); 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 #[test]
791 fn test_edge_created_above_threshold() {
792 let mut g = graph_with_threshold(0.5);
793 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])); 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 #[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 #[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 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 #[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 let mut g2 = graph_with_threshold(0.0);
935 g2.add_node(node("a", unit(&[1.0, 0.0, 0.0])));
936 g2.add_node(node("b", unit(&[1.0, 0.5, 0.0])));
938 g2.add_node(node("c", unit(&[0.0, 1.0, 0.5])));
940 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 #[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 let sub = g.subgraph(&["a", "b"]); assert!(!sub.edges.contains_key(&SgEdge::key("a", "c")));
983 }
984
985 #[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 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 #[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 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 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 #[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 #[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 #[test]
1129 fn test_edge_key_same_regardless_of_order() {
1130 assert_eq!(SgEdge::key("foo", "bar"), SgEdge::key("bar", "foo"));
1131 }
1132
1133 #[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 #[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])); let n = g.get_node("a").expect("node must exist");
1153 assert_eq!(n.embedding, vec![0.0, 1.0]);
1154 }
1155
1156 #[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 #[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 #[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}