Skip to main content

busbar_sf_agentscript/graph/
queries.rs

1//! Query operations on the reference graph.
2
3use super::edges::RefEdge;
4use super::nodes::RefNode;
5use super::RefGraph;
6use petgraph::algo::toposort;
7use petgraph::graph::NodeIndex;
8use petgraph::visit::EdgeRef;
9use petgraph::Direction;
10
11/// Result of a query operation.
12#[derive(Debug, Clone)]
13pub struct QueryResult {
14    /// The nodes matching the query
15    pub nodes: Vec<NodeIndex>,
16}
17
18impl QueryResult {
19    /// Check if the query returned any results.
20    pub fn is_empty(&self) -> bool {
21        self.nodes.is_empty()
22    }
23
24    /// Get the number of results.
25    pub fn len(&self) -> usize {
26        self.nodes.len()
27    }
28}
29
30impl RefGraph {
31    /// Find all nodes that use (reference) the given node.
32    ///
33    /// This returns nodes that have outgoing edges pointing to the target.
34    pub fn find_usages(&self, target: NodeIndex) -> QueryResult {
35        let nodes = self
36            .graph
37            .edges_directed(target, Direction::Incoming)
38            .map(|e| e.source())
39            .collect();
40
41        QueryResult { nodes }
42    }
43
44    /// Find all nodes that the given node depends on.
45    ///
46    /// This returns nodes that the source has outgoing edges pointing to.
47    pub fn find_dependencies(&self, source: NodeIndex) -> QueryResult {
48        let nodes = self
49            .graph
50            .edges_directed(source, Direction::Outgoing)
51            .map(|e| e.target())
52            .collect();
53
54        QueryResult { nodes }
55    }
56
57    /// Find all topics that transition to the given topic.
58    pub fn find_incoming_transitions(&self, topic: NodeIndex) -> QueryResult {
59        let nodes = self
60            .graph
61            .edges_directed(topic, Direction::Incoming)
62            .filter(|e| {
63                matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates | RefEdge::Routes)
64            })
65            .map(|e| e.source())
66            .collect();
67
68        QueryResult { nodes }
69    }
70
71    /// Find all topics that the given topic transitions to.
72    pub fn find_outgoing_transitions(&self, topic: NodeIndex) -> QueryResult {
73        let nodes = self
74            .graph
75            .edges_directed(topic, Direction::Outgoing)
76            .filter(|e| matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates))
77            .map(|e| e.target())
78            .collect();
79
80        QueryResult { nodes }
81    }
82
83    /// Find all reasoning actions that invoke the given action definition.
84    pub fn find_action_invokers(&self, action_def: NodeIndex) -> QueryResult {
85        let nodes = self
86            .graph
87            .edges_directed(action_def, Direction::Incoming)
88            .filter(|e| matches!(e.weight(), RefEdge::Invokes))
89            .map(|e| e.source())
90            .collect();
91
92        QueryResult { nodes }
93    }
94
95    /// Find all actions that read the given variable.
96    pub fn find_variable_readers(&self, variable: NodeIndex) -> QueryResult {
97        let nodes = self
98            .graph
99            .edges_directed(variable, Direction::Incoming)
100            .filter(|e| matches!(e.weight(), RefEdge::Reads))
101            .map(|e| e.source())
102            .collect();
103
104        QueryResult { nodes }
105    }
106
107    /// Find all actions that write to the given variable.
108    pub fn find_variable_writers(&self, variable: NodeIndex) -> QueryResult {
109        let nodes = self
110            .graph
111            .edges_directed(variable, Direction::Incoming)
112            .filter(|e| matches!(e.weight(), RefEdge::Writes))
113            .map(|e| e.source())
114            .collect();
115
116        QueryResult { nodes }
117    }
118
119    /// Get a topological ordering of topics (for execution order).
120    ///
121    /// Returns None if there are cycles.
122    pub fn topic_execution_order(&self) -> Option<Vec<NodeIndex>> {
123        // Build a subgraph of just topics and transition edges
124        let topic_indices: Vec<_> = self.topics.values().copied().collect();
125
126        // Use toposort on the full graph, then filter to just topics
127        toposort(&self.graph, None).ok().map(|sorted| {
128            sorted
129                .into_iter()
130                .filter(|idx| topic_indices.contains(idx))
131                .collect()
132        })
133    }
134
135    /// Get all reasoning actions in a topic.
136    pub fn get_topic_reasoning_actions(&self, topic_name: &str) -> Vec<NodeIndex> {
137        self.reasoning_actions
138            .iter()
139            .filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
140            .collect()
141    }
142
143    /// Get all action definitions in a topic.
144    pub fn get_topic_action_defs(&self, topic_name: &str) -> Vec<NodeIndex> {
145        self.action_defs
146            .iter()
147            .filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
148            .collect()
149    }
150
151    /// Get summary statistics about the graph.
152    pub fn stats(&self) -> GraphStats {
153        let mut stats = GraphStats::default();
154
155        for idx in self.graph.node_indices() {
156            match self.graph.node_weight(idx) {
157                Some(RefNode::Topic { .. }) => stats.topics += 1,
158                Some(RefNode::ActionDef { .. }) => stats.action_defs += 1,
159                Some(RefNode::ReasoningAction { .. }) => stats.reasoning_actions += 1,
160                Some(RefNode::Variable { .. }) => stats.variables += 1,
161                Some(RefNode::StartAgent { .. }) => stats.has_start_agent = true,
162                Some(RefNode::Connection { .. }) => stats.connections += 1,
163                None => {}
164            }
165        }
166
167        for edge in self.graph.edge_references() {
168            match edge.weight() {
169                RefEdge::TransitionsTo | RefEdge::Delegates => stats.transitions += 1,
170                RefEdge::Invokes => stats.invocations += 1,
171                RefEdge::Reads => stats.reads += 1,
172                RefEdge::Writes => stats.writes += 1,
173                _ => {}
174            }
175        }
176
177        stats
178    }
179}
180
181/// Summary statistics about the graph.
182#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
183pub struct GraphStats {
184    pub topics: usize,
185    pub action_defs: usize,
186    pub reasoning_actions: usize,
187    pub variables: usize,
188    pub connections: usize,
189    pub has_start_agent: bool,
190    pub transitions: usize,
191    pub invocations: usize,
192    pub reads: usize,
193    pub writes: usize,
194}
195
196impl GraphStats {
197    /// Total number of definitions.
198    pub fn total_definitions(&self) -> usize {
199        self.topics + self.action_defs + self.reasoning_actions + self.variables + self.connections
200    }
201
202    /// Total number of edges.
203    pub fn total_edges(&self) -> usize {
204        self.transitions + self.invocations + self.reads + self.writes
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::graph::RefGraph;
211
212    fn parse_and_build(source: &str) -> RefGraph {
213        let ast = crate::parse(source).expect("Failed to parse");
214        RefGraph::from_ast(&ast).expect("Failed to build graph")
215    }
216
217    /// Source with two topics and a one-way transition: start → topic_a → topic_b.
218    fn two_topic_source() -> &'static str {
219        r#"config:
220   agent_name: "Test"
221
222start_agent selector:
223   description: "Route"
224   reasoning:
225      instructions: "Select"
226      actions:
227         go_a: @utils.transition to @topic.topic_a
228            description: "Go to A"
229
230topic topic_a:
231   description: "Topic A"
232   reasoning:
233      instructions: "In A"
234      actions:
235         go_b: @utils.transition to @topic.topic_b
236            description: "Go to B"
237
238topic topic_b:
239   description: "Topic B"
240   reasoning:
241      instructions: "In B"
242"#
243    }
244
245    #[test]
246    fn test_find_outgoing_transitions_from_topic_a() {
247        // topic_a transitions to topic_b via @utils.transition, so
248        // find_outgoing_transitions(topic_a) should return [topic_b].
249        let graph = parse_and_build(two_topic_source());
250        let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
251        let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
252
253        let result = graph.find_outgoing_transitions(topic_a_idx);
254        assert_eq!(result.len(), 1, "Expected exactly 1 outgoing transition from topic_a");
255        assert_eq!(result.nodes[0], topic_b_idx, "Expected transition target to be topic_b");
256    }
257
258    #[test]
259    fn test_find_incoming_transitions_to_topic_b() {
260        // topic_b is only reachable from topic_a, so find_incoming_transitions(topic_b)
261        // should return [topic_a].
262        let graph = parse_and_build(two_topic_source());
263        let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
264        let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
265
266        let result = graph.find_incoming_transitions(topic_b_idx);
267        assert_eq!(result.len(), 1, "Expected exactly 1 incoming transition to topic_b");
268        assert_eq!(result.nodes[0], topic_a_idx, "Expected transition source to be topic_a");
269    }
270
271    #[test]
272    fn test_find_outgoing_transitions_empty_for_leaf_topic() {
273        // topic_b has no outgoing transitions — it is a leaf node.
274        let graph = parse_and_build(two_topic_source());
275        let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
276
277        let result = graph.find_outgoing_transitions(topic_b_idx);
278        assert!(result.is_empty(), "Expected no outgoing transitions from leaf topic_b");
279    }
280
281    #[test]
282    fn test_topic_execution_order_for_acyclic_graph() {
283        // An acyclic start → topic_a → topic_b graph should yield a valid topological
284        // ordering where topic_a appears before topic_b.
285        let graph = parse_and_build(two_topic_source());
286        let order = graph.topic_execution_order();
287        assert!(order.is_some(), "Expected a valid topological order for an acyclic graph");
288
289        let order = order.unwrap();
290        let topic_a_pos = order
291            .iter()
292            .position(|&idx| idx == graph.get_topic("topic_a").unwrap());
293        let topic_b_pos = order
294            .iter()
295            .position(|&idx| idx == graph.get_topic("topic_b").unwrap());
296
297        assert!(topic_a_pos.is_some(), "topic_a should appear in execution order");
298        assert!(topic_b_pos.is_some(), "topic_b should appear in execution order");
299        assert!(
300            topic_a_pos.unwrap() < topic_b_pos.unwrap(),
301            "topic_a should come before topic_b in topological order"
302        );
303    }
304
305    #[test]
306    fn test_stats_counts_nodes_correctly() {
307        // Verify that stats() correctly counts topics, action defs, and variables.
308        let source = r#"config:
309   agent_name: "Test"
310
311variables:
312   order_id: mutable string = ""
313      description: "Order ID"
314
315start_agent selector:
316   description: "Route"
317   reasoning:
318      instructions: "Select"
319      actions:
320         go_main: @utils.transition to @topic.main
321            description: "Go to main"
322
323topic main:
324   description: "Main topic"
325
326   actions:
327      get_order:
328         description: "Gets an order"
329         inputs:
330            id: string
331               description: "Order identifier"
332         outputs:
333            status: string
334               description: "Order status"
335         target: "flow://GetOrder"
336
337   reasoning:
338      instructions: "Help"
339"#;
340        let graph = parse_and_build(source);
341        let stats = graph.stats();
342
343        assert_eq!(stats.topics, 1, "Expected 1 topic");
344        assert!(stats.has_start_agent, "Expected start_agent to be present");
345        assert_eq!(stats.action_defs, 1, "Expected 1 action def (get_order)");
346        assert_eq!(stats.variables, 1, "Expected 1 variable (order_id)");
347        // At least one edge should exist (the Routes edge from start_agent → main)
348        assert!(graph.edge_count() > 0, "Expected at least one edge in the graph");
349    }
350}