Skip to main content

etdl_compiler/
fault_tree.rs

1use etdl_parser::ast::{EtlDocument, FaultTree, GateType};
2use etdl_parser::spanned::SpanKey;
3use std::collections::{BTreeMap, HashMap, VecDeque};
4
5use crate::validate::Diagnostic;
6
7pub type FaultTreeProbabilities = BTreeMap<String, f64>;
8
9pub fn resolve_fault_trees(
10    doc: &EtlDocument,
11    diagnostics: &mut Vec<Diagnostic>,
12) -> FaultTreeProbabilities {
13    let mut results = BTreeMap::new();
14
15    let fault_trees = match &doc.fault_trees {
16        Some(fts) => fts,
17        None => return results,
18    };
19
20    for (ft_id, ft) in fault_trees {
21        match compute_top_event_probability(ft) {
22            Ok(prob) => {
23                results.insert(ft_id.clone(), prob);
24            }
25            Err(e) => {
26                diagnostics.push(
27                    Diagnostic::error(
28                        "V-401",
29                        format!("fault tree '{}': error computing probability: {}", ft_id, e),
30                    )
31                    .at(SpanKey::FaultTree {
32                        tree: ft_id.clone(),
33                    }),
34                );
35            }
36        }
37    }
38
39    results
40}
41
42fn compute_top_event_probability(ft: &FaultTree) -> Result<f64, String> {
43    let mut probs: HashMap<String, f64> = HashMap::new();
44
45    for (be_id, be) in &ft.basic_events {
46        let prob = compute_basic_event_probability(be)?;
47        probs.insert(be_id.clone(), prob);
48    }
49
50    let gates = match &ft.gates {
51        Some(g) => g,
52        None => {
53            let root_id = &ft.top_event.root_cause;
54            if let Some(&prob) = probs.get(root_id) {
55                return Ok(prob);
56            } else {
57                return Err(format!(
58                    "topEvent.rootCause '{}' not found in basic events and no gates defined",
59                    root_id
60                ));
61            }
62        }
63    };
64
65    let order = topological_sort_gates(gates, &ft.top_event.root_cause)?;
66
67    for gate_id in &order {
68        let gate = gates
69            .get(gate_id)
70            .ok_or_else(|| format!("gate '{}' not found during resolution", gate_id))?;
71
72        let input_probs: Vec<f64> = gate
73            .inputs
74            .iter()
75            .map(|input| {
76                probs
77                    .get(input.as_str())
78                    .copied()
79                    .ok_or_else(|| format!("probability for '{}' not resolved", input))
80            })
81            .collect::<Result<Vec<_>, _>>()?;
82
83        let gate_prob = compute_gate_probability(&gate.gate_type, &input_probs, gate.k)?;
84        probs.insert(gate_id.clone(), gate_prob);
85    }
86
87    let root_id = &ft.top_event.root_cause;
88    probs
89        .get(root_id)
90        .copied()
91        .ok_or_else(|| format!("topEvent.rootCause '{}' probability not resolved", root_id))
92}
93
94fn compute_basic_event_probability(be: &etdl_parser::ast::BasicEvent) -> Result<f64, String> {
95    if let Some(ref failure_rate) = be.failure_rate {
96        let mission_time = be
97            .mission_time
98            .ok_or("failureRate set but missionTime missing")?;
99        Ok(1.0 - (-failure_rate * mission_time).exp())
100    } else if let Some(prob) = be.probability {
101        if !(0.0..=1.0).contains(&prob) {
102            return Err(format!("probability {} out of range [0, 1]", prob));
103        }
104        Ok(prob)
105    } else {
106        Err("basic event has neither probability nor failureRate".to_string())
107    }
108}
109
110fn compute_gate_probability(
111    gate_type: &GateType,
112    inputs: &[f64],
113    k: Option<u32>,
114) -> Result<f64, String> {
115    match gate_type {
116        GateType::And => Ok(inputs.iter().product()),
117        GateType::Or => {
118            let complement: f64 = inputs.iter().map(|p| 1.0 - p).product();
119            Ok(1.0 - complement)
120        }
121        GateType::Not => {
122            if inputs.len() != 1 {
123                return Err("NOT gate requires exactly 1 input".to_string());
124            }
125            if inputs[0] < 0.0 || inputs[0] > 1.0 {
126                return Err(format!(
127                    "NOT gate input probability {} out of range",
128                    inputs[0]
129                ));
130            }
131            Ok(1.0 - inputs[0])
132        }
133        GateType::Xor => {
134            if inputs.len() != 2 {
135                return Err("XOR gate requires exactly 2 inputs".to_string());
136            }
137            Ok(inputs[0] + inputs[1] - 2.0 * inputs[0] * inputs[1])
138        }
139        GateType::Voting => {
140            let k_val = k.ok_or("VOTING gate requires k")? as usize;
141            let n = inputs.len();
142
143            if k_val < 1 || k_val > n {
144                return Err(format!("VOTING gate: k={} out of range [1, {}]", k_val, n));
145            }
146
147            if inputs.iter().all(|&p| (p - inputs[0]).abs() < 1e-10) {
148                let p = inputs[0].clamp(0.0, 1.0);
149                let mut total = 0.0;
150                for j in k_val..=n {
151                    total +=
152                        binomial_coeff(n, j) * p.powi(j as i32) * (1.0 - p).powi((n - j) as i32);
153                }
154                Ok(total.clamp(0.0, 1.0))
155            } else {
156                let mut poly = vec![1.0];
157                for &p in inputs {
158                    poly = multiply_polynomial(&poly, &[1.0 - p, p]);
159                }
160                let mut total = 0.0;
161                for j in k_val..=n {
162                    if j < poly.len() {
163                        total += poly[j];
164                    }
165                }
166                Ok(total.clamp(0.0, 1.0))
167            }
168        }
169        GateType::Inhibit => {
170            if inputs.len() != 2 {
171                return Err("INHIBIT gate requires exactly 2 inputs".to_string());
172            }
173            Ok(inputs[0] * inputs[1])
174        }
175        GateType::PriorityAnd => {
176            let n = inputs.len();
177            if n < 2 {
178                return Err("PRIORITY_AND gate requires at least 2 inputs".to_string());
179            }
180            // All n inputs must occur in the listed order. Assuming each
181            // ordering is equally likely: P = (prod p_i) / n!.
182            // Computed in log space to avoid factorial overflow for large n.
183            let mut log_p = 0.0;
184            for p in inputs {
185                let p = (*p).clamp(0.0, 1.0);
186                if p <= 0.0 {
187                    return Ok(0.0);
188                }
189                log_p += p.ln();
190            }
191            log_p -= ln_factorial(n);
192            Ok(log_p.exp().clamp(0.0, 1.0))
193        }
194    }
195}
196
197/// ln(n!) computed without overflow: direct f64 product for n ≤ 170 (where
198/// n! still fits in f64), and the log-gamma approximation beyond (where the
199/// exact value is astronomically small and precision is irrelevant).
200fn ln_factorial(n: usize) -> f64 {
201    if n <= 170 {
202        let mut f = 1.0f64;
203        for i in 2..=n {
204            f *= i as f64;
205        }
206        f.ln()
207    } else {
208        ln_gamma((n as f64) + 1.0)
209    }
210}
211
212/// Natural logarithm of the gamma function (Lanczos approximation), giving
213/// ln(n!) for integer n+1. Used only for n > 170 where direct products would
214/// overflow; ~1e-12 relative accuracy is ample at those magnitudes.
215fn ln_gamma(x: f64) -> f64 {
216    const G: f64 = 7.0;
217    const P: [f64; 9] = [
218        0.999_999_999_999_809_9,
219        676.5203681218851,
220        -1259.1392167224028,
221        771.323_428_777_653_1,
222        -176.615_029_162_140_6,
223        12.507343278686905,
224        -0.13857109526572012,
225        9.984_369_578_019_572e-6,
226        1.5056327351493116e-7,
227    ];
228
229    if x < 0.5 {
230        return std::f64::consts::PI.ln()
231            - (std::f64::consts::PI * x).sin().ln()
232            - ln_gamma(1.0 - x);
233    }
234    let x_minus_one = x - 1.0;
235    let mut a = P[0];
236    let t = x_minus_one + G + 0.5;
237    for i in 1..9 {
238        a += P[i] / (x_minus_one + i as f64);
239    }
240    0.5 * (2.0 * std::f64::consts::PI).ln() + (x_minus_one + 0.5) * t.ln() - t + a.ln()
241}
242
243fn binomial_coeff(n: usize, k: usize) -> f64 {
244    if k > n {
245        return 0.0;
246    }
247    // ln(C(n,k)) = ln(n!) - ln(k!) - ln((n-k)!)
248    let ln = ln_factorial(n) - ln_factorial(k) - ln_factorial(n - k);
249    ln.exp().round()
250}
251
252fn multiply_polynomial(a: &[f64], b: &[f64]) -> Vec<f64> {
253    let mut result = vec![0.0; a.len() + b.len() - 1];
254    for (i, &coeff_a) in a.iter().enumerate() {
255        for (j, &coeff_b) in b.iter().enumerate() {
256            result[i + j] += coeff_a * coeff_b;
257        }
258    }
259    result
260}
261
262fn topological_sort_gates(
263    gates: &BTreeMap<String, etdl_parser::ast::Gate>,
264    root_id: &str,
265) -> Result<Vec<String>, String> {
266    let mut in_degree: HashMap<&str, usize> = HashMap::new();
267    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
268
269    for gate_id in gates.keys() {
270        in_degree.entry(gate_id.as_str()).or_insert(0);
271        adj.entry(gate_id.as_str()).or_default();
272    }
273
274    for (gate_id, gate) in gates {
275        for input in &gate.inputs {
276            if gates.contains_key(input.as_str()) {
277                adj.entry(input.as_str())
278                    .or_default()
279                    .push(gate_id.as_str());
280                *in_degree.entry(gate_id.as_str()).or_insert(0) += 1;
281            }
282        }
283    }
284
285    let mut queue: VecDeque<&str> = VecDeque::new();
286    // Iterate gates in sorted (BTreeMap) order for deterministic output.
287    for gate_id in gates.keys() {
288        let deg = in_degree.get(gate_id.as_str()).copied().unwrap_or(0);
289        if deg == 0 {
290            queue.push_back(gate_id.as_str());
291        }
292    }
293
294    let mut order = Vec::new();
295    while let Some(id) = queue.pop_front() {
296        order.push(id.to_string());
297        if let Some(children) = adj.get(id) {
298            for &child in children {
299                if let Some(deg) = in_degree.get_mut(child) {
300                    *deg -= 1;
301                    if *deg == 0 {
302                        queue.push_back(child);
303                    }
304                }
305            }
306        }
307    }
308
309    if order.len() != gates.len() {
310        return Err("cycle detected in fault tree gates (V-403)".to_string());
311    }
312
313    if !order.contains(&root_id.to_string()) {
314        order.push(root_id.to_string());
315    }
316
317    Ok(order)
318}
319
320/// Maximum number of cut-set rows MOCUS will produce before aborting. Cut-set
321/// enumeration has exponential worst-case output (ETDL §8.6), so an
322/// implementation-defined cap is required; exceeding it is an error rather than
323/// unbounded memory growth.
324pub const MAX_CUT_SET_ROWS: usize = 100_000;
325
326pub fn enumerate_minimal_cut_sets(ft: &FaultTree) -> Result<Vec<Vec<String>>, String> {
327    let gates = match &ft.gates {
328        Some(g) => g,
329        None => {
330            return Ok(vec![vec![ft.top_event.root_cause.clone()]]);
331        }
332    };
333
334    for gate in gates.values() {
335        if matches!(gate.gate_type, GateType::Not | GateType::Xor) {
336            return Err(
337                "cannot enumerate cut sets for non-coherent fault tree (contains NOT or XOR gate)"
338                    .to_string(),
339            );
340        }
341    }
342
343    let mut rows: Vec<Vec<String>> = vec![vec![ft.top_event.root_cause.clone()]];
344
345    let mut changed = true;
346    while changed {
347        changed = false;
348        let mut new_rows = Vec::new();
349
350        for row in &rows {
351            if new_rows.len() > MAX_CUT_SET_ROWS {
352                return Err(format!(
353                    "cut set enumeration exceeded maximum row count {}; tree too large",
354                    MAX_CUT_SET_ROWS
355                ));
356            }
357
358            let gate_positions: Vec<(usize, &str)> = row
359                .iter()
360                .enumerate()
361                .filter(|(_, item)| gates.contains_key(item.as_str()))
362                .map(|(i, item)| (i, item.as_str()))
363                .collect();
364
365            if gate_positions.is_empty() {
366                new_rows.push(row.clone());
367                continue;
368            }
369
370            changed = true;
371            let (pos, gate_id) = gate_positions[0];
372            let gate = &gates[gate_id];
373
374            match gate.gate_type {
375                GateType::Or => {
376                    for input in &gate.inputs {
377                        let mut new_row = row.clone();
378                        new_row.remove(pos);
379                        new_row.insert(pos, input.clone());
380                        new_rows.push(new_row);
381                    }
382                }
383                GateType::And | GateType::Inhibit | GateType::PriorityAnd => {
384                    let mut new_row = row.clone();
385                    new_row.remove(pos);
386                    for (offset, input) in gate.inputs.iter().enumerate() {
387                        new_row.insert(pos + offset, input.clone());
388                    }
389                    new_rows.push(new_row);
390                }
391                GateType::Voting => {
392                    let k = gate.k.unwrap_or(1) as usize;
393                    let combinations = generate_combinations(&gate.inputs, k);
394                    for combo in &combinations {
395                        let mut new_row = row.clone();
396                        new_row.remove(pos);
397                        for (offset, input) in combo.iter().enumerate() {
398                            new_row.insert(pos + offset, input.clone());
399                        }
400                        new_rows.push(new_row);
401                    }
402                }
403                _ => {
404                    return Err(format!(
405                        "unexpected gate type {:?} in cut set enumeration",
406                        gate.gate_type
407                    ));
408                }
409            }
410        }
411
412        rows = new_rows;
413        rows = minimize_rows(rows);
414    }
415
416    Ok(rows)
417}
418
419fn generate_combinations<T: Clone>(items: &[T], k: usize) -> Vec<Vec<T>> {
420    if k == 0 {
421        return vec![vec![]];
422    }
423    if items.is_empty() {
424        return vec![];
425    }
426
427    let mut result = Vec::new();
428    let first = &items[0];
429    let rest = &items[1..];
430
431    for mut combo in generate_combinations(rest, k - 1) {
432        let mut new_combo = vec![first.clone()];
433        new_combo.append(&mut combo);
434        result.push(new_combo);
435    }
436
437    for combo in generate_combinations(rest, k) {
438        result.push(combo);
439    }
440
441    result
442}
443
444fn minimize_rows(rows: Vec<Vec<String>>) -> Vec<Vec<String>> {
445    let mut sorted_rows: Vec<Vec<String>> = rows
446        .into_iter()
447        .map(|mut row| {
448            row.sort();
449            row.dedup();
450            row
451        })
452        .collect();
453
454    let mut i = 0;
455    while i < sorted_rows.len() {
456        let row_i = sorted_rows[i].clone();
457        sorted_rows.retain(|row_j| {
458            if std::ptr::eq(row_j, &row_i) {
459                return true;
460            }
461            let set_i: std::collections::BTreeSet<_> = row_i.iter().collect();
462            let set_j: std::collections::BTreeSet<_> = row_j.iter().collect();
463            !set_i.is_subset(&set_j)
464        });
465        i += 1;
466    }
467
468    sorted_rows
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn inhibit_gate_is_product() {
477        let p = compute_gate_probability(&GateType::Inhibit, &[0.1, 0.5], None).unwrap();
478        assert!((p - 0.05).abs() < 1e-12);
479    }
480
481    #[test]
482    fn voting_heterogeneous_matches_binomial() {
483        // 2-of-3 with equal probabilities should equal the binomial tail.
484        let p = compute_gate_probability(&GateType::Voting, &[0.5, 0.5, 0.5], Some(2)).unwrap();
485        let expected = 0.5; // P(X>=2) for Bin(3,0.5)
486        assert!((p - expected).abs() < 1e-9, "got {}", p);
487    }
488
489    #[test]
490    fn voting_heterogeneous_polynomial() {
491        // 2-of-3 with distinct probabilities: compute via the generating
492        // polynomial directly.
493        let a = 0.1;
494        let b = 0.2;
495        let c = 0.3;
496        let p = compute_gate_probability(&GateType::Voting, &[a, b, c], Some(2)).unwrap();
497        let expected = a * b * (1.0 - c) + a * (1.0 - b) * c + (1.0 - a) * b * c + a * b * c;
498        assert!((p - expected).abs() < 1e-9, "got {}", p);
499    }
500
501    #[test]
502    fn binomial_coeff_does_not_overflow() {
503        // C(70, 35) overflows usize; the f64 implementation must still work.
504        let c = binomial_coeff(70, 35);
505        assert!(c > 0.0);
506        // Exact value is 112186277816656760000 ≈ 1.121862778e20.
507        assert!(
508            (c - 1.121862778e20).abs() / 1.121862778e20 < 1e-6,
509            "got {}",
510            c
511        );
512    }
513
514    #[test]
515    fn priority_and_large_n_no_overflow() {
516        // n inputs of probability 1.0 give P = 1/n!; this must not overflow
517        // the u64 path (20! fits, 21! does not).
518        let twenty = vec![1.0; 20];
519        let p20 = compute_gate_probability(&GateType::PriorityAnd, &twenty, None).unwrap();
520        let exact_20 = (1u64..=20).fold(1.0f64, |acc, i| acc * i as f64);
521        assert!((p20 - 1.0 / exact_20).abs() < 1e-20, "got {}", p20);
522
523        let twenty_one = vec![1.0; 21];
524        let p21 = compute_gate_probability(&GateType::PriorityAnd, &twenty_one, None).unwrap();
525        // 21! ≈ 5.109e19, so P ≈ 1.957e-20 (computed via direct f64 product).
526        let exact_21 = (1u64..=21).fold(1.0f64, |acc, i| acc * i as f64);
527        assert!((p21 - 1.0 / exact_21).abs() < 1e-20, "got {}", p21);
528        // Sanity: the results are tiny but positive numbers.
529        assert!(p20 > 0.0 && p20 < 1e-15);
530        assert!(p21 > 0.0 && p21 < 1e-15);
531    }
532
533    #[test]
534    fn ln_gamma_consistency() {
535        // ln(6!) = ln(720) ≈ 6.5792
536        assert!((ln_factorial(6) - 720.0f64.ln()).abs() < 1e-9);
537        // Large n: compare log-space binomial against direct small-n result.
538        let small = binomial_coeff(10, 5);
539        assert!((small - 252.0).abs() < 1e-6);
540    }
541
542    #[test]
543    fn inhibit_requires_two_inputs() {
544        assert!(compute_gate_probability(&GateType::Inhibit, &[0.1], None).is_err());
545    }
546
547    #[test]
548    fn priority_and_uses_uniform_ordering() {
549        // P(A then B) = (0.2 * 0.3) / 2! = 0.03
550        let p = compute_gate_probability(&GateType::PriorityAnd, &[0.2, 0.3], None).unwrap();
551        assert!((p - 0.03).abs() < 1e-12);
552    }
553
554    #[test]
555    fn priority_and_three_inputs() {
556        // (0.2 * 0.3 * 0.4) / 3! = 0.024 / 6 = 0.004
557        let p = compute_gate_probability(&GateType::PriorityAnd, &[0.2, 0.3, 0.4], None).unwrap();
558        assert!((p - 0.004).abs() < 1e-12);
559    }
560
561    #[test]
562    fn priority_and_requires_two_inputs() {
563        assert!(compute_gate_probability(&GateType::PriorityAnd, &[0.1], None).is_err());
564    }
565}