1use std::collections::{HashMap, HashSet, VecDeque};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum EdgeKind {
16 Similar,
18 Citation,
20 SameCluster,
22}
23
24#[derive(Debug, Clone)]
26pub struct DocGraphNode {
27 pub doc_id: String,
29 pub embedding: Vec<f64>,
31 pub metadata: HashMap<String, String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct DocGraphEdge {
38 pub source: String,
40 pub target: String,
42 pub kind: EdgeKind,
44 pub weight: f64,
46}
47
48#[derive(Debug, Clone)]
50pub struct DocumentGraphStats {
51 pub node_count: usize,
52 pub edge_count: usize,
53 pub avg_degree: f64,
54 pub component_count: usize,
55}
56
57pub fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
64 if a.len() != b.len() || a.is_empty() {
65 return 0.0;
66 }
67 let mut dot = 0.0_f64;
68 let mut mag_a = 0.0_f64;
69 let mut mag_b = 0.0_f64;
70 for (x, y) in a.iter().zip(b.iter()) {
71 dot += x * y;
72 mag_a += x * x;
73 mag_b += y * y;
74 }
75 let denom = mag_a.sqrt() * mag_b.sqrt();
76 if denom == 0.0 {
77 0.0
78 } else {
79 dot / denom
80 }
81}
82
83pub struct SemanticDocumentGraph {
90 nodes: HashMap<String, DocGraphNode>,
91 edges: Vec<DocGraphEdge>,
92 adjacency: HashMap<String, Vec<usize>>,
94 similarity_threshold: f64,
95}
96
97impl SemanticDocumentGraph {
98 pub fn new(similarity_threshold: f64) -> Self {
101 Self {
102 nodes: HashMap::new(),
103 edges: Vec::new(),
104 adjacency: HashMap::new(),
105 similarity_threshold,
106 }
107 }
108
109 pub fn add_node(
112 &mut self,
113 doc_id: &str,
114 embedding: Vec<f64>,
115 metadata: HashMap<String, String>,
116 ) {
117 let node = DocGraphNode {
118 doc_id: doc_id.to_string(),
119 embedding,
120 metadata,
121 };
122 self.nodes.insert(doc_id.to_string(), node);
123 self.adjacency.entry(doc_id.to_string()).or_default();
125 }
126
127 pub fn remove_node(&mut self, doc_id: &str) -> bool {
130 if self.nodes.remove(doc_id).is_none() {
131 return false;
132 }
133
134 let indices_to_remove: HashSet<usize> = self
136 .adjacency
137 .remove(doc_id)
138 .unwrap_or_default()
139 .into_iter()
140 .collect();
141
142 if indices_to_remove.is_empty() {
143 return true;
144 }
145
146 let old_edges = std::mem::take(&mut self.edges);
149 let mut new_adjacency: HashMap<String, Vec<usize>> =
150 self.nodes.keys().map(|k| (k.clone(), Vec::new())).collect();
151
152 for (old_idx, edge) in old_edges.into_iter().enumerate() {
153 if indices_to_remove.contains(&old_idx) {
154 continue;
155 }
156 let new_idx = self.edges.len();
157 if let Some(list) = new_adjacency.get_mut(&edge.source) {
158 list.push(new_idx);
159 }
160 if let Some(list) = new_adjacency.get_mut(&edge.target) {
161 list.push(new_idx);
162 }
163 self.edges.push(edge);
164 }
165
166 self.adjacency = new_adjacency;
167 true
168 }
169
170 pub fn add_edge(
173 &mut self,
174 source: &str,
175 target: &str,
176 kind: EdgeKind,
177 weight: f64,
178 ) -> Result<(), String> {
179 if !self.nodes.contains_key(source) {
180 return Err(format!("source node '{}' does not exist", source));
181 }
182 if !self.nodes.contains_key(target) {
183 return Err(format!("target node '{}' does not exist", target));
184 }
185
186 let idx = self.edges.len();
187 self.edges.push(DocGraphEdge {
188 source: source.to_string(),
189 target: target.to_string(),
190 kind,
191 weight,
192 });
193
194 self.adjacency
195 .entry(source.to_string())
196 .or_default()
197 .push(idx);
198 self.adjacency
199 .entry(target.to_string())
200 .or_default()
201 .push(idx);
202
203 Ok(())
204 }
205
206 pub fn auto_link_similar(&mut self) {
212 let ids: Vec<String> = self.nodes.keys().cloned().collect();
213 let len = ids.len();
214
215 let mut new_edges: Vec<(String, String, f64)> = Vec::new();
217
218 for i in 0..len {
219 for j in (i + 1)..len {
220 let a = self
221 .nodes
222 .get(&ids[i])
223 .map(|n| n.embedding.as_slice())
224 .unwrap_or(&[]);
225 let b = self
226 .nodes
227 .get(&ids[j])
228 .map(|n| n.embedding.as_slice())
229 .unwrap_or(&[]);
230
231 let sim = cosine_sim(a, b);
232 if sim >= self.similarity_threshold {
233 new_edges.push((ids[i].clone(), ids[j].clone(), sim));
234 }
235 }
236 }
237
238 for (src, tgt, w) in new_edges {
239 let idx = self.edges.len();
240 self.edges.push(DocGraphEdge {
241 source: src.clone(),
242 target: tgt.clone(),
243 kind: EdgeKind::Similar,
244 weight: w,
245 });
246 self.adjacency.entry(src).or_default().push(idx);
247 self.adjacency.entry(tgt).or_default().push(idx);
248 }
249 }
250
251 pub fn neighbors(&self, doc_id: &str) -> Vec<(&DocGraphNode, f64)> {
253 let indices = match self.adjacency.get(doc_id) {
254 Some(v) => v,
255 None => return Vec::new(),
256 };
257
258 let mut result = Vec::new();
259 for &idx in indices {
260 if let Some(edge) = self.edges.get(idx) {
261 let other_id = if edge.source == doc_id {
262 &edge.target
263 } else {
264 &edge.source
265 };
266 if let Some(node) = self.nodes.get(other_id) {
267 result.push((node, edge.weight));
268 }
269 }
270 }
271 result
272 }
273
274 pub fn shortest_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
278 if !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
279 return None;
280 }
281 if from == to {
282 return Some(vec![from.to_string()]);
283 }
284
285 let mut visited: HashSet<String> = HashSet::new();
286 let mut queue: VecDeque<String> = VecDeque::new();
287 let mut parent: HashMap<String, String> = HashMap::new();
288
289 visited.insert(from.to_string());
290 queue.push_back(from.to_string());
291
292 while let Some(current) = queue.pop_front() {
293 if let Some(indices) = self.adjacency.get(¤t) {
294 for &idx in indices {
295 if let Some(edge) = self.edges.get(idx) {
296 let neighbor = if edge.source == current {
297 &edge.target
298 } else {
299 &edge.source
300 };
301 if visited.contains(neighbor) {
302 continue;
303 }
304 visited.insert(neighbor.clone());
305 parent.insert(neighbor.clone(), current.clone());
306
307 if neighbor == to {
308 let mut path = vec![to.to_string()];
310 let mut cur = to.to_string();
311 while let Some(p) = parent.get(&cur) {
312 path.push(p.clone());
313 cur = p.clone();
314 }
315 path.reverse();
316 return Some(path);
317 }
318 queue.push_back(neighbor.clone());
319 }
320 }
321 }
322 }
323
324 None
325 }
326
327 pub fn connected_components(&self) -> Vec<Vec<String>> {
329 let mut visited: HashSet<String> = HashSet::new();
330 let mut components: Vec<Vec<String>> = Vec::new();
331
332 for id in self.nodes.keys() {
333 if visited.contains(id) {
334 continue;
335 }
336 let mut component = Vec::new();
337 let mut stack = vec![id.clone()];
338 while let Some(cur) = stack.pop() {
339 if !visited.insert(cur.clone()) {
340 continue;
341 }
342 component.push(cur.clone());
343
344 if let Some(indices) = self.adjacency.get(&cur) {
345 for &idx in indices {
346 if let Some(edge) = self.edges.get(idx) {
347 let neighbor = if edge.source == cur {
348 &edge.target
349 } else {
350 &edge.source
351 };
352 if !visited.contains(neighbor) {
353 stack.push(neighbor.clone());
354 }
355 }
356 }
357 }
358 }
359 component.sort();
360 components.push(component);
361 }
362
363 components.sort_by(|a, b| a.first().cmp(&b.first()));
364 components
365 }
366
367 pub fn node_count(&self) -> usize {
369 self.nodes.len()
370 }
371
372 pub fn edge_count(&self) -> usize {
374 self.edges.len()
375 }
376
377 pub fn degree(&self, doc_id: &str) -> usize {
380 self.adjacency.get(doc_id).map(|v| v.len()).unwrap_or(0)
381 }
382
383 pub fn stats(&self) -> DocumentGraphStats {
385 let nc = self.node_count();
386 let ec = self.edge_count();
387 let avg = if nc == 0 {
388 0.0
389 } else {
390 (2.0 * ec as f64) / nc as f64
392 };
393 let cc = self.connected_components().len();
394 DocumentGraphStats {
395 node_count: nc,
396 edge_count: ec,
397 avg_degree: avg,
398 component_count: cc,
399 }
400 }
401}
402
403#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn empty_meta() -> HashMap<String, String> {
412 HashMap::new()
413 }
414
415 fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
416 pairs
417 .iter()
418 .map(|(k, v)| (k.to_string(), v.to_string()))
419 .collect()
420 }
421
422 #[test]
425 fn cosine_sim_identical_vectors() {
426 let v = vec![1.0, 2.0, 3.0];
427 let s = cosine_sim(&v, &v);
428 assert!((s - 1.0).abs() < 1e-9);
429 }
430
431 #[test]
432 fn cosine_sim_orthogonal() {
433 let a = vec![1.0, 0.0];
434 let b = vec![0.0, 1.0];
435 assert!(cosine_sim(&a, &b).abs() < 1e-9);
436 }
437
438 #[test]
439 fn cosine_sim_opposite() {
440 let a = vec![1.0, 0.0];
441 let b = vec![-1.0, 0.0];
442 assert!((cosine_sim(&a, &b) + 1.0).abs() < 1e-9);
443 }
444
445 #[test]
446 fn cosine_sim_different_lengths() {
447 assert_eq!(cosine_sim(&[1.0, 2.0], &[1.0]), 0.0);
448 }
449
450 #[test]
451 fn cosine_sim_empty() {
452 assert_eq!(cosine_sim(&[], &[]), 0.0);
453 }
454
455 #[test]
456 fn cosine_sim_zero_vector() {
457 assert_eq!(cosine_sim(&[0.0, 0.0], &[1.0, 2.0]), 0.0);
458 }
459
460 #[test]
463 fn add_node_increases_count() {
464 let mut g = SemanticDocumentGraph::new(0.7);
465 assert_eq!(g.node_count(), 0);
466 g.add_node("a", vec![1.0], empty_meta());
467 assert_eq!(g.node_count(), 1);
468 g.add_node("b", vec![2.0], empty_meta());
469 assert_eq!(g.node_count(), 2);
470 }
471
472 #[test]
473 fn add_node_replaces_existing() {
474 let mut g = SemanticDocumentGraph::new(0.7);
475 g.add_node("a", vec![1.0], meta(&[("k", "v1")]));
476 g.add_node("a", vec![2.0], meta(&[("k", "v2")]));
477 assert_eq!(g.node_count(), 1);
478 }
479
480 #[test]
481 fn remove_node_returns_false_for_missing() {
482 let mut g = SemanticDocumentGraph::new(0.7);
483 assert!(!g.remove_node("x"));
484 }
485
486 #[test]
487 fn remove_node_decreases_count() {
488 let mut g = SemanticDocumentGraph::new(0.7);
489 g.add_node("a", vec![1.0], empty_meta());
490 g.add_node("b", vec![2.0], empty_meta());
491 assert!(g.remove_node("a"));
492 assert_eq!(g.node_count(), 1);
493 }
494
495 #[test]
496 fn remove_node_cascades_edges() {
497 let mut g = SemanticDocumentGraph::new(0.7);
498 g.add_node("a", vec![1.0], empty_meta());
499 g.add_node("b", vec![2.0], empty_meta());
500 g.add_node("c", vec![3.0], empty_meta());
501 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
502 g.add_edge("b", "c", EdgeKind::Citation, 1.0).ok();
503 g.add_edge("a", "c", EdgeKind::Similar, 0.8).ok();
504 assert_eq!(g.edge_count(), 3);
505
506 g.remove_node("a");
507 assert_eq!(g.edge_count(), 1);
509 assert_eq!(g.degree("b"), 1);
510 assert_eq!(g.degree("c"), 1);
511 }
512
513 #[test]
516 fn add_edge_ok() {
517 let mut g = SemanticDocumentGraph::new(0.7);
518 g.add_node("a", vec![1.0], empty_meta());
519 g.add_node("b", vec![2.0], empty_meta());
520 assert!(g.add_edge("a", "b", EdgeKind::Citation, 1.0).is_ok());
521 assert_eq!(g.edge_count(), 1);
522 }
523
524 #[test]
525 fn add_edge_missing_source() {
526 let mut g = SemanticDocumentGraph::new(0.7);
527 g.add_node("b", vec![2.0], empty_meta());
528 let r = g.add_edge("x", "b", EdgeKind::Citation, 1.0);
529 assert!(r.is_err());
530 assert!(r.err().unwrap_or_default().contains("source"));
531 }
532
533 #[test]
534 fn add_edge_missing_target() {
535 let mut g = SemanticDocumentGraph::new(0.7);
536 g.add_node("a", vec![1.0], empty_meta());
537 let r = g.add_edge("a", "x", EdgeKind::Citation, 1.0);
538 assert!(r.is_err());
539 assert!(r.err().unwrap_or_default().contains("target"));
540 }
541
542 #[test]
543 fn add_edge_both_missing() {
544 let mut g = SemanticDocumentGraph::new(0.7);
545 assert!(g.add_edge("x", "y", EdgeKind::Citation, 1.0).is_err());
546 }
547
548 #[test]
551 fn auto_link_similar_above_threshold() {
552 let mut g = SemanticDocumentGraph::new(0.9);
553 g.add_node("a", vec![1.0, 0.0, 0.0], empty_meta());
555 g.add_node("b", vec![1.0, 0.01, 0.0], empty_meta());
556 g.auto_link_similar();
557 assert_eq!(g.edge_count(), 1);
559 }
560
561 #[test]
562 fn auto_link_similar_below_threshold() {
563 let mut g = SemanticDocumentGraph::new(0.99);
564 g.add_node("a", vec![1.0, 0.0], empty_meta());
565 g.add_node("b", vec![0.0, 1.0], empty_meta());
566 g.auto_link_similar();
567 assert_eq!(g.edge_count(), 0);
568 }
569
570 #[test]
571 fn auto_link_similar_multiple_pairs() {
572 let mut g = SemanticDocumentGraph::new(0.5);
573 g.add_node("a", vec![1.0, 0.0], empty_meta());
574 g.add_node("b", vec![0.9, 0.1], empty_meta());
575 g.add_node("c", vec![0.0, 1.0], empty_meta());
576 g.auto_link_similar();
577 assert_eq!(g.edge_count(), 1);
582 }
583
584 #[test]
587 fn neighbors_returns_correct_set() {
588 let mut g = SemanticDocumentGraph::new(0.7);
589 g.add_node("a", vec![1.0], empty_meta());
590 g.add_node("b", vec![2.0], empty_meta());
591 g.add_node("c", vec![3.0], empty_meta());
592 g.add_edge("a", "b", EdgeKind::Citation, 0.9).ok();
593 g.add_edge("a", "c", EdgeKind::Similar, 0.8).ok();
594
595 let nbrs = g.neighbors("a");
596 assert_eq!(nbrs.len(), 2);
597
598 let ids: HashSet<String> = nbrs.iter().map(|(n, _)| n.doc_id.clone()).collect();
599 assert!(ids.contains("b"));
600 assert!(ids.contains("c"));
601 }
602
603 #[test]
604 fn neighbors_for_missing_node() {
605 let g = SemanticDocumentGraph::new(0.7);
606 assert!(g.neighbors("x").is_empty());
607 }
608
609 #[test]
610 fn neighbors_no_edges() {
611 let mut g = SemanticDocumentGraph::new(0.7);
612 g.add_node("a", vec![1.0], empty_meta());
613 assert!(g.neighbors("a").is_empty());
614 }
615
616 #[test]
619 fn shortest_path_direct() {
620 let mut g = SemanticDocumentGraph::new(0.7);
621 g.add_node("a", vec![1.0], empty_meta());
622 g.add_node("b", vec![2.0], empty_meta());
623 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
624
625 let path = g.shortest_path("a", "b");
626 assert_eq!(path, Some(vec!["a".to_string(), "b".to_string()]));
627 }
628
629 #[test]
630 fn shortest_path_multi_hop() {
631 let mut g = SemanticDocumentGraph::new(0.7);
632 g.add_node("a", vec![1.0], empty_meta());
633 g.add_node("b", vec![2.0], empty_meta());
634 g.add_node("c", vec![3.0], empty_meta());
635 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
636 g.add_edge("b", "c", EdgeKind::Citation, 1.0).ok();
637
638 let path = g.shortest_path("a", "c");
639 assert_eq!(
640 path,
641 Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
642 );
643 }
644
645 #[test]
646 fn shortest_path_same_node() {
647 let mut g = SemanticDocumentGraph::new(0.7);
648 g.add_node("a", vec![1.0], empty_meta());
649 assert_eq!(g.shortest_path("a", "a"), Some(vec!["a".to_string()]));
650 }
651
652 #[test]
653 fn shortest_path_no_connection() {
654 let mut g = SemanticDocumentGraph::new(0.7);
655 g.add_node("a", vec![1.0], empty_meta());
656 g.add_node("b", vec![2.0], empty_meta());
657 assert_eq!(g.shortest_path("a", "b"), None);
658 }
659
660 #[test]
661 fn shortest_path_missing_node() {
662 let g = SemanticDocumentGraph::new(0.7);
663 assert_eq!(g.shortest_path("x", "y"), None);
664 }
665
666 #[test]
669 fn connected_components_single() {
670 let mut g = SemanticDocumentGraph::new(0.7);
671 g.add_node("a", vec![1.0], empty_meta());
672 g.add_node("b", vec![2.0], empty_meta());
673 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
674
675 let cc = g.connected_components();
676 assert_eq!(cc.len(), 1);
677 assert_eq!(cc[0].len(), 2);
678 }
679
680 #[test]
681 fn connected_components_multiple() {
682 let mut g = SemanticDocumentGraph::new(0.7);
683 g.add_node("a", vec![1.0], empty_meta());
684 g.add_node("b", vec![2.0], empty_meta());
685 g.add_node("c", vec![3.0], empty_meta());
686 g.add_node("d", vec![4.0], empty_meta());
687 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
688 g.add_edge("c", "d", EdgeKind::SameCluster, 1.0).ok();
689
690 let cc = g.connected_components();
691 assert_eq!(cc.len(), 2);
692 }
693
694 #[test]
695 fn connected_components_all_isolated() {
696 let mut g = SemanticDocumentGraph::new(0.7);
697 g.add_node("a", vec![1.0], empty_meta());
698 g.add_node("b", vec![2.0], empty_meta());
699 g.add_node("c", vec![3.0], empty_meta());
700 let cc = g.connected_components();
701 assert_eq!(cc.len(), 3);
702 }
703
704 #[test]
707 fn degree_counts_correctly() {
708 let mut g = SemanticDocumentGraph::new(0.7);
709 g.add_node("a", vec![1.0], empty_meta());
710 g.add_node("b", vec![2.0], empty_meta());
711 g.add_node("c", vec![3.0], empty_meta());
712 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
713 g.add_edge("a", "c", EdgeKind::Citation, 1.0).ok();
714 assert_eq!(g.degree("a"), 2);
715 assert_eq!(g.degree("b"), 1);
716 assert_eq!(g.degree("c"), 1);
717 }
718
719 #[test]
720 fn degree_missing_node() {
721 let g = SemanticDocumentGraph::new(0.7);
722 assert_eq!(g.degree("x"), 0);
723 }
724
725 #[test]
728 fn stats_empty_graph() {
729 let g = SemanticDocumentGraph::new(0.7);
730 let s = g.stats();
731 assert_eq!(s.node_count, 0);
732 assert_eq!(s.edge_count, 0);
733 assert_eq!(s.avg_degree, 0.0);
734 assert_eq!(s.component_count, 0);
735 }
736
737 #[test]
738 fn stats_non_empty() {
739 let mut g = SemanticDocumentGraph::new(0.7);
740 g.add_node("a", vec![1.0], empty_meta());
741 g.add_node("b", vec![2.0], empty_meta());
742 g.add_node("c", vec![3.0], empty_meta());
743 g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
744 g.add_edge("b", "c", EdgeKind::Similar, 0.8).ok();
745
746 let s = g.stats();
747 assert_eq!(s.node_count, 3);
748 assert_eq!(s.edge_count, 2);
749 assert!((s.avg_degree - 4.0 / 3.0).abs() < 1e-9);
751 assert_eq!(s.component_count, 1);
752 }
753
754 #[test]
757 fn empty_graph_counts() {
758 let g = SemanticDocumentGraph::new(0.7);
759 assert_eq!(g.node_count(), 0);
760 assert_eq!(g.edge_count(), 0);
761 }
762
763 #[test]
766 fn metadata_is_stored() {
767 let mut g = SemanticDocumentGraph::new(0.7);
768 g.add_node("a", vec![1.0], meta(&[("title", "hello")]));
769 let nbrs_unused = g.neighbors("a"); drop(nbrs_unused);
771 assert_eq!(g.node_count(), 1);
774 }
775
776 #[test]
779 fn edge_kind_preserved() {
780 let mut g = SemanticDocumentGraph::new(0.7);
781 g.add_node("a", vec![1.0, 0.0], empty_meta());
782 g.add_node("b", vec![1.0, 0.01], empty_meta());
783 g.add_edge("a", "b", EdgeKind::SameCluster, 0.5).ok();
784 g.auto_link_similar();
785 assert_eq!(g.edge_count(), 2);
787 }
788
789 #[test]
792 fn complex_graph_scenario() {
793 let mut g = SemanticDocumentGraph::new(0.8);
794 for i in 0..5 {
795 let id = format!("doc{}", i);
796 let emb = vec![(i as f64) * 0.1 + 0.5, 1.0 - (i as f64) * 0.1];
797 g.add_node(&id, emb, meta(&[("idx", &i.to_string())]));
798 }
799 for i in 0..4 {
801 g.add_edge(
802 &format!("doc{}", i),
803 &format!("doc{}", i + 1),
804 EdgeKind::Citation,
805 1.0,
806 )
807 .ok();
808 }
809
810 assert_eq!(g.node_count(), 5);
811 assert_eq!(g.edge_count(), 4);
812 assert_eq!(g.connected_components().len(), 1);
813
814 let path = g.shortest_path("doc0", "doc4");
815 assert!(path.is_some());
816 let path = path.unwrap_or_default();
817 assert_eq!(path.len(), 5);
818 assert_eq!(path[0], "doc0");
819 assert_eq!(path[4], "doc4");
820
821 g.remove_node("doc2");
823 assert_eq!(g.node_count(), 4);
824 assert_eq!(g.edge_count(), 2);
826 assert_eq!(g.connected_components().len(), 2);
828 assert_eq!(g.shortest_path("doc0", "doc4"), None);
829 }
830}