clnrm_core/validation/
graph_validator.rs

1//! Graph topology validation for span relationships
2//!
3//! Validates graph structure of OTEL spans including:
4//! - Required edges (must_include)
5//! - Forbidden edges (must_not_cross)
6//! - Acyclicity checks
7
8use crate::error::{CleanroomError, Result};
9use crate::validation::span_validator::SpanData;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// Graph topology expectations for span relationships
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct GraphExpectation {
16    /// Required edges: list of (parent_name, child_name) tuples that MUST exist
17    pub must_include: Vec<(String, String)>,
18
19    /// Forbidden edges: list of (parent_name, child_name) tuples that MUST NOT exist
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub must_not_cross: Option<Vec<(String, String)>>,
22
23    /// If true, validates that the span graph has no cycles
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub acyclic: Option<bool>,
26}
27
28impl GraphExpectation {
29    /// Create a new GraphExpectation with required edges only
30    pub fn new(must_include: Vec<(String, String)>) -> Self {
31        Self {
32            must_include,
33            must_not_cross: None,
34            acyclic: None,
35        }
36    }
37
38    /// Set forbidden edges
39    pub fn with_must_not_cross(mut self, must_not_cross: Vec<(String, String)>) -> Self {
40        self.must_not_cross = Some(must_not_cross);
41        self
42    }
43
44    /// Enable acyclicity check
45    pub fn with_acyclic(mut self, acyclic: bool) -> Self {
46        self.acyclic = Some(acyclic);
47        self
48    }
49
50    /// Validate graph topology against the given spans
51    ///
52    /// # Arguments
53    /// * `spans` - The spans to validate
54    ///
55    /// # Returns
56    /// * `Result<()>` - Ok if validation passes, error with details if it fails
57    ///
58    /// # Errors
59    /// * Missing required edges
60    /// * Presence of forbidden edges
61    /// * Cycles detected when acyclic=true
62    pub fn validate(&self, spans: &[SpanData]) -> Result<()> {
63        let validator = GraphValidator::new(spans);
64
65        // Validate must_include edges
66        for (parent_name, child_name) in &self.must_include {
67            validator.validate_edge_exists(parent_name, child_name)?;
68        }
69
70        // Validate must_not_cross edges
71        if let Some(ref forbidden_edges) = self.must_not_cross {
72            for (parent_name, child_name) in forbidden_edges {
73                validator.validate_edge_not_exists(parent_name, child_name)?;
74            }
75        }
76
77        // Validate acyclicity
78        if let Some(true) = self.acyclic {
79            validator.validate_acyclic()?;
80        }
81
82        Ok(())
83    }
84}
85
86/// Graph validator for span relationships
87pub struct GraphValidator<'a> {
88    /// The spans being validated
89    spans: &'a [SpanData],
90
91    /// Map from span_id to SpanData for quick lookup
92    span_by_id: HashMap<String, &'a SpanData>,
93
94    /// Map from span name to list of spans with that name
95    spans_by_name: HashMap<String, Vec<&'a SpanData>>,
96}
97
98impl<'a> GraphValidator<'a> {
99    /// Create a new GraphValidator
100    pub fn new(spans: &'a [SpanData]) -> Self {
101        let mut span_by_id = HashMap::new();
102        let mut spans_by_name: HashMap<String, Vec<&SpanData>> = HashMap::new();
103
104        for span in spans {
105            span_by_id.insert(span.span_id.clone(), span);
106            spans_by_name
107                .entry(span.name.clone())
108                .or_default()
109                .push(span);
110        }
111
112        Self {
113            spans,
114            span_by_id,
115            spans_by_name,
116        }
117    }
118
119    /// Validate that at least one edge exists between parent_name and child_name
120    ///
121    /// # Arguments
122    /// * `parent_name` - Name of parent span
123    /// * `child_name` - Name of child span
124    ///
125    /// # Returns
126    /// * `Result<()>` - Ok if edge exists, error otherwise
127    ///
128    /// # Errors
129    /// * Parent span not found
130    /// * Child span not found
131    /// * No edge found between parent and child
132    pub fn validate_edge_exists(&self, parent_name: &str, child_name: &str) -> Result<()> {
133        let parent_spans = self.spans_by_name.get(parent_name).ok_or_else(|| {
134            CleanroomError::validation_error(format!(
135                "Graph validation failed: parent span '{}' not found",
136                parent_name
137            ))
138        })?;
139
140        let child_spans = self.spans_by_name.get(child_name).ok_or_else(|| {
141            CleanroomError::validation_error(format!(
142                "Graph validation failed: child span '{}' not found",
143                child_name
144            ))
145        })?;
146
147        // Check if any child has any parent as its parent_span_id
148        let edge_exists = child_spans.iter().any(|child| {
149            if let Some(ref parent_id) = child.parent_span_id {
150                parent_spans
151                    .iter()
152                    .any(|parent| &parent.span_id == parent_id)
153            } else {
154                false
155            }
156        });
157
158        if !edge_exists {
159            return Err(CleanroomError::validation_error(format!(
160                "Graph validation failed: required edge '{}' -> '{}' not found",
161                parent_name, child_name
162            )));
163        }
164
165        Ok(())
166    }
167
168    /// Validate that NO edge exists between parent_name and child_name
169    ///
170    /// # Arguments
171    /// * `parent_name` - Name of parent span
172    /// * `child_name` - Name of child span
173    ///
174    /// # Returns
175    /// * `Result<()>` - Ok if edge does NOT exist, error if it does
176    ///
177    /// # Errors
178    /// * Forbidden edge was found
179    pub fn validate_edge_not_exists(&self, parent_name: &str, child_name: &str) -> Result<()> {
180        // If either span doesn't exist, the edge can't exist (valid)
181        let Some(parent_spans) = self.spans_by_name.get(parent_name) else {
182            return Ok(());
183        };
184
185        let Some(child_spans) = self.spans_by_name.get(child_name) else {
186            return Ok(());
187        };
188
189        // Check if any child has any parent as its parent_span_id
190        let edge_exists = child_spans.iter().any(|child| {
191            if let Some(ref parent_id) = child.parent_span_id {
192                parent_spans
193                    .iter()
194                    .any(|parent| &parent.span_id == parent_id)
195            } else {
196                false
197            }
198        });
199
200        if edge_exists {
201            return Err(CleanroomError::validation_error(format!(
202                "Graph validation failed: forbidden edge '{}' -> '{}' found",
203                parent_name, child_name
204            )));
205        }
206
207        Ok(())
208    }
209
210    /// Validate that the span graph is acyclic (no cycles)
211    ///
212    /// Uses depth-first search with visited tracking to detect cycles.
213    ///
214    /// # Returns
215    /// * `Result<()>` - Ok if acyclic, error if cycle detected
216    ///
217    /// # Errors
218    /// * Cycle detected in graph
219    pub fn validate_acyclic(&self) -> Result<()> {
220        // Track visited spans and spans in current DFS path
221        let mut visited = HashSet::new();
222        let mut in_path = HashSet::new();
223
224        // DFS from each span to detect cycles
225        for span in self.spans {
226            if !visited.contains(&span.span_id) {
227                if let Some(cycle_path) =
228                    self.detect_cycle_dfs(span, &mut visited, &mut in_path, &mut Vec::new())
229                {
230                    return Err(CleanroomError::validation_error(format!(
231                        "Graph validation failed: cycle detected in span graph: {}",
232                        cycle_path.join(" -> ")
233                    )));
234                }
235            }
236        }
237
238        Ok(())
239    }
240
241    /// Perform DFS to detect cycles, returning cycle path if found
242    fn detect_cycle_dfs(
243        &self,
244        span: &SpanData,
245        visited: &mut HashSet<String>,
246        in_path: &mut HashSet<String>,
247        path: &mut Vec<String>,
248    ) -> Option<Vec<String>> {
249        visited.insert(span.span_id.clone());
250        in_path.insert(span.span_id.clone());
251        path.push(span.name.clone());
252
253        // Check parent (reverse direction - child points to parent)
254        if let Some(ref parent_id) = span.parent_span_id {
255            if let Some(parent) = self.span_by_id.get(parent_id) {
256                if in_path.contains(parent_id) {
257                    // Cycle detected - build cycle path
258                    path.push(parent.name.clone());
259                    return Some(path.clone());
260                }
261
262                if !visited.contains(parent_id) {
263                    if let Some(cycle) = self.detect_cycle_dfs(parent, visited, in_path, path) {
264                        return Some(cycle);
265                    }
266                }
267            }
268        }
269
270        in_path.remove(&span.span_id);
271        path.pop();
272        None
273    }
274
275    /// Get all edges in the graph as (parent_name, child_name) pairs
276    pub fn get_all_edges(&self) -> Vec<(String, String)> {
277        let mut edges = Vec::new();
278
279        for child in self.spans {
280            if let Some(ref parent_id) = child.parent_span_id {
281                if let Some(parent) = self.span_by_id.get(parent_id) {
282                    edges.push((parent.name.clone(), child.name.clone()));
283                }
284            }
285        }
286
287        edges
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use std::collections::HashMap;
295
296    fn create_span(name: &str, span_id: &str, parent_id: Option<&str>) -> SpanData {
297        SpanData {
298            name: name.to_string(),
299            span_id: span_id.to_string(),
300            parent_span_id: parent_id.map(|s| s.to_string()),
301            trace_id: "trace123".to_string(),
302            attributes: HashMap::new(),
303            start_time_unix_nano: Some(1000000),
304            end_time_unix_nano: Some(2000000),
305            kind: None,
306            events: None,
307            resource_attributes: HashMap::new(),
308        }
309    }
310
311    #[test]
312    fn test_graph_validator_edge_exists_valid() {
313        // Arrange
314        let spans = vec![
315            create_span("parent", "span1", None),
316            create_span("child", "span2", Some("span1")),
317        ];
318        let validator = GraphValidator::new(&spans);
319
320        // Act
321        let result = validator.validate_edge_exists("parent", "child");
322
323        // Assert
324        assert!(result.is_ok());
325    }
326
327    #[test]
328    fn test_graph_validator_edge_exists_missing() {
329        // Arrange
330        let spans = vec![
331            create_span("parent", "span1", None),
332            create_span("child", "span2", None),
333        ];
334        let validator = GraphValidator::new(&spans);
335
336        // Act
337        let result = validator.validate_edge_exists("parent", "child");
338
339        // Assert
340        assert!(result.is_err());
341        let err_msg = result.unwrap_err().to_string();
342        assert!(err_msg.contains("required edge"));
343        assert!(err_msg.contains("parent"));
344        assert!(err_msg.contains("child"));
345    }
346
347    #[test]
348    fn test_graph_validator_edge_exists_parent_not_found() {
349        // Arrange
350        let spans = vec![create_span("child", "span2", None)];
351        let validator = GraphValidator::new(&spans);
352
353        // Act
354        let result = validator.validate_edge_exists("parent", "child");
355
356        // Assert
357        assert!(result.is_err());
358        let err_msg = result.unwrap_err().to_string();
359        assert!(err_msg.contains("parent span"));
360        assert!(err_msg.contains("not found"));
361    }
362
363    #[test]
364    fn test_graph_validator_edge_exists_child_not_found() {
365        // Arrange
366        let spans = vec![create_span("parent", "span1", None)];
367        let validator = GraphValidator::new(&spans);
368
369        // Act
370        let result = validator.validate_edge_exists("parent", "child");
371
372        // Assert
373        assert!(result.is_err());
374        let err_msg = result.unwrap_err().to_string();
375        assert!(err_msg.contains("child span"));
376        assert!(err_msg.contains("not found"));
377    }
378
379    #[test]
380    fn test_graph_validator_edge_not_exists_valid() {
381        // Arrange
382        let spans = vec![
383            create_span("parent", "span1", None),
384            create_span("child", "span2", None),
385        ];
386        let validator = GraphValidator::new(&spans);
387
388        // Act
389        let result = validator.validate_edge_not_exists("parent", "child");
390
391        // Assert
392        assert!(result.is_ok());
393    }
394
395    #[test]
396    fn test_graph_validator_edge_not_exists_fails_when_edge_present() {
397        // Arrange
398        let spans = vec![
399            create_span("parent", "span1", None),
400            create_span("child", "span2", Some("span1")),
401        ];
402        let validator = GraphValidator::new(&spans);
403
404        // Act
405        let result = validator.validate_edge_not_exists("parent", "child");
406
407        // Assert
408        assert!(result.is_err());
409        let err_msg = result.unwrap_err().to_string();
410        assert!(err_msg.contains("forbidden edge"));
411        assert!(err_msg.contains("parent"));
412        assert!(err_msg.contains("child"));
413    }
414
415    #[test]
416    fn test_graph_validator_edge_not_exists_valid_when_parent_missing() {
417        // Arrange
418        let spans = vec![create_span("child", "span2", None)];
419        let validator = GraphValidator::new(&spans);
420
421        // Act
422        let result = validator.validate_edge_not_exists("parent", "child");
423
424        // Assert
425        assert!(result.is_ok());
426    }
427
428    #[test]
429    fn test_graph_validator_acyclic_valid_linear_chain() {
430        // Arrange - linear chain: root -> a -> b
431        let spans = vec![
432            create_span("root", "span1", None),
433            create_span("a", "span2", Some("span1")),
434            create_span("b", "span3", Some("span2")),
435        ];
436        let validator = GraphValidator::new(&spans);
437
438        // Act
439        let result = validator.validate_acyclic();
440
441        // Assert
442        assert!(result.is_ok());
443    }
444
445    #[test]
446    fn test_graph_validator_acyclic_valid_tree() {
447        // Arrange - tree: root -> [a, b], a -> c
448        let spans = vec![
449            create_span("root", "span1", None),
450            create_span("a", "span2", Some("span1")),
451            create_span("b", "span3", Some("span1")),
452            create_span("c", "span4", Some("span2")),
453        ];
454        let validator = GraphValidator::new(&spans);
455
456        // Act
457        let result = validator.validate_acyclic();
458
459        // Assert
460        assert!(result.is_ok());
461    }
462
463    #[test]
464    fn test_graph_validator_acyclic_detects_self_loop() {
465        // Arrange - self-loop: a -> a
466        let spans = vec![create_span("a", "span1", Some("span1"))];
467        let validator = GraphValidator::new(&spans);
468
469        // Act
470        let result = validator.validate_acyclic();
471
472        // Assert
473        assert!(result.is_err());
474        let err_msg = result.unwrap_err().to_string();
475        assert!(err_msg.contains("cycle detected"));
476    }
477
478    #[test]
479    fn test_graph_validator_acyclic_valid_multiple_roots() {
480        // Arrange - multiple independent trees
481        let spans = vec![
482            create_span("root1", "span1", None),
483            create_span("a", "span2", Some("span1")),
484            create_span("root2", "span3", None),
485            create_span("b", "span4", Some("span3")),
486        ];
487        let validator = GraphValidator::new(&spans);
488
489        // Act
490        let result = validator.validate_acyclic();
491
492        // Assert
493        assert!(result.is_ok());
494    }
495
496    #[test]
497    fn test_graph_expectation_validate_must_include() {
498        // Arrange
499        let spans = vec![
500            create_span("parent", "span1", None),
501            create_span("child", "span2", Some("span1")),
502        ];
503        let expectation = GraphExpectation::new(vec![("parent".to_string(), "child".to_string())]);
504
505        // Act
506        let result = expectation.validate(&spans);
507
508        // Assert
509        assert!(result.is_ok());
510    }
511
512    #[test]
513    fn test_graph_expectation_validate_must_include_fails() {
514        // Arrange
515        let spans = vec![
516            create_span("parent", "span1", None),
517            create_span("child", "span2", None),
518        ];
519        let expectation = GraphExpectation::new(vec![("parent".to_string(), "child".to_string())]);
520
521        // Act
522        let result = expectation.validate(&spans);
523
524        // Assert
525        assert!(result.is_err());
526    }
527
528    #[test]
529    fn test_graph_expectation_validate_must_not_cross() {
530        // Arrange
531        let spans = vec![
532            create_span("a", "span1", None),
533            create_span("b", "span2", None),
534        ];
535        let expectation = GraphExpectation::new(vec![])
536            .with_must_not_cross(vec![("a".to_string(), "b".to_string())]);
537
538        // Act
539        let result = expectation.validate(&spans);
540
541        // Assert
542        assert!(result.is_ok());
543    }
544
545    #[test]
546    fn test_graph_expectation_validate_must_not_cross_fails() {
547        // Arrange
548        let spans = vec![
549            create_span("a", "span1", None),
550            create_span("b", "span2", Some("span1")),
551        ];
552        let expectation = GraphExpectation::new(vec![])
553            .with_must_not_cross(vec![("a".to_string(), "b".to_string())]);
554
555        // Act
556        let result = expectation.validate(&spans);
557
558        // Assert
559        assert!(result.is_err());
560    }
561
562    #[test]
563    fn test_graph_expectation_validate_acyclic() {
564        // Arrange
565        let spans = vec![
566            create_span("root", "span1", None),
567            create_span("a", "span2", Some("span1")),
568            create_span("b", "span3", Some("span2")),
569        ];
570        let expectation = GraphExpectation::new(vec![]).with_acyclic(true);
571
572        // Act
573        let result = expectation.validate(&spans);
574
575        // Assert
576        assert!(result.is_ok());
577    }
578
579    #[test]
580    fn test_graph_expectation_validate_combined_requirements() {
581        // Arrange
582        let spans = vec![
583            create_span("parent", "span1", None),
584            create_span("child1", "span2", Some("span1")),
585            create_span("child2", "span3", Some("span1")),
586            create_span("grandchild", "span4", Some("span2")),
587        ];
588        let expectation = GraphExpectation::new(vec![
589            ("parent".to_string(), "child1".to_string()),
590            ("parent".to_string(), "child2".to_string()),
591        ])
592        .with_must_not_cross(vec![("child1".to_string(), "child2".to_string())])
593        .with_acyclic(true);
594
595        // Act
596        let result = expectation.validate(&spans);
597
598        // Assert
599        assert!(result.is_ok());
600    }
601
602    #[test]
603    fn test_graph_expectation_multiple_spans_same_name() {
604        // Arrange - multiple spans with same name (common in distributed traces)
605        let spans = vec![
606            create_span("http.request", "span1", None),
607            create_span("http.request", "span2", None),
608            create_span("db.query", "span3", Some("span1")),
609            create_span("db.query", "span4", Some("span2")),
610        ];
611        let expectation =
612            GraphExpectation::new(vec![("http.request".to_string(), "db.query".to_string())]);
613
614        // Act
615        let result = expectation.validate(&spans);
616
617        // Assert - should pass because at least one edge exists
618        assert!(result.is_ok());
619    }
620
621    #[test]
622    fn test_graph_validator_get_all_edges() {
623        // Arrange
624        let spans = vec![
625            create_span("root", "span1", None),
626            create_span("a", "span2", Some("span1")),
627            create_span("b", "span3", Some("span1")),
628            create_span("c", "span4", Some("span2")),
629        ];
630        let validator = GraphValidator::new(&spans);
631
632        // Act
633        let edges = validator.get_all_edges();
634
635        // Assert
636        assert_eq!(edges.len(), 3);
637        assert!(edges.contains(&("root".to_string(), "a".to_string())));
638        assert!(edges.contains(&("root".to_string(), "b".to_string())));
639        assert!(edges.contains(&("a".to_string(), "c".to_string())));
640    }
641}