Skip to main content

stygian_graph/domain/
introspection.rs

1//! Graph introspection types and queries
2//!
3//! Provides runtime inspection of DAG structure, execution state, and analysis.
4//!
5//! # Example
6//!
7//! ```
8//! use stygian_graph::domain::graph::{Pipeline, Node, Edge, DagExecutor};
9//! use stygian_graph::domain::introspection::GraphSnapshot;
10//! use serde_json::json;
11//!
12//! let mut pipeline = Pipeline::new("example");
13//! pipeline.add_node(Node::new("fetch", "http", json!({"url": "https://example.com"})));
14//! pipeline.add_node(Node::new("extract", "ai", json!({})));
15//! pipeline.add_edge(Edge::new("fetch", "extract"));
16//!
17//! let executor = DagExecutor::from_pipeline(&pipeline).unwrap();
18//! let snapshot = executor.snapshot();
19//!
20//! assert_eq!(snapshot.node_count, 2);
21//! assert_eq!(snapshot.edge_count, 1);
22//! ```
23
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26
27/// Information about a single node in the graph
28///
29/// # Example
30///
31/// ```
32/// use stygian_graph::domain::introspection::NodeInfo;
33///
34/// let info = NodeInfo {
35///     id: "fetch".to_string(),
36///     service: "http".to_string(),
37///     depth: 0,
38///     predecessors: vec![],
39///     successors: vec!["extract".to_string()],
40///     in_degree: 0,
41///     out_degree: 1,
42///     config: serde_json::json!({"url": "https://example.com"}),
43///     metadata: serde_json::json!(null),
44/// };
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct NodeInfo {
48    /// Unique node identifier
49    pub id: String,
50
51    /// Service type (http, ai, browser, etc.)
52    pub service: String,
53
54    /// Depth in the graph (distance from root nodes)
55    pub depth: usize,
56
57    /// IDs of nodes that feed into this node
58    pub predecessors: Vec<String>,
59
60    /// IDs of nodes this node feeds into
61    pub successors: Vec<String>,
62
63    /// Number of incoming edges
64    pub in_degree: usize,
65
66    /// Number of outgoing edges
67    pub out_degree: usize,
68
69    /// Node configuration
70    pub config: serde_json::Value,
71
72    /// Node metadata
73    pub metadata: serde_json::Value,
74}
75
76/// Information about a single edge in the graph
77///
78/// # Example
79///
80/// ```
81/// use stygian_graph::domain::introspection::EdgeInfo;
82///
83/// let edge = EdgeInfo {
84///     from: "fetch".to_string(),
85///     to: "extract".to_string(),
86///     config: serde_json::json!(null),
87/// };
88/// ```
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EdgeInfo {
91    /// Source node ID
92    pub from: String,
93
94    /// Target node ID
95    pub to: String,
96
97    /// Edge configuration (transforms, filters, etc.)
98    pub config: serde_json::Value,
99}
100
101/// Execution wave: a group of nodes that can run concurrently
102///
103/// Nodes in the same wave have no dependencies on each other.
104///
105/// # Example
106///
107/// ```
108/// use stygian_graph::domain::introspection::ExecutionWave;
109///
110/// let wave = ExecutionWave {
111///     level: 0,
112///     node_ids: vec!["fetch_a".to_string(), "fetch_b".to_string()],
113/// };
114/// ```
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ExecutionWave {
117    /// Wave level (0 = root nodes, 1 = their dependents, etc.)
118    pub level: usize,
119
120    /// Node IDs in this wave (can run concurrently)
121    pub node_ids: Vec<String>,
122}
123
124/// Critical path analysis result
125///
126/// Identifies the longest execution path through the graph.
127///
128/// # Example
129///
130/// ```
131/// use stygian_graph::domain::introspection::CriticalPath;
132///
133/// let path = CriticalPath {
134///     nodes: vec!["fetch".to_string(), "process".to_string(), "store".to_string()],
135///     length: 3,
136/// };
137/// ```
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CriticalPath {
140    /// Node IDs on the critical path, in execution order
141    pub nodes: Vec<String>,
142
143    /// Path length (number of nodes)
144    pub length: usize,
145}
146
147/// Graph connectivity metrics
148///
149/// # Example
150///
151/// ```
152/// use stygian_graph::domain::introspection::ConnectivityMetrics;
153///
154/// let metrics = ConnectivityMetrics {
155///     is_connected: true,
156///     component_count: 1,
157///     root_nodes: vec!["fetch".to_string()],
158///     leaf_nodes: vec!["store".to_string()],
159///     max_depth: 3,
160///     avg_degree: 1.5,
161/// };
162/// ```
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ConnectivityMetrics {
165    /// Whether all nodes are reachable from at least one root
166    pub is_connected: bool,
167
168    /// Number of weakly connected components
169    pub component_count: usize,
170
171    /// Nodes with no incoming edges (starting points)
172    pub root_nodes: Vec<String>,
173
174    /// Nodes with no outgoing edges (endpoints)
175    pub leaf_nodes: Vec<String>,
176
177    /// Maximum depth of the graph
178    pub max_depth: usize,
179
180    /// Average node degree (in + out)
181    pub avg_degree: f64,
182}
183
184/// Complete snapshot of graph structure and analysis
185///
186/// Provides a comprehensive view of the graph state for introspection.
187///
188/// # Example
189///
190/// ```
191/// use stygian_graph::domain::graph::{Pipeline, Node, DagExecutor};
192/// use serde_json::json;
193///
194/// let mut pipeline = Pipeline::new("test");
195/// pipeline.add_node(Node::new("fetch", "http", json!({})));
196///
197/// let executor = DagExecutor::from_pipeline(&pipeline).unwrap();
198/// let snapshot = executor.snapshot();
199///
200/// assert_eq!(snapshot.node_count, 1);
201/// assert_eq!(snapshot.edge_count, 0);
202/// ```
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct GraphSnapshot {
205    /// Total number of nodes
206    pub node_count: usize,
207
208    /// Total number of edges
209    pub edge_count: usize,
210
211    /// All nodes with their info
212    pub nodes: Vec<NodeInfo>,
213
214    /// All edges with their info
215    pub edges: Vec<EdgeInfo>,
216
217    /// Execution waves (concurrent groups)
218    pub waves: Vec<ExecutionWave>,
219
220    /// Topological execution order
221    pub topological_order: Vec<String>,
222
223    /// Critical path through the graph
224    pub critical_path: CriticalPath,
225
226    /// Connectivity metrics
227    pub connectivity: ConnectivityMetrics,
228
229    /// Service type distribution (service -> count)
230    pub service_distribution: HashMap<String, usize>,
231}
232
233/// Query for filtering nodes in introspection
234///
235/// # Example
236///
237/// ```
238/// use stygian_graph::domain::introspection::NodeQuery;
239///
240/// // Find all HTTP service nodes
241/// let query = NodeQuery {
242///     service: Some("http".to_string()),
243///     ..Default::default()
244/// };
245///
246/// // Find root nodes only
247/// let root_query = NodeQuery {
248///     is_root: Some(true),
249///     ..Default::default()
250/// };
251/// ```
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253pub struct NodeQuery {
254    /// Filter by service type
255    pub service: Option<String>,
256
257    /// Filter by exact node ID
258    pub id: Option<String>,
259
260    /// Filter by ID pattern (substring match)
261    pub id_pattern: Option<String>,
262
263    /// Only root nodes (in_degree = 0)
264    pub is_root: Option<bool>,
265
266    /// Only leaf nodes (out_degree = 0)
267    pub is_leaf: Option<bool>,
268
269    /// Minimum depth
270    pub min_depth: Option<usize>,
271
272    /// Maximum depth
273    pub max_depth: Option<usize>,
274}
275
276impl NodeQuery {
277    /// Create a query that matches all nodes
278    ///
279    /// # Example
280    ///
281    /// ```
282    /// use stygian_graph::domain::introspection::NodeQuery;
283    ///
284    /// let query = NodeQuery::all();
285    /// ```
286    #[must_use]
287    pub fn all() -> Self {
288        Self::default()
289    }
290
291    /// Create a query filtering by service type
292    ///
293    /// # Example
294    ///
295    /// ```
296    /// use stygian_graph::domain::introspection::NodeQuery;
297    ///
298    /// let query = NodeQuery::by_service("http");
299    /// ```
300    #[must_use]
301    pub fn by_service(service: impl Into<String>) -> Self {
302        Self {
303            service: Some(service.into()),
304            ..Default::default()
305        }
306    }
307
308    /// Create a query for root nodes
309    ///
310    /// # Example
311    ///
312    /// ```
313    /// use stygian_graph::domain::introspection::NodeQuery;
314    ///
315    /// let query = NodeQuery::roots();
316    /// ```
317    #[must_use]
318    pub fn roots() -> Self {
319        Self {
320            is_root: Some(true),
321            ..Default::default()
322        }
323    }
324
325    /// Create a query for leaf nodes
326    ///
327    /// # Example
328    ///
329    /// ```
330    /// use stygian_graph::domain::introspection::NodeQuery;
331    ///
332    /// let query = NodeQuery::leaves();
333    /// ```
334    #[must_use]
335    pub fn leaves() -> Self {
336        Self {
337            is_leaf: Some(true),
338            ..Default::default()
339        }
340    }
341
342    /// Check if a node matches this query
343    #[must_use]
344    pub fn matches(&self, node: &NodeInfo) -> bool {
345        if let Some(ref service) = self.service
346            && &node.service != service
347        {
348            return false;
349        }
350
351        if let Some(ref id) = self.id
352            && &node.id != id
353        {
354            return false;
355        }
356
357        if let Some(ref pattern) = self.id_pattern
358            && !node.id.contains(pattern)
359        {
360            return false;
361        }
362
363        if let Some(is_root) = self.is_root
364            && is_root != (node.in_degree == 0)
365        {
366            return false;
367        }
368
369        if let Some(is_leaf) = self.is_leaf
370            && is_leaf != (node.out_degree == 0)
371        {
372            return false;
373        }
374
375        if let Some(min_depth) = self.min_depth
376            && node.depth < min_depth
377        {
378            return false;
379        }
380
381        if let Some(max_depth) = self.max_depth
382            && node.depth > max_depth
383        {
384            return false;
385        }
386
387        true
388    }
389}
390
391/// Dependency chain from one node to another
392///
393/// # Example
394///
395/// ```
396/// use stygian_graph::domain::introspection::DependencyChain;
397///
398/// let chain = DependencyChain {
399///     from: "fetch".to_string(),
400///     to: "store".to_string(),
401///     path: vec!["fetch".to_string(), "process".to_string(), "store".to_string()],
402///     distance: 2,
403/// };
404/// ```
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct DependencyChain {
407    /// Starting node ID
408    pub from: String,
409
410    /// Ending node ID
411    pub to: String,
412
413    /// All nodes in the path, from start to end
414    pub path: Vec<String>,
415
416    /// Number of edges between from and to
417    pub distance: usize,
418}
419
420/// Impact analysis when modifying a node
421///
422/// Shows what would be affected if a node changes.
423///
424/// # Example
425///
426/// ```
427/// use stygian_graph::domain::introspection::ImpactAnalysis;
428///
429/// let impact = ImpactAnalysis {
430///     node_id: "fetch".to_string(),
431///     upstream: vec![],
432///     downstream: vec!["extract".to_string(), "store".to_string()],
433///     total_affected: 2,
434/// };
435/// ```
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct ImpactAnalysis {
438    /// The node being analyzed
439    pub node_id: String,
440
441    /// All upstream dependencies (transitively)
442    pub upstream: Vec<String>,
443
444    /// All downstream dependents (transitively)
445    pub downstream: Vec<String>,
446
447    /// Total nodes affected (upstream + downstream)
448    pub total_affected: usize,
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_node_query_all() {
457        let query = NodeQuery::all();
458        let node = NodeInfo {
459            id: "test".to_string(),
460            service: "http".to_string(),
461            depth: 0,
462            predecessors: vec![],
463            successors: vec![],
464            in_degree: 0,
465            out_degree: 0,
466            config: serde_json::Value::Null,
467            metadata: serde_json::Value::Null,
468        };
469        assert!(query.matches(&node));
470    }
471
472    #[test]
473    fn test_node_query_by_service() {
474        let query = NodeQuery::by_service("http");
475        let http_node = NodeInfo {
476            id: "fetch".to_string(),
477            service: "http".to_string(),
478            depth: 0,
479            predecessors: vec![],
480            successors: vec![],
481            in_degree: 0,
482            out_degree: 0,
483            config: serde_json::Value::Null,
484            metadata: serde_json::Value::Null,
485        };
486        let ai_node = NodeInfo {
487            id: "extract".to_string(),
488            service: "ai".to_string(),
489            depth: 1,
490            predecessors: vec!["fetch".to_string()],
491            successors: vec![],
492            in_degree: 1,
493            out_degree: 0,
494            config: serde_json::Value::Null,
495            metadata: serde_json::Value::Null,
496        };
497
498        assert!(query.matches(&http_node));
499        assert!(!query.matches(&ai_node));
500    }
501
502    #[test]
503    fn test_node_query_roots() {
504        let query = NodeQuery::roots();
505        let root = NodeInfo {
506            id: "fetch".to_string(),
507            service: "http".to_string(),
508            depth: 0,
509            predecessors: vec![],
510            successors: vec!["extract".to_string()],
511            in_degree: 0,
512            out_degree: 1,
513            config: serde_json::Value::Null,
514            metadata: serde_json::Value::Null,
515        };
516        let non_root = NodeInfo {
517            id: "extract".to_string(),
518            service: "ai".to_string(),
519            depth: 1,
520            predecessors: vec!["fetch".to_string()],
521            successors: vec![],
522            in_degree: 1,
523            out_degree: 0,
524            config: serde_json::Value::Null,
525            metadata: serde_json::Value::Null,
526        };
527
528        assert!(query.matches(&root));
529        assert!(!query.matches(&non_root));
530    }
531
532    #[test]
533    fn test_node_query_depth_range() {
534        let query = NodeQuery {
535            min_depth: Some(1),
536            max_depth: Some(2),
537            ..Default::default()
538        };
539
540        let depth_0 = NodeInfo {
541            id: "a".to_string(),
542            service: "http".to_string(),
543            depth: 0,
544            predecessors: vec![],
545            successors: vec![],
546            in_degree: 0,
547            out_degree: 0,
548            config: serde_json::Value::Null,
549            metadata: serde_json::Value::Null,
550        };
551        let depth_1 = NodeInfo {
552            id: "b".to_string(),
553            service: "http".to_string(),
554            depth: 1,
555            predecessors: vec![],
556            successors: vec![],
557            in_degree: 1,
558            out_degree: 0,
559            config: serde_json::Value::Null,
560            metadata: serde_json::Value::Null,
561        };
562        let depth_3 = NodeInfo {
563            id: "c".to_string(),
564            service: "http".to_string(),
565            depth: 3,
566            predecessors: vec![],
567            successors: vec![],
568            in_degree: 1,
569            out_degree: 0,
570            config: serde_json::Value::Null,
571            metadata: serde_json::Value::Null,
572        };
573
574        assert!(!query.matches(&depth_0));
575        assert!(query.matches(&depth_1));
576        assert!(!query.matches(&depth_3));
577    }
578}