clnrm_core/otel/validators/
graph.rs1use crate::error::{CleanroomError, Result};
9use crate::validation::span_validator::SpanData;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ValidationResult {
16 pub passed: bool,
18 pub errors: Vec<String>,
20 pub edges_checked: usize,
22}
23
24impl ValidationResult {
25 pub fn pass(edges_checked: usize) -> Self {
27 Self {
28 passed: true,
29 errors: Vec::new(),
30 edges_checked,
31 }
32 }
33
34 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 pub fn add_error(&mut self, error: String) {
45 self.passed = false;
46 self.errors.push(error);
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct GraphExpectation {
53 pub must_include: Vec<(String, String)>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub must_not_cross: Option<Vec<(String, String)>>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub acyclic: Option<bool>,
63}
64
65impl GraphExpectation {
66 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 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 pub fn with_acyclic(mut self, acyclic: bool) -> Self {
83 self.acyclic = Some(acyclic);
84 self
85 }
86
87 pub fn validate(&self, spans: &[SpanData]) -> Result<ValidationResult> {
100 let validator = GraphValidator::new(spans);
101 let mut result = ValidationResult::pass(0);
102
103 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 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 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
132pub struct GraphValidator<'a> {
134 spans: &'a [SpanData],
136 #[allow(dead_code)]
138 span_by_id: HashMap<String, &'a SpanData>,
139 spans_by_name: HashMap<String, Vec<&'a SpanData>>,
141}
142
143impl<'a> GraphValidator<'a> {
144 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 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 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 pub fn validate_edge_not_exists(&self, parent_name: &str, child_name: &str) -> Result<()> {
198 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 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 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 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 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 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 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 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 let result = expectation.validate(&spans)?;
302
303 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 let spans = vec![
313 create_span("container.start", "span1", None),
314 create_span("container.exec", "span2", None), ];
316 let expectation = GraphExpectation::new(vec![("container.start".to_string(), "container.exec".to_string())]);
317
318 let result = expectation.validate(&spans)?;
320
321 assert!(!result.passed);
323 assert!(!result.errors.is_empty());
324 Ok(())
325 }
326
327 #[test]
328 fn test_graph_expectation_forbidden_edge() -> Result<()> {
329 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 let result = expectation.validate(&spans)?;
339
340 assert!(!result.passed);
342 assert!(!result.errors.is_empty());
343 Ok(())
344 }
345
346 #[test]
347 fn test_graph_expectation_acyclic_pass() -> Result<()> {
348 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 let result = expectation.validate(&spans)?;
358
359 assert!(result.passed);
361 Ok(())
362 }
363
364 #[test]
365 fn test_graph_validator_creation() {
366 let spans = vec![
368 create_span("test.span", "span1", None),
369 create_span("test.span", "span2", None),
370 ];
371
372 let validator = GraphValidator::new(&spans);
374
375 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}