Skip to main content

sara_core/graph/
knowledge_graph.rs

1//! Knowledge graph implementation using petgraph.
2
3use petgraph::Direction;
4use petgraph::graph::{DiGraph, NodeIndex};
5use petgraph::visit::EdgeRef;
6use std::collections::HashMap;
7
8use strsim::levenshtein;
9
10use crate::error::SaraError;
11use crate::model::{Item, ItemId, ItemType, RelationshipType};
12
13/// Result of looking up an item.
14#[derive(Debug)]
15pub enum LookupResult<'a> {
16    /// Item found.
17    Found(&'a Item),
18    /// Item not found, but similar items exist.
19    NotFound {
20        /// Suggestions for similar item IDs.
21        suggestions: Vec<&'a ItemId>,
22    },
23}
24
25/// The main knowledge graph container.
26#[derive(Debug)]
27pub struct KnowledgeGraph {
28    /// The underlying directed graph.
29    graph: DiGraph<Item, RelationshipType>,
30
31    /// Index for O(1) lookup by ItemId.
32    index: HashMap<ItemId, NodeIndex>,
33}
34
35impl KnowledgeGraph {
36    /// Creates a new empty knowledge graph.
37    pub fn new() -> Self {
38        Self {
39            graph: DiGraph::new(),
40            index: HashMap::new(),
41        }
42    }
43
44    /// Returns the number of items in the graph.
45    pub fn item_count(&self) -> usize {
46        self.graph.node_count()
47    }
48
49    /// Returns the number of relationships in the graph.
50    pub fn relationship_count(&self) -> usize {
51        self.graph.edge_count()
52    }
53
54    /// Adds an item to the graph.
55    fn add_item(&mut self, item: Item) -> NodeIndex {
56        let id = item.id.clone();
57        let idx = self.graph.add_node(item);
58        self.index.insert(id, idx);
59        idx
60    }
61
62    /// Adds a relationship between two items.
63    fn add_relationship(&mut self, from: &ItemId, to: &ItemId, rel_type: RelationshipType) {
64        if let (Some(from_idx), Some(to_idx)) = (self.index.get(from), self.index.get(to)) {
65            self.graph.add_edge(*from_idx, *to_idx, rel_type);
66        }
67    }
68
69    /// Gets an item by ID.
70    pub fn get(&self, id: &ItemId) -> Option<&Item> {
71        let idx = self.index.get(id)?;
72        self.graph.node_weight(*idx)
73    }
74
75    /// Gets a mutable reference to an item by ID.
76    pub fn get_mut(&mut self, id: &ItemId) -> Option<&mut Item> {
77        let idx = self.index.get(id)?;
78        self.graph.node_weight_mut(*idx)
79    }
80
81    /// Checks if an item exists in the graph.
82    pub fn contains(&self, id: &ItemId) -> bool {
83        self.index.contains_key(id)
84    }
85
86    /// Returns all items in the graph.
87    pub fn items(&self) -> impl Iterator<Item = &Item> {
88        self.graph.node_weights()
89    }
90
91    /// Returns all item IDs in the graph.
92    pub fn item_ids(&self) -> impl Iterator<Item = &ItemId> {
93        self.index.keys()
94    }
95
96    /// Returns all items of a specific type.
97    pub fn items_by_type(&self, item_type: ItemType) -> Vec<&Item> {
98        self.graph
99            .node_weights()
100            .filter(|item| item.item_type == item_type)
101            .collect()
102    }
103
104    /// Returns the count of items by type.
105    pub fn count_by_type(&self) -> HashMap<ItemType, usize> {
106        let mut counts = HashMap::new();
107        for item in self.graph.node_weights() {
108            *counts.entry(item.item_type).or_insert(0) += 1;
109        }
110        counts
111    }
112
113    /// Returns direct parents of an item (items that this item relates to upstream).
114    pub fn parents(&self, id: &ItemId) -> Vec<&Item> {
115        let Some(idx) = self.index.get(id) else {
116            return Vec::new();
117        };
118
119        self.graph
120            .edges_directed(*idx, Direction::Outgoing)
121            .filter(|edge| edge.weight().is_upstream())
122            .filter_map(|edge| self.graph.node_weight(edge.target()))
123            .collect()
124    }
125
126    /// Returns direct children of an item (items that relate to this item downstream).
127    pub fn children(&self, id: &ItemId) -> Vec<&Item> {
128        let Some(idx) = self.index.get(id) else {
129            return Vec::new();
130        };
131
132        self.graph
133            .edges_directed(*idx, Direction::Incoming)
134            .filter(|edge| edge.weight().is_upstream())
135            .filter_map(|edge| self.graph.node_weight(edge.source()))
136            .collect()
137    }
138
139    /// Returns all items with no upstream parents (potential orphans).
140    pub fn orphans(&self) -> Vec<&Item> {
141        self.graph
142            .node_weights()
143            .filter(|item| {
144                // Solutions are allowed to have no parents (root of hierarchy)
145                if item.item_type.is_root() {
146                    return false;
147                }
148                // Check if item has any upstream relationships
149                !item.has_upstream()
150            })
151            .collect()
152    }
153
154    /// Returns the underlying petgraph for advanced operations.
155    pub fn inner(&self) -> &DiGraph<Item, RelationshipType> {
156        &self.graph
157    }
158
159    /// Returns a mutable reference to the underlying petgraph.
160    pub fn inner_mut(&mut self) -> &mut DiGraph<Item, RelationshipType> {
161        &mut self.graph
162    }
163
164    /// Returns the node index for an item ID.
165    pub fn node_index(&self, id: &ItemId) -> Option<NodeIndex> {
166        self.index.get(id).copied()
167    }
168
169    /// Returns all relationships in the graph.
170    pub fn relationships(&self) -> Vec<(ItemId, ItemId, RelationshipType)> {
171        self.graph
172            .edge_references()
173            .filter_map(|edge| {
174                let from = self.graph.node_weight(edge.source())?;
175                let to = self.graph.node_weight(edge.target())?;
176                Some((from.id.clone(), to.id.clone(), *edge.weight()))
177            })
178            .collect()
179    }
180
181    /// Looks up an item by ID.
182    ///
183    /// If the item is not found, returns suggestions for similar IDs.
184    pub fn lookup(&self, id: &str) -> LookupResult<'_> {
185        let item_id = ItemId::new_unchecked(id);
186
187        if let Some(item) = self.get(&item_id) {
188            return LookupResult::Found(item);
189        }
190
191        let suggestions = self
192            .find_similar_ids_scored(id, 5)
193            .into_iter()
194            .map(|(id, _)| id)
195            .collect();
196        LookupResult::NotFound { suggestions }
197    }
198
199    /// Looks up an item by ID, returning suggestions if not found.
200    ///
201    /// Returns a `SaraError::ItemNotFound` with similar ID suggestions
202    /// when the item is missing.
203    pub fn lookup_or_suggest(&self, id: &str) -> Result<&Item, SaraError> {
204        let item_id = ItemId::new_unchecked(id);
205
206        if let Some(item) = self.get(&item_id) {
207            return Ok(item);
208        }
209
210        let suggestions = self.find_similar_ids(id, 3);
211        Err(SaraError::ItemNotFound {
212            id: id.to_string(),
213            suggestions,
214        })
215    }
216
217    /// Finds item IDs similar to the given query string using Levenshtein distance.
218    ///
219    /// Returns up to `max_suggestions` similar item IDs, sorted by distance.
220    /// Only includes suggestions with a reasonable edit distance.
221    pub fn find_similar_ids(&self, query: &str, max_suggestions: usize) -> Vec<String> {
222        self.find_similar_ids_scored(query, max_suggestions)
223            .into_iter()
224            .map(|(id, _)| id.as_str().to_string())
225            .collect()
226    }
227
228    /// Checks if parent items exist for the given item type.
229    ///
230    /// Solution has no parent requirement and always returns Ok.
231    pub fn check_parent_exists(&self, item_type: ItemType) -> Result<(), SaraError> {
232        let Some(parent_type) = item_type.required_parent_type() else {
233            return Ok(());
234        };
235
236        let has_parents = self.items().any(|item| item.item_type == parent_type);
237
238        if has_parents {
239            Ok(())
240        } else {
241            Err(SaraError::MissingParent {
242                item_type: item_type.display_name().to_string(),
243                parent_type: parent_type.display_name().to_string(),
244            })
245        }
246    }
247
248    /// Core implementation for finding similar IDs with Levenshtein distance scoring.
249    fn find_similar_ids_scored(
250        &self,
251        query: &str,
252        max_suggestions: usize,
253    ) -> Vec<(&ItemId, usize)> {
254        let query_lower = query.to_lowercase();
255
256        let mut scored: Vec<_> = self
257            .item_ids()
258            .map(|id| {
259                let id_lower = id.as_str().to_lowercase();
260                let distance = levenshtein(&query_lower, &id_lower);
261                (id, distance)
262            })
263            .collect();
264
265        scored.sort_by_key(|(_, distance)| *distance);
266
267        scored
268            .into_iter()
269            .filter(|(_, distance)| *distance <= query.len().max(3))
270            .take(max_suggestions)
271            .collect()
272    }
273}
274
275impl Default for KnowledgeGraph {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281/// Builder for constructing knowledge graphs.
282#[derive(Debug, Default)]
283pub struct KnowledgeGraphBuilder {
284    items: Vec<Item>,
285}
286
287impl KnowledgeGraphBuilder {
288    /// Creates a new graph builder.
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Adds an item to the graph.
294    pub fn add_item(mut self, item: Item) -> Self {
295        self.items.push(item);
296        self
297    }
298
299    /// Adds multiple items to the graph.
300    pub fn add_items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
301        self.items.extend(items);
302        self
303    }
304
305    /// Builds the knowledge graph.
306    pub fn build(self) -> Result<KnowledgeGraph, SaraError> {
307        let mut graph = KnowledgeGraph::new();
308
309        // First pass: collect all relationship edges from items (before moving them)
310        let edges: Vec<_> = self.items.iter().flat_map(Self::collect_edges).collect();
311
312        // Second pass: move items into the graph (no clone needed)
313        for item in self.items {
314            graph.add_item(item);
315        }
316
317        // Third pass: add relationship edges
318        for (from, to, rel_type) in edges {
319            graph.add_relationship(&from, &to, rel_type);
320        }
321
322        Ok(graph)
323    }
324
325    /// Collects all relationship edges for an item as (from, to, type) tuples.
326    fn collect_edges(item: &Item) -> Vec<(ItemId, ItemId, RelationshipType)> {
327        let mut edges = Vec::new();
328
329        for rel in &item.relationships {
330            edges.push((item.id.clone(), rel.to.clone(), rel.relationship_type));
331
332            // For certain relationship types, add the inverse edge for bidirectional traversal
333            let inverse = match rel.relationship_type {
334                RelationshipType::Justifies => Some(RelationshipType::IsJustifiedBy),
335                RelationshipType::IsRefinedBy => Some(RelationshipType::Refines),
336                RelationshipType::Derives => Some(RelationshipType::DerivesFrom),
337                RelationshipType::IsSatisfiedBy => Some(RelationshipType::Satisfies),
338                RelationshipType::IsJustifiedBy => Some(RelationshipType::Justifies),
339                _ => None,
340            };
341            if let Some(inv_type) = inverse {
342                edges.push((rel.to.clone(), item.id.clone(), inv_type));
343            }
344        }
345
346        // Peer dependencies (for requirement types, stored in attributes)
347        for target_id in item.attributes.depends_on() {
348            edges.push((
349                item.id.clone(),
350                target_id.clone(),
351                RelationshipType::DependsOn,
352            ));
353        }
354
355        // ADR supersession (peer relationships between ADRs, stored in attributes)
356        for target_id in item.attributes.supersedes() {
357            edges.push((
358                item.id.clone(),
359                target_id.clone(),
360                RelationshipType::Supersedes,
361            ));
362            edges.push((
363                target_id.clone(),
364                item.id.clone(),
365                RelationshipType::IsSupersededBy,
366            ));
367        }
368
369        edges
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::model::Relationship;
377    use crate::test_utils::{
378        create_test_adr, create_test_item, create_test_item_with_relationships,
379    };
380
381    #[test]
382    fn test_add_and_get_item() {
383        let graph = KnowledgeGraphBuilder::new()
384            .add_item(create_test_item("SOL-001", ItemType::Solution))
385            .build()
386            .unwrap();
387
388        let id = ItemId::new_unchecked("SOL-001");
389        assert!(graph.contains(&id));
390        assert_eq!(graph.get(&id).unwrap().name, "Test SOL-001");
391    }
392
393    #[test]
394    fn test_items_by_type() {
395        let graph = KnowledgeGraphBuilder::new()
396            .add_item(create_test_item("SOL-001", ItemType::Solution))
397            .add_item(create_test_item("UC-001", ItemType::UseCase))
398            .add_item(create_test_item("UC-002", ItemType::UseCase))
399            .build()
400            .unwrap();
401
402        let solutions = graph.items_by_type(ItemType::Solution);
403        assert_eq!(solutions.len(), 1);
404
405        let use_cases = graph.items_by_type(ItemType::UseCase);
406        assert_eq!(use_cases.len(), 2);
407    }
408
409    #[test]
410    fn test_item_count() {
411        let graph = KnowledgeGraphBuilder::new().build().unwrap();
412        assert_eq!(graph.item_count(), 0);
413
414        let graph = KnowledgeGraphBuilder::new()
415            .add_item(create_test_item("SOL-001", ItemType::Solution))
416            .build()
417            .unwrap();
418        assert_eq!(graph.item_count(), 1);
419
420        let graph = KnowledgeGraphBuilder::new()
421            .add_item(create_test_item("SOL-001", ItemType::Solution))
422            .add_item(create_test_item("UC-001", ItemType::UseCase))
423            .build()
424            .unwrap();
425        assert_eq!(graph.item_count(), 2);
426    }
427
428    #[test]
429    fn test_build_simple_graph() {
430        let graph = KnowledgeGraphBuilder::new()
431            .add_item(create_test_item("SOL-001", ItemType::Solution))
432            .build()
433            .unwrap();
434
435        assert_eq!(graph.item_count(), 1);
436    }
437
438    #[test]
439    fn test_build_graph_with_relationships() {
440        let sol = create_test_item("SOL-001", ItemType::Solution);
441        let uc = create_test_item_with_relationships(
442            "UC-001",
443            ItemType::UseCase,
444            vec![Relationship::new(
445                ItemId::new_unchecked("SOL-001"),
446                RelationshipType::Refines,
447            )],
448        );
449
450        let graph = KnowledgeGraphBuilder::new()
451            .add_item(sol)
452            .add_item(uc)
453            .build()
454            .unwrap();
455
456        assert_eq!(graph.item_count(), 2);
457        assert_eq!(graph.relationship_count(), 1);
458    }
459
460    #[test]
461    fn test_adr_justifies_relationship() {
462        // Create a system architecture item
463        let sysarch = create_test_item("SYSARCH-001", ItemType::SystemArchitecture);
464        // Create an ADR that justifies it
465        let adr = create_test_adr("ADR-001", &["SYSARCH-001"], &[]);
466
467        let graph = KnowledgeGraphBuilder::new()
468            .add_item(sysarch)
469            .add_item(adr)
470            .build()
471            .unwrap();
472
473        assert_eq!(graph.item_count(), 2);
474        // ADR-001 -> Justifies -> SYSARCH-001
475        // SYSARCH-001 -> IsJustifiedBy -> ADR-001
476        assert_eq!(graph.relationship_count(), 2);
477    }
478
479    #[test]
480    fn test_adr_supersession_relationship() {
481        // Create two ADRs where the newer one supersedes the older
482        let adr_old = create_test_adr("ADR-001", &[], &[]);
483        let adr_new = create_test_adr("ADR-002", &[], &["ADR-001"]);
484
485        let graph = KnowledgeGraphBuilder::new()
486            .add_item(adr_old)
487            .add_item(adr_new)
488            .build()
489            .unwrap();
490
491        assert_eq!(graph.item_count(), 2);
492        // ADR-002 -> Supersedes -> ADR-001
493        // ADR-001 -> IsSupersededBy -> ADR-002
494        assert_eq!(graph.relationship_count(), 2);
495    }
496
497    #[test]
498    fn test_lookup_found() {
499        let graph = KnowledgeGraphBuilder::new()
500            .add_item(create_test_item("SOL-001", ItemType::Solution))
501            .build()
502            .unwrap();
503
504        match graph.lookup("SOL-001") {
505            LookupResult::Found(item) => {
506                assert_eq!(item.id.as_str(), "SOL-001");
507            }
508            LookupResult::NotFound { .. } => panic!("Expected to find item"),
509        }
510    }
511
512    #[test]
513    fn test_lookup_not_found_with_suggestions() {
514        let graph = KnowledgeGraphBuilder::new()
515            .add_item(create_test_item("SOL-001", ItemType::Solution))
516            .add_item(create_test_item("SOL-002", ItemType::Solution))
517            .add_item(create_test_item("UC-001", ItemType::UseCase))
518            .build()
519            .unwrap();
520
521        match graph.lookup("SOL-003") {
522            LookupResult::Found(_) => panic!("Should not find item"),
523            LookupResult::NotFound { suggestions } => {
524                assert!(!suggestions.is_empty());
525                let suggestion_strs: Vec<_> = suggestions.iter().map(|id| id.as_str()).collect();
526                assert!(
527                    suggestion_strs.contains(&"SOL-001") || suggestion_strs.contains(&"SOL-002")
528                );
529            }
530        }
531    }
532}