Skip to main content

etdl_compiler/
validate.rs

1use etdl_parser::ast::{
2    BasicEventType, EtlDocument, EventTree, FaultTree, Gate, GateType, Node,
3};
4use etdl_parser::asyncapi::AsyncApiRegistry;
5use etdl_parser::ecel::Condition;
6use std::collections::{BTreeMap, HashMap};
7
8#[derive(Debug, Clone)]
9pub struct Diagnostic {
10    pub code: String,
11    pub severity: DiagnosticSeverity,
12    pub message: String,
13    pub line: Option<u32>,
14    pub column: Option<u32>,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum DiagnosticSeverity {
19    Error,
20    Warning,
21}
22
23impl Diagnostic {
24    pub fn error(code: &str, message: String) -> Self {
25        Diagnostic {
26            code: code.to_string(),
27            severity: DiagnosticSeverity::Error,
28            message,
29            line: None,
30            column: None,
31        }
32    }
33
34    pub fn warning(code: &str, message: String) -> Self {
35        Diagnostic {
36            code: code.to_string(),
37            severity: DiagnosticSeverity::Warning,
38            message,
39            line: None,
40            column: None,
41        }
42    }
43
44    pub fn with_position(mut self, line: u32, column: u32) -> Self {
45        self.line = Some(line);
46        self.column = Some(column);
47        self
48    }
49
50    pub fn is_error(&self) -> bool {
51        self.severity == DiagnosticSeverity::Error
52    }
53}
54
55pub fn validate_document(
56    doc: &EtlDocument,
57    registry: &AsyncApiRegistry,
58    diagnostics: &mut Vec<Diagnostic>,
59) {
60    validate_references(doc, registry, diagnostics);
61    validate_event_trees(doc, registry, diagnostics);
62    validate_fault_trees(doc, diagnostics);
63}
64
65fn validate_references(
66    doc: &EtlDocument,
67    registry: &AsyncApiRegistry,
68    diagnostics: &mut Vec<Diagnostic>,
69) {
70    for (alias, _location) in &doc.asyncapi_imports {
71        if alias
72            .chars()
73            .any(|c| !c.is_ascii_alphanumeric() && c != '_')
74        {
75            diagnostics.push(Diagnostic::error(
76                "E-103",
77                format!("import alias '{}' contains invalid characters", alias),
78            ));
79        }
80    }
81
82    for (_tree_name, tree) in &doc.event_trees {
83        validate_external_ref(
84            &tree.initiating_event.message,
85            doc,
86            registry,
87            diagnostics,
88            "initiatingEvent.message",
89        );
90
91        for (node_id, node) in &tree.nodes {
92            match node {
93                Node::Operation(op) => {
94                    if let Some(ref emits_ref) = op.emits {
95                        validate_external_ref(emits_ref, doc, registry, diagnostics, &format!("nodes.{}.emits", node_id));
96                    }
97                }
98                Node::Consequence(cons) => {
99                    if let Some(ref channel_ref) = cons.channel {
100                        validate_external_ref(channel_ref, doc, registry, diagnostics, &format!("nodes.{}.channel", node_id));
101                    }
102                    if let Some(ref message_ref) = cons.message {
103                        validate_external_ref(message_ref, doc, registry, diagnostics, &format!("nodes.{}.message", node_id));
104                    }
105                }
106                _ => {}
107            }
108        }
109    }
110
111    if let Some(ref fault_trees) = doc.fault_trees {
112        for (_ft_name, ft) in fault_trees {
113            if let Some(ref msg_ref) = ft.top_event.message {
114                validate_external_ref(msg_ref, doc, registry, diagnostics, "topEvent.message");
115            }
116            for (_be_name, be) in &ft.basic_events {
117                if let Some(ref msg_ref) = be.message {
118                    validate_external_ref(msg_ref, doc, registry, diagnostics, "basicEvent.message");
119                }
120            }
121        }
122    }
123}
124
125fn validate_external_ref(
126    ext_ref: &etdl_parser::ast::ExternalRef,
127    doc: &EtlDocument,
128    registry: &AsyncApiRegistry,
129    diagnostics: &mut Vec<Diagnostic>,
130    context: &str,
131) {
132    if !doc.asyncapi_imports.contains_key(&ext_ref.alias) {
133        diagnostics.push(Diagnostic::error(
134            "E-103",
135            format!(
136                "{}: import alias '{}' is not a key in asyncapi_imports",
137                context, ext_ref.alias
138            ),
139        ));
140        return;
141    }
142
143    if registry.resolve(ext_ref).is_err() {
144        diagnostics.push(Diagnostic::error(
145            "E-104",
146            format!(
147                "{}: JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
148                context, ext_ref.pointer, ext_ref.alias
149            ),
150        ));
151    }
152}
153
154fn validate_event_trees(
155    doc: &EtlDocument,
156    _registry: &AsyncApiRegistry,
157    diagnostics: &mut Vec<Diagnostic>,
158) {
159    for (tree_name, tree) in &doc.event_trees {
160        validate_tree_structure(tree_name, tree, diagnostics);
161    }
162}
163
164fn validate_tree_structure(
165    tree_name: &str,
166    tree: &EventTree,
167    diagnostics: &mut Vec<Diagnostic>,
168) {
169    check_node_references(tree_name, tree, diagnostics);
170    check_dag(tree_name, tree, diagnostics);
171    check_reachability(tree_name, tree, diagnostics);
172    check_terminal_paths(tree_name, tree, diagnostics);
173    check_barrier_rules(tree_name, tree, diagnostics);
174    check_operation_rules(tree_name, tree, diagnostics);
175    check_consequence_rules(tree_name, tree, diagnostics);
176}
177
178fn check_node_references(
179    tree_name: &str,
180    tree: &EventTree,
181    diagnostics: &mut Vec<Diagnostic>,
182) {
183    if !tree.nodes.contains_key(&tree.initiating_event.next) {
184        diagnostics.push(Diagnostic::error(
185            "V-101",
186            format!(
187                "tree '{}': initiatingEvent.next '{}' does not resolve to a node in this tree",
188                tree_name, tree.initiating_event.next
189            ),
190        ));
191    }
192
193    for (node_id, node) in &tree.nodes {
194        let next_targets: Vec<&str> = match node {
195            Node::Barrier(barrier) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
196            Node::Operation(op) => {
197                let mut targets = vec![op.next.as_str()];
198                if let Some(ref on_fail) = op.on_failure {
199                    targets.push(on_fail.as_str());
200                }
201                targets
202            }
203            Node::Consequence(_) => continue,
204        };
205
206        for target in next_targets {
207            if !tree.nodes.contains_key(target) {
208                diagnostics.push(Diagnostic::error(
209                    "V-101",
210                    format!(
211                        "tree '{}': node '{}' references '{}' which does not exist in this tree",
212                        tree_name, node_id, target
213                    ),
214                ));
215            }
216        }
217    }
218}
219
220fn check_dag(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
221    #[derive(Clone, Copy, PartialEq)]
222    enum Color {
223        White,
224        Gray,
225        Black,
226    }
227
228    let mut colors: HashMap<&str, Color> = HashMap::new();
229    for node_id in tree.nodes.keys() {
230        colors.insert(node_id.as_str(), Color::White);
231    }
232
233    fn dfs<'a>(
234        node: &'a str,
235        tree: &'a EventTree,
236        colors: &mut HashMap<&'a str, Color>,
237        diagnostics: &mut Vec<Diagnostic>,
238        tree_name: &str,
239    ) {
240        colors.insert(node, Color::Gray);
241
242        let next_nodes: Vec<&str> = match tree.nodes.get(node) {
243            Some(Node::Barrier(barrier)) => {
244                barrier.branches.iter().map(|b| b.next.as_str()).collect()
245            }
246            Some(Node::Operation(op)) => {
247                let mut targets = vec![op.next.as_str()];
248                if let Some(ref on_fail) = op.on_failure {
249                    targets.push(on_fail.as_str());
250                }
251                targets
252            }
253            Some(Node::Consequence(_)) => return,
254            None => return,
255        };
256
257        for next in next_nodes {
258            match colors.get(next) {
259                Some(Color::Gray) => {
260                    diagnostics.push(Diagnostic::error(
261                        "V-102",
262                        format!(
263                            "tree '{}': cycle detected involving node '{}' -> '{}'",
264                            tree_name, node, next
265                        ),
266                    ));
267                }
268                Some(Color::White) => {
269                    dfs(next, tree, colors, diagnostics, tree_name);
270                }
271                _ => {}
272            }
273        }
274
275        colors.insert(node, Color::Black);
276    }
277
278    let start_id = tree.initiating_event.next.as_str();
279    if tree.nodes.contains_key(start_id) {
280        dfs(start_id, tree, &mut colors, diagnostics, tree_name);
281    }
282}
283
284fn check_reachability(
285    tree_name: &str,
286    tree: &EventTree,
287    diagnostics: &mut Vec<Diagnostic>,
288) {
289    let mut reachable: HashMap<&str, bool> = HashMap::new();
290    for node_id in tree.nodes.keys() {
291        reachable.insert(node_id.as_str(), false);
292    }
293
294    let start_id = tree.initiating_event.next.as_str();
295    if tree.nodes.contains_key(start_id) {
296        reachable.insert(start_id, true);
297        propagate_reachability(start_id, tree, &mut reachable);
298    }
299
300    for (node_id, &is_reachable) in &reachable {
301        if !is_reachable {
302            diagnostics.push(Diagnostic::error(
303                "V-103",
304                format!(
305                    "tree '{}': node '{}' is unreachable from initiatingEvent",
306                    tree_name, node_id
307                ),
308            ));
309        }
310    }
311}
312
313fn propagate_reachability<'a>(
314    node_id: &'a str,
315    tree: &'a EventTree,
316    reachable: &mut HashMap<&'a str, bool>,
317) {
318    let next_nodes: Vec<&str> = match tree.nodes.get(node_id) {
319        Some(Node::Barrier(barrier)) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
320        Some(Node::Operation(op)) => {
321            let mut targets = vec![op.next.as_str()];
322            if let Some(ref on_fail) = op.on_failure {
323                targets.push(on_fail.as_str());
324            }
325            targets
326        }
327        Some(Node::Consequence(_)) => return,
328        None => return,
329    };
330
331    for next in next_nodes {
332        if let Some(was_reachable) = reachable.get_mut(next) {
333            if !*was_reachable {
334                *was_reachable = true;
335                propagate_reachability(next, tree, reachable);
336            }
337        }
338    }
339}
340
341fn check_terminal_paths(
342    tree_name: &str,
343    tree: &EventTree,
344    diagnostics: &mut Vec<Diagnostic>,
345) {
346    fn check_termination<'a>(
347        node_id: &'a str,
348        tree: &'a EventTree,
349        visited: &mut Vec<&'a str>,
350        tree_name: &str,
351        diagnostics: &mut Vec<Diagnostic>,
352    ) -> bool {
353        if visited.contains(&node_id) {
354            return false;
355        }
356        visited.push(node_id);
357
358        match tree.nodes.get(node_id) {
359            Some(Node::Consequence(_)) => {
360                visited.pop();
361                return true;
362            }
363            Some(Node::Barrier(barrier)) => {
364                let mut all_terminal = true;
365                for branch in &barrier.branches {
366                    if !check_termination(&branch.next, tree, visited, tree_name, diagnostics) {
367                        all_terminal = false;
368                    }
369                }
370                visited.pop();
371                all_terminal
372            }
373            Some(Node::Operation(op)) => {
374                let mut all_terminal = true;
375                if !check_termination(&op.next, tree, visited, tree_name, diagnostics) {
376                    all_terminal = false;
377                }
378                if let Some(ref on_fail) = op.on_failure {
379                    if !check_termination(on_fail, tree, visited, tree_name, diagnostics) {
380                        all_terminal = false;
381                    }
382                }
383                visited.pop();
384                all_terminal
385            }
386            None => {
387                visited.pop();
388                false
389            }
390        }
391    }
392
393    let start_id = tree.initiating_event.next.as_str();
394    if tree.nodes.contains_key(start_id) {
395        let mut visited = Vec::new();
396        check_termination(start_id, tree, &mut visited, tree_name, diagnostics);
397    }
398}
399
400fn check_barrier_rules(
401    tree_name: &str,
402    tree: &EventTree,
403    diagnostics: &mut Vec<Diagnostic>,
404) {
405    for (node_id, node) in &tree.nodes {
406        if let Node::Barrier(barrier) = node {
407            if barrier.branches.len() < 2 {
408                diagnostics.push(Diagnostic::error(
409                    "V-201",
410                    format!(
411                        "tree '{}': barrier '{}' has fewer than 2 branches",
412                        tree_name, node_id
413                    ),
414                ));
415            }
416
417            let mut default_count = 0;
418            let mut last_is_default = false;
419            for (i, branch) in barrier.branches.iter().enumerate() {
420                if branch.condition == Condition::Default {
421                    default_count += 1;
422                    if i == barrier.branches.len() - 1 {
423                        last_is_default = true;
424                    }
425                }
426            }
427            if default_count > 1 {
428                diagnostics.push(Diagnostic::error(
429                    "V-202",
430                    format!(
431                        "tree '{}': barrier '{}' has more than one default branch",
432                        tree_name, node_id
433                    ),
434                ));
435            } else if default_count == 1 && !last_is_default {
436                diagnostics.push(Diagnostic::error(
437                    "V-202",
438                    format!(
439                        "tree '{}': barrier '{}' default branch is not the last branch",
440                        tree_name, node_id
441                    ),
442                ));
443            }
444
445            for (i, branch) in barrier.branches.iter().enumerate() {
446                if branch.condition == Condition::Default {
447                    continue;
448                }
449                let has_prob = branch.effective_probability().is_some()
450                    || branch.probability_source.is_some();
451                if !has_prob {
452                    diagnostics.push(Diagnostic::error(
453                        "V-203",
454                        format!(
455                            "tree '{}': barrier '{}' branch {} has no probability or probabilitySource",
456                            tree_name, node_id, i
457                        ),
458                    ));
459                }
460            }
461        }
462    }
463}
464
465fn check_operation_rules(
466    tree_name: &str,
467    tree: &EventTree,
468    diagnostics: &mut Vec<Diagnostic>,
469) {
470    for (node_id, node) in &tree.nodes {
471        if let Node::Operation(op) = node {
472            if op.on_failure.is_none() {
473                diagnostics.push(Diagnostic::warning(
474                    "W-401",
475                    format!(
476                        "tree '{}': operation '{}' has no onFailure path",
477                        tree_name, node_id
478                    ),
479                ));
480            }
481        }
482    }
483}
484
485fn check_consequence_rules(
486    tree_name: &str,
487    tree: &EventTree,
488    diagnostics: &mut Vec<Diagnostic>,
489) {
490    for (node_id, node) in &tree.nodes {
491        if let Node::Consequence(cons) = node {
492            match cons.consequence_operation {
493                etdl_parser::ast::ConsequenceOperation::Send => {
494                    if cons.channel.is_none() || cons.message.is_none() {
495                        diagnostics.push(Diagnostic::error(
496                            "V-302",
497                            format!(
498                                "tree '{}': consequence '{}' has operation: send but omits channel or message",
499                                tree_name, node_id
500                            ),
501                        ));
502                    }
503                }
504                etdl_parser::ast::ConsequenceOperation::Terminate => {}
505            }
506        }
507    }
508}
509
510fn validate_fault_trees(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
511    let fault_trees = match &doc.fault_trees {
512        Some(fts) => fts,
513        None => return,
514    };
515
516    for (ft_name, ft) in fault_trees {
517        check_fault_tree_structure(ft_name, ft, diagnostics);
518        check_gate_rules(ft_name, ft, diagnostics);
519        check_basic_event_rules(ft_name, ft, diagnostics);
520    }
521}
522
523fn check_fault_tree_structure(
524    ft_name: &str,
525    ft: &FaultTree,
526    diagnostics: &mut Vec<Diagnostic>,
527) {
528    let mut known_ids: HashMap<&str, bool> = HashMap::new();
529
530    if let Some(ref gates) = ft.gates {
531        for gate_id in gates.keys() {
532            known_ids.insert(gate_id.as_str(), false);
533        }
534    }
535    for be_id in ft.basic_events.keys() {
536        if known_ids.contains_key(be_id.as_str()) {
537            diagnostics.push(Diagnostic::error(
538                "V-402",
539                format!(
540                    "fault tree '{}': gate and basic event share ID '{}'",
541                    ft_name, be_id
542                ),
543            ));
544        }
545        known_ids.insert(be_id.as_str(), false);
546    }
547
548    for (be_id, be) in &ft.basic_events {
549        match be.event_type {
550            Some(BasicEventType::House) => {
551                if be.probability.is_some() || be.failure_rate.is_some() {
552                    diagnostics.push(Diagnostic::warning(
553                        "W-406",
554                        format!(
555                            "fault tree '{}': house event '{}' declares a probability/failureRate; house events are boundary conditions and their value is not a computed leaf probability",
556                            ft_name, be_id
557                        ),
558                    ));
559                }
560            }
561            Some(BasicEventType::Undeveloped) => {
562                if be.probability.is_none() && be.failure_rate.is_none() {
563                    diagnostics.push(Diagnostic::warning(
564                        "W-407",
565                        format!(
566                            "fault tree '{}': undeveloped event '{}' has no probability/failureRate; treat its probability as unquantified",
567                            ft_name, be_id
568                        ),
569                    ));
570                }
571            }
572            _ => {}
573        }
574    }
575
576    let root_id = ft.top_event.root_cause.as_str();
577    match known_ids.get(root_id) {
578        None => {
579            diagnostics.push(Diagnostic::error(
580                "V-401",
581                format!(
582                    "fault tree '{}': topEvent.rootCause '{}' does not resolve to a gate or basic event",
583                    ft_name, root_id
584                ),
585            ));
586        }
587        Some(_) => {
588            known_ids.insert(root_id, true);
589        }
590    }
591
592    if let Some(ref gates) = ft.gates {
593        for (gate_id, gate) in gates {
594            for input in &gate.inputs {
595                match known_ids.get(input.as_str()) {
596                    None => {
597                        diagnostics.push(Diagnostic::error(
598                            "V-401",
599                            format!(
600                                "fault tree '{}': gate '{}' input '{}' does not resolve",
601                                ft_name, gate_id, input
602                            ),
603                        ));
604                    }
605                    Some(_) => {
606                        known_ids.insert(input.as_str(), true);
607                    }
608                }
609            }
610        }
611    }
612
613    check_fault_tree_dag(ft_name, ft, diagnostics);
614
615    for (&id, &is_reachable) in &known_ids {
616        if !is_reachable && id != root_id {
617            diagnostics.push(Diagnostic::error(
618                "V-404",
619                format!(
620                    "fault tree '{}': '{}' is not reachable from topEvent.rootCause",
621                    ft_name, id
622                ),
623            ));
624        }
625    }
626
627    check_transfers(ft_name, ft, diagnostics);
628}
629
630fn check_transfers(
631    ft_name: &str,
632    ft: &FaultTree,
633    diagnostics: &mut Vec<Diagnostic>,
634) {
635    let transfers = match &ft.transfers {
636        Some(t) => t,
637        None => return,
638    };
639
640    for (transfer_id, transfer) in transfers {
641        let target = transfer.target.trim_start_matches("#");
642        if !target.starts_with("/faultTrees/") {
643            diagnostics.push(Diagnostic::error(
644                "V-506",
645                format!(
646                    "fault tree '{}': transfer '{}' target '{}' must be an Internal Reference of the form '#/faultTrees/<id>/...'",
647                    ft_name, transfer_id, transfer.target
648                ),
649            ));
650        }
651        if let Some(label) = &transfer.label {
652            if label.trim().is_empty() {
653                diagnostics.push(Diagnostic::warning(
654                    "W-405",
655                    format!(
656                        "fault tree '{}': transfer '{}' has an empty label",
657                        ft_name, transfer_id
658                    ),
659                ));
660            }
661        }
662    }
663}
664
665fn check_fault_tree_dag(
666    ft_name: &str,
667    ft: &FaultTree,
668    diagnostics: &mut Vec<Diagnostic>,
669) {
670    let gates = match &ft.gates {
671        Some(g) => g,
672        None => return,
673    };
674
675    #[derive(Clone, Copy, PartialEq)]
676    enum Color {
677        White,
678        Gray,
679        Black,
680    }
681
682    let mut colors: HashMap<&str, Color> = HashMap::new();
683    for gate_id in gates.keys() {
684        colors.insert(gate_id.as_str(), Color::White);
685    }
686
687    fn dfs_gate<'a>(
688        gate_id: &'a str,
689        gates: &'a BTreeMap<String, Gate>,
690        colors: &mut HashMap<&'a str, Color>,
691        diagnostics: &mut Vec<Diagnostic>,
692        ft_name: &str,
693    ) {
694        if let Some(Color::Black) = colors.get(gate_id) {
695            return;
696        }
697        if let Some(Color::Gray) = colors.get(gate_id) {
698            return;
699        }
700
701        colors.insert(gate_id, Color::Gray);
702
703        if let Some(gate) = gates.get(gate_id) {
704            for input in &gate.inputs {
705                if gates.contains_key(input.as_str()) {
706                    match colors.get(input.as_str()) {
707                        Some(Color::Gray) => {
708                            diagnostics.push(Diagnostic::error(
709                                "V-403",
710                                format!(
711                                    "fault tree '{}': cycle detected involving gate '{}' -> '{}'",
712                                    ft_name, gate_id, input
713                                ),
714                            ));
715                        }
716                        Some(Color::White) => {
717                            dfs_gate(input, gates, colors, diagnostics, ft_name);
718                        }
719                        _ => {}
720                    }
721                }
722            }
723        }
724
725        colors.insert(gate_id, Color::Black);
726    }
727
728    let root_id = ft.top_event.root_cause.as_str();
729    if gates.contains_key(root_id) {
730        dfs_gate(root_id, gates, &mut colors, diagnostics, ft_name);
731    }
732}
733
734fn check_gate_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
735    let gates = match &ft.gates {
736        Some(g) => g,
737        None => return,
738    };
739
740    for (gate_id, gate) in gates {
741        let n = gate.inputs.len();
742
743        match gate.gate_type {
744            GateType::And | GateType::Or => {
745                if n < 2 {
746                    diagnostics.push(Diagnostic::error(
747                        "V-501",
748                        format!(
749                            "fault tree '{}': {:?} gate '{}' has {} input(s), minimum 2 required",
750                            ft_name, gate.gate_type, gate_id, n
751                        ),
752                    ));
753                }
754            }
755            GateType::Not => {
756                if n != 1 {
757                    diagnostics.push(Diagnostic::error(
758                        "V-501",
759                        format!(
760                            "fault tree '{}': NOT gate '{}' has {} input(s), exactly 1 required",
761                            ft_name, gate_id, n
762                        ),
763                    ));
764                }
765            }
766            GateType::Xor => {
767                if n != 2 {
768                    diagnostics.push(Diagnostic::error(
769                        "V-501",
770                        format!(
771                            "fault tree '{}': XOR gate '{}' has {} input(s), exactly 2 required",
772                            ft_name, gate_id, n
773                        ),
774                    ));
775                }
776            }
777            GateType::Voting => {
778                if n < 2 {
779                    diagnostics.push(Diagnostic::error(
780                        "V-501",
781                        format!(
782                            "fault tree '{}': VOTING gate '{}' has {} input(s), minimum 2 required",
783                            ft_name, gate_id, n
784                        ),
785                    ));
786                }
787                if let Some(k) = gate.k {
788                    if k < 1 || k as usize > n {
789                        diagnostics.push(Diagnostic::error(
790                            "V-502",
791                            format!(
792                                "fault tree '{}': VOTING gate '{}' k={} must satisfy 1 <= k <= n={}",
793                                ft_name, gate_id, k, n
794                            ),
795                        ));
796                    }
797                } else {
798                    diagnostics.push(Diagnostic::error(
799                        "V-502",
800                        format!(
801                            "fault tree '{}': VOTING gate '{}' missing required 'k' field",
802                            ft_name, gate_id
803                        ),
804                    ));
805                }
806            }
807            GateType::Inhibit => {
808                if n != 2 {
809                    diagnostics.push(Diagnostic::error(
810                        "V-501",
811                        format!(
812                            "fault tree '{}': INHIBIT gate '{}' has {} input(s), exactly 2 required",
813                            ft_name, gate_id, n
814                        ),
815                    ));
816                }
817                if gate.inhibit_condition.is_none() {
818                    diagnostics.push(Diagnostic::error(
819                        "V-505",
820                        format!(
821                            "fault tree '{}': INHIBIT gate '{}' missing required 'inhibitCondition' field",
822                            ft_name, gate_id
823                        ),
824                    ));
825                }
826            }
827            GateType::PriorityAnd => {
828                if n < 2 {
829                    diagnostics.push(Diagnostic::error(
830                        "V-501",
831                        format!(
832                            "fault tree '{}': PRIORITY_AND gate '{}' has {} input(s), minimum 2 required",
833                            ft_name, gate_id, n
834                        ),
835                    ));
836                }
837            }
838        }
839    }
840}
841
842fn check_basic_event_rules(
843    ft_name: &str,
844    ft: &FaultTree,
845    diagnostics: &mut Vec<Diagnostic>,
846) {
847    for (be_id, be) in &ft.basic_events {
848        let has_prob = be.probability.is_some();
849        let has_rate = be.failure_rate.is_some();
850        let has_time = be.mission_time.is_some();
851
852        if has_prob && has_rate {
853            diagnostics.push(Diagnostic::error(
854                "V-503",
855                format!(
856                    "fault tree '{}': basic event '{}' supplies both probability and failureRate",
857                    ft_name, be_id
858                ),
859            ));
860        } else if !has_prob && !has_rate {
861            diagnostics.push(Diagnostic::error(
862                "V-503",
863                format!(
864                    "fault tree '{}': basic event '{}' supplies neither probability nor failureRate",
865                    ft_name, be_id
866                ),
867            ));
868        }
869
870        if has_rate && !has_time {
871            diagnostics.push(Diagnostic::error(
872                "V-504",
873                format!(
874                    "fault tree '{}': basic event '{}' has failureRate but no missionTime",
875                    ft_name, be_id
876                ),
877            ));
878        }
879    }
880}
881
882pub type FaultTreeProbabilities = BTreeMap<String, f64>;
883
884pub fn resolve_probability_links(
885    doc: &EtlDocument,
886    fault_tree_probs: &FaultTreeProbabilities,
887    diagnostics: &mut Vec<Diagnostic>,
888) -> BTreeMap<String, f64> {
889    let mut branch_probs: BTreeMap<String, f64> = BTreeMap::new();
890
891    for (_tree_name, tree) in &doc.event_trees {
892        for (node_id, node) in &tree.nodes {
893            match node {
894                Node::Barrier(barrier) => {
895                    for (i, branch) in barrier.branches.iter().enumerate() {
896                        let key = format!("{}.branch.{}", node_id, i);
897
898                        if let Some(ref ps) = branch.probability_source {
899                            let ft_id = extract_fault_tree_id(&ps.pointer);
900                            if let Some(&prob) = fault_tree_probs.get(&ft_id) {
901                                if let Some(cached) = branch.effective_probability() {
902                                    if (cached - prob).abs() > 0.001 {
903                                        diagnostics.push(Diagnostic::warning(
904                                            "W-402",
905                                            format!(
906                                                "branch '{}[{}]' cached probability {} drifted from fault tree computed {}",
907                                                node_id, i, cached, prob
908                                            ),
909                                        ));
910                                    }
911                                }
912                                branch_probs.insert(key, prob);
913                            } else {
914                                diagnostics.push(Diagnostic::error(
915                                    "E-105",
916                                    format!(
917                                        "branch '{}[{}]' probabilitySource references unknown fault tree",
918                                        node_id, i
919                                    ),
920                                ));
921                            }
922                        } else if let Some(prob) = branch.effective_probability() {
923                            branch_probs.insert(key, prob);
924                        }
925                    }
926                }
927                Node::Operation(op) => {
928                    if let Some(ref ps) = op.on_failure_probability_source {
929                        let ft_id = extract_fault_tree_id(&ps.pointer);
930                        if let Some(&prob) = fault_tree_probs.get(&ft_id) {
931                            branch_probs.insert(format!("{}.onFailure", node_id), prob);
932                        }
933                    }
934                }
935                _ => {}
936            }
937        }
938    }
939
940    branch_probs
941}
942
943fn extract_fault_tree_id(pointer: &str) -> String {
944    let parts: Vec<&str> = pointer.trim_start_matches("#/faultTrees/").split('/').collect();
945    parts[0].to_string()
946}
947
948pub fn validate_probability_sums(
949    doc: &EtlDocument,
950    _resolved_probs: &BTreeMap<String, f64>,
951    diagnostics: &mut Vec<Diagnostic>,
952) {
953    for (tree_name, tree) in &doc.event_trees {
954        for (node_id, node) in &tree.nodes {
955            if let Node::Barrier(barrier) = node {
956                let sum: f64 = barrier
957                    .branches
958                    .iter()
959                    .enumerate()
960                    .filter_map(|(i, b)| {
961                        if b.condition == Condition::Default {
962                            None
963                        } else if let Some(ref _ps) = b.probability_source {
964                            _resolved_probs
965                                .get(&format!("{}.branch.{}", node_id, i))
966                                .copied()
967                        } else {
968                            b.effective_probability()
969                        }
970                    })
971                    .sum();
972
973                if !barrier.branches.is_empty() && sum > 0.0 {
974                    if (sum - 1.0).abs() > 0.0001 {
975                        let default_prob = (1.0 - sum).max(0.0);
976                        if default_prob < 0.0 {
977                            diagnostics.push(Diagnostic::error(
978                                "V-203",
979                                format!(
980                                    "tree '{}': barrier '{}' branch probabilities sum to {} (must be 1.0 within ±0.0001)",
981                                    tree_name, node_id, sum
982                                ),
983                            ));
984                        }
985                    }
986                }
987            }
988        }
989    }
990}