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}