Skip to main content

silk/
engine.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use crate::graph::MaterializedGraph;
4
5/// BFS traversal result — node IDs in visit order.
6pub fn bfs(
7    graph: &MaterializedGraph,
8    start: &str,
9    max_depth: Option<usize>,
10    edge_type_filter: Option<&str>,
11) -> Vec<String> {
12    let mut visited = HashSet::new();
13    let mut result = Vec::new();
14    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
15
16    if graph.get_node(start).is_none() {
17        return result;
18    }
19
20    visited.insert(start.to_string());
21    queue.push_back((start.to_string(), 0));
22
23    while let Some((node_id, depth)) = queue.pop_front() {
24        result.push(node_id.clone());
25
26        if let Some(max) = max_depth {
27            if depth >= max {
28                continue;
29            }
30        }
31
32        let edges = graph.outgoing_edges(&node_id);
33        for edge in edges {
34            if let Some(filter) = edge_type_filter {
35                if edge.edge_type != filter {
36                    continue;
37                }
38            }
39            if !visited.contains(&edge.target_id) {
40                visited.insert(edge.target_id.clone());
41                queue.push_back((edge.target_id.clone(), depth + 1));
42            }
43        }
44    }
45
46    result
47}
48
49/// Shortest path between two nodes (unweighted BFS).
50/// Returns the path as a list of node IDs (including start and end),
51/// or None if no path exists.
52pub fn shortest_path(graph: &MaterializedGraph, start: &str, end: &str) -> Option<Vec<String>> {
53    if graph.get_node(start).is_none() || graph.get_node(end).is_none() {
54        return None;
55    }
56    if start == end {
57        return Some(vec![start.to_string()]);
58    }
59
60    let mut visited = HashSet::new();
61    let mut parent: HashMap<String, String> = HashMap::new();
62    let mut queue: VecDeque<String> = VecDeque::new();
63
64    visited.insert(start.to_string());
65    queue.push_back(start.to_string());
66
67    while let Some(current) = queue.pop_front() {
68        for edge in graph.outgoing_edges(&current) {
69            if !visited.contains(&edge.target_id) {
70                visited.insert(edge.target_id.clone());
71                parent.insert(edge.target_id.clone(), current.clone());
72                if edge.target_id == end {
73                    // Reconstruct path.
74                    let mut path = vec![end.to_string()];
75                    let mut cur = end.to_string();
76                    while let Some(p) = parent.get(&cur) {
77                        path.push(p.clone());
78                        cur = p.clone();
79                    }
80                    path.reverse();
81                    return Some(path);
82                }
83                queue.push_back(edge.target_id.clone());
84            }
85        }
86    }
87
88    None
89}
90
91/// Impact analysis: reverse BFS from a node — "what depends on this?"
92/// Traverses incoming edges to find all nodes that transitively depend on `node_id`.
93pub fn impact_analysis(
94    graph: &MaterializedGraph,
95    node_id: &str,
96    max_depth: Option<usize>,
97) -> Vec<String> {
98    let mut visited = HashSet::new();
99    let mut result = Vec::new();
100    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
101
102    if graph.get_node(node_id).is_none() {
103        return result;
104    }
105
106    visited.insert(node_id.to_string());
107    queue.push_back((node_id.to_string(), 0));
108
109    while let Some((current, depth)) = queue.pop_front() {
110        result.push(current.clone());
111
112        if let Some(max) = max_depth {
113            if depth >= max {
114                continue;
115            }
116        }
117
118        for edge in graph.incoming_edges(&current) {
119            if !visited.contains(&edge.source_id) {
120                visited.insert(edge.source_id.clone());
121                queue.push_back((edge.source_id.clone(), depth + 1));
122            }
123        }
124    }
125
126    result
127}
128
129/// Extract subgraph: all nodes and edges within N hops of a start node.
130/// Returns (node_ids, edge_ids).
131pub fn subgraph(graph: &MaterializedGraph, start: &str, hops: usize) -> (Vec<String>, Vec<String>) {
132    let mut visited_nodes = HashSet::new();
133    let mut visited_edges = HashSet::new();
134    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
135
136    if graph.get_node(start).is_none() {
137        return (vec![], vec![]);
138    }
139
140    visited_nodes.insert(start.to_string());
141    queue.push_back((start.to_string(), 0));
142
143    while let Some((node_id, depth)) = queue.pop_front() {
144        if depth >= hops {
145            continue;
146        }
147
148        // Outgoing.
149        for edge in graph.outgoing_edges(&node_id) {
150            visited_edges.insert(edge.edge_id.clone());
151            if !visited_nodes.contains(&edge.target_id) {
152                visited_nodes.insert(edge.target_id.clone());
153                queue.push_back((edge.target_id.clone(), depth + 1));
154            }
155        }
156        // Incoming.
157        for edge in graph.incoming_edges(&node_id) {
158            visited_edges.insert(edge.edge_id.clone());
159            if !visited_nodes.contains(&edge.source_id) {
160                visited_nodes.insert(edge.source_id.clone());
161                queue.push_back((edge.source_id.clone(), depth + 1));
162            }
163        }
164    }
165
166    (
167        visited_nodes.into_iter().collect(),
168        visited_edges.into_iter().collect(),
169    )
170}
171
172/// Pattern match: find chains matching a sequence of node types connected by edges.
173/// E.g., `["signal", "rule", "plan", "action"]` finds all MAPE-K loops.
174/// Returns list of chains, each chain being a list of node_ids.
175/// Find chains of nodes matching a type sequence (e.g., `["signal", "rule", "plan"]`).
176///
177/// Complexity: O(n * b^d) where n = nodes of first type, b = average branching,
178/// d = sequence length. Bounded by `max_results` to prevent runaway expansion on
179/// dense graphs. Cycle-safe: a node cannot appear twice in the same chain.
180pub fn pattern_match(
181    graph: &MaterializedGraph,
182    type_sequence: &[&str],
183    max_results: usize,
184) -> Vec<Vec<String>> {
185    if type_sequence.is_empty() {
186        return vec![];
187    }
188
189    let mut results = Vec::new();
190
191    // Start from all nodes of the first type.
192    let start_nodes = graph.nodes_by_type(type_sequence[0]);
193    for start in start_nodes {
194        let mut chains = vec![vec![start.node_id.clone()]];
195
196        for &next_type in &type_sequence[1..] {
197            let mut extended = Vec::new();
198            for chain in &chains {
199                let last = chain.last().unwrap();
200                for edge in graph.outgoing_edges(last) {
201                    if let Some(target_node) = graph.get_node(&edge.target_id) {
202                        if target_node.node_type == next_type && !chain.contains(&edge.target_id) {
203                            let mut new_chain = chain.clone();
204                            new_chain.push(edge.target_id.clone());
205                            extended.push(new_chain);
206                            if results.len() + extended.len() >= max_results {
207                                results.extend(extended);
208                                results.truncate(max_results);
209                                return results;
210                            }
211                        }
212                    }
213                }
214            }
215            chains = extended;
216        }
217
218        results.extend(chains);
219        if results.len() >= max_results {
220            results.truncate(max_results);
221            return results;
222        }
223    }
224
225    results
226}
227
228/// Topological sort of nodes connected by directed edges.
229/// For DAGs only — returns None if a cycle is detected.
230pub fn topological_sort(graph: &MaterializedGraph) -> Option<Vec<String>> {
231    let nodes = graph.all_nodes();
232    let node_ids: HashSet<String> = nodes.iter().map(|n| n.node_id.clone()).collect();
233
234    // Compute in-degrees.
235    let mut in_degree: HashMap<String, usize> = node_ids.iter().map(|id| (id.clone(), 0)).collect();
236    for edge in graph.all_edges() {
237        if node_ids.contains(&edge.target_id) && node_ids.contains(&edge.source_id) {
238            *in_degree.entry(edge.target_id.clone()).or_default() += 1;
239        }
240    }
241
242    let mut queue: VecDeque<String> = in_degree
243        .iter()
244        .filter(|(_, &deg)| deg == 0)
245        .map(|(id, _)| id.clone())
246        .collect();
247
248    // Sort for determinism.
249    let mut sorted: Vec<String> = queue.drain(..).collect();
250    sorted.sort();
251    queue.extend(sorted);
252
253    let mut result = Vec::new();
254    while let Some(node_id) = queue.pop_front() {
255        result.push(node_id.clone());
256        for edge in graph.outgoing_edges(&node_id) {
257            if let Some(deg) = in_degree.get_mut(&edge.target_id) {
258                *deg -= 1;
259                if *deg == 0 {
260                    queue.push_back(edge.target_id.clone());
261                }
262            }
263        }
264    }
265
266    if result.len() == node_ids.len() {
267        Some(result)
268    } else {
269        None // Cycle detected.
270    }
271}
272
273/// Cycle detection: returns true if the graph contains a cycle.
274pub fn has_cycle(graph: &MaterializedGraph) -> bool {
275    topological_sort(graph).is_none()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::clock::LamportClock;
282    use crate::entry::{Entry, GraphOp};
283    use crate::ontology::{EdgeTypeDef, NodeTypeDef, Ontology};
284    use std::collections::BTreeMap;
285
286    fn test_ontology() -> Ontology {
287        Ontology {
288            node_types: BTreeMap::from([
289                (
290                    "entity".into(),
291                    NodeTypeDef {
292                        description: None,
293                        properties: BTreeMap::new(),
294                        subtypes: None,
295                    },
296                ),
297                (
298                    "signal".into(),
299                    NodeTypeDef {
300                        description: None,
301                        properties: BTreeMap::new(),
302                        subtypes: None,
303                    },
304                ),
305                (
306                    "rule".into(),
307                    NodeTypeDef {
308                        description: None,
309                        properties: BTreeMap::new(),
310                        subtypes: None,
311                    },
312                ),
313                (
314                    "plan".into(),
315                    NodeTypeDef {
316                        description: None,
317                        properties: BTreeMap::new(),
318                        subtypes: None,
319                    },
320                ),
321                (
322                    "action".into(),
323                    NodeTypeDef {
324                        description: None,
325                        properties: BTreeMap::new(),
326                        subtypes: None,
327                    },
328                ),
329            ]),
330            edge_types: BTreeMap::from([
331                (
332                    "DEPENDS_ON".into(),
333                    EdgeTypeDef {
334                        description: None,
335                        source_types: vec!["entity".into()],
336                        target_types: vec!["entity".into()],
337                        properties: BTreeMap::new(),
338                    },
339                ),
340                (
341                    "TRIGGERS".into(),
342                    EdgeTypeDef {
343                        description: None,
344                        source_types: vec!["signal".into()],
345                        target_types: vec!["rule".into()],
346                        properties: BTreeMap::new(),
347                    },
348                ),
349                (
350                    "PRODUCES".into(),
351                    EdgeTypeDef {
352                        description: None,
353                        source_types: vec!["rule".into(), "plan".into(), "action".into()],
354                        target_types: vec!["plan".into(), "action".into(), "signal".into()],
355                        properties: BTreeMap::new(),
356                    },
357                ),
358            ]),
359        }
360    }
361
362    fn make_entry(op: GraphOp, clock_time: u64) -> Entry {
363        Entry::new(
364            op,
365            vec![],
366            vec![],
367            LamportClock::with_values("test", clock_time, 0),
368            "test",
369        )
370    }
371
372    fn add_node(id: &str, ntype: &str, clock: u64) -> Entry {
373        make_entry(
374            GraphOp::AddNode {
375                node_id: id.into(),
376                node_type: ntype.into(),
377                label: id.into(),
378                properties: BTreeMap::new(),
379                subtype: None,
380            },
381            clock,
382        )
383    }
384
385    fn add_edge(id: &str, etype: &str, src: &str, tgt: &str, clock: u64) -> Entry {
386        make_entry(
387            GraphOp::AddEdge {
388                edge_id: id.into(),
389                edge_type: etype.into(),
390                source_id: src.into(),
391                target_id: tgt.into(),
392                properties: BTreeMap::new(),
393            },
394            clock,
395        )
396    }
397
398    /// Build a linear chain: A → B → C → D
399    fn linear_graph() -> MaterializedGraph {
400        let mut g = MaterializedGraph::new(test_ontology());
401        g.apply(&add_node("a", "entity", 1));
402        g.apply(&add_node("b", "entity", 2));
403        g.apply(&add_node("c", "entity", 3));
404        g.apply(&add_node("d", "entity", 4));
405        g.apply(&add_edge("ab", "DEPENDS_ON", "a", "b", 5));
406        g.apply(&add_edge("bc", "DEPENDS_ON", "b", "c", 6));
407        g.apply(&add_edge("cd", "DEPENDS_ON", "c", "d", 7));
408        g
409    }
410
411    #[test]
412    fn bfs_traversal_from_node() {
413        let g = linear_graph();
414        let visited = bfs(&g, "a", None, None);
415        assert_eq!(visited, vec!["a", "b", "c", "d"]);
416    }
417
418    #[test]
419    fn bfs_respects_depth_limit() {
420        let g = linear_graph();
421        let visited = bfs(&g, "a", Some(2), None);
422        // depth 0: a, depth 1: b, depth 2: c (but c's children not explored)
423        assert_eq!(visited, vec!["a", "b", "c"]);
424    }
425
426    #[test]
427    fn bfs_filters_edge_types() {
428        let mut g = MaterializedGraph::new(test_ontology());
429        g.apply(&add_node("a", "entity", 1));
430        g.apply(&add_node("b", "entity", 2));
431        g.apply(&add_node("c", "entity", 3));
432        g.apply(&add_edge("ab", "DEPENDS_ON", "a", "b", 4));
433        g.apply(&add_edge("ac", "DEPENDS_ON", "a", "c", 5));
434        // Add a different edge type that should be filtered out.
435        // (Using DEPENDS_ON for simplicity — in real ontology this would be different)
436
437        let visited = bfs(&g, "a", None, Some("DEPENDS_ON"));
438        assert!(visited.contains(&"a".to_string()));
439        assert!(visited.contains(&"b".to_string()));
440        assert!(visited.contains(&"c".to_string()));
441
442        // Filter by nonexistent type → only start node.
443        let visited2 = bfs(&g, "a", None, Some("NONEXISTENT"));
444        assert_eq!(visited2, vec!["a"]);
445    }
446
447    #[test]
448    fn shortest_path_finds_path() {
449        let g = linear_graph();
450        let path = shortest_path(&g, "a", "d").unwrap();
451        assert_eq!(path, vec!["a", "b", "c", "d"]);
452    }
453
454    #[test]
455    fn shortest_path_no_path() {
456        let mut g = MaterializedGraph::new(test_ontology());
457        g.apply(&add_node("a", "entity", 1));
458        g.apply(&add_node("b", "entity", 2));
459        // No edge between them.
460        assert!(shortest_path(&g, "a", "b").is_none());
461    }
462
463    #[test]
464    fn impact_analysis_reverse_traversal() {
465        let g = linear_graph(); // a → b → c → d
466                                // "What depends on d?" → reverse: c, b, a
467        let impact = impact_analysis(&g, "d", None);
468        assert!(impact.contains(&"d".to_string()));
469        assert!(impact.contains(&"c".to_string()));
470        assert!(impact.contains(&"b".to_string()));
471        assert!(impact.contains(&"a".to_string()));
472    }
473
474    #[test]
475    fn subgraph_extraction() {
476        let g = linear_graph(); // a → b → c → d
477        let (nodes, edges) = subgraph(&g, "b", 1);
478        // 1 hop from b: a (incoming), c (outgoing)
479        assert!(nodes.contains(&"b".to_string()));
480        assert!(nodes.contains(&"a".to_string()));
481        assert!(nodes.contains(&"c".to_string()));
482        assert!(!nodes.contains(&"d".to_string())); // 2 hops away
483        assert_eq!(edges.len(), 2); // ab, bc
484    }
485
486    #[test]
487    fn pattern_match_mape_k_loop() {
488        let mut g = MaterializedGraph::new(test_ontology());
489        g.apply(&add_node("sig1", "signal", 1));
490        g.apply(&add_node("rule1", "rule", 2));
491        g.apply(&add_node("plan1", "plan", 3));
492        g.apply(&add_node("act1", "action", 4));
493        g.apply(&add_edge("e1", "TRIGGERS", "sig1", "rule1", 5));
494        g.apply(&add_edge("e2", "PRODUCES", "rule1", "plan1", 6));
495        g.apply(&add_edge("e3", "PRODUCES", "plan1", "act1", 7));
496
497        let chains = pattern_match(&g, &["signal", "rule", "plan", "action"], 1000);
498        assert_eq!(chains.len(), 1);
499        assert_eq!(chains[0], vec!["sig1", "rule1", "plan1", "act1"]);
500    }
501
502    #[test]
503    fn topological_sort_dependency_order() {
504        let g = linear_graph(); // a → b → c → d
505        let sorted = topological_sort(&g).unwrap();
506        // a must come before b, b before c, c before d.
507        let pos = |id: &str| sorted.iter().position(|x| x == id).unwrap();
508        assert!(pos("a") < pos("b"));
509        assert!(pos("b") < pos("c"));
510        assert!(pos("c") < pos("d"));
511    }
512
513    #[test]
514    fn cycle_detection() {
515        let mut g = MaterializedGraph::new(test_ontology());
516        g.apply(&add_node("a", "entity", 1));
517        g.apply(&add_node("b", "entity", 2));
518        g.apply(&add_node("c", "entity", 3));
519        g.apply(&add_edge("ab", "DEPENDS_ON", "a", "b", 4));
520        g.apply(&add_edge("bc", "DEPENDS_ON", "b", "c", 5));
521        g.apply(&add_edge("ca", "DEPENDS_ON", "c", "a", 6)); // cycle!
522
523        assert!(has_cycle(&g));
524        assert!(topological_sort(&g).is_none());
525    }
526}