Skip to main content

oxirs_graphrag/
summarizer.rs

1//! # Knowledge Graph Subgraph Summarizer
2//!
3//! Cluster-based abstraction for summarising KG subgraphs.
4//!
5//! The [`SubgraphSummarizer`] groups nodes by their `node_type` into clusters,
6//! selects the most-connected node of each cluster as its representative, and
7//! produces both structured [`SummaryCluster`] data and a human-readable text
8//! summary paragraph.
9//!
10//! ## Example
11//!
12//! ```rust
13//! use oxirs_graphrag::summarizer::{
14//!     KgEdge, KgNode, KgSubgraph, SubgraphSummarizer,
15//! };
16//! use std::collections::HashMap;
17//!
18//! let mut graph = KgSubgraph::new();
19//! graph.add_node(KgNode {
20//!     id: "e1".to_string(),
21//!     label: "Alice".to_string(),
22//!     node_type: "Person".to_string(),
23//!     properties: HashMap::new(),
24//! });
25//! graph.add_node(KgNode {
26//!     id: "e2".to_string(),
27//!     label: "Bob".to_string(),
28//!     node_type: "Person".to_string(),
29//!     properties: HashMap::new(),
30//! });
31//! graph.add_edge(KgEdge {
32//!     source: "e1".to_string(),
33//!     target: "e2".to_string(),
34//!     relation: "knows".to_string(),
35//!     weight: 1.0,
36//! });
37//!
38//! let summarizer = SubgraphSummarizer::new();
39//! let clusters = summarizer.summarize(&graph, 10);
40//! assert_eq!(clusters.len(), 1); // one "Person" cluster
41//! ```
42
43use std::collections::HashMap;
44
45// ─── GraphSummarizer (v0.4.0) ────────────────────────────────────────────────
46
47mod 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    /// A compact summary of a large KG subgraph, suitable for LLM context windows.
54    #[derive(Debug, Clone)]
55    pub struct GraphSummary {
56        /// Representative entity IRIs (one per community, by in-degree centrality).
57        pub entities: Vec<String>,
58        /// Selected triples involving representative nodes and top
59        /// predicates, ordered by predicate frequency (most frequent
60        /// first; ties break alphabetically by predicate, then by input
61        /// order).
62        pub relations: Vec<(String, String, String)>,
63        /// Human-readable label for each detected community.
64        pub community_labels: Vec<String>,
65    }
66
67    impl GraphSummary {
68        /// Serialise to a natural-language paragraph for LLM context.
69        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            // Collect unique predicates from selected relations, ordered by first appearance.
83            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    /// Compresses a large KG subgraph (as raw triples) into a [`GraphSummary`]
117    /// capped at `max_nodes` entities and `max_triples` relations.
118    ///
119    /// Pipeline:
120    /// 1. Community detection via Leiden (reusing `src/graph/community.rs`).
121    /// 2. Per-community representative selection by in-degree centrality
122    ///    (ties break deterministically on the lexicographically smallest
123    ///    entity IRI).
124    /// 3. Predicate frequency ranking.
125    /// 4. Triple selection, truncation, and frequency-ordered output
126    ///    (most frequent predicate first).
127    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        /// Summarise the given triples into a [`GraphSummary`].
141        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            // ── Convert to crate Triple for community detector ────────────────
151            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            // ── Build in-degree map: how many triples point TO each node ─────
157            let mut in_degree: HashMap<String, usize> = HashMap::new();
158            for (s, _, o) in triples {
159                // Initialise subject with 0 if not present (to ensure all nodes tracked).
160                in_degree.entry(s.clone()).or_insert(0);
161                *in_degree.entry(o.clone()).or_insert(0) += 1;
162            }
163
164            // ── Community detection via Leiden ────────────────────────────────
165            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            // ── Per-community: pick representative by max in-degree ───────────
173            // Use rayon for parallel centroid selection when there are many communities.
174            // Ties on in-degree break deterministically on the lexicographically
175            // smallest entity, so the result does not depend on the (hash-based)
176            // iteration order of the community's entity list.
177            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(&deg_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            // ── Predicate frequency ranking ───────────────────────────────────
196            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            // ── Build entities list (capped at max_nodes) ────────────────────
207            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            // ── Select relations: triples involving representatives + top preds
217            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 still below max_triples, fill from any triple with a representative subject.
227            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            // ── Order relations by the predicate frequency ranking ───────────
242            // Most frequent predicate first; ties break alphabetically by
243            // predicate, and the stable sort preserves input order within the
244            // same predicate.
245            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} // mod graph_summarizer_impl
265
266pub use graph_summarizer_impl::{GraphSummarizer, GraphSummary};
267
268// ─── Graph primitives ─────────────────────────────────────────────────────────
269
270/// A node in a knowledge-graph subgraph.
271#[derive(Debug, Clone)]
272pub struct KgNode {
273    /// Unique node identifier (URI or local name).
274    pub id: String,
275    /// Human-readable label.
276    pub label: String,
277    /// Semantic type / RDF class.
278    pub node_type: String,
279    /// Arbitrary key-value properties.
280    pub properties: HashMap<String, String>,
281}
282
283impl KgNode {
284    /// Create a node with no properties.
285    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/// A directed, weighted edge between two KG nodes.
300#[derive(Debug, Clone)]
301pub struct KgEdge {
302    /// Source node identifier.
303    pub source: String,
304    /// Target node identifier.
305    pub target: String,
306    /// Relation type / predicate label.
307    pub relation: String,
308    /// Edge weight (e.g. confidence score).
309    pub weight: f64,
310}
311
312impl KgEdge {
313    /// Create an unweighted (weight = 1.0) edge.
314    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// ─── KgSubgraph ──────────────────────────────────────────────────────────────
329
330/// A subgraph of a knowledge graph, consisting of nodes and directed edges.
331#[derive(Debug, Clone, Default)]
332pub struct KgSubgraph {
333    /// All nodes in the subgraph.
334    pub nodes: Vec<KgNode>,
335    /// All edges in the subgraph.
336    pub edges: Vec<KgEdge>,
337}
338
339impl KgSubgraph {
340    /// Create an empty subgraph.
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    /// Add a node.
346    pub fn add_node(&mut self, node: KgNode) {
347        self.nodes.push(node);
348    }
349
350    /// Add an edge.
351    pub fn add_edge(&mut self, edge: KgEdge) {
352        self.edges.push(edge);
353    }
354
355    /// Number of nodes.
356    pub fn node_count(&self) -> usize {
357        self.nodes.len()
358    }
359
360    /// Number of edges.
361    pub fn edge_count(&self) -> usize {
362        self.edges.len()
363    }
364
365    /// Return the node with the given `id`, or `None`.
366    pub fn node(&self, id: &str) -> Option<&KgNode> {
367        self.nodes.iter().find(|n| n.id == id)
368    }
369}
370
371// ─── Summary output ──────────────────────────────────────────────────────────
372
373/// A cluster of KG nodes of the same type, with a representative and summary.
374#[derive(Debug, Clone)]
375pub struct SummaryCluster {
376    /// Sequential cluster identifier.
377    pub id: usize,
378    /// Node ID chosen as the representative (most-connected node in cluster).
379    pub representative_node: String,
380    /// All node IDs belonging to this cluster.
381    pub member_nodes: Vec<String>,
382    /// Number of edges whose both endpoints are within this cluster.
383    pub internal_edges: usize,
384    /// Brief human-readable label derived from the cluster's node type.
385    pub summary_label: String,
386}
387
388impl SummaryCluster {
389    /// Size of the cluster (number of member nodes).
390    pub fn size(&self) -> usize {
391        self.member_nodes.len()
392    }
393}
394
395// ─── SubgraphSummarizer ───────────────────────────────────────────────────────
396
397/// Produces cluster-based summaries of KG subgraphs.
398pub struct SubgraphSummarizer;
399
400impl SubgraphSummarizer {
401    /// Create a new summarizer (stateless).
402    pub fn new() -> Self {
403        Self
404    }
405
406    /// Summarise `graph` into at most `max_clusters` [`SummaryCluster`]s.
407    ///
408    /// ## Algorithm
409    ///
410    /// 1. Group nodes by `node_type`.
411    /// 2. If there are more types than `max_clusters`, merge the smallest groups
412    ///    into an `"Other"` cluster so that only `max_clusters` remain.
413    /// 3. For each cluster, count internal edges and pick the most-connected
414    ///    node (highest undirected degree within the whole graph) as
415    ///    the representative.
416    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        // Group node IDs by node_type.
422        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        // Sort groups deterministically by type name.
431        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        // Merge overflow groups into "Other" if too many types.
435        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        // Build undirected degree map over the entire graph.
451        let degree_map = build_degree_map(graph);
452
453        // Build the clusters.
454        groups
455            .into_iter()
456            .enumerate()
457            .map(|(cluster_id, (node_type, members))| {
458                // Pick representative: member with highest degree.
459                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                // Count internal edges (both endpoints in this cluster).
466                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    /// Return the top-`top_n` relation types by frequency of occurrence.
489    ///
490    /// Ties are broken by relation name (alphabetical ascending).
491    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    /// Compute the undirected degree of `node_id` in `graph`.
506    ///
507    /// Both outgoing and incoming edges are counted (a self-loop counts twice).
508    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    /// Generate a human-readable summary paragraph from a set of clusters.
517    ///
518    /// The paragraph lists each cluster's representative node, member count, and
519    /// internal edge count, ending with the total number of clusters.
520    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
551// ─── Internal helpers ─────────────────────────────────────────────────────────
552
553/// Build an undirected-degree map over all edges in `graph`.
554fn 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// ─── Tests ────────────────────────────────────────────────────────────────────
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    // ── Helpers ──────────────────────────────────────────────────────────────
572
573    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    // ── KgSubgraph basics ────────────────────────────────────────────────────
593
594    #[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    // ── KgNode simple constructor ─────────────────────────────────────────────
630
631    #[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    // ── KgEdge unweighted ─────────────────────────────────────────────────────
641
642    #[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    // ── summarize: empty graph ────────────────────────────────────────────────
649
650    #[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    // ── summarize: single type ────────────────────────────────────────────────
665
666    #[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    // ── summarize: multiple types ─────────────────────────────────────────────
688
689    #[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        // Should have 3 clusters: T1, T2, Other (T3+T4+T5)
724        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    // ── summarize: representative selection ──────────────────────────────────
730
731    #[test]
732    fn test_representative_is_most_connected() {
733        // "b" has degree 2, "a" and "c" have degree 1 — b should be representative
734        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    // ── summarize: internal edges ─────────────────────────────────────────────
753
754    #[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        // Edges between different types — should not appear as internal.
768        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    // ── summarize: cluster IDs ────────────────────────────────────────────────
782
783    #[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    // ── extract_key_relations ─────────────────────────────────────────────────
794
795    #[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    // ── node_degree ───────────────────────────────────────────────────────────
858
859    #[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        // a appears in edges: (a→b), (a→c), (b→a) = 3
882        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    // ── generate_text_summary ─────────────────────────────────────────────────
893
894    #[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    // ── SummaryCluster helpers ────────────────────────────────────────────────
971
972    #[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    // ── default impl ──────────────────────────────────────────────────────────
985
986    #[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// ─── GraphSummarizer tests ────────────────────────────────────────────────────
1001
1002#[cfg(test)]
1003mod graph_summarizer_tests {
1004    use super::GraphSummarizer;
1005
1006    /// Build a synthetic set of triples for testing.
1007    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        // "knows" appears 5×, "worksAt" appears 3×, "locatedIn" appears 2×.
1083        // The most-used predicate must appear first among the relations.
1084        let summarizer = GraphSummarizer::new(10, 20);
1085        let triples = sample_triples();
1086        let summary = summarizer.summarize(&triples);
1087        // Collect the first predicate that appears in relations.
1088        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        // If relations are empty, the max_triples was 0 — skip the ordering check.
1096    }
1097}