1use std::collections::{HashMap, HashSet};
7
8use tracing::debug;
9
10use graphify_core::graph::KnowledgeGraph;
11use graphify_core::model::{BridgeNode, GodNode, Surprise};
12
13pub fn god_nodes(graph: &KnowledgeGraph, top_n: usize) -> Vec<GodNode> {
23 let generic_labels = ["lib", "mod", "main", "index", "init"];
24
25 let mut candidates: Vec<GodNode> = graph
26 .node_ids()
27 .into_iter()
28 .filter(|id| !is_file_node(graph, id) && !is_method_stub(graph, id))
29 .map(|id| {
30 let node = graph.get_node(&id).unwrap();
31 let label = if generic_labels.contains(&node.label.as_str()) {
32 disambiguate_label(&node.label, &node.source_file)
35 } else {
36 node.label.clone()
37 };
38 GodNode {
39 id: id.clone(),
40 label,
41 degree: graph.degree(&id),
42 community: node.community,
43 }
44 })
45 .collect();
46
47 candidates.sort_by(|a, b| b.degree.cmp(&a.degree));
48 candidates.truncate(top_n);
49 debug!("found {} god node candidates", candidates.len());
50 candidates
51}
52
53fn disambiguate_label(label: &str, source_file: &str) -> String {
61 let parts: Vec<&str> = source_file.split('/').collect();
62 for (i, &segment) in parts.iter().enumerate() {
64 if segment == "src" && i > 0 {
65 return format!("{}::{}", parts[i - 1], label);
66 }
67 }
68 if parts.len() >= 2 {
70 return format!("{}::{}", parts[parts.len() - 2], label);
71 }
72 label.to_string()
73}
74
75pub fn surprising_connections(
88 graph: &KnowledgeGraph,
89 communities: &HashMap<usize, Vec<String>>,
90 top_n: usize,
91) -> Vec<Surprise> {
92 let node_to_community: HashMap<&str, usize> = communities
94 .iter()
95 .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
96 .collect();
97
98 let mut surprises: Vec<(f64, Surprise)> = Vec::new();
99
100 for (src, tgt, edge) in graph.edges_with_endpoints() {
101 if is_file_node(graph, src) || is_file_node(graph, tgt) {
103 continue;
104 }
105 if is_method_stub(graph, src) || is_method_stub(graph, tgt) {
106 continue;
107 }
108
109 let src_comm = node_to_community.get(src).copied().unwrap_or(usize::MAX);
110 let tgt_comm = node_to_community.get(tgt).copied().unwrap_or(usize::MAX);
111
112 let mut score = 0.0;
113
114 if src_comm != tgt_comm {
116 score += 2.0;
117 }
118
119 let src_node = graph.get_node(src);
121 let tgt_node = graph.get_node(tgt);
122 if let (Some(sn), Some(tn)) = (src_node, tgt_node)
123 && !sn.source_file.is_empty()
124 && !tn.source_file.is_empty()
125 && sn.source_file != tn.source_file
126 {
127 score += 1.0;
128 }
129
130 use graphify_core::confidence::Confidence;
132 match edge.confidence {
133 Confidence::Ambiguous => score += 3.0,
134 Confidence::Inferred => score += 1.5,
135 Confidence::Extracted => {}
136 }
137
138 if score > 0.0 {
139 surprises.push((
140 score,
141 Surprise {
142 source: src.to_string(),
143 target: tgt.to_string(),
144 source_community: src_comm,
145 target_community: tgt_comm,
146 relation: edge.relation.clone(),
147 },
148 ));
149 }
150 }
151
152 surprises.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
153 surprises.truncate(top_n);
154 debug!("found {} surprising connections", surprises.len());
155 surprises.into_iter().map(|(_, s)| s).collect()
156}
157
158pub fn suggest_questions(
171 graph: &KnowledgeGraph,
172 communities: &HashMap<usize, Vec<String>>,
173 community_labels: &HashMap<usize, String>,
174 top_n: usize,
175) -> Vec<HashMap<String, String>> {
176 let mut questions: Vec<HashMap<String, String>> = Vec::new();
177
178 {
180 use graphify_core::confidence::Confidence;
181 for (src, tgt, edge) in graph.edges_with_endpoints() {
182 if edge.confidence == Confidence::Ambiguous {
183 let mut q = HashMap::new();
184 q.insert("category".into(), "ambiguous_relationship".into());
185 q.insert(
186 "question".into(),
187 format!(
188 "What is the exact relationship between '{}' and '{}'? (marked as {})",
189 src, tgt, edge.relation
190 ),
191 );
192 q.insert("source".into(), src.to_string());
193 q.insert("target".into(), tgt.to_string());
194 questions.push(q);
195 }
196 }
197 }
198
199 {
201 let node_to_comm: HashMap<&str, usize> = communities
202 .iter()
203 .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
204 .collect();
205
206 for id in graph.node_ids() {
207 if is_file_node(graph, &id) {
208 continue;
209 }
210 let nbrs = graph.get_neighbors(&id);
211 let nbr_comms: HashSet<usize> = nbrs
212 .iter()
213 .filter_map(|n| node_to_comm.get(n.id.as_str()).copied())
214 .collect();
215 if nbr_comms.len() >= 3 {
216 let comm_names: Vec<String> = nbr_comms
217 .iter()
218 .filter_map(|c| community_labels.get(c).cloned())
219 .collect();
220 let mut q = HashMap::new();
221 q.insert("category".into(), "bridge_node".into());
222 q.insert(
223 "question".into(),
224 format!(
225 "How does '{}' relate to {} different communities{}?",
226 id,
227 nbr_comms.len(),
228 if comm_names.is_empty() {
229 String::new()
230 } else {
231 format!(" ({})", comm_names.join(", "))
232 }
233 ),
234 );
235 q.insert("node".into(), id.clone());
236 questions.push(q);
237 }
238 }
239 }
240
241 {
243 use graphify_core::confidence::Confidence;
244 let gods = god_nodes(graph, 5);
245 for g in &gods {
246 let has_inferred = graph.edges_with_endpoints().iter().any(|(s, t, e)| {
247 (*s == g.id || *t == g.id) && e.confidence == Confidence::Inferred
248 });
249 if has_inferred {
250 let mut q = HashMap::new();
251 q.insert("category".into(), "verification".into());
252 q.insert(
253 "question".into(),
254 format!(
255 "Can you verify the inferred relationships of '{}' (degree {})?",
256 g.label, g.degree
257 ),
258 );
259 q.insert("node".into(), g.id.clone());
260 questions.push(q);
261 }
262 }
263 }
264
265 {
267 for id in graph.node_ids() {
268 if graph.degree(&id) == 0
269 && !is_file_node(graph, &id)
270 && let Some(node) = graph.get_node(&id)
271 {
272 let mut q = HashMap::new();
273 q.insert("category".into(), "isolated_node".into());
274 q.insert(
275 "question".into(),
276 format!(
277 "What role does '{}' play? It has no connections in the graph.",
278 node.label
279 ),
280 );
281 q.insert("node".into(), id.clone());
282 questions.push(q);
283 }
284 }
285 }
286
287 {
289 for (&cid, nodes) in communities {
290 let n = nodes.len();
291 if n <= 1 {
292 continue;
293 }
294 let cohesion = compute_cohesion(graph, nodes);
295 if cohesion < 0.3 {
296 let label = community_labels
297 .get(&cid)
298 .cloned()
299 .unwrap_or_else(|| format!("community-{cid}"));
300 let mut q = HashMap::new();
301 q.insert("category".into(), "low_cohesion".into());
302 q.insert(
303 "question".into(),
304 format!(
305 "Why is '{}' ({} nodes) loosely connected (cohesion {:.2})? Should it be split?",
306 label, n, cohesion
307 ),
308 );
309 q.insert("community".into(), cid.to_string());
310 questions.push(q);
311 }
312 }
313 }
314
315 questions.truncate(top_n);
316 debug!("generated {} questions", questions.len());
317 questions
318}
319
320pub fn graph_diff(
326 old: &KnowledgeGraph,
327 new: &KnowledgeGraph,
328) -> HashMap<String, serde_json::Value> {
329 let old_node_ids: HashSet<String> = old.node_ids().into_iter().collect();
330 let new_node_ids: HashSet<String> = new.node_ids().into_iter().collect();
331
332 let added_nodes: Vec<&String> = new_node_ids.difference(&old_node_ids).collect();
333 let removed_nodes: Vec<&String> = old_node_ids.difference(&new_node_ids).collect();
334
335 let old_edge_keys: HashSet<(String, String, String)> = old
337 .edges_with_endpoints()
338 .iter()
339 .map(|(s, t, e)| (s.to_string(), t.to_string(), e.relation.clone()))
340 .collect();
341 let new_edge_keys: HashSet<(String, String, String)> = new
342 .edges_with_endpoints()
343 .iter()
344 .map(|(s, t, e)| (s.to_string(), t.to_string(), e.relation.clone()))
345 .collect();
346
347 let added_edges: Vec<&(String, String, String)> =
348 new_edge_keys.difference(&old_edge_keys).collect();
349 let removed_edges: Vec<&(String, String, String)> =
350 old_edge_keys.difference(&new_edge_keys).collect();
351
352 let mut result = HashMap::new();
353 result.insert("added_nodes".into(), serde_json::json!(added_nodes));
354 result.insert("removed_nodes".into(), serde_json::json!(removed_nodes));
355 result.insert(
356 "added_edges".into(),
357 serde_json::json!(
358 added_edges
359 .iter()
360 .map(|(s, t, r)| { serde_json::json!({"source": s, "target": t, "relation": r}) })
361 .collect::<Vec<_>>()
362 ),
363 );
364 result.insert(
365 "removed_edges".into(),
366 serde_json::json!(
367 removed_edges
368 .iter()
369 .map(|(s, t, r)| { serde_json::json!({"source": s, "target": t, "relation": r}) })
370 .collect::<Vec<_>>()
371 ),
372 );
373 result.insert(
374 "summary".into(),
375 serde_json::json!({
376 "nodes_added": added_nodes.len(),
377 "nodes_removed": removed_nodes.len(),
378 "edges_added": added_edges.len(),
379 "edges_removed": removed_edges.len(),
380 "old_node_count": old.node_count(),
381 "new_node_count": new.node_count(),
382 "old_edge_count": old.edge_count(),
383 "new_edge_count": new.edge_count(),
384 }),
385 );
386
387 result
388}
389
390pub fn community_bridges(
399 graph: &KnowledgeGraph,
400 communities: &HashMap<usize, Vec<String>>,
401 top_n: usize,
402) -> Vec<BridgeNode> {
403 let node_to_community: HashMap<&str, usize> = communities
405 .iter()
406 .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
407 .collect();
408
409 let mut bridges: Vec<BridgeNode> = graph
410 .node_ids()
411 .into_iter()
412 .filter(|id| !is_file_node(graph, id))
413 .filter_map(|id| {
414 let node = graph.get_node(&id)?;
415 let my_comm = node_to_community.get(id.as_str()).copied()?;
416 let neighbors = graph.neighbor_ids(&id);
417 let total = neighbors.len();
418 if total == 0 {
419 return None;
420 }
421
422 let mut touched: HashSet<usize> = HashSet::new();
423 touched.insert(my_comm);
424 let mut cross = 0usize;
425 for nid in &neighbors {
426 let neighbor_comm = node_to_community
427 .get(nid.as_str())
428 .copied()
429 .unwrap_or(my_comm);
430 if neighbor_comm != my_comm {
431 cross += 1;
432 touched.insert(neighbor_comm);
433 }
434 }
435
436 if cross == 0 {
437 return None;
438 }
439
440 let ratio = cross as f64 / total as f64;
441 let mut communities_touched: Vec<usize> = touched.into_iter().collect();
442 communities_touched.sort();
443
444 Some(BridgeNode {
445 id: id.clone(),
446 label: node.label.clone(),
447 total_edges: total,
448 cross_community_edges: cross,
449 bridge_ratio: ratio,
450 communities_touched,
451 })
452 })
453 .collect();
454
455 bridges.sort_by(|a, b| {
456 b.bridge_ratio
457 .partial_cmp(&a.bridge_ratio)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 });
460 bridges.truncate(top_n);
461 bridges
462}
463
464fn is_file_node(graph: &KnowledgeGraph, node_id: &str) -> bool {
470 if let Some(node) = graph.get_node(node_id) {
471 if !node.source_file.is_empty()
473 && let Some(fname) = std::path::Path::new(&node.source_file).file_name()
474 && node.label == fname.to_string_lossy()
475 {
476 return true;
477 }
478 }
479 false
480}
481
482fn is_method_stub(graph: &KnowledgeGraph, node_id: &str) -> bool {
484 if let Some(node) = graph.get_node(node_id) {
485 if node.label.starts_with('.') && node.label.ends_with("()") {
487 return true;
488 }
489 if node.label.ends_with("()") && graph.degree(node_id) <= 1 {
491 return true;
492 }
493 }
494 false
495}
496
497#[cfg(test)]
499fn is_concept_node(graph: &KnowledgeGraph, node_id: &str) -> bool {
500 if let Some(node) = graph.get_node(node_id) {
501 if node.source_file.is_empty() {
502 return true;
503 }
504 let parts: Vec<&str> = node.source_file.split('/').collect();
505 if let Some(last) = parts.last() {
506 if !last.contains('.') {
507 return true;
508 }
509 }
510 }
511 false
512}
513
514fn compute_cohesion(graph: &KnowledgeGraph, community_nodes: &[String]) -> f64 {
516 let n = community_nodes.len();
517 if n <= 1 {
518 return 1.0;
519 }
520 let node_set: HashSet<&str> = community_nodes.iter().map(|s| s.as_str()).collect();
521 let mut actual_edges = 0usize;
522 for node_id in community_nodes {
523 for neighbor in graph.get_neighbors(node_id) {
524 if node_set.contains(neighbor.id.as_str()) {
525 actual_edges += 1;
526 }
527 }
528 }
529 actual_edges /= 2;
530 let possible = n * (n - 1) / 2;
531 if possible == 0 {
532 return 0.0;
533 }
534 actual_edges as f64 / possible as f64
535}
536
537#[cfg(test)]
542mod tests {
543 use super::*;
544 use graphify_core::confidence::Confidence;
545 use graphify_core::graph::KnowledgeGraph;
546 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
547 use std::collections::HashMap as StdHashMap;
548
549 fn make_node(id: &str, label: &str, source_file: &str) -> GraphNode {
550 GraphNode {
551 id: id.into(),
552 label: label.into(),
553 source_file: source_file.into(),
554 source_location: None,
555 node_type: NodeType::Class,
556 community: None,
557 extra: StdHashMap::new(),
558 }
559 }
560
561 fn make_edge(src: &str, tgt: &str, relation: &str, confidence: Confidence) -> GraphEdge {
562 GraphEdge {
563 source: src.into(),
564 target: tgt.into(),
565 relation: relation.into(),
566 confidence,
567 confidence_score: 1.0,
568 source_file: "test.rs".into(),
569 source_location: None,
570 weight: 1.0,
571 extra: StdHashMap::new(),
572 }
573 }
574
575 fn simple_node(id: &str) -> GraphNode {
576 make_node(id, id, "test.rs")
577 }
578
579 fn simple_edge(src: &str, tgt: &str) -> GraphEdge {
580 make_edge(src, tgt, "calls", Confidence::Extracted)
581 }
582
583 fn build_graph(nodes: &[GraphNode], edges: &[GraphEdge]) -> KnowledgeGraph {
584 let mut g = KnowledgeGraph::new();
585 for n in nodes {
586 let _ = g.add_node(n.clone());
587 }
588 for e in edges {
589 let _ = g.add_edge(e.clone());
590 }
591 g
592 }
593
594 #[test]
597 fn god_nodes_empty_graph() {
598 let g = KnowledgeGraph::new();
599 assert!(god_nodes(&g, 5).is_empty());
600 }
601
602 #[test]
603 fn god_nodes_returns_highest_degree() {
604 let g = build_graph(
605 &[
606 simple_node("hub"),
607 simple_node("a"),
608 simple_node("b"),
609 simple_node("c"),
610 simple_node("leaf"),
611 ],
612 &[
613 simple_edge("hub", "a"),
614 simple_edge("hub", "b"),
615 simple_edge("hub", "c"),
616 simple_edge("a", "leaf"),
617 ],
618 );
619 let gods = god_nodes(&g, 2);
620 assert_eq!(gods.len(), 2);
621 assert_eq!(gods[0].id, "hub");
622 assert_eq!(gods[0].degree, 3);
623 }
624
625 #[test]
626 fn god_nodes_skips_file_nodes() {
627 let g = build_graph(
628 &[
629 make_node("file_hub", "main.rs", "src/main.rs"), simple_node("a"),
631 simple_node("b"),
632 ],
633 &[simple_edge("file_hub", "a"), simple_edge("file_hub", "b")],
634 );
635 let gods = god_nodes(&g, 5);
636 assert!(gods.iter().all(|g| g.id != "file_hub"));
638 }
639
640 #[test]
641 fn god_nodes_skips_method_stubs() {
642 let g = build_graph(
643 &[
644 make_node("stub", ".init()", "test.rs"), simple_node("a"),
646 ],
647 &[simple_edge("stub", "a")],
648 );
649 let gods = god_nodes(&g, 5);
650 assert!(gods.iter().all(|g| g.id != "stub"));
651 }
652
653 #[test]
656 fn surprising_connections_empty() {
657 let g = KnowledgeGraph::new();
658 let communities = HashMap::new();
659 assert!(surprising_connections(&g, &communities, 5).is_empty());
660 }
661
662 #[test]
663 fn cross_community_edge_is_surprising() {
664 let g = build_graph(
665 &[simple_node("a"), simple_node("b")],
666 &[simple_edge("a", "b")],
667 );
668 let mut communities = HashMap::new();
669 communities.insert(0, vec!["a".into()]);
670 communities.insert(1, vec!["b".into()]);
671 let surprises = surprising_connections(&g, &communities, 10);
672 assert!(!surprises.is_empty());
673 assert_eq!(surprises[0].source_community, 0);
674 assert_eq!(surprises[0].target_community, 1);
675 }
676
677 #[test]
678 fn ambiguous_edge_is_surprising() {
679 let g = build_graph(
680 &[simple_node("a"), simple_node("b")],
681 &[make_edge("a", "b", "relates", Confidence::Ambiguous)],
682 );
683 let mut communities = HashMap::new();
684 communities.insert(0, vec!["a".into(), "b".into()]);
685 let surprises = surprising_connections(&g, &communities, 10);
686 assert!(!surprises.is_empty());
687 }
688
689 #[test]
692 fn suggest_questions_empty() {
693 let g = KnowledgeGraph::new();
694 let qs = suggest_questions(&g, &HashMap::new(), &HashMap::new(), 10);
695 assert!(qs.is_empty());
696 }
697
698 #[test]
699 fn suggest_questions_ambiguous_edge() {
700 let g = build_graph(
701 &[simple_node("a"), simple_node("b")],
702 &[make_edge("a", "b", "relates", Confidence::Ambiguous)],
703 );
704 let mut communities = HashMap::new();
705 communities.insert(0, vec!["a".into(), "b".into()]);
706 let qs = suggest_questions(&g, &communities, &HashMap::new(), 10);
707 let has_ambiguous = qs.iter().any(|q| {
708 q.get("category")
709 .map(|c| c == "ambiguous_relationship")
710 .unwrap_or(false)
711 });
712 assert!(has_ambiguous);
713 }
714
715 #[test]
716 fn suggest_questions_isolated_node() {
717 let g = build_graph(&[simple_node("lonely")], &[]);
718 let communities = HashMap::new();
719 let qs = suggest_questions(&g, &communities, &HashMap::new(), 10);
720 let has_isolated = qs.iter().any(|q| {
721 q.get("category")
722 .map(|c| c == "isolated_node")
723 .unwrap_or(false)
724 });
725 assert!(has_isolated);
726 }
727
728 #[test]
731 fn graph_diff_identical() {
732 let g = build_graph(
733 &[simple_node("a"), simple_node("b")],
734 &[simple_edge("a", "b")],
735 );
736 let diff = graph_diff(&g, &g);
737 let summary = diff.get("summary").unwrap();
738 assert_eq!(summary["nodes_added"], 0);
739 assert_eq!(summary["nodes_removed"], 0);
740 }
741
742 #[test]
743 fn graph_diff_added_node() {
744 let old = build_graph(&[simple_node("a")], &[]);
745 let new = build_graph(&[simple_node("a"), simple_node("b")], &[]);
746 let diff = graph_diff(&old, &new);
747 let summary = diff.get("summary").unwrap();
748 assert_eq!(summary["nodes_added"], 1);
749 assert_eq!(summary["nodes_removed"], 0);
750 }
751
752 #[test]
753 fn graph_diff_removed_node() {
754 let old = build_graph(&[simple_node("a"), simple_node("b")], &[]);
755 let new = build_graph(&[simple_node("a")], &[]);
756 let diff = graph_diff(&old, &new);
757 let summary = diff.get("summary").unwrap();
758 assert_eq!(summary["nodes_removed"], 1);
759 }
760
761 #[test]
764 fn is_file_node_true() {
765 let g = build_graph(&[make_node("f", "main.rs", "src/main.rs")], &[]);
766 assert!(is_file_node(&g, "f"));
767 }
768
769 #[test]
770 fn is_file_node_false() {
771 let g = build_graph(&[simple_node("a")], &[]);
772 assert!(!is_file_node(&g, "a"));
773 }
774
775 #[test]
776 fn is_method_stub_true() {
777 let g = build_graph(&[make_node("m", ".init()", "test.rs")], &[]);
778 assert!(is_method_stub(&g, "m"));
779 }
780
781 #[test]
782 fn is_concept_node_no_source() {
783 let g = build_graph(&[make_node("c", "SomeConcept", "")], &[]);
784 assert!(is_concept_node(&g, "c"));
785 }
786
787 #[test]
788 fn god_nodes_disambiguates_lib_labels() {
789 let mut n1 = make_node("lib1", "lib", "crates/graphify-export/src/lib.rs");
790 n1.node_type = NodeType::Module;
791 let mut n2 = make_node("lib2", "lib", "crates/graphify-analyze/src/lib.rs");
792 n2.node_type = NodeType::Module;
793 let a = simple_node("a");
794 let b = simple_node("b");
795 let c = simple_node("c");
796
797 let g = build_graph(
798 &[n1, n2, a, b, c],
799 &[
800 simple_edge("lib1", "a"),
801 simple_edge("lib1", "b"),
802 simple_edge("lib1", "c"),
803 simple_edge("lib2", "a"),
804 simple_edge("lib2", "b"),
805 ],
806 );
807
808 let gods = god_nodes(&g, 5);
809 let labels: Vec<&str> = gods.iter().map(|g| g.label.as_str()).collect();
810 assert!(
812 labels.contains(&"graphify-export::lib"),
813 "missing graphify-export::lib in {labels:?}"
814 );
815 assert!(
816 labels.contains(&"graphify-analyze::lib"),
817 "missing graphify-analyze::lib in {labels:?}"
818 );
819 }
820
821 #[test]
822 fn god_nodes_preserves_non_generic_labels() {
823 let n = make_node("auth", "AuthService", "src/auth.rs");
824 let a = simple_node("a");
825 let b = simple_node("b");
826
827 let g = build_graph(
828 &[n, a, b],
829 &[simple_edge("auth", "a"), simple_edge("auth", "b")],
830 );
831
832 let gods = god_nodes(&g, 5);
833 assert!(gods.iter().any(|g| g.label == "AuthService"));
834 }
835
836 #[test]
837 fn community_bridges_finds_cross_community_nodes() {
838 let mut a = simple_node("a");
839 a.community = Some(0);
840 let mut b = simple_node("b");
841 b.community = Some(0);
842 let mut c = simple_node("c");
843 c.community = Some(1);
844 let mut bridge = simple_node("bridge");
845 bridge.community = Some(0);
846
847 let g = build_graph(
848 &[a, b, c, bridge.clone()],
849 &[
850 simple_edge("bridge", "a"),
851 simple_edge("bridge", "b"),
852 simple_edge("bridge", "c"),
853 ],
854 );
855
856 let communities: HashMap<usize, Vec<String>> = [
857 (0, vec!["a".into(), "b".into(), "bridge".into()]),
858 (1, vec!["c".into()]),
859 ]
860 .into();
861
862 let bridges = community_bridges(&g, &communities, 10);
863 assert!(!bridges.is_empty(), "should find at least one bridge");
864 assert!(
867 bridges.iter().any(|b| b.id == "bridge"),
868 "bridge node should appear in results"
869 );
870 let bridge_entry = bridges.iter().find(|b| b.id == "bridge").unwrap();
871 assert_eq!(bridge_entry.cross_community_edges, 1);
872 assert_eq!(bridge_entry.total_edges, 3);
873 assert!(bridge_entry.communities_touched.contains(&0));
874 assert!(bridge_entry.communities_touched.contains(&1));
875 }
876
877 #[test]
878 fn community_bridges_empty_when_single_community() {
879 let mut a = simple_node("a");
880 a.community = Some(0);
881 let mut b = simple_node("b");
882 b.community = Some(0);
883
884 let g = build_graph(&[a, b], &[simple_edge("a", "b")]);
885
886 let communities: HashMap<usize, Vec<String>> = [(0, vec!["a".into(), "b".into()])].into();
887
888 let bridges = community_bridges(&g, &communities, 10);
889 assert!(bridges.is_empty(), "no bridges in single community");
890 }
891}