use etdl_parser::ast::{
BasicEventType, EtlDocument, EventTree, FaultTree, Gate, GateType, Node,
};
use etdl_parser::asyncapi::AsyncApiRegistry;
use etdl_parser::ecel::Condition;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub code: String,
pub severity: DiagnosticSeverity,
pub message: String,
pub line: Option<u32>,
pub column: Option<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiagnosticSeverity {
Error,
Warning,
}
impl Diagnostic {
pub fn error(code: &str, message: String) -> Self {
Diagnostic {
code: code.to_string(),
severity: DiagnosticSeverity::Error,
message,
line: None,
column: None,
}
}
pub fn warning(code: &str, message: String) -> Self {
Diagnostic {
code: code.to_string(),
severity: DiagnosticSeverity::Warning,
message,
line: None,
column: None,
}
}
pub fn with_position(mut self, line: u32, column: u32) -> Self {
self.line = Some(line);
self.column = Some(column);
self
}
pub fn is_error(&self) -> bool {
self.severity == DiagnosticSeverity::Error
}
}
pub fn validate_document(
doc: &EtlDocument,
registry: &AsyncApiRegistry,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_references(doc, registry, diagnostics);
validate_event_trees(doc, registry, diagnostics);
validate_fault_trees(doc, diagnostics);
}
fn validate_references(
doc: &EtlDocument,
registry: &AsyncApiRegistry,
diagnostics: &mut Vec<Diagnostic>,
) {
for (alias, _location) in &doc.asyncapi_imports {
if alias
.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_')
{
diagnostics.push(Diagnostic::error(
"E-103",
format!("import alias '{}' contains invalid characters", alias),
));
}
}
for (_tree_name, tree) in &doc.event_trees {
validate_external_ref(
&tree.initiating_event.message,
doc,
registry,
diagnostics,
"initiatingEvent.message",
);
for (node_id, node) in &tree.nodes {
match node {
Node::Operation(op) => {
if let Some(ref emits_ref) = op.emits {
validate_external_ref(emits_ref, doc, registry, diagnostics, &format!("nodes.{}.emits", node_id));
}
}
Node::Consequence(cons) => {
if let Some(ref channel_ref) = cons.channel {
validate_external_ref(channel_ref, doc, registry, diagnostics, &format!("nodes.{}.channel", node_id));
}
if let Some(ref message_ref) = cons.message {
validate_external_ref(message_ref, doc, registry, diagnostics, &format!("nodes.{}.message", node_id));
}
}
_ => {}
}
}
}
if let Some(ref fault_trees) = doc.fault_trees {
for (_ft_name, ft) in fault_trees {
if let Some(ref msg_ref) = ft.top_event.message {
validate_external_ref(msg_ref, doc, registry, diagnostics, "topEvent.message");
}
for (_be_name, be) in &ft.basic_events {
if let Some(ref msg_ref) = be.message {
validate_external_ref(msg_ref, doc, registry, diagnostics, "basicEvent.message");
}
}
}
}
}
fn validate_external_ref(
ext_ref: &etdl_parser::ast::ExternalRef,
doc: &EtlDocument,
registry: &AsyncApiRegistry,
diagnostics: &mut Vec<Diagnostic>,
context: &str,
) {
if !doc.asyncapi_imports.contains_key(&ext_ref.alias) {
diagnostics.push(Diagnostic::error(
"E-103",
format!(
"{}: import alias '{}' is not a key in asyncapi_imports",
context, ext_ref.alias
),
));
return;
}
if registry.resolve(ext_ref).is_err() {
diagnostics.push(Diagnostic::error(
"E-104",
format!(
"{}: JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
context, ext_ref.pointer, ext_ref.alias
),
));
}
}
fn validate_event_trees(
doc: &EtlDocument,
_registry: &AsyncApiRegistry,
diagnostics: &mut Vec<Diagnostic>,
) {
for (tree_name, tree) in &doc.event_trees {
validate_tree_structure(tree_name, tree, diagnostics);
}
}
fn validate_tree_structure(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
check_node_references(tree_name, tree, diagnostics);
check_dag(tree_name, tree, diagnostics);
check_reachability(tree_name, tree, diagnostics);
check_terminal_paths(tree_name, tree, diagnostics);
check_barrier_rules(tree_name, tree, diagnostics);
check_operation_rules(tree_name, tree, diagnostics);
check_consequence_rules(tree_name, tree, diagnostics);
}
fn check_node_references(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
if !tree.nodes.contains_key(&tree.initiating_event.next) {
diagnostics.push(Diagnostic::error(
"V-101",
format!(
"tree '{}': initiatingEvent.next '{}' does not resolve to a node in this tree",
tree_name, tree.initiating_event.next
),
));
}
for (node_id, node) in &tree.nodes {
let next_targets: Vec<&str> = match node {
Node::Barrier(barrier) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
Node::Operation(op) => {
let mut targets = vec![op.next.as_str()];
if let Some(ref on_fail) = op.on_failure {
targets.push(on_fail.as_str());
}
targets
}
Node::Consequence(_) => continue,
};
for target in next_targets {
if !tree.nodes.contains_key(target) {
diagnostics.push(Diagnostic::error(
"V-101",
format!(
"tree '{}': node '{}' references '{}' which does not exist in this tree",
tree_name, node_id, target
),
));
}
}
}
}
fn check_dag(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
#[derive(Clone, Copy, PartialEq)]
enum Color {
White,
Gray,
Black,
}
let mut colors: HashMap<&str, Color> = HashMap::new();
for node_id in tree.nodes.keys() {
colors.insert(node_id.as_str(), Color::White);
}
fn dfs<'a>(
node: &'a str,
tree: &'a EventTree,
colors: &mut HashMap<&'a str, Color>,
diagnostics: &mut Vec<Diagnostic>,
tree_name: &str,
) {
colors.insert(node, Color::Gray);
let next_nodes: Vec<&str> = match tree.nodes.get(node) {
Some(Node::Barrier(barrier)) => {
barrier.branches.iter().map(|b| b.next.as_str()).collect()
}
Some(Node::Operation(op)) => {
let mut targets = vec![op.next.as_str()];
if let Some(ref on_fail) = op.on_failure {
targets.push(on_fail.as_str());
}
targets
}
Some(Node::Consequence(_)) => return,
None => return,
};
for next in next_nodes {
match colors.get(next) {
Some(Color::Gray) => {
diagnostics.push(Diagnostic::error(
"V-102",
format!(
"tree '{}': cycle detected involving node '{}' -> '{}'",
tree_name, node, next
),
));
}
Some(Color::White) => {
dfs(next, tree, colors, diagnostics, tree_name);
}
_ => {}
}
}
colors.insert(node, Color::Black);
}
let start_id = tree.initiating_event.next.as_str();
if tree.nodes.contains_key(start_id) {
dfs(start_id, tree, &mut colors, diagnostics, tree_name);
}
}
fn check_reachability(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut reachable: HashMap<&str, bool> = HashMap::new();
for node_id in tree.nodes.keys() {
reachable.insert(node_id.as_str(), false);
}
let start_id = tree.initiating_event.next.as_str();
if tree.nodes.contains_key(start_id) {
reachable.insert(start_id, true);
propagate_reachability(start_id, tree, &mut reachable);
}
for (node_id, &is_reachable) in &reachable {
if !is_reachable {
diagnostics.push(Diagnostic::error(
"V-103",
format!(
"tree '{}': node '{}' is unreachable from initiatingEvent",
tree_name, node_id
),
));
}
}
}
fn propagate_reachability<'a>(
node_id: &'a str,
tree: &'a EventTree,
reachable: &mut HashMap<&'a str, bool>,
) {
let next_nodes: Vec<&str> = match tree.nodes.get(node_id) {
Some(Node::Barrier(barrier)) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
Some(Node::Operation(op)) => {
let mut targets = vec![op.next.as_str()];
if let Some(ref on_fail) = op.on_failure {
targets.push(on_fail.as_str());
}
targets
}
Some(Node::Consequence(_)) => return,
None => return,
};
for next in next_nodes {
if let Some(was_reachable) = reachable.get_mut(next) {
if !*was_reachable {
*was_reachable = true;
propagate_reachability(next, tree, reachable);
}
}
}
}
fn check_terminal_paths(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
fn check_termination<'a>(
node_id: &'a str,
tree: &'a EventTree,
visited: &mut Vec<&'a str>,
tree_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) -> bool {
if visited.contains(&node_id) {
return false;
}
visited.push(node_id);
match tree.nodes.get(node_id) {
Some(Node::Consequence(_)) => {
visited.pop();
return true;
}
Some(Node::Barrier(barrier)) => {
let mut all_terminal = true;
for branch in &barrier.branches {
if !check_termination(&branch.next, tree, visited, tree_name, diagnostics) {
all_terminal = false;
}
}
visited.pop();
all_terminal
}
Some(Node::Operation(op)) => {
let mut all_terminal = true;
if !check_termination(&op.next, tree, visited, tree_name, diagnostics) {
all_terminal = false;
}
if let Some(ref on_fail) = op.on_failure {
if !check_termination(on_fail, tree, visited, tree_name, diagnostics) {
all_terminal = false;
}
}
visited.pop();
all_terminal
}
None => {
visited.pop();
false
}
}
}
let start_id = tree.initiating_event.next.as_str();
if tree.nodes.contains_key(start_id) {
let mut visited = Vec::new();
check_termination(start_id, tree, &mut visited, tree_name, diagnostics);
}
}
fn check_barrier_rules(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
for (node_id, node) in &tree.nodes {
if let Node::Barrier(barrier) = node {
if barrier.branches.len() < 2 {
diagnostics.push(Diagnostic::error(
"V-201",
format!(
"tree '{}': barrier '{}' has fewer than 2 branches",
tree_name, node_id
),
));
}
let mut default_count = 0;
let mut last_is_default = false;
for (i, branch) in barrier.branches.iter().enumerate() {
if branch.condition == Condition::Default {
default_count += 1;
if i == barrier.branches.len() - 1 {
last_is_default = true;
}
}
}
if default_count > 1 {
diagnostics.push(Diagnostic::error(
"V-202",
format!(
"tree '{}': barrier '{}' has more than one default branch",
tree_name, node_id
),
));
} else if default_count == 1 && !last_is_default {
diagnostics.push(Diagnostic::error(
"V-202",
format!(
"tree '{}': barrier '{}' default branch is not the last branch",
tree_name, node_id
),
));
}
for (i, branch) in barrier.branches.iter().enumerate() {
if branch.condition == Condition::Default {
continue;
}
let has_prob = branch.effective_probability().is_some()
|| branch.probability_source.is_some();
if !has_prob {
diagnostics.push(Diagnostic::error(
"V-203",
format!(
"tree '{}': barrier '{}' branch {} has no probability or probabilitySource",
tree_name, node_id, i
),
));
}
}
}
}
}
fn check_operation_rules(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
for (node_id, node) in &tree.nodes {
if let Node::Operation(op) = node {
if op.on_failure.is_none() {
diagnostics.push(Diagnostic::warning(
"W-401",
format!(
"tree '{}': operation '{}' has no onFailure path",
tree_name, node_id
),
));
}
}
}
}
fn check_consequence_rules(
tree_name: &str,
tree: &EventTree,
diagnostics: &mut Vec<Diagnostic>,
) {
for (node_id, node) in &tree.nodes {
if let Node::Consequence(cons) = node {
match cons.consequence_operation {
etdl_parser::ast::ConsequenceOperation::Send => {
if cons.channel.is_none() || cons.message.is_none() {
diagnostics.push(Diagnostic::error(
"V-302",
format!(
"tree '{}': consequence '{}' has operation: send but omits channel or message",
tree_name, node_id
),
));
}
}
etdl_parser::ast::ConsequenceOperation::Terminate => {}
}
}
}
}
fn validate_fault_trees(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
let fault_trees = match &doc.fault_trees {
Some(fts) => fts,
None => return,
};
for (ft_name, ft) in fault_trees {
check_fault_tree_structure(ft_name, ft, diagnostics);
check_gate_rules(ft_name, ft, diagnostics);
check_basic_event_rules(ft_name, ft, diagnostics);
}
}
fn check_fault_tree_structure(
ft_name: &str,
ft: &FaultTree,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut known_ids: HashMap<&str, bool> = HashMap::new();
if let Some(ref gates) = ft.gates {
for gate_id in gates.keys() {
known_ids.insert(gate_id.as_str(), false);
}
}
for be_id in ft.basic_events.keys() {
if known_ids.contains_key(be_id.as_str()) {
diagnostics.push(Diagnostic::error(
"V-402",
format!(
"fault tree '{}': gate and basic event share ID '{}'",
ft_name, be_id
),
));
}
known_ids.insert(be_id.as_str(), false);
}
for (be_id, be) in &ft.basic_events {
match be.event_type {
Some(BasicEventType::House) => {
if be.probability.is_some() || be.failure_rate.is_some() {
diagnostics.push(Diagnostic::warning(
"W-406",
format!(
"fault tree '{}': house event '{}' declares a probability/failureRate; house events are boundary conditions and their value is not a computed leaf probability",
ft_name, be_id
),
));
}
}
Some(BasicEventType::Undeveloped) => {
if be.probability.is_none() && be.failure_rate.is_none() {
diagnostics.push(Diagnostic::warning(
"W-407",
format!(
"fault tree '{}': undeveloped event '{}' has no probability/failureRate; treat its probability as unquantified",
ft_name, be_id
),
));
}
}
_ => {}
}
}
let root_id = ft.top_event.root_cause.as_str();
match known_ids.get(root_id) {
None => {
diagnostics.push(Diagnostic::error(
"V-401",
format!(
"fault tree '{}': topEvent.rootCause '{}' does not resolve to a gate or basic event",
ft_name, root_id
),
));
}
Some(_) => {
known_ids.insert(root_id, true);
}
}
if let Some(ref gates) = ft.gates {
for (gate_id, gate) in gates {
for input in &gate.inputs {
match known_ids.get(input.as_str()) {
None => {
diagnostics.push(Diagnostic::error(
"V-401",
format!(
"fault tree '{}': gate '{}' input '{}' does not resolve",
ft_name, gate_id, input
),
));
}
Some(_) => {
known_ids.insert(input.as_str(), true);
}
}
}
}
}
check_fault_tree_dag(ft_name, ft, diagnostics);
for (&id, &is_reachable) in &known_ids {
if !is_reachable && id != root_id {
diagnostics.push(Diagnostic::error(
"V-404",
format!(
"fault tree '{}': '{}' is not reachable from topEvent.rootCause",
ft_name, id
),
));
}
}
check_transfers(ft_name, ft, diagnostics);
}
fn check_transfers(
ft_name: &str,
ft: &FaultTree,
diagnostics: &mut Vec<Diagnostic>,
) {
let transfers = match &ft.transfers {
Some(t) => t,
None => return,
};
for (transfer_id, transfer) in transfers {
let target = transfer.target.trim_start_matches("#");
if !target.starts_with("/faultTrees/") {
diagnostics.push(Diagnostic::error(
"V-506",
format!(
"fault tree '{}': transfer '{}' target '{}' must be an Internal Reference of the form '#/faultTrees/<id>/...'",
ft_name, transfer_id, transfer.target
),
));
}
if let Some(label) = &transfer.label {
if label.trim().is_empty() {
diagnostics.push(Diagnostic::warning(
"W-405",
format!(
"fault tree '{}': transfer '{}' has an empty label",
ft_name, transfer_id
),
));
}
}
}
}
fn check_fault_tree_dag(
ft_name: &str,
ft: &FaultTree,
diagnostics: &mut Vec<Diagnostic>,
) {
let gates = match &ft.gates {
Some(g) => g,
None => return,
};
#[derive(Clone, Copy, PartialEq)]
enum Color {
White,
Gray,
Black,
}
let mut colors: HashMap<&str, Color> = HashMap::new();
for gate_id in gates.keys() {
colors.insert(gate_id.as_str(), Color::White);
}
fn dfs_gate<'a>(
gate_id: &'a str,
gates: &'a BTreeMap<String, Gate>,
colors: &mut HashMap<&'a str, Color>,
diagnostics: &mut Vec<Diagnostic>,
ft_name: &str,
) {
if let Some(Color::Black) = colors.get(gate_id) {
return;
}
if let Some(Color::Gray) = colors.get(gate_id) {
return;
}
colors.insert(gate_id, Color::Gray);
if let Some(gate) = gates.get(gate_id) {
for input in &gate.inputs {
if gates.contains_key(input.as_str()) {
match colors.get(input.as_str()) {
Some(Color::Gray) => {
diagnostics.push(Diagnostic::error(
"V-403",
format!(
"fault tree '{}': cycle detected involving gate '{}' -> '{}'",
ft_name, gate_id, input
),
));
}
Some(Color::White) => {
dfs_gate(input, gates, colors, diagnostics, ft_name);
}
_ => {}
}
}
}
}
colors.insert(gate_id, Color::Black);
}
let root_id = ft.top_event.root_cause.as_str();
if gates.contains_key(root_id) {
dfs_gate(root_id, gates, &mut colors, diagnostics, ft_name);
}
}
fn check_gate_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
let gates = match &ft.gates {
Some(g) => g,
None => return,
};
for (gate_id, gate) in gates {
let n = gate.inputs.len();
match gate.gate_type {
GateType::And | GateType::Or => {
if n < 2 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': {:?} gate '{}' has {} input(s), minimum 2 required",
ft_name, gate.gate_type, gate_id, n
),
));
}
}
GateType::Not => {
if n != 1 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': NOT gate '{}' has {} input(s), exactly 1 required",
ft_name, gate_id, n
),
));
}
}
GateType::Xor => {
if n != 2 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': XOR gate '{}' has {} input(s), exactly 2 required",
ft_name, gate_id, n
),
));
}
}
GateType::Voting => {
if n < 2 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': VOTING gate '{}' has {} input(s), minimum 2 required",
ft_name, gate_id, n
),
));
}
if let Some(k) = gate.k {
if k < 1 || k as usize > n {
diagnostics.push(Diagnostic::error(
"V-502",
format!(
"fault tree '{}': VOTING gate '{}' k={} must satisfy 1 <= k <= n={}",
ft_name, gate_id, k, n
),
));
}
} else {
diagnostics.push(Diagnostic::error(
"V-502",
format!(
"fault tree '{}': VOTING gate '{}' missing required 'k' field",
ft_name, gate_id
),
));
}
}
GateType::Inhibit => {
if n != 2 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': INHIBIT gate '{}' has {} input(s), exactly 2 required",
ft_name, gate_id, n
),
));
}
if gate.inhibit_condition.is_none() {
diagnostics.push(Diagnostic::error(
"V-505",
format!(
"fault tree '{}': INHIBIT gate '{}' missing required 'inhibitCondition' field",
ft_name, gate_id
),
));
}
}
GateType::PriorityAnd => {
if n < 2 {
diagnostics.push(Diagnostic::error(
"V-501",
format!(
"fault tree '{}': PRIORITY_AND gate '{}' has {} input(s), minimum 2 required",
ft_name, gate_id, n
),
));
}
}
}
}
}
fn check_basic_event_rules(
ft_name: &str,
ft: &FaultTree,
diagnostics: &mut Vec<Diagnostic>,
) {
for (be_id, be) in &ft.basic_events {
let has_prob = be.probability.is_some();
let has_rate = be.failure_rate.is_some();
let has_time = be.mission_time.is_some();
if has_prob && has_rate {
diagnostics.push(Diagnostic::error(
"V-503",
format!(
"fault tree '{}': basic event '{}' supplies both probability and failureRate",
ft_name, be_id
),
));
} else if !has_prob && !has_rate {
diagnostics.push(Diagnostic::error(
"V-503",
format!(
"fault tree '{}': basic event '{}' supplies neither probability nor failureRate",
ft_name, be_id
),
));
}
if has_rate && !has_time {
diagnostics.push(Diagnostic::error(
"V-504",
format!(
"fault tree '{}': basic event '{}' has failureRate but no missionTime",
ft_name, be_id
),
));
}
}
}
pub type FaultTreeProbabilities = BTreeMap<String, f64>;
pub fn resolve_probability_links(
doc: &EtlDocument,
fault_tree_probs: &FaultTreeProbabilities,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, f64> {
let mut branch_probs: BTreeMap<String, f64> = BTreeMap::new();
for (_tree_name, tree) in &doc.event_trees {
for (node_id, node) in &tree.nodes {
match node {
Node::Barrier(barrier) => {
for (i, branch) in barrier.branches.iter().enumerate() {
let key = format!("{}.branch.{}", node_id, i);
if let Some(ref ps) = branch.probability_source {
let ft_id = extract_fault_tree_id(&ps.pointer);
if let Some(&prob) = fault_tree_probs.get(&ft_id) {
if let Some(cached) = branch.effective_probability() {
if (cached - prob).abs() > 0.001 {
diagnostics.push(Diagnostic::warning(
"W-402",
format!(
"branch '{}[{}]' cached probability {} drifted from fault tree computed {}",
node_id, i, cached, prob
),
));
}
}
branch_probs.insert(key, prob);
} else {
diagnostics.push(Diagnostic::error(
"E-105",
format!(
"branch '{}[{}]' probabilitySource references unknown fault tree",
node_id, i
),
));
}
} else if let Some(prob) = branch.effective_probability() {
branch_probs.insert(key, prob);
}
}
}
Node::Operation(op) => {
if let Some(ref ps) = op.on_failure_probability_source {
let ft_id = extract_fault_tree_id(&ps.pointer);
if let Some(&prob) = fault_tree_probs.get(&ft_id) {
branch_probs.insert(format!("{}.onFailure", node_id), prob);
}
}
}
_ => {}
}
}
}
branch_probs
}
fn extract_fault_tree_id(pointer: &str) -> String {
let parts: Vec<&str> = pointer.trim_start_matches("#/faultTrees/").split('/').collect();
parts[0].to_string()
}
pub fn validate_probability_sums(
doc: &EtlDocument,
_resolved_probs: &BTreeMap<String, f64>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (tree_name, tree) in &doc.event_trees {
for (node_id, node) in &tree.nodes {
if let Node::Barrier(barrier) = node {
let sum: f64 = barrier
.branches
.iter()
.enumerate()
.filter_map(|(i, b)| {
if b.condition == Condition::Default {
None
} else if let Some(ref _ps) = b.probability_source {
_resolved_probs
.get(&format!("{}.branch.{}", node_id, i))
.copied()
} else {
b.effective_probability()
}
})
.sum();
if !barrier.branches.is_empty() && sum > 0.0 {
if (sum - 1.0).abs() > 0.0001 {
let default_prob = (1.0 - sum).max(0.0);
if default_prob < 0.0 {
diagnostics.push(Diagnostic::error(
"V-203",
format!(
"tree '{}': barrier '{}' branch probabilities sum to {} (must be 1.0 within ±0.0001)",
tree_name, node_id, sum
),
));
}
}
}
}
}
}
}