1use etdl_parser::ast::{BasicEventType, EtlDocument, EventTree, FaultTree, Gate, GateType, Node};
2use etdl_parser::asyncapi::AsyncApiRegistry;
3use etdl_parser::ecel::Condition;
4use etdl_parser::spanned::SpanKey;
5use std::collections::{BTreeMap, HashMap};
6
7#[derive(Debug, Clone)]
8pub struct Diagnostic {
9 pub code: String,
10 pub severity: DiagnosticSeverity,
11 pub message: String,
12 pub line: Option<u32>,
13 pub column: Option<u32>,
14 pub end_line: Option<u32>,
15 pub end_column: Option<u32>,
16 pub key: Option<SpanKey>,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub enum DiagnosticSeverity {
22 Error,
23 Warning,
24}
25
26impl Diagnostic {
27 pub fn error(code: &str, message: String) -> Self {
28 Diagnostic {
29 code: code.to_string(),
30 severity: DiagnosticSeverity::Error,
31 message,
32 line: None,
33 column: None,
34 end_line: None,
35 end_column: None,
36 key: None,
37 }
38 }
39
40 pub fn warning(code: &str, message: String) -> Self {
41 Diagnostic {
42 code: code.to_string(),
43 severity: DiagnosticSeverity::Warning,
44 message,
45 line: None,
46 column: None,
47 end_line: None,
48 end_column: None,
49 key: None,
50 }
51 }
52
53 pub fn with_position(mut self, line: u32, column: u32) -> Self {
54 self.line = Some(line);
55 self.column = Some(column);
56 self
57 }
58
59 pub fn at(mut self, key: SpanKey) -> Self {
61 self.key = Some(key);
62 self
63 }
64
65 pub fn is_error(&self) -> bool {
66 self.severity == DiagnosticSeverity::Error
67 }
68}
69
70pub fn validate_document(
71 doc: &EtlDocument,
72 registry: &AsyncApiRegistry,
73 diagnostics: &mut Vec<Diagnostic>,
74) {
75 validate_document_with_extensions(doc, registry, &[], diagnostics);
76}
77
78pub fn validate_document_with_extensions(
88 doc: &EtlDocument,
89 registry: &AsyncApiRegistry,
90 registered_extensions: &[&str],
91 diagnostics: &mut Vec<Diagnostic>,
92) {
93 validate_language_version(doc, diagnostics);
94 validate_supplements(doc, registered_extensions, diagnostics);
95 validate_references(doc, registry, diagnostics);
96 validate_event_trees(doc, registry, diagnostics);
97 validate_fault_trees(doc, diagnostics);
98}
99
100pub const SUPPORTED_SUPPLEMENTS: &[&str] = &["etdl.reliability"];
104
105fn supplement_is_supported(id: &str, registered_extensions: &[&str]) -> bool {
108 crate::extension::builtin_registry().contains(id) || registered_extensions.contains(&id)
109}
110
111fn validate_supplements(doc: &EtlDocument, registered_extensions: &[&str], diagnostics: &mut Vec<Diagnostic>) {
114 for sup in &doc.supplements {
115 let valid_id = sup
117 .id
118 .strip_prefix("etdl.")
119 .map(|domain| {
120 !domain.is_empty()
121 && domain
122 .chars()
123 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
124 })
125 .unwrap_or(false);
126 if !valid_id {
127 diagnostics.push(Diagnostic::error(
128 "E-106",
129 format!(
130 "supplement id '{}' is not a valid supplement identifier (must be 'etdl.<domain>')",
131 sup.id
132 ),
133 ));
134 }
135
136 let supported = supplement_is_supported(&sup.id, registered_extensions);
137
138 let major = parse_supplement_major(&sup.version);
140 match major {
141 None => {
142 diagnostics.push(Diagnostic::error(
143 "E-107",
144 format!(
145 "supplement '{}' version '{}' is not valid SemVer",
146 sup.id, sup.version
147 ),
148 ));
149 }
150 Some(m) => {
151 if supported {
152 const SUPPORTED_RELIABILITY_MAJOR: u64 = 1;
153 if m > SUPPORTED_RELIABILITY_MAJOR {
154 diagnostics.push(Diagnostic::error(
155 "E-107",
156 format!(
157 "supplement '{}' version '{}' uses future major {} (supports major {})",
158 sup.id, sup.version, m, SUPPORTED_RELIABILITY_MAJOR
159 ),
160 ));
161 }
162 }
163 }
164 }
165
166 if sup.required && !supported {
168 diagnostics.push(Diagnostic::error(
169 "E-108",
170 format!(
171 "supplement '{}' is required: true but is not implemented by this compiler",
172 sup.id
173 ),
174 ));
175 }
176
177 if !sup.required && !supported {
179 diagnostics.push(Diagnostic::warning(
180 "W-407",
181 format!(
182 "supplement '{}' is not implemented by this compiler; its semantics will not be applied",
183 sup.id
184 ),
185 ));
186 }
187 }
188}
189
190pub fn validate_libraries(
198 doc: &EtlDocument,
199 lib_errors: &[crate::stdlib::LibraryError],
200 diagnostics: &mut Vec<Diagnostic>,
201) {
202 for import in &doc.libraries {
203 let valid_name = !import.name.is_empty()
204 && !import.name.starts_with('.')
205 && !import.name.ends_with('.')
206 && !import.name.contains("..")
207 && import
208 .name
209 .chars()
210 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
211 if !valid_name {
212 diagnostics.push(Diagnostic::error(
213 "E-113",
214 format!(
215 "library name '{}' is not a valid library identifier (dotted lowercase \
216 segments, e.g. 'std.events')",
217 import.name
218 ),
219 ));
220 }
221 }
222
223 for err in lib_errors {
224 use crate::stdlib::LibraryError;
225 match err {
226 LibraryError::Cyclic { .. } => {
227 diagnostics.push(Diagnostic::error("E-117", err.to_string()));
228 }
229 LibraryError::IncompatibleVersion { .. } => {
230 diagnostics.push(Diagnostic::error("E-114", err.to_string()));
231 }
232 LibraryError::InvalidManifest { .. } => {
233 diagnostics.push(Diagnostic::error("E-115", err.to_string()));
234 }
235 LibraryError::NotFound { name, .. } | LibraryError::Shadowing { name, .. } => {
236 let required = doc
237 .libraries
238 .iter()
239 .find(|l| &l.name == name)
240 .map(|l| l.required)
241 .unwrap_or(true);
242 if required {
243 diagnostics.push(Diagnostic::error("E-116", err.to_string()));
244 } else {
245 diagnostics.push(Diagnostic::warning("W-409", err.to_string()));
246 }
247 }
248 }
249 }
250}
251
252fn parse_supplement_major(version: &str) -> Option<u64> {
253 let trimmed = version.trim();
254 if trimmed.is_empty() {
255 return None;
256 }
257 let major_part = trimmed.split(['.', '+']).next()?;
258 major_part.trim().parse::<u64>().ok()
259}
260
261pub fn declares_supplement(doc: &EtlDocument, id: &str) -> bool {
264 doc.supplements.iter().any(|s| s.id == id)
265}
266
267fn validate_language_version(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
271 const SUPPORTED_MAJOR: u64 = 1;
272
273 let parse_major = |v: &str| -> Option<u64> {
274 let trimmed = v.trim();
275 if trimmed.is_empty() {
276 return None;
277 }
278 let major_part = trimmed.split(['.', '+']).next()?;
279 major_part.trim().parse::<u64>().ok()
280 };
281
282 match parse_major(&doc.etdl) {
283 None => {
284 diagnostics.push(Diagnostic::error(
285 "E-100",
286 format!(
287 "document 'etdl' version '{}' is not a valid semantic version",
288 doc.etdl
289 ),
290 ));
291 }
292 Some(major) if major > SUPPORTED_MAJOR => {
293 diagnostics.push(Diagnostic::error(
294 "E-100",
295 format!(
296 "document 'etdl' version '{}' has major version {} which is not supported by this compiler (supports major {})",
297 doc.etdl, major, SUPPORTED_MAJOR
298 ),
299 ));
300 }
301 Some(_) => {
302 }
304 }
305}
306
307fn validate_references(
308 doc: &EtlDocument,
309 registry: &AsyncApiRegistry,
310 diagnostics: &mut Vec<Diagnostic>,
311) {
312 for alias in doc.asyncapi_imports.keys() {
313 if alias
314 .chars()
315 .any(|c| !c.is_ascii_alphanumeric() && c != '_')
316 {
317 diagnostics.push(
318 Diagnostic::error(
319 "E-103",
320 format!("import alias '{}' contains invalid characters", alias),
321 )
322 .at(SpanKey::ImportAlias {
323 alias: alias.clone(),
324 }),
325 );
326 }
327 }
328
329 for (tree_name, tree) in &doc.event_trees {
330 validate_message_ref(
331 &tree.initiating_event.message,
332 doc,
333 registry,
334 diagnostics,
335 "initiatingEvent.message",
336 SpanKey::InitiatingEvent {
337 tree: tree_name.clone(),
338 field: "message",
339 },
340 );
341
342 for (node_id, node) in &tree.nodes {
343 match node {
344 Node::Operation(op) => {
345 if let Some(ref emits_ref) = op.emits {
346 validate_message_ref(
347 emits_ref,
348 doc,
349 registry,
350 diagnostics,
351 &format!("nodes.{}.emits", node_id),
352 SpanKey::NodeField {
353 tree: tree_name.clone(),
354 id: node_id.clone(),
355 field: "emits",
356 },
357 );
358 }
359 }
360 Node::Consequence(cons) => {
361 if let Some(ref channel_ref) = cons.channel {
362 validate_channel_ref(
363 channel_ref,
364 doc,
365 registry,
366 diagnostics,
367 &format!("nodes.{}.channel", node_id),
368 SpanKey::NodeField {
369 tree: tree_name.clone(),
370 id: node_id.clone(),
371 field: "channel",
372 },
373 );
374 }
375 if let Some(ref message_ref) = cons.message {
376 validate_message_ref(
377 message_ref,
378 doc,
379 registry,
380 diagnostics,
381 &format!("nodes.{}.message", node_id),
382 SpanKey::NodeField {
383 tree: tree_name.clone(),
384 id: node_id.clone(),
385 field: "message",
386 },
387 );
388 }
389 }
390 _ => {}
391 }
392 }
393 }
394
395 if let Some(ref fault_trees) = doc.fault_trees {
396 for (ft_name, ft) in fault_trees {
397 if let Some(ref msg_ref) = ft.top_event.message {
398 validate_message_ref(
399 msg_ref,
400 doc,
401 registry,
402 diagnostics,
403 "topEvent.message",
404 SpanKey::TopEvent {
405 tree: ft_name.clone(),
406 field: "message",
407 },
408 );
409 }
410 for (be_name, be) in &ft.basic_events {
411 if let Some(ref msg_ref) = be.message {
412 validate_message_ref(
413 msg_ref,
414 doc,
415 registry,
416 diagnostics,
417 "basicEvent.message",
418 SpanKey::BasicEventField {
419 tree: ft_name.clone(),
420 id: be_name.clone(),
421 field: "message",
422 },
423 );
424 }
425 }
426 }
427 }
428}
429
430fn validate_external_ref(
431 ext_ref: &etdl_parser::ast::ExternalRef,
432 doc: &EtlDocument,
433 registry: &AsyncApiRegistry,
434 diagnostics: &mut Vec<Diagnostic>,
435 context: &str,
436 key: SpanKey,
437) {
438 if !doc.asyncapi_imports.contains_key(&ext_ref.alias) {
439 diagnostics.push(
440 Diagnostic::error(
441 "E-103",
442 format!(
443 "{}: import alias '{}' is not a key in asyncapi_imports",
444 context, ext_ref.alias
445 ),
446 )
447 .at(key),
448 );
449 return;
450 }
451
452 if registry.resolve(ext_ref).is_err() {
453 diagnostics.push(
454 Diagnostic::error(
455 "E-104",
456 format!(
457 "{}: JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
458 context, ext_ref.pointer, ext_ref.alias
459 ),
460 )
461 .at(key),
462 );
463 }
464}
465
466fn validate_message_ref(
472 msg_ref: &etdl_parser::ast::MessageRef,
473 doc: &EtlDocument,
474 registry: &AsyncApiRegistry,
475 diagnostics: &mut Vec<Diagnostic>,
476 context: &str,
477 key: SpanKey,
478) {
479 match msg_ref {
480 etdl_parser::ast::MessageRef::External(ext_ref) => {
481 validate_external_ref(ext_ref, doc, registry, diagnostics, context, key);
482 }
483 etdl_parser::ast::MessageRef::Internal(int_ref) => {
484 if registry.resolve_message(doc, msg_ref).is_err() {
485 diagnostics.push(
486 Diagnostic::error(
487 "E-105",
488 format!(
489 "{}: internal reference '{}' does not resolve to an entry under components.messages",
490 context, int_ref.pointer
491 ),
492 )
493 .at(key),
494 );
495 }
496 }
497 }
498}
499
500fn validate_channel_ref(
507 channel_ref: &etdl_parser::ast::ChannelRef,
508 doc: &EtlDocument,
509 registry: &AsyncApiRegistry,
510 diagnostics: &mut Vec<Diagnostic>,
511 context: &str,
512 key: SpanKey,
513) {
514 match channel_ref {
515 etdl_parser::ast::ChannelRef::External(ext_ref) => {
516 validate_external_ref(ext_ref, doc, registry, diagnostics, context, key);
517 }
518 etdl_parser::ast::ChannelRef::Bare(name) => {
519 if !doc.asyncapi_imports.is_empty() {
520 diagnostics.push(
521 Diagnostic::error(
522 "E-101",
523 format!(
524 "{}: bare channel name '{}' is only permitted when the document declares no asyncapi_imports (Section 5.3.5); use an External Reference",
525 context, name
526 ),
527 )
528 .at(key),
529 );
530 }
531 }
532 }
533}
534
535fn validate_event_trees(
536 doc: &EtlDocument,
537 _registry: &AsyncApiRegistry,
538 diagnostics: &mut Vec<Diagnostic>,
539) {
540 for (tree_name, tree) in &doc.event_trees {
541 validate_tree_structure(tree_name, tree, diagnostics);
542 }
543}
544
545fn validate_tree_structure(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
546 check_node_references(tree_name, tree, diagnostics);
547 check_dag(tree_name, tree, diagnostics);
548 check_reachability(tree_name, tree, diagnostics);
549 check_terminal_paths(tree_name, tree, diagnostics);
550 check_barrier_rules(tree_name, tree, diagnostics);
551 check_operation_rules(tree_name, tree, diagnostics);
552 check_consequence_rules(tree_name, tree, diagnostics);
553}
554
555fn check_node_references(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
556 if !tree.nodes.contains_key(&tree.initiating_event.next) {
557 diagnostics.push(
558 Diagnostic::error(
559 "V-101",
560 format!(
561 "tree '{}': initiatingEvent.next '{}' does not resolve to a node in this tree",
562 tree_name, tree.initiating_event.next
563 ),
564 )
565 .at(SpanKey::InitiatingEvent {
566 tree: tree_name.to_string(),
567 field: "next",
568 }),
569 );
570 }
571
572 for (node_id, node) in &tree.nodes {
573 let next_targets: Vec<(&str, SpanKey)> = match node {
574 Node::Barrier(barrier) => barrier
575 .branches
576 .iter()
577 .enumerate()
578 .map(|(i, b)| {
579 (
580 b.next.as_str(),
581 SpanKey::BranchField {
582 tree: tree_name.to_string(),
583 id: node_id.clone(),
584 branch: i,
585 field: "next",
586 },
587 )
588 })
589 .collect(),
590 Node::Operation(op) => {
591 let mut targets = vec![(
592 op.next.as_str(),
593 SpanKey::NodeField {
594 tree: tree_name.to_string(),
595 id: node_id.clone(),
596 field: "next",
597 },
598 )];
599 if let Some(ref on_fail) = op.on_failure {
600 targets.push((
601 on_fail.as_str(),
602 SpanKey::NodeField {
603 tree: tree_name.to_string(),
604 id: node_id.clone(),
605 field: "on_failure",
606 },
607 ));
608 }
609 targets
610 }
611 Node::Consequence(_) => continue,
612 };
613
614 for (target, key) in next_targets {
615 if !tree.nodes.contains_key(target) {
616 diagnostics.push(
617 Diagnostic::error(
618 "V-101",
619 format!(
620 "tree '{}': node '{}' references '{}' which does not exist in this tree",
621 tree_name, node_id, target
622 ),
623 )
624 .at(key),
625 );
626 }
627 }
628 }
629}
630
631fn check_dag(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
632 #[derive(Clone, Copy, PartialEq)]
633 enum Color {
634 White,
635 Gray,
636 Black,
637 }
638
639 let mut colors: HashMap<&str, Color> = HashMap::new();
640 for node_id in tree.nodes.keys() {
641 colors.insert(node_id.as_str(), Color::White);
642 }
643
644 fn dfs<'a>(
645 node: &'a str,
646 tree: &'a EventTree,
647 colors: &mut HashMap<&'a str, Color>,
648 diagnostics: &mut Vec<Diagnostic>,
649 tree_name: &str,
650 ) {
651 colors.insert(node, Color::Gray);
652
653 let next_nodes: Vec<&str> = match tree.nodes.get(node) {
654 Some(Node::Barrier(barrier)) => {
655 barrier.branches.iter().map(|b| b.next.as_str()).collect()
656 }
657 Some(Node::Operation(op)) => {
658 let mut targets = vec![op.next.as_str()];
659 if let Some(ref on_fail) = op.on_failure {
660 targets.push(on_fail.as_str());
661 }
662 targets
663 }
664 Some(Node::Consequence(_)) => {
665 colors.insert(node, Color::Black);
668 return;
669 }
670 None => return,
671 };
672
673 for next in next_nodes {
674 match colors.get(next) {
675 Some(Color::Gray) => {
676 diagnostics.push(
677 Diagnostic::error(
678 "V-102",
679 format!(
680 "tree '{}': cycle detected involving node '{}' -> '{}'",
681 tree_name, node, next
682 ),
683 )
684 .at(SpanKey::Node {
685 tree: tree_name.to_string(),
686 id: node.to_string(),
687 }),
688 );
689 }
690 Some(Color::White) => {
691 dfs(next, tree, colors, diagnostics, tree_name);
692 }
693 _ => {}
694 }
695 }
696
697 colors.insert(node, Color::Black);
698 }
699
700 let start_id = tree.initiating_event.next.as_str();
701 if tree.nodes.contains_key(start_id) {
702 dfs(start_id, tree, &mut colors, diagnostics, tree_name);
703 }
704}
705
706fn check_reachability(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
707 let mut reachable: HashMap<&str, bool> = HashMap::new();
708 for node_id in tree.nodes.keys() {
709 reachable.insert(node_id.as_str(), false);
710 }
711
712 let start_id = tree.initiating_event.next.as_str();
713 if tree.nodes.contains_key(start_id) {
714 reachable.insert(start_id, true);
715 propagate_reachability(start_id, tree, &mut reachable);
716 }
717
718 for (node_id, &is_reachable) in &reachable {
719 if !is_reachable {
720 diagnostics.push(
721 Diagnostic::error(
722 "V-103",
723 format!(
724 "tree '{}': node '{}' is unreachable from initiatingEvent",
725 tree_name, node_id
726 ),
727 )
728 .at(SpanKey::Node {
729 tree: tree_name.to_string(),
730 id: node_id.to_string(),
731 }),
732 );
733 }
734 }
735}
736
737fn propagate_reachability<'a>(
738 node_id: &'a str,
739 tree: &'a EventTree,
740 reachable: &mut HashMap<&'a str, bool>,
741) {
742 let next_nodes: Vec<&str> = match tree.nodes.get(node_id) {
743 Some(Node::Barrier(barrier)) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
744 Some(Node::Operation(op)) => {
745 let mut targets = vec![op.next.as_str()];
746 if let Some(ref on_fail) = op.on_failure {
747 targets.push(on_fail.as_str());
748 }
749 targets
750 }
751 Some(Node::Consequence(_)) => return,
752 None => return,
753 };
754
755 for next in next_nodes {
756 if let Some(was_reachable) = reachable.get_mut(next) {
757 if !*was_reachable {
758 *was_reachable = true;
759 propagate_reachability(next, tree, reachable);
760 }
761 }
762 }
763}
764
765fn check_terminal_paths(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
766 fn check_termination<'a>(
767 node_id: &'a str,
768 tree: &'a EventTree,
769 visited: &mut Vec<&'a str>,
770 tree_name: &str,
771 diagnostics: &mut Vec<Diagnostic>,
772 ) -> bool {
773 if visited.contains(&node_id) {
774 return false;
775 }
776 visited.push(node_id);
777
778 match tree.nodes.get(node_id) {
779 Some(Node::Consequence(_)) => {
780 visited.pop();
781 true
782 }
783 Some(Node::Barrier(barrier)) => {
784 let mut all_terminal = true;
785 for branch in &barrier.branches {
786 if !check_termination(&branch.next, tree, visited, tree_name, diagnostics) {
787 all_terminal = false;
788 }
789 }
790 visited.pop();
791 all_terminal
792 }
793 Some(Node::Operation(op)) => {
794 let mut all_terminal = true;
795 if !check_termination(&op.next, tree, visited, tree_name, diagnostics) {
796 all_terminal = false;
797 }
798 if let Some(ref on_fail) = op.on_failure {
799 if !check_termination(on_fail, tree, visited, tree_name, diagnostics) {
800 all_terminal = false;
801 }
802 }
803 visited.pop();
804 all_terminal
805 }
806 None => {
807 visited.pop();
808 false
809 }
810 }
811 }
812
813 let start_id = tree.initiating_event.next.as_str();
814 if tree.nodes.contains_key(start_id) {
815 let mut visited = Vec::new();
816 let terminates = check_termination(start_id, tree, &mut visited, tree_name, diagnostics);
817 if !terminates {
818 diagnostics.push(
819 Diagnostic::error(
820 "V-104",
821 format!(
822 "tree '{}': a path from initiatingEvent '{}' does not terminate in a consequence node (every path must end in a Consequence)",
823 tree_name, tree.initiating_event.id
824 ),
825 )
826 .at(SpanKey::InitiatingEvent {
827 tree: tree_name.to_string(),
828 field: "next",
829 }),
830 );
831 }
832 }
833}
834
835fn check_barrier_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
836 for (node_id, node) in &tree.nodes {
837 if let Node::Barrier(barrier) = node {
838 if barrier.branches.len() < 2 {
839 diagnostics.push(
840 Diagnostic::error(
841 "V-201",
842 format!(
843 "tree '{}': barrier '{}' has fewer than 2 branches",
844 tree_name, node_id
845 ),
846 )
847 .at(SpanKey::Node {
848 tree: tree_name.to_string(),
849 id: node_id.clone(),
850 }),
851 );
852 }
853
854 let mut default_count = 0;
855 let mut last_is_default = false;
856 for (i, branch) in barrier.branches.iter().enumerate() {
857 if branch.condition == Condition::Default {
858 default_count += 1;
859 if i == barrier.branches.len() - 1 {
860 last_is_default = true;
861 }
862 }
863 }
864 if default_count > 1 {
865 diagnostics.push(
866 Diagnostic::error(
867 "V-202",
868 format!(
869 "tree '{}': barrier '{}' has more than one default branch",
870 tree_name, node_id
871 ),
872 )
873 .at(SpanKey::Node {
874 tree: tree_name.to_string(),
875 id: node_id.clone(),
876 }),
877 );
878 } else if default_count == 1 && !last_is_default {
879 diagnostics.push(
880 Diagnostic::error(
881 "V-202",
882 format!(
883 "tree '{}': barrier '{}' default branch is not the last branch",
884 tree_name, node_id
885 ),
886 )
887 .at(SpanKey::Node {
888 tree: tree_name.to_string(),
889 id: node_id.clone(),
890 }),
891 );
892 }
893
894 for (i, branch) in barrier.branches.iter().enumerate() {
895 if branch.condition == Condition::Default {
896 continue;
897 }
898 let has_prob =
899 branch.effective_probability().is_some() || branch.probability_source.is_some();
900 if !has_prob {
901 diagnostics.push(
902 Diagnostic::error(
903 "V-203",
904 format!(
905 "tree '{}': barrier '{}' branch {} has no probability or probabilitySource",
906 tree_name, node_id, i
907 ),
908 )
909 .at(SpanKey::BranchField {
910 tree: tree_name.to_string(),
911 id: node_id.clone(),
912 branch: i,
913 field: "probability",
914 }),
915 );
916 }
917 }
918 }
919 }
920}
921
922fn check_operation_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
923 for (node_id, node) in &tree.nodes {
924 if let Node::Operation(op) = node {
925 if op.on_failure.is_none() {
926 diagnostics.push(
927 Diagnostic::warning(
928 "W-401",
929 format!(
930 "tree '{}': operation '{}' has no onFailure path",
931 tree_name, node_id
932 ),
933 )
934 .at(SpanKey::Node {
935 tree: tree_name.to_string(),
936 id: node_id.clone(),
937 }),
938 );
939 }
940
941 if !is_rust_ident(&op.handler) {
946 diagnostics.push(
947 Diagnostic::error(
948 "V-301",
949 format!(
950 "tree '{}': operation '{}' handler '{}' is not a valid identifier (must start with a letter or underscore and contain only letters, digits, and underscores)",
951 tree_name, node_id, op.handler
952 ),
953 )
954 .at(SpanKey::Node {
955 tree: tree_name.to_string(),
956 id: node_id.clone(),
957 }),
958 );
959 }
960 }
961 }
962}
963
964fn is_rust_ident(s: &str) -> bool {
965 let mut chars = s.chars();
966 match chars.next() {
967 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
968 _ => return false,
969 }
970 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
971}
972
973fn check_consequence_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
974 for (node_id, node) in &tree.nodes {
975 if let Node::Consequence(cons) = node {
976 match cons.consequence_operation {
977 etdl_parser::ast::ConsequenceOperation::Send => {
978 if cons.channel.is_none() || cons.message.is_none() {
979 diagnostics.push(
980 Diagnostic::error(
981 "V-302",
982 format!(
983 "tree '{}': consequence '{}' has operation: send but omits channel or message",
984 tree_name, node_id
985 ),
986 )
987 .at(SpanKey::Node {
988 tree: tree_name.to_string(),
989 id: node_id.clone(),
990 }),
991 );
992 }
993 }
994 etdl_parser::ast::ConsequenceOperation::Terminate => {}
995 }
996 }
997 }
998}
999
1000fn validate_fault_trees(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
1001 let fault_trees = match &doc.fault_trees {
1002 Some(fts) => fts,
1003 None => return,
1004 };
1005
1006 for (ft_name, ft) in fault_trees {
1007 check_fault_tree_structure(doc, ft_name, ft, diagnostics);
1008 check_gate_rules(ft_name, ft, diagnostics);
1009 check_basic_event_rules(ft_name, ft, diagnostics);
1010 }
1011}
1012
1013fn check_fault_tree_structure(
1014 doc: &EtlDocument,
1015 ft_name: &str,
1016 ft: &FaultTree,
1017 diagnostics: &mut Vec<Diagnostic>,
1018) {
1019 let mut known_ids: HashMap<&str, bool> = HashMap::new();
1020
1021 if let Some(ref gates) = ft.gates {
1022 for gate_id in gates.keys() {
1023 known_ids.insert(gate_id.as_str(), false);
1024 }
1025 }
1026 for be_id in ft.basic_events.keys() {
1027 if known_ids.contains_key(be_id.as_str()) {
1028 diagnostics.push(
1029 Diagnostic::error(
1030 "V-402",
1031 format!(
1032 "fault tree '{}': gate and basic event share ID '{}'",
1033 ft_name, be_id
1034 ),
1035 )
1036 .at(SpanKey::BasicEvent {
1037 tree: ft_name.to_string(),
1038 id: be_id.clone(),
1039 }),
1040 );
1041 }
1042 known_ids.insert(be_id.as_str(), false);
1043 }
1044
1045 for (be_id, be) in &ft.basic_events {
1046 if let Some(BasicEventType::House) = be.event_type {
1047 if be.probability.is_some() || be.failure_rate.is_some() {
1048 diagnostics.push(
1049 Diagnostic::warning(
1050 "W-406",
1051 format!(
1052 "fault tree '{}': house event '{}' declares a probability/failureRate; house events are boundary conditions and their value is not a computed leaf probability",
1053 ft_name, be_id
1054 ),
1055 )
1056 .at(SpanKey::BasicEvent {
1057 tree: ft_name.to_string(),
1058 id: be_id.clone(),
1059 }),
1060 );
1061 }
1062 }
1063 }
1064
1065 let root_id = ft.top_event.root_cause.as_str();
1066 match known_ids.get(root_id) {
1067 None => {
1068 diagnostics.push(
1069 Diagnostic::error(
1070 "V-401",
1071 format!(
1072 "fault tree '{}': topEvent.rootCause '{}' does not resolve to a gate or basic event",
1073 ft_name, root_id
1074 ),
1075 )
1076 .at(SpanKey::TopEvent {
1077 tree: ft_name.to_string(),
1078 field: "root_cause",
1079 }),
1080 );
1081 }
1082 Some(_) => {
1083 known_ids.insert(root_id, true);
1084 }
1085 }
1086
1087 if let Some(ref gates) = ft.gates {
1088 for (gate_id, gate) in gates {
1089 for (idx, input) in gate.inputs.iter().enumerate() {
1090 match known_ids.get(input.as_str()) {
1091 None => {
1092 diagnostics.push(
1093 Diagnostic::error(
1094 "V-401",
1095 format!(
1096 "fault tree '{}': gate '{}' input '{}' does not resolve",
1097 ft_name, gate_id, input
1098 ),
1099 )
1100 .at(SpanKey::GateInput {
1101 tree: ft_name.to_string(),
1102 id: gate_id.clone(),
1103 idx,
1104 }),
1105 );
1106 }
1107 Some(_) => {
1108 known_ids.insert(input.as_str(), true);
1109 }
1110 }
1111 }
1112 }
1113 }
1114
1115 check_fault_tree_dag(ft_name, ft, diagnostics);
1116
1117 let mut unreachable: Vec<&str> = known_ids
1119 .iter()
1120 .filter(|(id, &is_reachable)| !is_reachable && **id != root_id)
1121 .map(|(id, _)| *id)
1122 .collect();
1123 unreachable.sort_unstable();
1124
1125 for id in unreachable {
1126 let key = if ft.gates.as_ref().is_some_and(|g| g.contains_key(id)) {
1127 SpanKey::Gate {
1128 tree: ft_name.to_string(),
1129 id: id.to_string(),
1130 }
1131 } else {
1132 SpanKey::BasicEvent {
1133 tree: ft_name.to_string(),
1134 id: id.to_string(),
1135 }
1136 };
1137 diagnostics.push(
1138 Diagnostic::error(
1139 "V-404",
1140 format!(
1141 "fault tree '{}': '{}' is not reachable from topEvent.rootCause",
1142 ft_name, id
1143 ),
1144 )
1145 .at(key),
1146 );
1147 }
1148
1149 check_transfers(doc, ft_name, ft, diagnostics);
1150}
1151
1152fn check_transfers(
1153 doc: &EtlDocument,
1154 ft_name: &str,
1155 ft: &FaultTree,
1156 diagnostics: &mut Vec<Diagnostic>,
1157) {
1158 let transfers = match &ft.transfers {
1159 Some(t) => t,
1160 None => return,
1161 };
1162
1163 for (transfer_id, transfer) in transfers {
1164 let target = transfer.target.trim_start_matches("#");
1165 if !target.starts_with("/faultTrees/") {
1166 diagnostics.push(
1167 Diagnostic::error(
1168 "V-506",
1169 format!(
1170 "fault tree '{}': transfer '{}' target '{}' must be an Internal Reference of the form '#/faultTrees/<id>/...'",
1171 ft_name, transfer_id, transfer.target
1172 ),
1173 )
1174 .at(SpanKey::Transfer {
1175 tree: ft_name.to_string(),
1176 id: transfer_id.clone(),
1177 field: "target",
1178 }),
1179 );
1180 } else {
1181 let tree_id = target.trim_start_matches("/faultTrees/").split('/').next();
1183 let tree_exists = match tree_id {
1184 Some(id) => doc
1185 .fault_trees
1186 .as_ref()
1187 .is_some_and(|fts| fts.contains_key(id)),
1188 None => false,
1189 };
1190 if !tree_exists {
1191 diagnostics.push(
1192 Diagnostic::error(
1193 "V-506",
1194 format!(
1195 "fault tree '{}': transfer '{}' target '{}' references fault tree '{}' which does not exist in this document",
1196 ft_name,
1197 transfer_id,
1198 transfer.target,
1199 tree_id.unwrap_or("")
1200 ),
1201 )
1202 .at(SpanKey::Transfer {
1203 tree: ft_name.to_string(),
1204 id: transfer_id.clone(),
1205 field: "target",
1206 }),
1207 );
1208 }
1209 }
1210 if let Some(label) = &transfer.label {
1211 if label.trim().is_empty() {
1212 diagnostics.push(
1213 Diagnostic::warning(
1214 "W-405",
1215 format!(
1216 "fault tree '{}': transfer '{}' has an empty label",
1217 ft_name, transfer_id
1218 ),
1219 )
1220 .at(SpanKey::Transfer {
1221 tree: ft_name.to_string(),
1222 id: transfer_id.clone(),
1223 field: "label",
1224 }),
1225 );
1226 }
1227 }
1228 }
1229}
1230
1231fn check_fault_tree_dag(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
1232 let gates = match &ft.gates {
1233 Some(g) => g,
1234 None => return,
1235 };
1236
1237 #[derive(Clone, Copy, PartialEq)]
1238 enum Color {
1239 White,
1240 Gray,
1241 Black,
1242 }
1243
1244 let mut colors: HashMap<&str, Color> = HashMap::new();
1245 for gate_id in gates.keys() {
1246 colors.insert(gate_id.as_str(), Color::White);
1247 }
1248
1249 fn dfs_gate<'a>(
1250 gate_id: &'a str,
1251 gates: &'a BTreeMap<String, Gate>,
1252 colors: &mut HashMap<&'a str, Color>,
1253 diagnostics: &mut Vec<Diagnostic>,
1254 ft_name: &str,
1255 ) {
1256 if let Some(Color::Black) = colors.get(gate_id) {
1257 return;
1258 }
1259 if let Some(Color::Gray) = colors.get(gate_id) {
1260 return;
1261 }
1262
1263 colors.insert(gate_id, Color::Gray);
1264
1265 if let Some(gate) = gates.get(gate_id) {
1266 for input in &gate.inputs {
1267 if gates.contains_key(input.as_str()) {
1268 match colors.get(input.as_str()) {
1269 Some(Color::Gray) => {
1270 diagnostics.push(
1271 Diagnostic::error(
1272 "V-403",
1273 format!(
1274 "fault tree '{}': cycle detected involving gate '{}' -> '{}'",
1275 ft_name, gate_id, input
1276 ),
1277 )
1278 .at(SpanKey::Gate {
1279 tree: ft_name.to_string(),
1280 id: gate_id.to_string(),
1281 }),
1282 );
1283 }
1284 Some(Color::White) => {
1285 dfs_gate(input, gates, colors, diagnostics, ft_name);
1286 }
1287 _ => {}
1288 }
1289 }
1290 }
1291 }
1292
1293 colors.insert(gate_id, Color::Black);
1294 }
1295
1296 let root_id = ft.top_event.root_cause.as_str();
1297 if gates.contains_key(root_id) {
1298 dfs_gate(root_id, gates, &mut colors, diagnostics, ft_name);
1299 }
1300}
1301
1302fn check_gate_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
1303 let gates = match &ft.gates {
1304 Some(g) => g,
1305 None => return,
1306 };
1307
1308 for (gate_id, gate) in gates {
1309 let n = gate.inputs.len();
1310
1311 match gate.gate_type {
1312 GateType::And | GateType::Or => {
1313 if n < 2 {
1314 diagnostics.push(
1315 Diagnostic::error(
1316 "V-501",
1317 format!(
1318 "fault tree '{}': {:?} gate '{}' has {} input(s), minimum 2 required",
1319 ft_name, gate.gate_type, gate_id, n
1320 ),
1321 )
1322 .at(SpanKey::Gate {
1323 tree: ft_name.to_string(),
1324 id: gate_id.clone(),
1325 }),
1326 );
1327 }
1328 }
1329 GateType::Not => {
1330 if n != 1 {
1331 diagnostics.push(
1332 Diagnostic::error(
1333 "V-501",
1334 format!(
1335 "fault tree '{}': NOT gate '{}' has {} input(s), exactly 1 required",
1336 ft_name, gate_id, n
1337 ),
1338 )
1339 .at(SpanKey::Gate {
1340 tree: ft_name.to_string(),
1341 id: gate_id.clone(),
1342 }),
1343 );
1344 }
1345 }
1346 GateType::Xor => {
1347 if n != 2 {
1348 diagnostics.push(
1349 Diagnostic::error(
1350 "V-501",
1351 format!(
1352 "fault tree '{}': XOR gate '{}' has {} input(s), exactly 2 required",
1353 ft_name, gate_id, n
1354 ),
1355 )
1356 .at(SpanKey::Gate {
1357 tree: ft_name.to_string(),
1358 id: gate_id.clone(),
1359 }),
1360 );
1361 }
1362 }
1363 GateType::Voting => {
1364 if n < 2 {
1365 diagnostics.push(
1366 Diagnostic::error(
1367 "V-501",
1368 format!(
1369 "fault tree '{}': VOTING gate '{}' has {} input(s), minimum 2 required",
1370 ft_name, gate_id, n
1371 ),
1372 )
1373 .at(SpanKey::Gate {
1374 tree: ft_name.to_string(),
1375 id: gate_id.clone(),
1376 }),
1377 );
1378 }
1379 if let Some(k) = gate.k {
1380 if k < 1 || k as usize > n {
1381 diagnostics.push(
1382 Diagnostic::error(
1383 "V-502",
1384 format!(
1385 "fault tree '{}': VOTING gate '{}' k={} must satisfy 1 <= k <= n={}",
1386 ft_name, gate_id, k, n
1387 ),
1388 )
1389 .at(SpanKey::GateField {
1390 tree: ft_name.to_string(),
1391 id: gate_id.clone(),
1392 field: "k",
1393 }),
1394 );
1395 }
1396 } else {
1397 diagnostics.push(
1398 Diagnostic::error(
1399 "V-502",
1400 format!(
1401 "fault tree '{}': VOTING gate '{}' missing required 'k' field",
1402 ft_name, gate_id
1403 ),
1404 )
1405 .at(SpanKey::GateField {
1406 tree: ft_name.to_string(),
1407 id: gate_id.clone(),
1408 field: "k",
1409 }),
1410 );
1411 }
1412 }
1413 GateType::Inhibit => {
1414 if n != 2 {
1415 diagnostics.push(
1416 Diagnostic::error(
1417 "V-501",
1418 format!(
1419 "fault tree '{}': INHIBIT gate '{}' has {} input(s), exactly 2 required",
1420 ft_name, gate_id, n
1421 ),
1422 )
1423 .at(SpanKey::Gate {
1424 tree: ft_name.to_string(),
1425 id: gate_id.clone(),
1426 }),
1427 );
1428 }
1429 if gate.inhibit_condition.is_none() {
1430 diagnostics.push(
1431 Diagnostic::error(
1432 "V-505",
1433 format!(
1434 "fault tree '{}': INHIBIT gate '{}' missing required 'inhibitCondition' field",
1435 ft_name, gate_id
1436 ),
1437 )
1438 .at(SpanKey::GateField {
1439 tree: ft_name.to_string(),
1440 id: gate_id.clone(),
1441 field: "inhibit_condition",
1442 }),
1443 );
1444 }
1445 }
1446 GateType::PriorityAnd => {
1447 if n < 2 {
1448 diagnostics.push(
1449 Diagnostic::error(
1450 "V-501",
1451 format!(
1452 "fault tree '{}': PRIORITY_AND gate '{}' has {} input(s), minimum 2 required",
1453 ft_name, gate_id, n
1454 ),
1455 )
1456 .at(SpanKey::Gate {
1457 tree: ft_name.to_string(),
1458 id: gate_id.clone(),
1459 }),
1460 );
1461 }
1462 }
1463 }
1464 }
1465}
1466
1467fn basic_event_numeric_error(ft_name: &str, be_id: &str, detail: String) -> Diagnostic {
1468 Diagnostic::error(
1469 "V-507",
1470 format!("fault tree '{}': basic event '{}': {}", ft_name, be_id, detail),
1471 )
1472 .at(SpanKey::BasicEvent {
1473 tree: ft_name.to_string(),
1474 id: be_id.to_string(),
1475 })
1476}
1477
1478fn check_basic_event_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
1479 for (be_id, be) in &ft.basic_events {
1480 let has_prob = be.probability.is_some();
1481 let has_rate = be.failure_rate.is_some();
1482 let has_time = be.mission_time.is_some();
1483 let external_source = be
1489 .extensions
1490 .get("x-reliability")
1491 .and_then(|v| v.get("source"))
1492 .is_some();
1493
1494 if has_prob && has_rate {
1495 diagnostics.push(
1496 Diagnostic::error(
1497 "V-503",
1498 format!(
1499 "fault tree '{}': basic event '{}' supplies both probability and failureRate",
1500 ft_name, be_id
1501 ),
1502 )
1503 .at(SpanKey::BasicEvent {
1504 tree: ft_name.to_string(),
1505 id: be_id.clone(),
1506 }),
1507 );
1508 } else if !has_prob && !has_rate && !external_source {
1509 diagnostics.push(
1510 Diagnostic::error(
1511 "V-503",
1512 format!(
1513 "fault tree '{}': basic event '{}' supplies neither probability nor failureRate",
1514 ft_name, be_id
1515 ),
1516 )
1517 .at(SpanKey::BasicEvent {
1518 tree: ft_name.to_string(),
1519 id: be_id.clone(),
1520 }),
1521 );
1522 }
1523
1524 if has_rate && !has_time {
1525 diagnostics.push(
1526 Diagnostic::error(
1527 "V-504",
1528 format!(
1529 "fault tree '{}': basic event '{}' has failureRate but no missionTime",
1530 ft_name, be_id
1531 ),
1532 )
1533 .at(SpanKey::BasicEvent {
1534 tree: ft_name.to_string(),
1535 id: be_id.clone(),
1536 }),
1537 );
1538 }
1539
1540 if let Some(p) = be.probability {
1549 if !p.is_finite() {
1550 diagnostics.push(basic_event_numeric_error(
1551 ft_name,
1552 be_id,
1553 format!("probability {} is not finite (NaN or infinity)", p),
1554 ));
1555 } else if !(0.0..=1.0).contains(&p) {
1556 diagnostics.push(basic_event_numeric_error(
1557 ft_name,
1558 be_id,
1559 format!("probability {} is outside the valid range [0, 1]", p),
1560 ));
1561 }
1562 }
1563 if let Some(r) = be.failure_rate {
1564 if !r.is_finite() {
1565 diagnostics.push(basic_event_numeric_error(
1566 ft_name,
1567 be_id,
1568 format!("failureRate {} is not finite (NaN or infinity)", r),
1569 ));
1570 } else if r < 0.0 {
1571 diagnostics.push(basic_event_numeric_error(
1572 ft_name,
1573 be_id,
1574 format!("failureRate {} is negative; a rate must be >= 0", r),
1575 ));
1576 }
1577 }
1578 if let Some(t) = be.mission_time {
1579 if !t.is_finite() {
1580 diagnostics.push(basic_event_numeric_error(
1581 ft_name,
1582 be_id,
1583 format!("missionTime {} is not finite (NaN or infinity)", t),
1584 ));
1585 } else if t < 0.0 {
1586 diagnostics.push(basic_event_numeric_error(
1587 ft_name,
1588 be_id,
1589 format!("missionTime {} is negative; a duration must be >= 0", t),
1590 ));
1591 }
1592 }
1593 }
1594}
1595
1596pub type FaultTreeProbabilities = BTreeMap<String, f64>;
1597
1598pub fn resolve_probability_links(
1599 doc: &EtlDocument,
1600 fault_tree_probs: &FaultTreeProbabilities,
1601 diagnostics: &mut Vec<Diagnostic>,
1602) -> BTreeMap<String, f64> {
1603 let mut branch_probs: BTreeMap<String, f64> = BTreeMap::new();
1604
1605 for (tree_name, tree) in &doc.event_trees {
1606 for (node_id, node) in &tree.nodes {
1607 match node {
1608 Node::Barrier(barrier) => {
1609 for (i, branch) in barrier.branches.iter().enumerate() {
1610 let key = format!("{}.branch.{}", node_id, i);
1611
1612 if let Some(ref ps) = branch.probability_source {
1613 let ft_id = extract_fault_tree_id(&ps.pointer);
1614 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
1615 if let Some(cached) = branch.effective_probability() {
1616 if (cached - prob).abs() > 0.001 {
1617 diagnostics.push(
1618 Diagnostic::warning(
1619 "W-402",
1620 format!(
1621 "branch '{}[{}]' cached probability {} drifted from fault tree computed {}",
1622 node_id, i, cached, prob
1623 ),
1624 )
1625 .at(SpanKey::BranchField {
1626 tree: tree_name.clone(),
1627 id: node_id.clone(),
1628 branch: i,
1629 field: "probability",
1630 }),
1631 );
1632 }
1633 }
1634 branch_probs.insert(key, prob);
1635 } else {
1636 diagnostics.push(
1637 Diagnostic::error(
1638 "E-105",
1639 format!(
1640 "branch '{}[{}]' probabilitySource references unknown fault tree",
1641 node_id, i
1642 ),
1643 )
1644 .at(SpanKey::BranchField {
1645 tree: tree_name.clone(),
1646 id: node_id.clone(),
1647 branch: i,
1648 field: "probability_source",
1649 }),
1650 );
1651 }
1652 } else if let Some(prob) = branch.effective_probability() {
1653 branch_probs.insert(key, prob);
1654 }
1655 }
1656 }
1657 Node::Operation(op) => {
1658 if let Some(ref ps) = op.on_failure_probability_source {
1659 let ft_id = extract_fault_tree_id(&ps.pointer);
1660 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
1661 branch_probs.insert(format!("{}.onFailure", node_id), prob);
1662 }
1663 }
1664 }
1665 _ => {}
1666 }
1667 }
1668 }
1669
1670 branch_probs
1671}
1672
1673fn extract_fault_tree_id(pointer: &str) -> String {
1674 let parts: Vec<&str> = pointer
1675 .trim_start_matches("#/faultTrees/")
1676 .split('/')
1677 .collect();
1678 parts[0].to_string()
1679}
1680
1681pub fn validate_probability_sums(
1682 doc: &EtlDocument,
1683 resolved_probs: &BTreeMap<String, f64>,
1684 diagnostics: &mut Vec<Diagnostic>,
1685) {
1686 for (tree_name, tree) in &doc.event_trees {
1687 for (node_id, node) in &tree.nodes {
1688 if let Node::Barrier(barrier) = node {
1689 let mut sum: f64 = 0.0;
1690 let mut all_declared = true;
1691
1692 for (i, b) in barrier.branches.iter().enumerate() {
1693 let prob = if let Some(_ps) = &b.probability_source {
1694 resolved_probs
1695 .get(&format!("{}.branch.{}", node_id, i))
1696 .copied()
1697 } else {
1698 b.effective_probability()
1699 };
1700
1701 match prob {
1702 Some(p) => {
1703 if !(0.0..=1.0).contains(&p) {
1705 diagnostics.push(
1706 Diagnostic::error(
1707 "V-203",
1708 format!(
1709 "tree '{}': barrier '{}' branch '{}' probability {} is outside [0,1]",
1710 tree_name, node_id, b.outcome, p
1711 ),
1712 )
1713 .at(SpanKey::BranchField {
1714 tree: tree_name.clone(),
1715 id: node_id.clone(),
1716 branch: i,
1717 field: "probability",
1718 }),
1719 );
1720 }
1721 sum += p;
1722 }
1723 None => {
1724 all_declared = false;
1725 }
1726 }
1727 }
1728
1729 if !barrier.branches.is_empty() && all_declared && (sum - 1.0).abs() > 0.0001 {
1730 diagnostics.push(
1731 Diagnostic::error(
1732 "V-203",
1733 format!(
1734 "tree '{}': barrier '{}' branch probabilities sum to {:.4} (must be 1.0 within ±0.0001)",
1735 tree_name, node_id, sum
1736 ),
1737 )
1738 .at(SpanKey::Node {
1739 tree: tree_name.clone(),
1740 id: node_id.clone(),
1741 }),
1742 );
1743 }
1744 }
1745 }
1746 }
1747}