Skip to main content

oximedia_graph/
graph_validation.rs

1//! Validation of filter/pipeline graphs.
2#![allow(dead_code)]
3
4/// A single issue found during graph validation.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum GraphIssue {
7    /// A directed cycle exists; the graph cannot be processed in order.
8    CycleDetected {
9        /// Human-readable description of where the cycle was detected.
10        description: String,
11    },
12    /// A node has no incoming connections when at least one is required.
13    DisconnectedInput {
14        /// Label of the node that is missing an incoming connection.
15        node_label: String,
16    },
17    /// A node has no outgoing connections when at least one is required.
18    DisconnectedOutput {
19        /// Label of the node that is missing an outgoing connection.
20        node_label: String,
21    },
22    /// Two adjacent nodes have incompatible formats / types.
23    IncompatibleFormat {
24        /// Label of the upstream (source) node.
25        from_label: String,
26        /// Label of the downstream (destination) node.
27        to_label: String,
28    },
29    /// A node references a resource that does not exist.
30    MissingResource {
31        /// Label of the node with the missing resource reference.
32        node_label: String,
33        /// Name / identifier of the missing resource.
34        resource: String,
35    },
36    /// Informational warning; does not block execution.
37    Warning {
38        /// Human-readable warning message.
39        message: String,
40    },
41}
42
43impl GraphIssue {
44    /// Returns `true` if this issue must be resolved before the graph can run.
45    #[must_use]
46    pub fn is_fatal(&self) -> bool {
47        !matches!(self, Self::Warning { .. })
48    }
49
50    /// Short category tag for the issue.
51    #[must_use]
52    pub fn category(&self) -> &'static str {
53        match self {
54            Self::CycleDetected { .. } => "cycle",
55            Self::DisconnectedInput { .. } => "disconnected_input",
56            Self::DisconnectedOutput { .. } => "disconnected_output",
57            Self::IncompatibleFormat { .. } => "format_mismatch",
58            Self::MissingResource { .. } => "missing_resource",
59            Self::Warning { .. } => "warning",
60        }
61    }
62}
63
64/// The aggregated result of a validation pass.
65#[derive(Debug, Clone, Default)]
66pub struct GraphValidationResult {
67    issues: Vec<GraphIssue>,
68}
69
70impl GraphValidationResult {
71    /// Create an empty (pass) result.
72    #[must_use]
73    pub fn new() -> Self {
74        Self { issues: Vec::new() }
75    }
76
77    /// Add an issue to the result.
78    pub fn push(&mut self, issue: GraphIssue) {
79        self.issues.push(issue);
80    }
81
82    /// Returns `true` if any fatal issue is present.
83    #[must_use]
84    pub fn has_fatal(&self) -> bool {
85        self.issues.iter().any(|i| i.is_fatal())
86    }
87
88    /// Returns `true` if there are zero issues (no warnings either).
89    #[must_use]
90    pub fn is_clean(&self) -> bool {
91        self.issues.is_empty()
92    }
93
94    /// All issues in this result.
95    #[must_use]
96    pub fn issues(&self) -> &[GraphIssue] {
97        &self.issues
98    }
99
100    /// Count of fatal issues.
101    #[must_use]
102    pub fn fatal_count(&self) -> usize {
103        self.issues.iter().filter(|i| i.is_fatal()).count()
104    }
105
106    /// Count of warnings (non-fatal issues).
107    #[must_use]
108    pub fn warning_count(&self) -> usize {
109        self.issues.iter().filter(|i| !i.is_fatal()).count()
110    }
111}
112
113/// A graph description suitable for validation – minimal representation.
114#[derive(Debug, Clone)]
115pub struct GraphDescriptor {
116    /// `(from_index, to_index)` directed edges.
117    pub edges: Vec<(usize, usize)>,
118    /// Node labels (index-aligned with nodes).
119    pub node_labels: Vec<String>,
120    /// Whether each node requires at least one incoming edge.
121    pub requires_input: Vec<bool>,
122    /// Whether each node requires at least one outgoing edge.
123    pub requires_output: Vec<bool>,
124}
125
126impl GraphDescriptor {
127    /// Create a descriptor for `n` nodes with no edges.
128    #[must_use]
129    pub fn new(n: usize) -> Self {
130        Self {
131            edges: Vec::new(),
132            node_labels: (0..n).map(|i| format!("node_{i}")).collect(),
133            requires_input: vec![true; n],
134            requires_output: vec![true; n],
135        }
136    }
137
138    /// Add a directed edge.
139    pub fn add_edge(&mut self, from: usize, to: usize) {
140        self.edges.push((from, to));
141    }
142}
143
144/// Validates a [`GraphDescriptor`] and produces a [`GraphValidationResult`].
145pub struct GraphValidator;
146
147impl GraphValidator {
148    /// Run all checks on `desc` and return the combined result.
149    #[must_use]
150    pub fn validate(desc: &GraphDescriptor) -> GraphValidationResult {
151        let mut result = GraphValidationResult::new();
152        Self::check_connectivity(desc, &mut result);
153        Self::check_cycles(desc, &mut result);
154        result
155    }
156
157    /// Count the number of directed cycles found in `desc`.
158    ///
159    /// Uses DFS to count strongly-connected back-edges.
160    #[must_use]
161    pub fn cycle_count(desc: &GraphDescriptor) -> usize {
162        let n = desc.node_labels.len();
163        if n == 0 {
164            return 0;
165        }
166
167        // Build adjacency list
168        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
169        for &(f, t) in &desc.edges {
170            if f < n && t < n {
171                adj[f].push(t);
172            }
173        }
174
175        // 0 = unvisited, 1 = in-stack, 2 = done
176        let mut color = vec![0u8; n];
177        let mut back_edges = 0usize;
178
179        fn dfs(node: usize, adj: &[Vec<usize>], color: &mut Vec<u8>, back_edges: &mut usize) {
180            color[node] = 1;
181            for &nb in &adj[node] {
182                if color[nb] == 1 {
183                    *back_edges += 1;
184                } else if color[nb] == 0 {
185                    dfs(nb, adj, color, back_edges);
186                }
187            }
188            color[node] = 2;
189        }
190
191        for i in 0..n {
192            if color[i] == 0 {
193                dfs(i, &adj, &mut color, &mut back_edges);
194            }
195        }
196        back_edges
197    }
198
199    // -- private helpers --
200
201    fn check_connectivity(desc: &GraphDescriptor, result: &mut GraphValidationResult) {
202        let n = desc.node_labels.len();
203        let mut has_incoming = vec![false; n];
204        let mut has_outgoing = vec![false; n];
205
206        for &(f, t) in &desc.edges {
207            if f < n {
208                has_outgoing[f] = true;
209            }
210            if t < n {
211                has_incoming[t] = true;
212            }
213        }
214
215        for i in 0..n {
216            if desc.requires_input[i] && !has_incoming[i] {
217                result.push(GraphIssue::DisconnectedInput {
218                    node_label: desc.node_labels[i].clone(),
219                });
220            }
221            if desc.requires_output[i] && !has_outgoing[i] {
222                result.push(GraphIssue::DisconnectedOutput {
223                    node_label: desc.node_labels[i].clone(),
224                });
225            }
226        }
227    }
228
229    fn check_cycles(desc: &GraphDescriptor, result: &mut GraphValidationResult) {
230        if Self::cycle_count(desc) > 0 {
231            result.push(GraphIssue::CycleDetected {
232                description: "Directed cycle detected in graph".to_string(),
233            });
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn linear_desc() -> GraphDescriptor {
243        // 3 nodes: 0 → 1 → 2
244        // node 0: source (no input required)
245        // node 2: sink (no output required)
246        let mut d = GraphDescriptor::new(3);
247        d.requires_input[0] = false;
248        d.requires_output[2] = false;
249        d.add_edge(0, 1);
250        d.add_edge(1, 2);
251        d
252    }
253
254    // --- GraphIssue tests ---
255
256    #[test]
257    fn test_cycle_is_fatal() {
258        let issue = GraphIssue::CycleDetected {
259            description: "cycle".to_string(),
260        };
261        assert!(issue.is_fatal());
262    }
263
264    #[test]
265    fn test_warning_not_fatal() {
266        let issue = GraphIssue::Warning {
267            message: "note".to_string(),
268        };
269        assert!(!issue.is_fatal());
270    }
271
272    #[test]
273    fn test_disconnected_input_is_fatal() {
274        let issue = GraphIssue::DisconnectedInput {
275            node_label: "n".to_string(),
276        };
277        assert!(issue.is_fatal());
278    }
279
280    #[test]
281    fn test_category_cycle() {
282        let issue = GraphIssue::CycleDetected {
283            description: String::new(),
284        };
285        assert_eq!(issue.category(), "cycle");
286    }
287
288    #[test]
289    fn test_category_warning() {
290        let issue = GraphIssue::Warning {
291            message: String::new(),
292        };
293        assert_eq!(issue.category(), "warning");
294    }
295
296    // --- GraphValidationResult tests ---
297
298    #[test]
299    fn test_empty_result_is_clean() {
300        let r = GraphValidationResult::new();
301        assert!(r.is_clean());
302        assert!(!r.has_fatal());
303    }
304
305    #[test]
306    fn test_has_fatal_after_push_fatal() {
307        let mut r = GraphValidationResult::new();
308        r.push(GraphIssue::CycleDetected {
309            description: String::new(),
310        });
311        assert!(r.has_fatal());
312    }
313
314    #[test]
315    fn test_fatal_count() {
316        let mut r = GraphValidationResult::new();
317        r.push(GraphIssue::CycleDetected {
318            description: String::new(),
319        });
320        r.push(GraphIssue::Warning {
321            message: String::new(),
322        });
323        assert_eq!(r.fatal_count(), 1);
324    }
325
326    #[test]
327    fn test_warning_count() {
328        let mut r = GraphValidationResult::new();
329        r.push(GraphIssue::Warning {
330            message: "w".to_string(),
331        });
332        r.push(GraphIssue::Warning {
333            message: "w2".to_string(),
334        });
335        assert_eq!(r.warning_count(), 2);
336    }
337
338    // --- GraphValidator tests ---
339
340    #[test]
341    fn test_validate_linear_is_clean() {
342        let d = linear_desc();
343        let r = GraphValidator::validate(&d);
344        assert!(r.is_clean());
345    }
346
347    #[test]
348    fn test_validate_cycle_produces_fatal() {
349        let mut d = GraphDescriptor::new(2);
350        d.requires_input[0] = false;
351        d.requires_output[1] = false;
352        d.add_edge(0, 1);
353        d.add_edge(1, 0); // cycle
354        let r = GraphValidator::validate(&d);
355        assert!(r.has_fatal());
356    }
357
358    #[test]
359    fn test_validate_disconnected_input_detected() {
360        let mut d = GraphDescriptor::new(2);
361        d.requires_input[0] = false;
362        d.requires_input[1] = true;
363        d.requires_output[0] = false;
364        d.requires_output[1] = false;
365        // node 1 has no incoming edge despite requires_input = true
366        let r = GraphValidator::validate(&d);
367        assert!(r.has_fatal());
368    }
369
370    #[test]
371    fn test_cycle_count_no_cycle() {
372        let d = linear_desc();
373        assert_eq!(GraphValidator::cycle_count(&d), 0);
374    }
375
376    #[test]
377    fn test_cycle_count_single_cycle() {
378        let mut d = GraphDescriptor::new(3);
379        d.add_edge(0, 1);
380        d.add_edge(1, 2);
381        d.add_edge(2, 0);
382        assert_eq!(GraphValidator::cycle_count(&d), 1);
383    }
384
385    #[test]
386    fn test_cycle_count_empty_graph() {
387        let d = GraphDescriptor::new(0);
388        assert_eq!(GraphValidator::cycle_count(&d), 0);
389    }
390}