Skip to main content

oxirs_graphrag/graph/
traversal.rs

1//! Graph traversal for subgraph expansion
2
3use crate::{GraphRAGResult, ScoredEntity, Triple};
4use std::collections::{HashMap, HashSet};
5
6/// Graph traversal configuration
7#[derive(Debug, Clone)]
8pub struct TraversalConfig {
9    /// Maximum hops from seed entities
10    pub max_hops: usize,
11    /// Maximum edges per node
12    pub max_edges_per_node: usize,
13    /// Maximum total triples
14    pub max_triples: usize,
15    /// Predicates to follow (empty = all)
16    pub follow_predicates: HashSet<String>,
17    /// Predicates to exclude
18    pub exclude_predicates: HashSet<String>,
19    /// Whether to traverse inverse edges
20    pub traverse_inverse: bool,
21}
22
23impl Default for TraversalConfig {
24    fn default() -> Self {
25        Self {
26            max_hops: 2,
27            max_edges_per_node: 50,
28            max_triples: 500,
29            follow_predicates: HashSet::new(),
30            exclude_predicates: HashSet::new(),
31            traverse_inverse: true,
32        }
33    }
34}
35
36/// Graph traversal engine
37pub struct GraphTraversal {
38    config: TraversalConfig,
39}
40
41impl Default for GraphTraversal {
42    fn default() -> Self {
43        Self::new(TraversalConfig::default())
44    }
45}
46
47impl GraphTraversal {
48    pub fn new(config: TraversalConfig) -> Self {
49        Self { config }
50    }
51
52    /// Generate SPARQL query for N-hop expansion
53    pub fn generate_expansion_query(&self, seeds: &[ScoredEntity]) -> String {
54        if seeds.is_empty() {
55            return String::new();
56        }
57
58        let seed_uris: Vec<String> = seeds.iter().map(|s| format!("<{}>", s.uri)).collect();
59        let values = seed_uris.join(" ");
60
61        // Build predicate filter
62        let predicate_filter = if !self.config.exclude_predicates.is_empty() {
63            let excluded: Vec<String> = self
64                .config
65                .exclude_predicates
66                .iter()
67                .map(|p| format!("<{}>", p))
68                .collect();
69            format!("FILTER(?p NOT IN ({}))", excluded.join(", "))
70        } else {
71            String::new()
72        };
73
74        // Build path pattern based on hops
75        let path_pattern = match self.config.max_hops {
76            1 => "?seed ?p ?o".to_string(),
77            2 => "?seed ?p1 ?mid . ?mid ?p2 ?o".to_string(),
78            n => format!("?seed (:|!:){{1,{}}} ?o", n),
79        };
80
81        // Build CONSTRUCT query
82        format!(
83            r#"
84CONSTRUCT {{
85    ?seed ?p ?o .
86    ?s ?p2 ?seed .
87}}
88WHERE {{
89    VALUES ?seed {{ {} }}
90    {{
91        ?seed ?p ?o .
92        {}
93    }}
94    {}
95}}
96LIMIT {}
97"#,
98            values,
99            predicate_filter,
100            if self.config.traverse_inverse {
101                "UNION { ?s ?p2 ?seed . }"
102            } else {
103                ""
104            },
105            self.config.max_triples
106        )
107    }
108
109    /// Expand subgraph from triples (in-memory traversal)
110    pub fn expand_local(
111        &self,
112        seeds: &[ScoredEntity],
113        all_triples: &[Triple],
114    ) -> GraphRAGResult<Vec<Triple>> {
115        let seed_uris: HashSet<String> = seeds.iter().map(|s| s.uri.clone()).collect();
116
117        // Build adjacency index
118        let mut subject_index: HashMap<String, Vec<&Triple>> = HashMap::new();
119        let mut object_index: HashMap<String, Vec<&Triple>> = HashMap::new();
120
121        for triple in all_triples {
122            subject_index
123                .entry(triple.subject.clone())
124                .or_default()
125                .push(triple);
126            object_index
127                .entry(triple.object.clone())
128                .or_default()
129                .push(triple);
130        }
131
132        let mut visited: HashSet<String> = HashSet::new();
133        let mut result: Vec<Triple> = Vec::new();
134        let mut frontier: Vec<String> = seed_uris.iter().cloned().collect();
135
136        for hop in 0..self.config.max_hops {
137            if frontier.is_empty() || result.len() >= self.config.max_triples {
138                break;
139            }
140
141            let mut next_frontier: Vec<String> = Vec::new();
142
143            for node in &frontier {
144                if visited.contains(node) {
145                    continue;
146                }
147                visited.insert(node.clone());
148
149                // Get outgoing edges
150                if let Some(triples) = subject_index.get(node) {
151                    for triple in triples.iter().take(self.config.max_edges_per_node) {
152                        if self.should_follow_predicate(&triple.predicate) {
153                            result.push((*triple).clone());
154                            if hop < self.config.max_hops - 1 && !visited.contains(&triple.object) {
155                                next_frontier.push(triple.object.clone());
156                            }
157                        }
158                    }
159                }
160
161                // Get incoming edges (if enabled)
162                if self.config.traverse_inverse {
163                    if let Some(triples) = object_index.get(node) {
164                        for triple in triples.iter().take(self.config.max_edges_per_node) {
165                            if self.should_follow_predicate(&triple.predicate) {
166                                result.push((*triple).clone());
167                                if hop < self.config.max_hops - 1
168                                    && !visited.contains(&triple.subject)
169                                {
170                                    next_frontier.push(triple.subject.clone());
171                                }
172                            }
173                        }
174                    }
175                }
176
177                if result.len() >= self.config.max_triples {
178                    break;
179                }
180            }
181
182            frontier = next_frontier;
183        }
184
185        // Deduplicate
186        let mut seen: HashSet<(String, String, String)> = HashSet::new();
187        let deduped: Vec<Triple> = result
188            .into_iter()
189            .filter(|t| {
190                let key = (t.subject.clone(), t.predicate.clone(), t.object.clone());
191                if seen.contains(&key) {
192                    false
193                } else {
194                    seen.insert(key);
195                    true
196                }
197            })
198            .take(self.config.max_triples)
199            .collect();
200
201        Ok(deduped)
202    }
203
204    /// Check if predicate should be followed
205    fn should_follow_predicate(&self, predicate: &str) -> bool {
206        // If follow list is specified, predicate must be in it
207        if !self.config.follow_predicates.is_empty() {
208            return self.config.follow_predicates.contains(predicate);
209        }
210
211        // Otherwise, check exclude list
212        !self.config.exclude_predicates.contains(predicate)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::collections::HashMap;
220
221    #[test]
222    fn test_expansion_query_generation() {
223        let config = TraversalConfig {
224            max_hops: 2,
225            ..Default::default()
226        };
227        let traversal = GraphTraversal::new(config);
228
229        let seeds = vec![ScoredEntity {
230            uri: "http://example.org/entity1".to_string(),
231            score: 0.9,
232            source: crate::ScoreSource::Vector,
233            metadata: HashMap::new(),
234        }];
235
236        let query = traversal.generate_expansion_query(&seeds);
237        assert!(query.contains("http://example.org/entity1"));
238        assert!(query.contains("CONSTRUCT"));
239    }
240
241    #[test]
242    fn test_local_expansion() {
243        let traversal = GraphTraversal::default();
244
245        let seeds = vec![ScoredEntity {
246            uri: "http://a".to_string(),
247            score: 0.9,
248            source: crate::ScoreSource::Vector,
249            metadata: HashMap::new(),
250        }];
251
252        let triples = vec![
253            Triple::new("http://a", "http://rel", "http://b"),
254            Triple::new("http://b", "http://rel", "http://c"),
255            Triple::new("http://x", "http://rel", "http://y"),
256        ];
257
258        let result = traversal
259            .expand_local(&seeds, &triples)
260            .expect("should succeed");
261
262        // Should include a->b and b->c (2 hops from a)
263        assert!(result.iter().any(|t| t.subject == "http://a"));
264        assert!(result.iter().any(|t| t.subject == "http://b"));
265        // Should not include x->y (unconnected)
266        assert!(!result.iter().any(|t| t.subject == "http://x"));
267    }
268}