clnrm_core/otel/validators/
graph.rs

1//! Graph topology validation for fake-green detection
2//!
3//! Validates span graph structure to ensure tests actually executed:
4//! - Required edges (must_include): parent→child relationships that must exist
5//! - Forbidden edges (must_not_cross): isolation boundaries that must not be crossed
6//! - Acyclicity: ensures proper execution flow without cycles
7
8use crate::error::{CleanroomError, Result};
9use crate::validation::span_validator::SpanData;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// Graph validation result
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ValidationResult {
16    /// Whether validation passed
17    pub passed: bool,
18    /// Validation error messages
19    pub errors: Vec<String>,
20    /// Number of edges validated
21    pub edges_checked: usize,
22}
23
24impl ValidationResult {
25    /// Create a passing result
26    pub fn pass(edges_checked: usize) -> Self {
27        Self {
28            passed: true,
29            errors: Vec::new(),
30            edges_checked,
31        }
32    }
33
34    /// Create a failing result
35    pub fn fail(error: String, edges_checked: usize) -> Self {
36        Self {
37            passed: false,
38            errors: vec![error],
39            edges_checked,
40        }
41    }
42
43    /// Add an error
44    pub fn add_error(&mut self, error: String) {
45        self.passed = false;
46        self.errors.push(error);
47    }
48}
49
50/// Graph topology expectation for fake-green detection
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct GraphExpectation {
53    /// Required edges: (parent_name, child_name) that MUST exist
54    pub must_include: Vec<(String, String)>,
55
56    /// Forbidden edges: (parent_name, child_name) that MUST NOT exist
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub must_not_cross: Option<Vec<(String, String)>>,
59
60    /// If true, validates graph has no cycles
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub acyclic: Option<bool>,
63}
64
65impl GraphExpectation {
66    /// Create a new graph expectation with required edges
67    pub fn new(must_include: Vec<(String, String)>) -> Self {
68        Self {
69            must_include,
70            must_not_cross: None,
71            acyclic: None,
72        }
73    }
74
75    /// Set forbidden edges
76    pub fn with_must_not_cross(mut self, must_not_cross: Vec<(String, String)>) -> Self {
77        self.must_not_cross = Some(must_not_cross);
78        self
79    }
80
81    /// Enable acyclicity check
82    pub fn with_acyclic(mut self, acyclic: bool) -> Self {
83        self.acyclic = Some(acyclic);
84        self
85    }
86
87    /// Validate graph topology against spans
88    ///
89    /// # Arguments
90    /// * `spans` - All spans to validate
91    ///
92    /// # Returns
93    /// * `Result<ValidationResult>` - Validation result or error
94    ///
95    /// # Errors
96    /// * Missing required edges
97    /// * Presence of forbidden edges
98    /// * Cycles detected when acyclic=true
99    pub fn validate(&self, spans: &[SpanData]) -> Result<ValidationResult> {
100        let validator = GraphValidator::new(spans);
101        let mut result = ValidationResult::pass(0);
102
103        // Validate must_include edges
104        for (parent_name, child_name) in &self.must_include {
105            result.edges_checked += 1;
106            if let Err(e) = validator.validate_edge_exists(parent_name, child_name) {
107                result.add_error(e.message);
108            }
109        }
110
111        // Validate must_not_cross edges
112        if let Some(ref forbidden) = self.must_not_cross {
113            for (parent_name, child_name) in forbidden {
114                result.edges_checked += 1;
115                if let Err(e) = validator.validate_edge_not_exists(parent_name, child_name) {
116                    result.add_error(e.message);
117                }
118            }
119        }
120
121        // Validate acyclicity
122        if let Some(true) = self.acyclic {
123            if let Err(e) = validator.validate_acyclic() {
124                result.add_error(e.message);
125            }
126        }
127
128        Ok(result)
129    }
130}
131
132/// Graph validator internal implementation
133pub struct GraphValidator<'a> {
134    /// All spans
135    spans: &'a [SpanData],
136    /// Map from span_id to span (used for advanced graph analysis)
137    #[allow(dead_code)]
138    span_by_id: HashMap<String, &'a SpanData>,
139    /// Map from span name to spans with that name
140    spans_by_name: HashMap<String, Vec<&'a SpanData>>,
141}
142
143impl<'a> GraphValidator<'a> {
144    /// Create a new graph validator
145    pub fn new(spans: &'a [SpanData]) -> Self {
146        let mut span_by_id = HashMap::new();
147        let mut spans_by_name: HashMap<String, Vec<&SpanData>> = HashMap::new();
148
149        for span in spans {
150            span_by_id.insert(span.span_id.clone(), span);
151            spans_by_name.entry(span.name.clone()).or_default().push(span);
152        }
153
154        Self {
155            spans,
156            span_by_id,
157            spans_by_name,
158        }
159    }
160
161    /// Validate that at least one edge exists from parent_name to child_name
162    pub fn validate_edge_exists(&self, parent_name: &str, child_name: &str) -> Result<()> {
163        let parent_spans = self.spans_by_name.get(parent_name).ok_or_else(|| {
164            CleanroomError::validation_error(format!(
165                "Graph validation failed: parent span '{}' not found (fake-green: container never started?)",
166                parent_name
167            ))
168        })?;
169
170        let child_spans = self.spans_by_name.get(child_name).ok_or_else(|| {
171            CleanroomError::validation_error(format!(
172                "Graph validation failed: child span '{}' not found (fake-green: operation never executed?)",
173                child_name
174            ))
175        })?;
176
177        // Check if any child has any parent as its parent_span_id
178        let edge_exists = child_spans.iter().any(|child| {
179            if let Some(ref parent_id) = child.parent_span_id {
180                parent_spans.iter().any(|p| &p.span_id == parent_id)
181            } else {
182                false
183            }
184        });
185
186        if !edge_exists {
187            return Err(CleanroomError::validation_error(format!(
188                "Graph validation failed: required edge '{}' → '{}' not found (fake-green: parent-child relationship missing)",
189                parent_name, child_name
190            )));
191        }
192
193        Ok(())
194    }
195
196    /// Validate that NO edge exists from parent_name to child_name (isolation check)
197    pub fn validate_edge_not_exists(&self, parent_name: &str, child_name: &str) -> Result<()> {
198        // If either span doesn't exist, the edge doesn't exist (pass)
199        let Some(parent_spans) = self.spans_by_name.get(parent_name) else {
200            return Ok(());
201        };
202        let Some(child_spans) = self.spans_by_name.get(child_name) else {
203            return Ok(());
204        };
205
206        // Check if any child has any parent as its parent_span_id
207        let edge_exists = child_spans.iter().any(|child| {
208            if let Some(ref parent_id) = child.parent_span_id {
209                parent_spans.iter().any(|p| &p.span_id == parent_id)
210            } else {
211                false
212            }
213        });
214
215        if edge_exists {
216            return Err(CleanroomError::validation_error(format!(
217                "Graph validation failed: forbidden edge '{}' → '{}' exists (isolation violation)",
218                parent_name, child_name
219            )));
220        }
221
222        Ok(())
223    }
224
225    /// Validate that the span graph is acyclic
226    pub fn validate_acyclic(&self) -> Result<()> {
227        let mut visited = HashSet::new();
228        let mut rec_stack = HashSet::new();
229
230        for span in self.spans {
231            if !visited.contains(&span.span_id) {
232                self.dfs_cycle_check(span, &mut visited, &mut rec_stack)?;
233            }
234        }
235
236        Ok(())
237    }
238
239    /// DFS cycle detection
240    fn dfs_cycle_check(
241        &self,
242        span: &SpanData,
243        visited: &mut HashSet<String>,
244        rec_stack: &mut HashSet<String>,
245    ) -> Result<()> {
246        visited.insert(span.span_id.clone());
247        rec_stack.insert(span.span_id.clone());
248
249        // Visit children (spans that have this span as parent)
250        for potential_child in self.spans {
251            if let Some(ref parent_id) = potential_child.parent_span_id {
252                if parent_id == &span.span_id {
253                    // Found a child
254                    if !visited.contains(&potential_child.span_id) {
255                        self.dfs_cycle_check(potential_child, visited, rec_stack)?;
256                    } else if rec_stack.contains(&potential_child.span_id) {
257                        // Cycle detected
258                        return Err(CleanroomError::validation_error(format!(
259                            "Graph validation failed: cycle detected involving span '{}' → '{}'",
260                            span.name, potential_child.name
261                        )));
262                    }
263                }
264            }
265        }
266
267        rec_stack.remove(&span.span_id);
268        Ok(())
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn create_span(name: &str, span_id: &str, parent_id: Option<&str>) -> SpanData {
277        SpanData {
278            name: name.to_string(),
279            span_id: span_id.to_string(),
280            parent_span_id: parent_id.map(String::from),
281            trace_id: "trace123".to_string(),
282            attributes: HashMap::new(),
283            start_time_unix_nano: Some(1000000000),
284            end_time_unix_nano: Some(1100000000),
285            kind: None,
286            events: None,
287            resource_attributes: HashMap::new(),
288        }
289    }
290
291    #[test]
292    fn test_graph_expectation_edge_exists() -> Result<()> {
293        // Arrange
294        let spans = vec![
295            create_span("container.start", "span1", None),
296            create_span("container.exec", "span2", Some("span1")),
297        ];
298        let expectation = GraphExpectation::new(vec![("container.start".to_string(), "container.exec".to_string())]);
299
300        // Act
301        let result = expectation.validate(&spans)?;
302
303        // Assert
304        assert!(result.passed);
305        assert_eq!(result.edges_checked, 1);
306        Ok(())
307    }
308
309    #[test]
310    fn test_graph_expectation_edge_missing() -> Result<()> {
311        // Arrange
312        let spans = vec![
313            create_span("container.start", "span1", None),
314            create_span("container.exec", "span2", None), // No parent!
315        ];
316        let expectation = GraphExpectation::new(vec![("container.start".to_string(), "container.exec".to_string())]);
317
318        // Act
319        let result = expectation.validate(&spans)?;
320
321        // Assert
322        assert!(!result.passed);
323        assert!(!result.errors.is_empty());
324        Ok(())
325    }
326
327    #[test]
328    fn test_graph_expectation_forbidden_edge() -> Result<()> {
329        // Arrange
330        let spans = vec![
331            create_span("test1", "span1", None),
332            create_span("test2", "span2", Some("span1")),
333        ];
334        let expectation = GraphExpectation::new(vec![])
335            .with_must_not_cross(vec![("test1".to_string(), "test2".to_string())]);
336
337        // Act
338        let result = expectation.validate(&spans)?;
339
340        // Assert
341        assert!(!result.passed);
342        assert!(!result.errors.is_empty());
343        Ok(())
344    }
345
346    #[test]
347    fn test_graph_expectation_acyclic_pass() -> Result<()> {
348        // Arrange
349        let spans = vec![
350            create_span("root", "span1", None),
351            create_span("child1", "span2", Some("span1")),
352            create_span("child2", "span3", Some("span2")),
353        ];
354        let expectation = GraphExpectation::new(vec![]).with_acyclic(true);
355
356        // Act
357        let result = expectation.validate(&spans)?;
358
359        // Assert
360        assert!(result.passed);
361        Ok(())
362    }
363
364    #[test]
365    fn test_graph_validator_creation() {
366        // Arrange
367        let spans = vec![
368            create_span("test.span", "span1", None),
369            create_span("test.span", "span2", None),
370        ];
371
372        // Act
373        let validator = GraphValidator::new(&spans);
374
375        // Assert
376        assert_eq!(validator.spans.len(), 2);
377        assert_eq!(validator.span_by_id.len(), 2);
378        assert_eq!(validator.spans_by_name.get("test.span").map(|v| v.len()), Some(2));
379    }
380}