1use std::collections::{HashMap, HashSet, VecDeque};
12
13use crate::hash;
14use crate::ir::Graph;
15use crate::patch::Patch;
16use crate::types::*;
17
18#[derive(Debug, Clone)]
25pub struct ValidationReport {
26 pub errors: Vec<ValidationError>,
27 pub warnings: Vec<ValidationWarning>,
28 pub confidence_debt: Vec<ConfidenceDebt>,
29 pub replay_proof: Option<ReplayProof>,
30}
31
32impl ValidationReport {
33 pub fn is_committable(&self) -> bool {
36 self.errors.is_empty()
37 }
38
39 pub fn total_debt(&self) -> u32 {
41 self.confidence_debt.iter().map(|d| d.weight).sum()
42 }
43}
44
45#[derive(Debug, Clone)]
46pub struct ValidationError {
47 pub code: &'static str,
48 pub message: String,
49 pub node: Option<StableId>,
50}
51
52#[derive(Debug, Clone)]
53pub struct ValidationWarning {
54 pub code: &'static str,
55 pub message: String,
56 pub node: Option<StableId>,
57}
58
59#[derive(Debug, Clone)]
61pub struct ConfidenceDebt {
62 pub source: StableId,
63 pub reason: String,
64 pub confidence: ConfidenceScore,
65 pub weight: u32,
66}
67
68#[derive(Debug, Clone)]
70pub struct ReplayProof {
71 pub graph_hash: Hash,
72 pub patch_count: usize,
73 pub replayed_hash: Hash,
74 pub matches: bool,
75}
76
77pub fn validate_commit(graph: &Graph, patches: &[Patch]) -> ValidationReport {
83 let mut errors = Vec::new();
84 let mut warnings = Vec::new();
85 let mut confidence_debt = Vec::new();
86
87 check_topology(graph, &mut errors, &mut warnings);
89
90 check_domain_safety(graph, &mut errors, &mut warnings);
92
93 check_dependencies(graph, &mut errors, &mut warnings, &mut confidence_debt);
95
96 let replay_proof = check_determinism(graph, patches);
98
99 if let Some(ref proof) = replay_proof {
100 if !proof.matches {
101 errors.push(ValidationError {
102 code: "REPLAY_MISMATCH",
103 message: format!(
104 "replay produced different hash: expected {}, got {}",
105 hex_encode(&proof.graph_hash),
106 hex_encode(&proof.replayed_hash)
107 ),
108 node: None,
109 });
110 }
111 }
112
113 ValidationReport {
114 errors,
115 warnings,
116 confidence_debt,
117 replay_proof,
118 }
119}
120
121fn check_topology(
126 graph: &Graph,
127 errors: &mut Vec<ValidationError>,
128 warnings: &mut Vec<ValidationWarning>,
129) {
130 if !graph.edges.is_empty() {
132 let mut in_degree: HashMap<StableId, usize> = HashMap::new();
133 let mut adjacency: HashMap<StableId, Vec<StableId>> = HashMap::new();
134
135 for id in graph.nodes.keys() {
137 in_degree.entry(*id).or_insert(0);
138 adjacency.entry(*id).or_default();
139 }
140
141 for edge in graph.edges.values() {
143 if edge.kind == crate::ir::EdgeKind::Normal {
144 adjacency
145 .entry(edge.source.node_id)
146 .or_default()
147 .push(edge.target.node_id);
148 *in_degree.entry(edge.target.node_id).or_insert(0) += 1;
149 }
150 }
151
152 let mut queue: VecDeque<StableId> = in_degree
154 .iter()
155 .filter(|(_, °)| deg == 0)
156 .map(|(&id, _)| id)
157 .collect();
158
159 let mut visited = 0;
160 while let Some(node) = queue.pop_front() {
161 visited += 1;
162 if let Some(neighbors) = adjacency.get(&node) {
163 for &next in neighbors {
164 if let Some(deg) = in_degree.get_mut(&next) {
165 *deg -= 1;
166 if *deg == 0 {
167 queue.push_back(next);
168 }
169 }
170 }
171 }
172 }
173
174 if visited < graph.nodes.len() {
175 errors.push(ValidationError {
176 code: "CYCLE_DETECTED",
177 message: format!(
178 "causal cycle detected: {} nodes unreachable in topological sort",
179 graph.nodes.len() - visited
180 ),
181 node: None,
182 });
183 }
184 }
185
186 let connected: HashSet<StableId> = graph
188 .edges
189 .values()
190 .flat_map(|e| [e.source.node_id, e.target.node_id])
191 .collect();
192
193 for id in graph.nodes.keys() {
194 if !connected.contains(id) && graph.nodes.len() > 1 {
195 warnings.push(ValidationWarning {
196 code: "ISOLATED_NODE",
197 message: format!("node {} has no connections", id),
198 node: Some(*id),
199 });
200 }
201 }
202}
203
204fn check_domain_safety(
209 graph: &Graph,
210 errors: &mut Vec<ValidationError>,
211 _warnings: &mut Vec<ValidationWarning>,
212) {
213 for edge in graph.edges.values() {
214 let src_port = graph
215 .nodes
216 .get(&edge.source.node_id)
217 .and_then(|n| n.ports.iter().find(|p| p.name == edge.source.port_name));
218
219 let tgt_port = graph
220 .nodes
221 .get(&edge.target.node_id)
222 .and_then(|n| n.ports.iter().find(|p| p.name == edge.target.port_name));
223
224 if let (Some(src), Some(tgt)) = (src_port, tgt_port) {
225 if src.domain != tgt.domain {
227 errors.push(ValidationError {
228 code: "DOMAIN_CROSSING",
229 message: format!(
230 "edge {} crosses domains: {:?} -> {:?} (requires explicit bridge)",
231 edge.id, src.domain, tgt.domain
232 ),
233 node: None,
234 });
235 }
236
237 if src.direction != PortDirection::Output {
239 errors.push(ValidationError {
240 code: "PORT_DIRECTION",
241 message: format!(
242 "edge {} source port '{}' is not an output",
243 edge.id, edge.source.port_name
244 ),
245 node: Some(edge.source.node_id),
246 });
247 }
248 if tgt.direction != PortDirection::Input {
249 errors.push(ValidationError {
250 code: "PORT_DIRECTION",
251 message: format!(
252 "edge {} target port '{}' is not an input",
253 edge.id, edge.target.port_name
254 ),
255 node: Some(edge.target.node_id),
256 });
257 }
258 }
259 }
260}
261
262fn check_dependencies(
267 graph: &Graph,
268 _errors: &mut Vec<ValidationError>,
269 _warnings: &mut Vec<ValidationWarning>,
270 confidence_debt: &mut Vec<ConfidenceDebt>,
271) {
272 for (id, node) in &graph.nodes {
274 if let NodeKind::Foreign(plugin_name) = &node.kind {
275 confidence_debt.push(ConfidenceDebt {
276 source: *id,
277 reason: format!(
278 "foreign plugin '{}' is nondeterministic by default",
279 plugin_name
280 ),
281 confidence: ConfidenceScore::Suspicious,
282 weight: 30,
283 });
284 }
285 }
286
287 for edge in graph.edges.values() {
289 if !graph.nodes.contains_key(&edge.source.node_id) {
290 confidence_debt.push(ConfidenceDebt {
291 source: edge.id,
292 reason: format!("edge source node {} missing", edge.source.node_id),
293 confidence: ConfidenceScore::Unknown,
294 weight: 100,
295 });
296 }
297 if !graph.nodes.contains_key(&edge.target.node_id) {
298 confidence_debt.push(ConfidenceDebt {
299 source: edge.id,
300 reason: format!("edge target node {} missing", edge.target.node_id),
301 confidence: ConfidenceScore::Unknown,
302 weight: 100,
303 });
304 }
305 }
306}
307
308fn check_determinism(graph: &Graph, patches: &[Patch]) -> Option<ReplayProof> {
313 if patches.is_empty() {
314 return None;
315 }
316
317 let graph_hash = hash::hash_graph(graph);
318
319 match Graph::replay(patches) {
320 Ok(replayed) => {
321 let replayed_hash = hash::hash_graph(&replayed);
322 Some(ReplayProof {
323 graph_hash,
324 patch_count: patches.len(),
325 replayed_hash,
326 matches: graph_hash == replayed_hash,
327 })
328 }
329 Err(_) => {
330 Some(ReplayProof {
332 graph_hash,
333 patch_count: patches.len(),
334 replayed_hash: [0u8; 32],
335 matches: false,
336 })
337 }
338 }
339}
340
341fn hex_encode(bytes: &[u8]) -> String {
342 bytes.iter().map(|b| format!("{:02x}", b)).collect()
343}
344
345#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::ir::{Edge, Node};
353 use crate::patch::{Operation, Patch};
354
355 #[test]
356 fn test_valid_linear_graph() {
357 let src = Node::new_source("Sine");
358 let gain = Node::new_processor("Gain");
359 let sink = Node::new_sink("Output");
360
361 let e1 = Edge::new(
362 PortRef {
363 node_id: src.id,
364 port_name: "out".into(),
365 },
366 PortRef {
367 node_id: gain.id,
368 port_name: "in".into(),
369 },
370 );
371 let e2 = Edge::new(
372 PortRef {
373 node_id: gain.id,
374 port_name: "out".into(),
375 },
376 PortRef {
377 node_id: sink.id,
378 port_name: "in".into(),
379 },
380 );
381
382 let patch = Patch::from_operations(vec![
383 Operation::AddNode(src),
384 Operation::AddNode(gain),
385 Operation::AddNode(sink),
386 Operation::AddEdge(e1),
387 Operation::AddEdge(e2),
388 ]);
389
390 let mut graph = Graph::new();
391 graph.apply(&patch).unwrap();
392
393 let report = validate_commit(&graph, &[patch]);
394 assert!(report.is_committable(), "errors: {:?}", report.errors);
395 assert!(report.replay_proof.is_some());
396 assert!(report.replay_proof.unwrap().matches);
397 }
398
399 #[test]
400 fn test_isolated_node_warning() {
401 let src = Node::new_source("Sine");
402 let orphan = Node::new_processor("Orphan");
403
404 let patch =
405 Patch::from_operations(vec![Operation::AddNode(src), Operation::AddNode(orphan)]);
406
407 let mut graph = Graph::new();
408 graph.apply(&patch).unwrap();
409
410 let report = validate_commit(&graph, &[patch]);
411 assert!(report.is_committable()); assert!(!report.warnings.is_empty());
413 assert!(report.warnings.iter().any(|w| w.code == "ISOLATED_NODE"));
414 }
415
416 #[test]
417 fn test_foreign_node_confidence_debt() {
418 let foreign = Node {
419 id: StableId::new(),
420 kind: NodeKind::Foreign("SomeVST".into()),
421 ports: vec![],
422 config: Default::default(),
423 metadata: MetadataRef(None),
424 confidence: ConfidenceScore::Verified,
425 };
426
427 let patch = Patch::from_operations(vec![Operation::AddNode(foreign)]);
428 let mut graph = Graph::new();
429 graph.apply(&patch).unwrap();
430
431 let report = validate_commit(&graph, &[patch]);
432 assert!(!report.confidence_debt.is_empty());
433 }
434
435 #[test]
436 fn test_domain_crossing_error() {
437 let src = Node {
439 id: StableId::new(),
440 kind: NodeKind::Source,
441 ports: vec![TypedPort {
442 name: "out".into(),
443 direction: PortDirection::Output,
444 domain: ExecutionDomain::Sample,
445 data_type: DataType::Audio { channels: 2 },
446 semantic: PortSemantic::Signal,
447 polarity: PortPolarity::Bipolar,
448 }],
449 config: Default::default(),
450 metadata: MetadataRef(None),
451 confidence: ConfidenceScore::Verified,
452 };
453
454 let analyzer = Node {
455 id: StableId::new(),
456 kind: NodeKind::Analyzer,
457 ports: vec![TypedPort {
458 name: "in".into(),
459 direction: PortDirection::Input,
460 domain: ExecutionDomain::Block, data_type: DataType::Audio { channels: 2 },
462 semantic: PortSemantic::Signal,
463 polarity: PortPolarity::Bipolar,
464 }],
465 config: Default::default(),
466 metadata: MetadataRef(None),
467 confidence: ConfidenceScore::Verified,
468 };
469
470 let edge = Edge::new(
471 PortRef {
472 node_id: src.id,
473 port_name: "out".into(),
474 },
475 PortRef {
476 node_id: analyzer.id,
477 port_name: "in".into(),
478 },
479 );
480
481 let patch = Patch::from_operations(vec![
482 Operation::AddNode(src),
483 Operation::AddNode(analyzer),
484 Operation::AddEdge(edge),
485 ]);
486
487 let mut graph = Graph::new();
488 graph.apply(&patch).unwrap();
489
490 let report = validate_commit(&graph, &[patch]);
491 assert!(!report.is_committable());
492 assert!(report.errors.iter().any(|e| e.code == "DOMAIN_CROSSING"));
493 }
494}