Skip to main content

dirtydata_core/
validate.rs

1//! Commit Validation — §6.
2//!
3//! "Fail/Pass の二元論は現場を壊す。人類はグレーで生きてる。"
4//!
5//! Commit は以下を全通過しなければならない:
6//! - Topology Check (cycle detection, isolated side effects)
7//! - Type & Domain Safety (domain crossing validation)
8//! - Dependency Closure (asset existence, hash recording)
9//! - Deterministic Replayability
10
11use std::collections::{HashMap, HashSet, VecDeque};
12
13use crate::hash;
14use crate::ir::Graph;
15use crate::patch::Patch;
16use crate::types::*;
17
18// ──────────────────────────────────────────────
19// §6 ValidationReport — not binary pass/fail
20// ──────────────────────────────────────────────
21
22/// Commit validation result.
23/// Errors block commit. Warnings don't. Confidence debt is tracked.
24#[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    /// Can this graph be committed?
34    /// Only errors block — warnings and debt are informational.
35    pub fn is_committable(&self) -> bool {
36        self.errors.is_empty()
37    }
38
39    /// Total confidence debt score.
40    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/// Confidence debt — things we can't fully verify but won't block.
60#[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/// Proof that the graph can be deterministically replayed.
69#[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
77// ──────────────────────────────────────────────
78// Main validation entry point
79// ──────────────────────────────────────────────
80
81/// Validate a graph for commit readiness.
82pub 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    // §6.1 Topology Check
88    check_topology(graph, &mut errors, &mut warnings);
89
90    // §6.2 Type & Domain Safety
91    check_domain_safety(graph, &mut errors, &mut warnings);
92
93    // §6.3 Dependency Closure
94    check_dependencies(graph, &mut errors, &mut warnings, &mut confidence_debt);
95
96    // §6.4 Deterministic Replayability
97    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
121// ──────────────────────────────────────────────
122// §6.1 Topology Check
123// ──────────────────────────────────────────────
124
125fn check_topology(
126    graph: &Graph,
127    errors: &mut Vec<ValidationError>,
128    warnings: &mut Vec<ValidationWarning>,
129) {
130    // Cycle detection via topological sort (Kahn's algorithm)
131    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        // Initialize all nodes
136        for id in graph.nodes.keys() {
137            in_degree.entry(*id).or_insert(0);
138            adjacency.entry(*id).or_default();
139        }
140
141        // Build adjacency from normal edges only (Feedback edges don't carry causal dependency)
142        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        // Kahn's algorithm
153        let mut queue: VecDeque<StableId> = in_degree
154            .iter()
155            .filter(|(_, &deg)| 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    // Isolated nodes warning (nodes with no edges)
187    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
204// ──────────────────────────────────────────────
205// §6.2 Type & Domain Safety
206// ──────────────────────────────────────────────
207
208fn 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            // Domain crossing check
226            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            // Direction check — source must be Output, target must be Input
238            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
262// ──────────────────────────────────────────────
263// §6.3 Dependency Closure
264// ──────────────────────────────────────────────
265
266fn check_dependencies(
267    graph: &Graph,
268    _errors: &mut Vec<ValidationError>,
269    _warnings: &mut Vec<ValidationWarning>,
270    confidence_debt: &mut Vec<ConfidenceDebt>,
271) {
272    // Check Foreign nodes — they carry inherent confidence debt
273    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    // Check dangling edge references
288    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
308// ──────────────────────────────────────────────
309// §6.4 Deterministic Replayability
310// ──────────────────────────────────────────────
311
312fn 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            // Replay itself failed — still produce a proof
331            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// ──────────────────────────────────────────────
346// Tests
347// ──────────────────────────────────────────────
348
349#[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()); // warnings don't block
412        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        // Create nodes with different domains
438        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, // Different domain!
461                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}