oximedia_graph/
graph_validation.rs1#![allow(dead_code)]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum GraphIssue {
7 CycleDetected {
9 description: String,
11 },
12 DisconnectedInput {
14 node_label: String,
16 },
17 DisconnectedOutput {
19 node_label: String,
21 },
22 IncompatibleFormat {
24 from_label: String,
26 to_label: String,
28 },
29 MissingResource {
31 node_label: String,
33 resource: String,
35 },
36 Warning {
38 message: String,
40 },
41}
42
43impl GraphIssue {
44 #[must_use]
46 pub fn is_fatal(&self) -> bool {
47 !matches!(self, Self::Warning { .. })
48 }
49
50 #[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#[derive(Debug, Clone, Default)]
66pub struct GraphValidationResult {
67 issues: Vec<GraphIssue>,
68}
69
70impl GraphValidationResult {
71 #[must_use]
73 pub fn new() -> Self {
74 Self { issues: Vec::new() }
75 }
76
77 pub fn push(&mut self, issue: GraphIssue) {
79 self.issues.push(issue);
80 }
81
82 #[must_use]
84 pub fn has_fatal(&self) -> bool {
85 self.issues.iter().any(|i| i.is_fatal())
86 }
87
88 #[must_use]
90 pub fn is_clean(&self) -> bool {
91 self.issues.is_empty()
92 }
93
94 #[must_use]
96 pub fn issues(&self) -> &[GraphIssue] {
97 &self.issues
98 }
99
100 #[must_use]
102 pub fn fatal_count(&self) -> usize {
103 self.issues.iter().filter(|i| i.is_fatal()).count()
104 }
105
106 #[must_use]
108 pub fn warning_count(&self) -> usize {
109 self.issues.iter().filter(|i| !i.is_fatal()).count()
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct GraphDescriptor {
116 pub edges: Vec<(usize, usize)>,
118 pub node_labels: Vec<String>,
120 pub requires_input: Vec<bool>,
122 pub requires_output: Vec<bool>,
124}
125
126impl GraphDescriptor {
127 #[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 pub fn add_edge(&mut self, from: usize, to: usize) {
140 self.edges.push((from, to));
141 }
142}
143
144pub struct GraphValidator;
146
147impl GraphValidator {
148 #[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 #[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 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 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 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 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 #[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 #[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 #[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); 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 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}