1use etdl_parser::ast::{
2 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 let root_id = ft.top_event.root_cause.as_str();
549 match known_ids.get(root_id) {
550 None => {
551 diagnostics.push(Diagnostic::error(
552 "V-401",
553 format!(
554 "fault tree '{}': topEvent.rootCause '{}' does not resolve to a gate or basic event",
555 ft_name, root_id
556 ),
557 ));
558 }
559 Some(_) => {
560 known_ids.insert(root_id, true);
561 }
562 }
563
564 if let Some(ref gates) = ft.gates {
565 for (gate_id, gate) in gates {
566 for input in &gate.inputs {
567 match known_ids.get(input.as_str()) {
568 None => {
569 diagnostics.push(Diagnostic::error(
570 "V-401",
571 format!(
572 "fault tree '{}': gate '{}' input '{}' does not resolve",
573 ft_name, gate_id, input
574 ),
575 ));
576 }
577 Some(_) => {
578 known_ids.insert(input.as_str(), true);
579 }
580 }
581 }
582 }
583 }
584
585 check_fault_tree_dag(ft_name, ft, diagnostics);
586
587 for (&id, &is_reachable) in &known_ids {
588 if !is_reachable && id != root_id {
589 diagnostics.push(Diagnostic::error(
590 "V-404",
591 format!(
592 "fault tree '{}': '{}' is not reachable from topEvent.rootCause",
593 ft_name, id
594 ),
595 ));
596 }
597 }
598}
599
600fn check_fault_tree_dag(
601 ft_name: &str,
602 ft: &FaultTree,
603 diagnostics: &mut Vec<Diagnostic>,
604) {
605 let gates = match &ft.gates {
606 Some(g) => g,
607 None => return,
608 };
609
610 #[derive(Clone, Copy, PartialEq)]
611 enum Color {
612 White,
613 Gray,
614 Black,
615 }
616
617 let mut colors: HashMap<&str, Color> = HashMap::new();
618 for gate_id in gates.keys() {
619 colors.insert(gate_id.as_str(), Color::White);
620 }
621
622 fn dfs_gate<'a>(
623 gate_id: &'a str,
624 gates: &'a BTreeMap<String, Gate>,
625 colors: &mut HashMap<&'a str, Color>,
626 diagnostics: &mut Vec<Diagnostic>,
627 ft_name: &str,
628 ) {
629 if let Some(Color::Black) = colors.get(gate_id) {
630 return;
631 }
632 if let Some(Color::Gray) = colors.get(gate_id) {
633 return;
634 }
635
636 colors.insert(gate_id, Color::Gray);
637
638 if let Some(gate) = gates.get(gate_id) {
639 for input in &gate.inputs {
640 if gates.contains_key(input.as_str()) {
641 match colors.get(input.as_str()) {
642 Some(Color::Gray) => {
643 diagnostics.push(Diagnostic::error(
644 "V-403",
645 format!(
646 "fault tree '{}': cycle detected involving gate '{}' -> '{}'",
647 ft_name, gate_id, input
648 ),
649 ));
650 }
651 Some(Color::White) => {
652 dfs_gate(input, gates, colors, diagnostics, ft_name);
653 }
654 _ => {}
655 }
656 }
657 }
658 }
659
660 colors.insert(gate_id, Color::Black);
661 }
662
663 let root_id = ft.top_event.root_cause.as_str();
664 if gates.contains_key(root_id) {
665 dfs_gate(root_id, gates, &mut colors, diagnostics, ft_name);
666 }
667}
668
669fn check_gate_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
670 let gates = match &ft.gates {
671 Some(g) => g,
672 None => return,
673 };
674
675 for (gate_id, gate) in gates {
676 let n = gate.inputs.len();
677
678 match gate.gate_type {
679 GateType::And | GateType::Or => {
680 if n < 2 {
681 diagnostics.push(Diagnostic::error(
682 "V-501",
683 format!(
684 "fault tree '{}': {:?} gate '{}' has {} input(s), minimum 2 required",
685 ft_name, gate.gate_type, gate_id, n
686 ),
687 ));
688 }
689 }
690 GateType::Not => {
691 if n != 1 {
692 diagnostics.push(Diagnostic::error(
693 "V-501",
694 format!(
695 "fault tree '{}': NOT gate '{}' has {} input(s), exactly 1 required",
696 ft_name, gate_id, n
697 ),
698 ));
699 }
700 }
701 GateType::Xor => {
702 if n != 2 {
703 diagnostics.push(Diagnostic::error(
704 "V-501",
705 format!(
706 "fault tree '{}': XOR gate '{}' has {} input(s), exactly 2 required",
707 ft_name, gate_id, n
708 ),
709 ));
710 }
711 }
712 GateType::Voting => {
713 if n < 2 {
714 diagnostics.push(Diagnostic::error(
715 "V-501",
716 format!(
717 "fault tree '{}': VOTING gate '{}' has {} input(s), minimum 2 required",
718 ft_name, gate_id, n
719 ),
720 ));
721 }
722 if let Some(k) = gate.k {
723 if k < 1 || k as usize > n {
724 diagnostics.push(Diagnostic::error(
725 "V-502",
726 format!(
727 "fault tree '{}': VOTING gate '{}' k={} must satisfy 1 <= k <= n={}",
728 ft_name, gate_id, k, n
729 ),
730 ));
731 }
732 } else {
733 diagnostics.push(Diagnostic::error(
734 "V-502",
735 format!(
736 "fault tree '{}': VOTING gate '{}' missing required 'k' field",
737 ft_name, gate_id
738 ),
739 ));
740 }
741 }
742 }
743 }
744}
745
746fn check_basic_event_rules(
747 ft_name: &str,
748 ft: &FaultTree,
749 diagnostics: &mut Vec<Diagnostic>,
750) {
751 for (be_id, be) in &ft.basic_events {
752 let has_prob = be.probability.is_some();
753 let has_rate = be.failure_rate.is_some();
754 let has_time = be.mission_time.is_some();
755
756 if has_prob && has_rate {
757 diagnostics.push(Diagnostic::error(
758 "V-503",
759 format!(
760 "fault tree '{}': basic event '{}' supplies both probability and failureRate",
761 ft_name, be_id
762 ),
763 ));
764 } else if !has_prob && !has_rate {
765 diagnostics.push(Diagnostic::error(
766 "V-503",
767 format!(
768 "fault tree '{}': basic event '{}' supplies neither probability nor failureRate",
769 ft_name, be_id
770 ),
771 ));
772 }
773
774 if has_rate && !has_time {
775 diagnostics.push(Diagnostic::error(
776 "V-504",
777 format!(
778 "fault tree '{}': basic event '{}' has failureRate but no missionTime",
779 ft_name, be_id
780 ),
781 ));
782 }
783 }
784}
785
786pub type FaultTreeProbabilities = BTreeMap<String, f64>;
787
788pub fn resolve_probability_links(
789 doc: &EtlDocument,
790 fault_tree_probs: &FaultTreeProbabilities,
791 diagnostics: &mut Vec<Diagnostic>,
792) -> BTreeMap<String, f64> {
793 let mut branch_probs: BTreeMap<String, f64> = BTreeMap::new();
794
795 for (_tree_name, tree) in &doc.event_trees {
796 for (node_id, node) in &tree.nodes {
797 match node {
798 Node::Barrier(barrier) => {
799 for (i, branch) in barrier.branches.iter().enumerate() {
800 let key = format!("{}.branch.{}", node_id, i);
801
802 if let Some(ref ps) = branch.probability_source {
803 let ft_id = extract_fault_tree_id(&ps.pointer);
804 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
805 if let Some(cached) = branch.effective_probability() {
806 if (cached - prob).abs() > 0.001 {
807 diagnostics.push(Diagnostic::warning(
808 "W-402",
809 format!(
810 "branch '{}[{}]' cached probability {} drifted from fault tree computed {}",
811 node_id, i, cached, prob
812 ),
813 ));
814 }
815 }
816 branch_probs.insert(key, prob);
817 } else {
818 diagnostics.push(Diagnostic::error(
819 "E-105",
820 format!(
821 "branch '{}[{}]' probabilitySource references unknown fault tree",
822 node_id, i
823 ),
824 ));
825 }
826 } else if let Some(prob) = branch.effective_probability() {
827 branch_probs.insert(key, prob);
828 }
829 }
830 }
831 Node::Operation(op) => {
832 if let Some(ref ps) = op.on_failure_probability_source {
833 let ft_id = extract_fault_tree_id(&ps.pointer);
834 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
835 branch_probs.insert(format!("{}.onFailure", node_id), prob);
836 }
837 }
838 }
839 _ => {}
840 }
841 }
842 }
843
844 branch_probs
845}
846
847fn extract_fault_tree_id(pointer: &str) -> String {
848 let parts: Vec<&str> = pointer.trim_start_matches("#/faultTrees/").split('/').collect();
849 parts[0].to_string()
850}
851
852pub fn validate_probability_sums(
853 doc: &EtlDocument,
854 _resolved_probs: &BTreeMap<String, f64>,
855 diagnostics: &mut Vec<Diagnostic>,
856) {
857 for (tree_name, tree) in &doc.event_trees {
858 for (node_id, node) in &tree.nodes {
859 if let Node::Barrier(barrier) = node {
860 let sum: f64 = barrier
861 .branches
862 .iter()
863 .enumerate()
864 .filter_map(|(i, b)| {
865 if b.condition == Condition::Default {
866 None
867 } else if let Some(ref _ps) = b.probability_source {
868 _resolved_probs
869 .get(&format!("{}.branch.{}", node_id, i))
870 .copied()
871 } else {
872 b.effective_probability()
873 }
874 })
875 .sum();
876
877 if !barrier.branches.is_empty() && sum > 0.0 {
878 if (sum - 1.0).abs() > 0.0001 {
879 let default_prob = (1.0 - sum).max(0.0);
880 if default_prob < 0.0 {
881 diagnostics.push(Diagnostic::error(
882 "V-203",
883 format!(
884 "tree '{}': barrier '{}' branch probabilities sum to {} (must be 1.0 within ±0.0001)",
885 tree_name, node_id, sum
886 ),
887 ));
888 }
889 }
890 }
891 }
892 }
893 }
894}