Skip to main content

graphify_build/
lib.rs

1//! Graph assembly and deduplication for graphify.
2//!
3//! Takes [`ExtractionResult`]s from multiple files and assembles them into a
4//! single [`KnowledgeGraph`], skipping dangling edges.
5
6mod codegraph_merge;
7
8pub use codegraph_merge::merge_codegraph_edges;
9
10use std::collections::HashSet;
11
12use tracing::debug;
13
14use graphify_core::error::Result;
15use graphify_core::graph::KnowledgeGraph;
16use graphify_core::model::ExtractionResult;
17
18/// Build a [`KnowledgeGraph`] from a single extraction result.
19///
20/// All nodes are added first; edges that reference unknown source/target
21/// nodes are silently skipped (dangling-edge protection).
22pub fn build_from_extraction(extraction: &ExtractionResult) -> Result<KnowledgeGraph> {
23    let mut graph = KnowledgeGraph::new();
24
25    for node in &extraction.nodes {
26        let _ = graph.add_node(node.clone());
27    }
28
29    let node_ids: HashSet<&str> = extraction.nodes.iter().map(|n| n.id.as_str()).collect();
30
31    let mut skipped = 0usize;
32    for edge in &extraction.edges {
33        if node_ids.contains(edge.source.as_str()) && node_ids.contains(edge.target.as_str()) {
34            let _ = graph.add_edge(edge.clone());
35        } else {
36            skipped += 1;
37        }
38    }
39    if skipped > 0 {
40        debug!("skipped {skipped} dangling edge(s)");
41    }
42
43    graph.set_hyperedges(extraction.hyperedges.clone());
44
45    Ok(graph)
46}
47
48/// Merge multiple extraction results into one graph.
49///
50/// Later extractions override earlier ones for same node IDs (first-write-wins
51/// via `add_node` which rejects duplicates, so the first occurrence is kept).
52pub fn build(extractions: &[ExtractionResult]) -> Result<KnowledgeGraph> {
53    let mut combined = ExtractionResult::default();
54    for ext in extractions {
55        combined.nodes.extend(ext.nodes.clone());
56        combined.edges.extend(ext.edges.clone());
57        combined.hyperedges.extend(ext.hyperedges.clone());
58    }
59    build_from_extraction(&combined)
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use graphify_core::confidence::Confidence;
66    use graphify_core::model::{GraphEdge, GraphNode, Hyperedge, NodeType};
67    use std::collections::HashMap;
68
69    fn make_node(id: &str) -> GraphNode {
70        GraphNode {
71            id: id.into(),
72            label: id.into(),
73            source_file: "test.rs".into(),
74            source_location: None,
75            node_type: NodeType::Class,
76            community: None,
77            extra: HashMap::new(),
78        }
79    }
80
81    fn make_edge(src: &str, tgt: &str) -> GraphEdge {
82        GraphEdge {
83            source: src.into(),
84            target: tgt.into(),
85            relation: "calls".into(),
86            confidence: Confidence::Extracted,
87            confidence_score: 1.0,
88            source_file: "test.rs".into(),
89            source_location: None,
90            weight: 1.0,
91            provenance: None,
92            extra: HashMap::new(),
93        }
94    }
95
96    #[test]
97    fn build_from_empty() {
98        let ext = ExtractionResult::default();
99        let graph = build_from_extraction(&ext).unwrap();
100        assert_eq!(graph.node_count(), 0);
101        assert_eq!(graph.edge_count(), 0);
102    }
103
104    #[test]
105    fn build_with_nodes_and_edges() {
106        let ext = ExtractionResult {
107            nodes: vec![make_node("a"), make_node("b"), make_node("c")],
108            edges: vec![make_edge("a", "b"), make_edge("b", "c")],
109            hyperedges: vec![],
110        };
111        let graph = build_from_extraction(&ext).unwrap();
112        assert_eq!(graph.node_count(), 3);
113        assert_eq!(graph.edge_count(), 2);
114        assert!(graph.get_node("a").is_some());
115        assert!(graph.get_node("b").is_some());
116        assert!(graph.get_node("c").is_some());
117    }
118
119    #[test]
120    fn dangling_edges_skipped() {
121        let ext = ExtractionResult {
122            nodes: vec![make_node("a"), make_node("b")],
123            edges: vec![
124                make_edge("a", "b"),       // valid
125                make_edge("a", "missing"), // dangling
126                make_edge("gone", "b"),    // dangling
127            ],
128            hyperedges: vec![],
129        };
130        let graph = build_from_extraction(&ext).unwrap();
131        assert_eq!(graph.node_count(), 2);
132        assert_eq!(graph.edge_count(), 1); // only a->b
133    }
134
135    #[test]
136    fn build_merges_multiple_extractions() {
137        let ext1 = ExtractionResult {
138            nodes: vec![make_node("a"), make_node("b")],
139            edges: vec![make_edge("a", "b")],
140            hyperedges: vec![],
141        };
142        let ext2 = ExtractionResult {
143            nodes: vec![make_node("c")],
144            edges: vec![make_edge("b", "c")],
145            hyperedges: vec![],
146        };
147        let graph = build(&[ext1, ext2]).unwrap();
148        assert_eq!(graph.node_count(), 3);
149        assert_eq!(graph.edge_count(), 2);
150    }
151
152    #[test]
153    fn duplicate_nodes_first_wins() {
154        let ext = ExtractionResult {
155            nodes: vec![make_node("a"), make_node("a")],
156            edges: vec![],
157            hyperedges: vec![],
158        };
159        let graph = build_from_extraction(&ext).unwrap();
160        assert_eq!(graph.node_count(), 1);
161    }
162
163    #[test]
164    fn hyperedges_stored() {
165        let ext = ExtractionResult {
166            nodes: vec![make_node("a"), make_node("b")],
167            edges: vec![],
168            hyperedges: vec![Hyperedge {
169                nodes: vec!["a".into(), "b".into()],
170                relation: "coexist".into(),
171                label: "together".into(),
172            }],
173        };
174        let graph = build_from_extraction(&ext).unwrap();
175        assert_eq!(graph.hyperedges.len(), 1);
176        assert_eq!(graph.hyperedges[0].relation, "coexist");
177    }
178}