1use std::collections::HashMap;
44
45mod graph_summarizer_impl {
48 use crate::graph::community::{CommunityConfig, CommunityDetector};
49 use crate::Triple;
50 use rayon::prelude::*;
51 use std::collections::HashMap;
52
53 #[derive(Debug, Clone)]
55 pub struct GraphSummary {
56 pub entities: Vec<String>,
58 pub relations: Vec<(String, String, String)>,
63 pub community_labels: Vec<String>,
65 }
66
67 impl GraphSummary {
68 pub fn to_text(&self) -> String {
70 if self.entities.is_empty() {
71 return "The graph is empty.".to_string();
72 }
73 let n_comm = self.community_labels.len();
74 let n_ent = self.entities.len();
75 let entity_list = self
76 .entities
77 .iter()
78 .take(5)
79 .cloned()
80 .collect::<Vec<_>>()
81 .join(", ");
82 let mut seen_preds: std::collections::HashSet<String> =
84 std::collections::HashSet::new();
85 let mut pred_list: Vec<String> = Vec::new();
86 for (_, p, _) in &self.relations {
87 if seen_preds.insert(p.clone()) {
88 pred_list.push(p.clone());
89 if pred_list.len() >= 5 {
90 break;
91 }
92 }
93 }
94 format!(
95 "The graph contains {} {} across {} {}. \
96 Key entities include: {}. \
97 Primary relationships: {}.",
98 n_ent,
99 if n_ent == 1 { "entity" } else { "entities" },
100 n_comm,
101 if n_comm == 1 {
102 "community"
103 } else {
104 "communities"
105 },
106 entity_list,
107 if pred_list.is_empty() {
108 "none".to_string()
109 } else {
110 pred_list.join(", ")
111 },
112 )
113 }
114 }
115
116 pub struct GraphSummarizer {
128 pub max_nodes: usize,
129 pub max_triples: usize,
130 }
131
132 impl GraphSummarizer {
133 pub fn new(max_nodes: usize, max_triples: usize) -> Self {
134 Self {
135 max_nodes,
136 max_triples,
137 }
138 }
139
140 pub fn summarize(&self, triples: &[(String, String, String)]) -> GraphSummary {
142 if triples.is_empty() {
143 return GraphSummary {
144 entities: Vec::new(),
145 relations: Vec::new(),
146 community_labels: Vec::new(),
147 };
148 }
149
150 let core_triples: Vec<Triple> = triples
152 .iter()
153 .map(|(s, p, o)| Triple::new(s.clone(), p.clone(), o.clone()))
154 .collect();
155
156 let mut in_degree: HashMap<String, usize> = HashMap::new();
158 for (s, _, o) in triples {
159 in_degree.entry(s.clone()).or_insert(0);
161 *in_degree.entry(o.clone()).or_insert(0) += 1;
162 }
163
164 let config = CommunityConfig {
166 min_community_size: 1,
167 ..CommunityConfig::default()
168 };
169 let detector = CommunityDetector::new(config);
170 let communities = detector.detect(&core_triples).unwrap_or_default();
171
172 let representatives: Vec<(String, String)> = communities
178 .par_iter()
179 .enumerate()
180 .filter_map(|(idx, comm)| {
181 let rep = comm
182 .entities
183 .iter()
184 .max_by(|a, b| {
185 let deg_a = in_degree.get(a.as_str()).copied().unwrap_or(0);
186 let deg_b = in_degree.get(b.as_str()).copied().unwrap_or(0);
187 deg_a.cmp(°_b).then_with(|| b.as_str().cmp(a.as_str()))
188 })
189 .cloned()?;
190 let label = format!("Community {} ({} entities)", idx, comm.entities.len());
191 Some((rep, label))
192 })
193 .collect();
194
195 let mut pred_freq: HashMap<&str, usize> = HashMap::new();
197 for (_, p, _) in triples {
198 *pred_freq.entry(p.as_str()).or_insert(0) += 1;
199 }
200 let mut pred_ranked: Vec<(&str, usize)> =
201 pred_freq.iter().map(|(p, c)| (*p, *c)).collect();
202 pred_ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
203 let top_predicates: std::collections::HashSet<&str> =
204 pred_ranked.iter().take(10).map(|(p, _)| *p).collect();
205
206 let entities: Vec<String> = representatives
208 .iter()
209 .map(|(rep, _)| rep.clone())
210 .take(self.max_nodes)
211 .collect();
212
213 let rep_set: std::collections::HashSet<&str> =
214 entities.iter().map(|s| s.as_str()).collect();
215
216 let mut selected: Vec<(String, String, String)> = triples
218 .iter()
219 .filter(|(s, p, _)| {
220 rep_set.contains(s.as_str()) && top_predicates.contains(p.as_str())
221 })
222 .map(|(s, p, o)| (s.clone(), p.clone(), o.clone()))
223 .take(self.max_triples)
224 .collect();
225
226 if selected.len() < self.max_triples {
228 for (s, p, o) in triples {
229 if selected.len() >= self.max_triples {
230 break;
231 }
232 if rep_set.contains(s.as_str()) {
233 let candidate = (s.clone(), p.clone(), o.clone());
234 if !selected.contains(&candidate) {
235 selected.push(candidate);
236 }
237 }
238 }
239 }
240
241 selected.sort_by(|a, b| {
246 let freq_a = pred_freq.get(a.1.as_str()).copied().unwrap_or(0);
247 let freq_b = pred_freq.get(b.1.as_str()).copied().unwrap_or(0);
248 freq_b.cmp(&freq_a).then_with(|| a.1.cmp(&b.1))
249 });
250
251 let community_labels: Vec<String> = representatives
252 .iter()
253 .take(self.max_nodes)
254 .map(|(_, label)| label.clone())
255 .collect();
256
257 GraphSummary {
258 entities,
259 relations: selected,
260 community_labels,
261 }
262 }
263 }
264} pub use graph_summarizer_impl::{GraphSummarizer, GraphSummary};
267
268#[derive(Debug, Clone)]
272pub struct KgNode {
273 pub id: String,
275 pub label: String,
277 pub node_type: String,
279 pub properties: HashMap<String, String>,
281}
282
283impl KgNode {
284 pub fn simple(
286 id: impl Into<String>,
287 label: impl Into<String>,
288 node_type: impl Into<String>,
289 ) -> Self {
290 Self {
291 id: id.into(),
292 label: label.into(),
293 node_type: node_type.into(),
294 properties: HashMap::new(),
295 }
296 }
297}
298
299#[derive(Debug, Clone)]
301pub struct KgEdge {
302 pub source: String,
304 pub target: String,
306 pub relation: String,
308 pub weight: f64,
310}
311
312impl KgEdge {
313 pub fn unweighted(
315 source: impl Into<String>,
316 target: impl Into<String>,
317 relation: impl Into<String>,
318 ) -> Self {
319 Self {
320 source: source.into(),
321 target: target.into(),
322 relation: relation.into(),
323 weight: 1.0,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default)]
332pub struct KgSubgraph {
333 pub nodes: Vec<KgNode>,
335 pub edges: Vec<KgEdge>,
337}
338
339impl KgSubgraph {
340 pub fn new() -> Self {
342 Self::default()
343 }
344
345 pub fn add_node(&mut self, node: KgNode) {
347 self.nodes.push(node);
348 }
349
350 pub fn add_edge(&mut self, edge: KgEdge) {
352 self.edges.push(edge);
353 }
354
355 pub fn node_count(&self) -> usize {
357 self.nodes.len()
358 }
359
360 pub fn edge_count(&self) -> usize {
362 self.edges.len()
363 }
364
365 pub fn node(&self, id: &str) -> Option<&KgNode> {
367 self.nodes.iter().find(|n| n.id == id)
368 }
369}
370
371#[derive(Debug, Clone)]
375pub struct SummaryCluster {
376 pub id: usize,
378 pub representative_node: String,
380 pub member_nodes: Vec<String>,
382 pub internal_edges: usize,
384 pub summary_label: String,
386}
387
388impl SummaryCluster {
389 pub fn size(&self) -> usize {
391 self.member_nodes.len()
392 }
393}
394
395pub struct SubgraphSummarizer;
399
400impl SubgraphSummarizer {
401 pub fn new() -> Self {
403 Self
404 }
405
406 pub fn summarize(&self, graph: &KgSubgraph, max_clusters: usize) -> Vec<SummaryCluster> {
417 if graph.nodes.is_empty() || max_clusters == 0 {
418 return Vec::new();
419 }
420
421 let mut type_groups: HashMap<String, Vec<String>> = HashMap::new();
423 for node in &graph.nodes {
424 type_groups
425 .entry(node.node_type.clone())
426 .or_default()
427 .push(node.id.clone());
428 }
429
430 let mut groups: Vec<(String, Vec<String>)> = type_groups.into_iter().collect();
432 groups.sort_by(|a, b| a.0.cmp(&b.0));
433
434 let groups = if groups.len() > max_clusters {
436 let (keep, overflow) = groups.split_at(max_clusters - 1);
437 let mut merged = keep.to_vec();
438 let other_members: Vec<String> = overflow
439 .iter()
440 .flat_map(|(_, ids)| ids.iter().cloned())
441 .collect();
442 if !other_members.is_empty() {
443 merged.push(("Other".to_string(), other_members));
444 }
445 merged
446 } else {
447 groups
448 };
449
450 let degree_map = build_degree_map(graph);
452
453 groups
455 .into_iter()
456 .enumerate()
457 .map(|(cluster_id, (node_type, members))| {
458 let representative = members
460 .iter()
461 .max_by_key(|id| degree_map.get(*id).copied().unwrap_or(0))
462 .cloned()
463 .unwrap_or_default();
464
465 let member_set: std::collections::HashSet<&str> =
467 members.iter().map(|s| s.as_str()).collect();
468 let internal_edges = graph
469 .edges
470 .iter()
471 .filter(|e| {
472 member_set.contains(e.source.as_str())
473 && member_set.contains(e.target.as_str())
474 })
475 .count();
476
477 SummaryCluster {
478 id: cluster_id,
479 representative_node: representative,
480 member_nodes: members,
481 internal_edges,
482 summary_label: format!("{node_type} cluster"),
483 }
484 })
485 .collect()
486 }
487
488 pub fn extract_key_relations(&self, graph: &KgSubgraph, top_n: usize) -> Vec<(String, usize)> {
492 if top_n == 0 {
493 return Vec::new();
494 }
495 let mut counts: HashMap<String, usize> = HashMap::new();
496 for edge in &graph.edges {
497 *counts.entry(edge.relation.clone()).or_insert(0) += 1;
498 }
499 let mut sorted: Vec<(String, usize)> = counts.into_iter().collect();
500 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
501 sorted.truncate(top_n);
502 sorted
503 }
504
505 pub fn node_degree(&self, graph: &KgSubgraph, node_id: &str) -> usize {
509 graph
510 .edges
511 .iter()
512 .filter(|e| e.source == node_id || e.target == node_id)
513 .count()
514 }
515
516 pub fn generate_text_summary(&self, clusters: &[SummaryCluster]) -> String {
521 if clusters.is_empty() {
522 return "The subgraph contains no identifiable clusters.".to_string();
523 }
524
525 let mut parts: Vec<String> = Vec::new();
526 for cluster in clusters {
527 parts.push(format!(
528 "The {} (representative: {}, {} members, {} internal edges)",
529 cluster.summary_label,
530 cluster.representative_node,
531 cluster.member_nodes.len(),
532 cluster.internal_edges,
533 ));
534 }
535
536 format!(
537 "{}. The subgraph contains {} cluster{}.",
538 parts.join(". "),
539 clusters.len(),
540 if clusters.len() == 1 { "" } else { "s" }
541 )
542 }
543}
544
545impl Default for SubgraphSummarizer {
546 fn default() -> Self {
547 Self::new()
548 }
549}
550
551fn build_degree_map(graph: &KgSubgraph) -> HashMap<String, usize> {
555 let mut map: HashMap<String, usize> = HashMap::new();
556 for edge in &graph.edges {
557 *map.entry(edge.source.clone()).or_insert(0) += 1;
558 if edge.source != edge.target {
559 *map.entry(edge.target.clone()).or_insert(0) += 1;
560 }
561 }
562 map
563}
564
565#[cfg(test)]
568mod tests {
569 use super::*;
570
571 fn node(id: &str, node_type: &str) -> KgNode {
574 KgNode::simple(id, id, node_type)
575 }
576
577 fn edge(src: &str, tgt: &str, rel: &str) -> KgEdge {
578 KgEdge::unweighted(src, tgt, rel)
579 }
580
581 fn make_graph_with_types(specs: &[(&str, &str)], edges: &[(&str, &str, &str)]) -> KgSubgraph {
582 let mut g = KgSubgraph::new();
583 for (id, typ) in specs {
584 g.add_node(node(id, typ));
585 }
586 for (s, t, r) in edges {
587 g.add_edge(edge(s, t, r));
588 }
589 g
590 }
591
592 #[test]
595 fn test_new_subgraph_empty() {
596 let g = KgSubgraph::new();
597 assert_eq!(g.node_count(), 0);
598 assert_eq!(g.edge_count(), 0);
599 }
600
601 #[test]
602 fn test_add_node_increments_count() {
603 let mut g = KgSubgraph::new();
604 g.add_node(node("n1", "Person"));
605 assert_eq!(g.node_count(), 1);
606 }
607
608 #[test]
609 fn test_add_edge_increments_count() {
610 let mut g = KgSubgraph::new();
611 g.add_edge(edge("a", "b", "knows"));
612 assert_eq!(g.edge_count(), 1);
613 }
614
615 #[test]
616 fn test_node_lookup_found() {
617 let mut g = KgSubgraph::new();
618 g.add_node(node("alice", "Person"));
619 assert!(g.node("alice").is_some());
620 assert_eq!(g.node("alice").expect("node").node_type, "Person");
621 }
622
623 #[test]
624 fn test_node_lookup_missing() {
625 let g = KgSubgraph::new();
626 assert!(g.node("nope").is_none());
627 }
628
629 #[test]
632 fn test_kg_node_simple() {
633 let n = KgNode::simple("id1", "Label", "Type");
634 assert_eq!(n.id, "id1");
635 assert_eq!(n.label, "Label");
636 assert_eq!(n.node_type, "Type");
637 assert!(n.properties.is_empty());
638 }
639
640 #[test]
643 fn test_kg_edge_unweighted_weight_one() {
644 let e = KgEdge::unweighted("a", "b", "rel");
645 assert_eq!(e.weight, 1.0);
646 }
647
648 #[test]
651 fn test_summarize_empty_graph() {
652 let g = KgSubgraph::new();
653 let s = SubgraphSummarizer::new();
654 assert!(s.summarize(&g, 10).is_empty());
655 }
656
657 #[test]
658 fn test_summarize_max_clusters_zero() {
659 let g = make_graph_with_types(&[("n1", "Person")], &[]);
660 let s = SubgraphSummarizer::new();
661 assert!(s.summarize(&g, 0).is_empty());
662 }
663
664 #[test]
667 fn test_summarize_single_type_one_cluster() {
668 let g = make_graph_with_types(&[("a", "Person"), ("b", "Person"), ("c", "Person")], &[]);
669 let s = SubgraphSummarizer::new();
670 let clusters = s.summarize(&g, 5);
671 assert_eq!(clusters.len(), 1);
672 assert_eq!(clusters[0].member_nodes.len(), 3);
673 }
674
675 #[test]
676 fn test_summarize_cluster_label_contains_type() {
677 let g = make_graph_with_types(&[("a", "Organization")], &[]);
678 let s = SubgraphSummarizer::new();
679 let clusters = s.summarize(&g, 5);
680 assert!(
681 clusters[0].summary_label.contains("Organization"),
682 "label should mention the type: {}",
683 clusters[0].summary_label
684 );
685 }
686
687 #[test]
690 fn test_summarize_two_types_two_clusters() {
691 let g = make_graph_with_types(&[("a", "Person"), ("b", "Person"), ("c", "Company")], &[]);
692 let s = SubgraphSummarizer::new();
693 let clusters = s.summarize(&g, 10);
694 assert_eq!(clusters.len(), 2);
695 }
696
697 #[test]
698 fn test_summarize_respects_max_clusters() {
699 let g = make_graph_with_types(&[("a", "T1"), ("b", "T2"), ("c", "T3"), ("d", "T4")], &[]);
700 let s = SubgraphSummarizer::new();
701 let clusters = s.summarize(&g, 2);
702 assert!(
703 clusters.len() <= 2,
704 "should have at most 2 clusters, got {}",
705 clusters.len()
706 );
707 }
708
709 #[test]
710 fn test_summarize_overflow_goes_to_other() {
711 let g = make_graph_with_types(
712 &[
713 ("a", "T1"),
714 ("b", "T2"),
715 ("c", "T3"),
716 ("d", "T4"),
717 ("e", "T5"),
718 ],
719 &[],
720 );
721 let s = SubgraphSummarizer::new();
722 let clusters = s.summarize(&g, 3);
723 assert_eq!(clusters.len(), 3);
725 let has_other = clusters.iter().any(|c| c.summary_label.contains("Other"));
726 assert!(has_other, "overflow should be merged into Other cluster");
727 }
728
729 #[test]
732 fn test_representative_is_most_connected() {
733 let g = make_graph_with_types(
735 &[("a", "Person"), ("b", "Person"), ("c", "Person")],
736 &[("a", "b", "knows"), ("b", "c", "knows")],
737 );
738 let s = SubgraphSummarizer::new();
739 let clusters = s.summarize(&g, 5);
740 assert_eq!(clusters.len(), 1);
741 assert_eq!(clusters[0].representative_node, "b");
742 }
743
744 #[test]
745 fn test_representative_exists_for_single_node_cluster() {
746 let g = make_graph_with_types(&[("solo", "Category")], &[]);
747 let s = SubgraphSummarizer::new();
748 let clusters = s.summarize(&g, 5);
749 assert_eq!(clusters[0].representative_node, "solo");
750 }
751
752 #[test]
755 fn test_internal_edges_all_within_cluster() {
756 let g = make_graph_with_types(
757 &[("a", "T"), ("b", "T"), ("c", "T")],
758 &[("a", "b", "r"), ("b", "c", "r")],
759 );
760 let s = SubgraphSummarizer::new();
761 let clusters = s.summarize(&g, 5);
762 assert_eq!(clusters[0].internal_edges, 2);
763 }
764
765 #[test]
766 fn test_internal_edges_none_cross_cluster() {
767 let g = make_graph_with_types(
769 &[("a", "T1"), ("b", "T1"), ("c", "T2")],
770 &[("a", "c", "cross"), ("b", "c", "cross")],
771 );
772 let s = SubgraphSummarizer::new();
773 let clusters = s.summarize(&g, 5);
774 for cluster in &clusters {
775 if cluster.summary_label.contains("T1") {
776 assert_eq!(cluster.internal_edges, 0);
777 }
778 }
779 }
780
781 #[test]
784 fn test_cluster_ids_are_sequential() {
785 let g = make_graph_with_types(&[("a", "T1"), ("b", "T2"), ("c", "T3")], &[]);
786 let s = SubgraphSummarizer::new();
787 let clusters = s.summarize(&g, 10);
788 for (i, c) in clusters.iter().enumerate() {
789 assert_eq!(c.id, i);
790 }
791 }
792
793 #[test]
796 fn test_key_relations_empty_graph() {
797 let g = KgSubgraph::new();
798 let s = SubgraphSummarizer::new();
799 assert!(s.extract_key_relations(&g, 5).is_empty());
800 }
801
802 #[test]
803 fn test_key_relations_top_n_zero() {
804 let mut g = KgSubgraph::new();
805 g.add_edge(edge("a", "b", "knows"));
806 let s = SubgraphSummarizer::new();
807 assert!(s.extract_key_relations(&g, 0).is_empty());
808 }
809
810 #[test]
811 fn test_key_relations_sorted_by_frequency() {
812 let g = make_graph_with_types(
813 &[("a", "T"), ("b", "T"), ("c", "T")],
814 &[
815 ("a", "b", "knows"),
816 ("b", "c", "knows"),
817 ("a", "c", "likes"),
818 ],
819 );
820 let s = SubgraphSummarizer::new();
821 let relations = s.extract_key_relations(&g, 5);
822 assert!(!relations.is_empty());
823 assert_eq!(relations[0].0, "knows");
824 assert_eq!(relations[0].1, 2);
825 }
826
827 #[test]
828 fn test_key_relations_truncated_to_top_n() {
829 let g = make_graph_with_types(
830 &[("a", "T"), ("b", "T"), ("c", "T")],
831 &[
832 ("a", "b", "r1"),
833 ("a", "b", "r2"),
834 ("a", "b", "r3"),
835 ("a", "b", "r4"),
836 ("a", "b", "r5"),
837 ],
838 );
839 let s = SubgraphSummarizer::new();
840 let relations = s.extract_key_relations(&g, 3);
841 assert!(relations.len() <= 3);
842 }
843
844 #[test]
845 fn test_key_relations_single_relation() {
846 let mut g = KgSubgraph::new();
847 for i in 0..5 {
848 g.add_edge(edge(&format!("n{i}"), &format!("n{}", i + 1), "knows"));
849 }
850 let s = SubgraphSummarizer::new();
851 let rels = s.extract_key_relations(&g, 1);
852 assert_eq!(rels.len(), 1);
853 assert_eq!(rels[0].0, "knows");
854 assert_eq!(rels[0].1, 5);
855 }
856
857 #[test]
860 fn test_node_degree_no_edges() {
861 let g = make_graph_with_types(&[("a", "T")], &[]);
862 let s = SubgraphSummarizer::new();
863 assert_eq!(s.node_degree(&g, "a"), 0);
864 }
865
866 #[test]
867 fn test_node_degree_outgoing_only() {
868 let g = make_graph_with_types(&[("a", "T"), ("b", "T")], &[("a", "b", "r")]);
869 let s = SubgraphSummarizer::new();
870 assert_eq!(s.node_degree(&g, "a"), 1);
871 assert_eq!(s.node_degree(&g, "b"), 1);
872 }
873
874 #[test]
875 fn test_node_degree_multiple_edges() {
876 let g = make_graph_with_types(
877 &[("a", "T"), ("b", "T"), ("c", "T")],
878 &[("a", "b", "r"), ("a", "c", "r"), ("b", "a", "r")],
879 );
880 let s = SubgraphSummarizer::new();
881 assert_eq!(s.node_degree(&g, "a"), 3);
883 }
884
885 #[test]
886 fn test_node_degree_missing_node() {
887 let g = KgSubgraph::new();
888 let s = SubgraphSummarizer::new();
889 assert_eq!(s.node_degree(&g, "ghost"), 0);
890 }
891
892 #[test]
895 fn test_text_summary_empty_clusters() {
896 let s = SubgraphSummarizer::new();
897 let text = s.generate_text_summary(&[]);
898 assert!(text.contains("no identifiable clusters"), "text: {text}");
899 }
900
901 #[test]
902 fn test_text_summary_single_cluster() {
903 let clusters = vec![SummaryCluster {
904 id: 0,
905 representative_node: "alice".to_string(),
906 member_nodes: vec!["alice".to_string(), "bob".to_string()],
907 internal_edges: 1,
908 summary_label: "Person cluster".to_string(),
909 }];
910 let s = SubgraphSummarizer::new();
911 let text = s.generate_text_summary(&clusters);
912 assert!(text.contains("alice"), "text: {text}");
913 assert!(text.contains("Person cluster"), "text: {text}");
914 assert!(text.contains("1 cluster"), "text: {text}");
915 }
916
917 #[test]
918 fn test_text_summary_multiple_clusters() {
919 let clusters = vec![
920 SummaryCluster {
921 id: 0,
922 representative_node: "a".to_string(),
923 member_nodes: vec!["a".to_string()],
924 internal_edges: 0,
925 summary_label: "Person cluster".to_string(),
926 },
927 SummaryCluster {
928 id: 1,
929 representative_node: "c".to_string(),
930 member_nodes: vec!["c".to_string(), "d".to_string()],
931 internal_edges: 1,
932 summary_label: "Company cluster".to_string(),
933 },
934 ];
935 let s = SubgraphSummarizer::new();
936 let text = s.generate_text_summary(&clusters);
937 assert!(text.contains("2 clusters"), "text: {text}");
938 assert!(text.contains("Person cluster"), "text: {text}");
939 assert!(text.contains("Company cluster"), "text: {text}");
940 }
941
942 #[test]
943 fn test_text_summary_contains_member_count() {
944 let clusters = vec![SummaryCluster {
945 id: 0,
946 representative_node: "x".to_string(),
947 member_nodes: vec!["x".to_string(), "y".to_string(), "z".to_string()],
948 internal_edges: 2,
949 summary_label: "T cluster".to_string(),
950 }];
951 let s = SubgraphSummarizer::new();
952 let text = s.generate_text_summary(&clusters);
953 assert!(text.contains("3 members"), "text: {text}");
954 }
955
956 #[test]
957 fn test_text_summary_contains_internal_edge_count() {
958 let clusters = vec![SummaryCluster {
959 id: 0,
960 representative_node: "x".to_string(),
961 member_nodes: vec!["x".to_string()],
962 internal_edges: 7,
963 summary_label: "T cluster".to_string(),
964 }];
965 let s = SubgraphSummarizer::new();
966 let text = s.generate_text_summary(&clusters);
967 assert!(text.contains("7 internal edges"), "text: {text}");
968 }
969
970 #[test]
973 fn test_cluster_size() {
974 let c = SummaryCluster {
975 id: 0,
976 representative_node: "r".to_string(),
977 member_nodes: vec!["a".to_string(), "b".to_string(), "c".to_string()],
978 internal_edges: 0,
979 summary_label: "X cluster".to_string(),
980 };
981 assert_eq!(c.size(), 3);
982 }
983
984 #[test]
987 fn test_default_summarizer() {
988 let s = SubgraphSummarizer;
989 let g = KgSubgraph::new();
990 assert!(s.summarize(&g, 5).is_empty());
991 }
992
993 #[test]
994 fn test_default_subgraph() {
995 let g = KgSubgraph::default();
996 assert_eq!(g.node_count(), 0);
997 }
998}
999
1000#[cfg(test)]
1003mod graph_summarizer_tests {
1004 use super::GraphSummarizer;
1005
1006 fn sample_triples() -> Vec<(String, String, String)> {
1008 vec![
1009 ("Alice".into(), "knows".into(), "Bob".into()),
1010 ("Bob".into(), "knows".into(), "Carol".into()),
1011 ("Carol".into(), "knows".into(), "Alice".into()),
1012 ("Alice".into(), "worksAt".into(), "ACME".into()),
1013 ("Bob".into(), "worksAt".into(), "ACME".into()),
1014 ("ACME".into(), "locatedIn".into(), "Berlin".into()),
1015 ("Dave".into(), "knows".into(), "Eve".into()),
1016 ("Eve".into(), "knows".into(), "Frank".into()),
1017 ("Dave".into(), "worksAt".into(), "WidgetCo".into()),
1018 ("WidgetCo".into(), "locatedIn".into(), "Paris".into()),
1019 ]
1020 }
1021
1022 #[test]
1023 fn test_summary_respects_max_nodes() {
1024 let summarizer = GraphSummarizer::new(3, 20);
1025 let triples = sample_triples();
1026 let summary = summarizer.summarize(&triples);
1027 assert!(
1028 summary.entities.len() <= 3,
1029 "expected ≤3 entities, got {}",
1030 summary.entities.len()
1031 );
1032 }
1033
1034 #[test]
1035 fn test_summary_respects_max_triples() {
1036 let summarizer = GraphSummarizer::new(10, 4);
1037 let triples = sample_triples();
1038 let summary = summarizer.summarize(&triples);
1039 assert!(
1040 summary.relations.len() <= 4,
1041 "expected ≤4 relations, got {}",
1042 summary.relations.len()
1043 );
1044 }
1045
1046 #[test]
1047 fn test_to_text_non_empty_on_non_empty_graph() {
1048 let summarizer = GraphSummarizer::new(10, 20);
1049 let triples = sample_triples();
1050 let summary = summarizer.summarize(&triples);
1051 let text = summary.to_text();
1052 assert!(!text.is_empty(), "to_text() should not be empty");
1053 }
1054
1055 #[test]
1056 fn test_empty_graph_returns_empty_summary() {
1057 let summarizer = GraphSummarizer::new(10, 20);
1058 let summary = summarizer.summarize(&[]);
1059 assert!(
1060 summary.entities.is_empty(),
1061 "empty graph: entities should be empty"
1062 );
1063 assert!(
1064 summary.relations.is_empty(),
1065 "empty graph: relations should be empty"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_community_labels_present_when_graph_has_nodes() {
1071 let summarizer = GraphSummarizer::new(10, 20);
1072 let triples = sample_triples();
1073 let summary = summarizer.summarize(&triples);
1074 assert!(
1075 !summary.community_labels.is_empty(),
1076 "community_labels should be non-empty when graph has nodes"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_predicate_frequency_ordering() {
1082 let summarizer = GraphSummarizer::new(10, 20);
1085 let triples = sample_triples();
1086 let summary = summarizer.summarize(&triples);
1087 if let Some((_, p, _)) = summary.relations.first() {
1089 assert_eq!(
1090 p.as_str(),
1091 "knows",
1092 "first predicate in relations should be 'knows' (most frequent), got '{p}'"
1093 );
1094 }
1095 }
1097}