Skip to main content

scirs2_graph/reliability/
network_reliability.rs

1//! Network reliability algorithms: Monte Carlo estimation, exact BDD computation,
2//! inclusion-exclusion polynomial, and component failure enumeration.
3//!
4//! ## Algorithms
5//!
6//! - **Monte Carlo two-terminal reliability**: sample edge subsets, check connectivity.
7//! - **Monte Carlo all-terminal reliability**: check full graph connectivity.
8//! - **ReliabilityPolynomial**: exact polynomial coefficients via inclusion-exclusion
9//!   on spanning trees / path enumeration (feasible for |E| ≤ 20).
10//! - **BDD** (Binary Decision Diagram): exact reliability for small networks via
11//!   ordered BDDs over edge variables.
12//! - **ComponentFailureTree**: ball-tree style structure for enumerating
13//!   minimal cuts / paths.
14
15use std::collections::{HashMap, VecDeque};
16
17use scirs2_core::ndarray::Array2;
18use scirs2_core::random::{Rng, RngExt, SeedableRng, StdRng};
19
20use crate::error::{GraphError, Result};
21
22// ─────────────────────────────────────────────────────────────────────────────
23// Helpers
24// ─────────────────────────────────────────────────────────────────────────────
25
26/// Extract the edge list and per-edge survival probabilities from an adjacency matrix.
27///
28/// For undirected graphs the matrix is treated as symmetric; each undirected
29/// edge `{i, j}` (i < j) is included once.
30fn extract_edges(adj: &Array2<f64>) -> Vec<(usize, usize, f64)> {
31    let n = adj.nrows();
32    let mut edges = Vec::new();
33    for i in 0..n {
34        for j in (i + 1)..n {
35            let w = adj[[i, j]];
36            if w > 0.0 {
37                // Clamp weight to [0,1] as survival probability
38                edges.push((i, j, w.clamp(0.0, 1.0)));
39            }
40        }
41    }
42    edges
43}
44
45/// BFS reachability check: can node `s` reach node `t` through active edges?
46///
47/// `active[e]` is true iff edge `e` is functioning.
48fn can_reach(n: usize, edges: &[(usize, usize, f64)], active: &[bool], s: usize, t: usize) -> bool {
49    if s == t {
50        return true;
51    }
52    // Build adjacency list for active edges
53    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
54    for (idx, &(u, v, _)) in edges.iter().enumerate() {
55        if active[idx] {
56            adj[u].push(v);
57            adj[v].push(u);
58        }
59    }
60    let mut visited = vec![false; n];
61    visited[s] = true;
62    let mut queue = VecDeque::new();
63    queue.push_back(s);
64    while let Some(node) = queue.pop_front() {
65        for &nb in &adj[node] {
66            if nb == t {
67                return true;
68            }
69            if !visited[nb] {
70                visited[nb] = true;
71                queue.push_back(nb);
72            }
73        }
74    }
75    false
76}
77
78/// BFS connectivity check: are all nodes reachable from node 0?
79fn is_fully_connected(n: usize, edges: &[(usize, usize, f64)], active: &[bool]) -> bool {
80    if n <= 1 {
81        return true;
82    }
83    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
84    for (idx, &(u, v, _)) in edges.iter().enumerate() {
85        if active[idx] {
86            adj[u].push(v);
87            adj[v].push(u);
88        }
89    }
90    let mut visited = vec![false; n];
91    visited[0] = true;
92    let mut queue = VecDeque::new();
93    queue.push_back(0usize);
94    let mut count = 1usize;
95    while let Some(node) = queue.pop_front() {
96        for &nb in &adj[node] {
97            if !visited[nb] {
98                visited[nb] = true;
99                count += 1;
100                queue.push_back(nb);
101            }
102        }
103    }
104    count == n
105}
106
107// ─────────────────────────────────────────────────────────────────────────────
108// NetworkReliability — two-terminal Monte Carlo
109// ─────────────────────────────────────────────────────────────────────────────
110
111/// Two-terminal network reliability estimator.
112///
113/// Estimates P(source `s` can reach terminal `t`) under independent edge
114/// failures via Monte Carlo sampling.
115///
116/// Each simulation trial:
117/// 1. For each edge `(u, v, p)`, keep the edge with probability `p`.
118/// 2. Run BFS to check whether `s` can reach `t`.
119///
120/// The estimate converges at rate `O(1/√N)` where `N` is the number of trials.
121#[derive(Debug, Clone)]
122pub struct NetworkReliability {
123    /// Source node index.
124    pub source: usize,
125    /// Terminal (target) node index.
126    pub terminal: usize,
127}
128
129impl NetworkReliability {
130    /// Create a two-terminal reliability estimator.
131    pub fn new(source: usize, terminal: usize) -> Self {
132        Self { source, terminal }
133    }
134
135    /// Estimate two-terminal reliability via Monte Carlo simulation.
136    ///
137    /// # Arguments
138    /// * `adj` — weighted adjacency matrix; weights are edge survival probabilities ∈ (0,1].
139    /// * `num_trials` — number of Monte Carlo samples.
140    /// * `seed` — optional RNG seed for reproducibility.
141    ///
142    /// # Returns
143    /// Estimated probability ∈ [0, 1].
144    pub fn monte_carlo(
145        &self,
146        adj: &Array2<f64>,
147        num_trials: usize,
148        seed: Option<u64>,
149    ) -> Result<f64> {
150        let n = adj.nrows();
151        if self.source >= n {
152            return Err(GraphError::InvalidParameter {
153                param: "source".into(),
154                value: self.source.to_string(),
155                expected: format!("< {n}"),
156                context: "NetworkReliability::monte_carlo".into(),
157            });
158        }
159        if self.terminal >= n {
160            return Err(GraphError::InvalidParameter {
161                param: "terminal".into(),
162                value: self.terminal.to_string(),
163                expected: format!("< {n}"),
164                context: "NetworkReliability::monte_carlo".into(),
165            });
166        }
167        if num_trials == 0 {
168            return Ok(0.0);
169        }
170
171        let edges = extract_edges(adj);
172        let m = edges.len();
173        let mut rng: StdRng = match seed {
174            Some(s) => StdRng::seed_from_u64(s),
175            None => StdRng::from_rng(&mut scirs2_core::random::rng()),
176        };
177
178        let mut successes = 0u64;
179        let mut active = vec![false; m];
180
181        for _ in 0..num_trials {
182            for (idx, &(_, _, p)) in edges.iter().enumerate() {
183                active[idx] = rng.random::<f64>() < p;
184            }
185            if can_reach(n, &edges, &active, self.source, self.terminal) {
186                successes += 1;
187            }
188        }
189
190        Ok(successes as f64 / num_trials as f64)
191    }
192
193    /// Compute a confidence interval for the Monte Carlo estimate.
194    ///
195    /// Returns `(estimate, half_width)` where the 95% CI is
196    /// `[estimate − half_width, estimate + half_width]`.
197    pub fn monte_carlo_with_ci(
198        &self,
199        adj: &Array2<f64>,
200        num_trials: usize,
201        seed: Option<u64>,
202    ) -> Result<(f64, f64)> {
203        let p_hat = self.monte_carlo(adj, num_trials, seed)?;
204        // Wilson-like bound: half-width ≈ 1.96 * sqrt(p(1-p)/n)
205        let half_width = if num_trials > 0 {
206            1.96 * (p_hat * (1.0 - p_hat) / num_trials as f64).sqrt()
207        } else {
208            1.0
209        };
210        Ok((p_hat, half_width))
211    }
212
213    /// Compute the exact two-terminal reliability for small graphs by
214    /// exhaustive enumeration of all 2^|E| edge subsets.
215    ///
216    /// Only feasible for `|E| ≤ 25`.
217    pub fn exact(&self, adj: &Array2<f64>) -> Result<f64> {
218        let n = adj.nrows();
219        if self.source >= n || self.terminal >= n {
220            return Err(GraphError::InvalidParameter {
221                param: "source/terminal".into(),
222                value: format!("{}/{}", self.source, self.terminal),
223                expected: format!("< {n}"),
224                context: "NetworkReliability::exact".into(),
225            });
226        }
227        let edges = extract_edges(adj);
228        let m = edges.len();
229        if m > 25 {
230            return Err(GraphError::InvalidParameter {
231                param: "num_edges".into(),
232                value: m.to_string(),
233                expected: "<= 25 for exact computation".into(),
234                context: "NetworkReliability::exact".into(),
235            });
236        }
237
238        let mut total = 0.0_f64;
239        for mask in 0u32..(1u32 << m) {
240            let active: Vec<bool> = (0..m).map(|i| (mask >> i) & 1 == 1).collect();
241            // Probability of this configuration
242            let prob: f64 = edges
243                .iter()
244                .enumerate()
245                .map(|(i, &(_, _, p))| if active[i] { p } else { 1.0 - p })
246                .product();
247            if can_reach(n, &edges, &active, self.source, self.terminal) {
248                total += prob;
249            }
250        }
251        Ok(total)
252    }
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// AllTerminalReliability — all-terminal Monte Carlo
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// All-terminal network reliability estimator.
260///
261/// Estimates P(all nodes mutually reachable) under independent edge failures.
262#[derive(Debug, Clone, Default)]
263pub struct AllTerminalReliability;
264
265impl AllTerminalReliability {
266    /// Create an all-terminal reliability estimator.
267    pub fn new() -> Self {
268        Self
269    }
270
271    /// Estimate all-terminal reliability via Monte Carlo simulation.
272    pub fn monte_carlo(
273        &self,
274        adj: &Array2<f64>,
275        num_trials: usize,
276        seed: Option<u64>,
277    ) -> Result<f64> {
278        let n = adj.nrows();
279        if n == 0 {
280            return Err(GraphError::InvalidGraph("empty adjacency".into()));
281        }
282        if num_trials == 0 {
283            return Ok(0.0);
284        }
285
286        let edges = extract_edges(adj);
287        let m = edges.len();
288        let mut rng: StdRng = match seed {
289            Some(s) => StdRng::seed_from_u64(s),
290            None => StdRng::from_rng(&mut scirs2_core::random::rng()),
291        };
292
293        let mut successes = 0u64;
294        let mut active = vec![false; m];
295
296        for _ in 0..num_trials {
297            for (idx, &(_, _, p)) in edges.iter().enumerate() {
298                active[idx] = rng.random::<f64>() < p;
299            }
300            if is_fully_connected(n, &edges, &active) {
301                successes += 1;
302            }
303        }
304
305        Ok(successes as f64 / num_trials as f64)
306    }
307
308    /// Exact all-terminal reliability (exhaustive; |E| ≤ 20).
309    pub fn exact(&self, adj: &Array2<f64>) -> Result<f64> {
310        let n = adj.nrows();
311        let edges = extract_edges(adj);
312        let m = edges.len();
313        if m > 20 {
314            return Err(GraphError::InvalidParameter {
315                param: "num_edges".into(),
316                value: m.to_string(),
317                expected: "<= 20 for exact computation".into(),
318                context: "AllTerminalReliability::exact".into(),
319            });
320        }
321        let mut total = 0.0_f64;
322        for mask in 0u32..(1u32 << m) {
323            let active: Vec<bool> = (0..m).map(|i| (mask >> i) & 1 == 1).collect();
324            let prob: f64 = edges
325                .iter()
326                .enumerate()
327                .map(|(i, &(_, _, p))| if active[i] { p } else { 1.0 - p })
328                .product();
329            if is_fully_connected(n, &edges, &active) {
330                total += prob;
331            }
332        }
333        Ok(total)
334    }
335}
336
337// ─────────────────────────────────────────────────────────────────────────────
338// ReliabilityPolynomial — exact polynomial coefficients
339// ─────────────────────────────────────────────────────────────────────────────
340
341/// Exact reliability polynomial for small networks (|E| ≤ 20).
342///
343/// For a network where all edges have the same survival probability `p`, the
344/// all-terminal reliability is a polynomial in `p`:
345///
346///   R(p) = Σ_{k=0}^{m} c_k p^k (1−p)^{m−k}
347///
348/// where `c_k` is the number of edge subsets of size `k` that make the graph
349/// connected.  This struct computes the coefficient vector `[c_0, c_1, …, c_m]`
350/// by exhaustive enumeration and stores it for fast evaluation at any `p`.
351#[derive(Debug, Clone)]
352pub struct ReliabilityPolynomial {
353    /// Coefficients: `coeffs[k]` = number of connected edge subsets of size `k`.
354    pub coeffs: Vec<u64>,
355    /// Total number of edges.
356    pub num_edges: usize,
357    /// Number of nodes.
358    pub num_nodes: usize,
359}
360
361impl ReliabilityPolynomial {
362    /// Compute the reliability polynomial for a graph where all edges have
363    /// equal survival probability.
364    ///
365    /// Only feasible for `|E| ≤ 20`.
366    pub fn compute(adj: &Array2<f64>) -> Result<Self> {
367        let n = adj.nrows();
368        // Treat the adjacency matrix as unweighted for the polynomial
369        let edges: Vec<(usize, usize)> = (0..n)
370            .flat_map(|i| {
371                (i + 1..n).filter_map(move |j| {
372                    if adj[[i, j]] > 0.0 {
373                        Some((i, j))
374                    } else {
375                        None
376                    }
377                })
378            })
379            .collect();
380        let m = edges.len();
381        if m > 20 {
382            return Err(GraphError::InvalidParameter {
383                param: "num_edges".into(),
384                value: m.to_string(),
385                expected: "<= 20 for polynomial computation".into(),
386                context: "ReliabilityPolynomial::compute".into(),
387            });
388        }
389
390        let mut coeffs = vec![0u64; m + 1];
391        // Enumerate all 2^m subsets
392        for mask in 0u32..(1u32 << m) {
393            let k = mask.count_ones() as usize;
394            let active: Vec<bool> = (0..m).map(|i| (mask >> i) & 1 == 1).collect();
395            // Build edge list with p=1 for active check
396            let active_edges: Vec<(usize, usize, f64)> =
397                edges.iter().map(|&(u, v)| (u, v, 1.0)).collect();
398            let active_flags: Vec<bool> = (0..m).map(|i| active[i]).collect();
399            if is_fully_connected(n, &active_edges, &active_flags) {
400                coeffs[k] += 1;
401            }
402        }
403
404        Ok(Self {
405            coeffs,
406            num_edges: m,
407            num_nodes: n,
408        })
409    }
410
411    /// Evaluate the reliability polynomial at survival probability `p`.
412    ///
413    /// R(p) = Σ_k c_k * p^k * (1−p)^{m−k}
414    pub fn evaluate(&self, p: f64) -> f64 {
415        let m = self.num_edges;
416        let q = 1.0 - p;
417        self.coeffs
418            .iter()
419            .enumerate()
420            .map(|(k, &c)| {
421                if c == 0 {
422                    0.0
423                } else {
424                    c as f64 * p.powi(k as i32) * q.powi((m - k) as i32)
425                }
426            })
427            .sum()
428    }
429
430    /// Return the minimum cut size (the lowest `k` with `coeffs[k] > 0`).
431    pub fn min_connected_edges(&self) -> usize {
432        self.coeffs
433            .iter()
434            .position(|&c| c > 0)
435            .unwrap_or(self.num_edges)
436    }
437
438    /// Return the total number of spanning subgraphs (sum of all coefficients).
439    pub fn total_connected_subgraphs(&self) -> u64 {
440        self.coeffs.iter().sum()
441    }
442}
443
444// ─────────────────────────────────────────────────────────────────────────────
445// BDD — Binary Decision Diagram for exact reliability
446// ─────────────────────────────────────────────────────────────────────────────
447
448/// BDD node type.
449#[derive(Debug, Clone)]
450enum BddNode {
451    /// Terminal node: 0 = failure, 1 = success.
452    Terminal(bool),
453    /// Internal node: variable index, low child, high child.
454    Internal { var: usize, low: usize, high: usize },
455}
456
457/// Binary Decision Diagram (BDD) for exact network reliability computation.
458///
459/// Represents the reliability function as an ordered BDD over edge boolean
460/// variables.  Each variable `x_e = 1` means edge `e` is functioning.
461///
462/// The BDD is built by Shannon expansion on each edge variable in order:
463/// - Set `x_e = 1` (edge alive): recurse on remaining problem.
464/// - Set `x_e = 0` (edge failed): recurse.
465/// - Merge isomorphic subgraphs (unique table) for compactness.
466///
467/// Reliability is computed by a single bottom-up traversal weighting
468/// each path by `p_e^{x_e} (1−p_e)^{1−x_e}`.
469#[derive(Debug)]
470pub struct BDD {
471    nodes: Vec<BddNode>,
472    /// Map (var, low_idx, high_idx) → node index (unique table)
473    unique: HashMap<(usize, usize, usize), usize>,
474    /// Total number of nodes (edges) in the network.
475    num_edges: usize,
476    /// Number of network nodes.
477    num_nodes: usize,
478    /// Edge list.
479    edges: Vec<(usize, usize)>,
480    /// Root node index.
481    root: usize,
482}
483
484impl BDD {
485    /// Build a BDD for all-terminal reliability.
486    ///
487    /// Only feasible for `|E| ≤ 20`.
488    pub fn build_all_terminal(adj: &Array2<f64>) -> Result<Self> {
489        let n = adj.nrows();
490        let edges: Vec<(usize, usize)> = (0..n)
491            .flat_map(|i| {
492                (i + 1..n).filter_map(move |j| {
493                    if adj[[i, j]] > 0.0 {
494                        Some((i, j))
495                    } else {
496                        None
497                    }
498                })
499            })
500            .collect();
501        let m = edges.len();
502        if m > 20 {
503            return Err(GraphError::InvalidParameter {
504                param: "num_edges".into(),
505                value: m.to_string(),
506                expected: "<= 20 for BDD".into(),
507                context: "BDD::build_all_terminal".into(),
508            });
509        }
510
511        let mut bdd = BDD {
512            nodes: Vec::new(),
513            unique: HashMap::new(),
514            num_edges: m,
515            num_nodes: n,
516            edges: edges.clone(),
517            root: 0,
518        };
519
520        // Terminal nodes: index 0 = False, index 1 = True
521        bdd.nodes.push(BddNode::Terminal(false));
522        bdd.nodes.push(BddNode::Terminal(true));
523
524        let active_mask = (1u32 << m) - 1; // all edges initially unknown
525        let root = bdd.build_node(0, active_mask, n, &edges);
526        bdd.root = root;
527        Ok(bdd)
528    }
529
530    /// Recursively build a BDD node for `var_idx`-th edge variable.
531    ///
532    /// `active_mask` represents which edges are currently "forced on" (1-bit).
533    /// We actually use a different approach: Shannon expansion.
534    fn build_node(
535        &mut self,
536        var: usize,
537        forced_on: u32,
538        n_nodes: usize,
539        edges: &[(usize, usize)],
540    ) -> usize {
541        let m = edges.len();
542        if var == m {
543            // All variables assigned; check if forced-on edges form connected graph
544            let active: Vec<bool> = (0..m).map(|i| (forced_on >> i) & 1 == 1).collect();
545            let edge_data: Vec<(usize, usize, f64)> =
546                edges.iter().map(|&(u, v)| (u, v, 1.0)).collect();
547            let connected = is_fully_connected(n_nodes, &edge_data, &active);
548            return if connected { 1 } else { 0 };
549        }
550
551        // Check if already computed
552        // For BDD with Shannon expansion we memoize on (var, forced_on)
553        // Using forced_on as a compact state representation
554        let key = (var, forced_on as usize, 0);
555        if let Some(&idx) = self.unique.get(&key) {
556            return idx;
557        }
558
559        // Shannon expansion on edge `var`
560        // Low child: edge `var` = 0 (failed)
561        let low_forced = forced_on & !(1u32 << var);
562        let low = self.build_node(var + 1, low_forced, n_nodes, edges);
563
564        // High child: edge `var` = 1 (alive)
565        let high_forced = forced_on | (1u32 << var);
566        let high = self.build_node(var + 1, high_forced, n_nodes, edges);
567
568        // If both children are the same, no need for new node (reduction rule)
569        if low == high {
570            self.unique.insert(key, low);
571            return low;
572        }
573
574        let idx = self.nodes.len();
575        self.nodes.push(BddNode::Internal { var, low, high });
576        self.unique.insert(key, idx);
577        idx
578    }
579
580    /// Compute the all-terminal reliability R = E[connected(G_p)] using the BDD.
581    ///
582    /// # Arguments
583    /// * `probs` — survival probability for each edge, in the same order as the
584    ///   adjacency matrix edge enumeration (upper-triangle, row-major).
585    pub fn reliability(&self, probs: &[f64]) -> Result<f64> {
586        if probs.len() != self.num_edges {
587            return Err(GraphError::InvalidParameter {
588                param: "probs.len()".into(),
589                value: probs.len().to_string(),
590                expected: self.num_edges.to_string(),
591                context: "BDD::reliability".into(),
592            });
593        }
594        Ok(self.eval_node(self.root, probs))
595    }
596
597    fn eval_node(&self, node_idx: usize, probs: &[f64]) -> f64 {
598        match &self.nodes[node_idx] {
599            BddNode::Terminal(t) => {
600                if *t {
601                    1.0
602                } else {
603                    0.0
604                }
605            }
606            BddNode::Internal { var, low, high } => {
607                let p = probs[*var];
608                let q = 1.0 - p;
609                q * self.eval_node(*low, probs) + p * self.eval_node(*high, probs)
610            }
611        }
612    }
613
614    /// Return the number of BDD nodes (size of the diagram).
615    pub fn size(&self) -> usize {
616        self.nodes.len()
617    }
618
619    /// Return the number of edges in the underlying network.
620    pub fn num_edges(&self) -> usize {
621        self.num_edges
622    }
623
624    /// Return the number of nodes in the underlying network.
625    pub fn num_network_nodes(&self) -> usize {
626        self.num_nodes
627    }
628}
629
630// ─────────────────────────────────────────────────────────────────────────────
631// ComponentFailureTree — ball-tree style enumeration of failure modes
632// ─────────────────────────────────────────────────────────────────────────────
633
634/// A node in the component failure tree.
635#[derive(Debug, Clone)]
636pub struct FailureNode {
637    /// The set of failed edge indices at this tree node.
638    pub failed_edges: Vec<usize>,
639    /// Whether this failure set constitutes a disconnecting cut.
640    pub is_cut: bool,
641    /// Probability of this exact failure set.
642    pub probability: f64,
643    /// Children nodes (further edge failures).
644    pub children: Vec<FailureNode>,
645}
646
647/// Ball-tree style structure for enumerating component failure modes.
648///
649/// Builds a tree where each level introduces one additional edge failure.
650/// The tree branches at each node for each remaining edge that could fail.
651/// This is useful for computing minimal cuts and identifying the most
652/// likely failure scenarios.
653#[derive(Debug)]
654pub struct ComponentFailureTree {
655    /// Root of the failure tree.
656    pub root: FailureNode,
657    /// All minimal cuts found during tree construction.
658    pub minimal_cuts: Vec<Vec<usize>>,
659    /// Number of edges.
660    pub num_edges: usize,
661    /// Number of nodes.
662    pub num_nodes: usize,
663}
664
665impl ComponentFailureTree {
666    /// Build the component failure tree for a network up to `max_depth` edge failures.
667    ///
668    /// # Arguments
669    /// * `adj` — weighted adjacency matrix.
670    /// * `max_depth` — maximum number of simultaneous edge failures to consider.
671    ///
672    /// `max_depth` should be kept small (≤ 5) for efficiency.
673    pub fn build(adj: &Array2<f64>, max_depth: usize) -> Result<Self> {
674        let n = adj.nrows();
675        let edges: Vec<(usize, usize, f64)> = extract_edges(adj);
676        let m = edges.len();
677
678        let mut minimal_cuts = Vec::new();
679        let root_active = vec![true; m];
680        let is_cut = !is_fully_connected(n, &edges, &root_active);
681
682        let root = FailureNode {
683            failed_edges: Vec::new(),
684            is_cut,
685            probability: 1.0,
686            children: Vec::new(),
687        };
688
689        let mut tree = ComponentFailureTree {
690            root,
691            minimal_cuts: Vec::new(),
692            num_edges: m,
693            num_nodes: n,
694        };
695
696        // Build tree via DFS
697        let failed: Vec<usize> = Vec::new();
698        let edge_probs: Vec<f64> = edges.iter().map(|&(_, _, p)| p).collect();
699        failure_tree_expand_node(
700            &mut tree.root.children,
701            &failed,
702            0,
703            max_depth,
704            n,
705            &edges,
706            &edge_probs,
707            m,
708            &mut minimal_cuts,
709        );
710        tree.minimal_cuts = minimal_cuts;
711
712        Ok(tree)
713    }
714
715    /// Return all minimal cuts found during tree construction.
716    pub fn minimal_cuts(&self) -> &[Vec<usize>] {
717        &self.minimal_cuts
718    }
719
720    /// Compute total probability of disconnection up to `max_depth` failures.
721    ///
722    /// This sums probabilities of all failure sets that are cuts, but avoids
723    /// double-counting by only summing *minimal* cuts' exact set probabilities.
724    pub fn unreliability_upper_bound(&self) -> f64 {
725        Self::sum_cut_probs(&self.root)
726    }
727
728    fn sum_cut_probs(node: &FailureNode) -> f64 {
729        let self_contribution = if node.is_cut { node.probability } else { 0.0 };
730        let child_sum: f64 = node.children.iter().map(Self::sum_cut_probs).sum();
731        self_contribution + child_sum
732    }
733}
734
735#[allow(clippy::too_many_arguments)]
736fn failure_tree_expand_node(
737    children: &mut Vec<FailureNode>,
738    parent_failed: &[usize],
739    start_edge: usize,
740    remaining_depth: usize,
741    n: usize,
742    edges: &[(usize, usize, f64)],
743    edge_probs: &[f64],
744    m: usize,
745    minimal_cuts: &mut Vec<Vec<usize>>,
746) {
747    if remaining_depth == 0 {
748        return;
749    }
750    for e in start_edge..m {
751        let mut failed = parent_failed.to_vec();
752        failed.push(e);
753
754        // Probability of this failure set (prob that exactly these edges fail)
755        let prob: f64 = (0..m)
756            .map(|i| {
757                if failed.contains(&i) {
758                    1.0 - edge_probs[i]
759                } else {
760                    edge_probs[i]
761                }
762            })
763            .product();
764
765        let active: Vec<bool> = (0..m).map(|i| !failed.contains(&i)).collect();
766        let is_cut = !is_fully_connected(n, edges, &active);
767
768        // Check if this is a minimal cut: is_cut AND parent (without e) was NOT a cut
769        if is_cut {
770            // Check if parent failure set already disconnects
771            let parent_active: Vec<bool> = (0..m).map(|i| !parent_failed.contains(&i)).collect();
772            let parent_is_cut = !is_fully_connected(n, edges, &parent_active);
773            if !parent_is_cut {
774                minimal_cuts.push(failed.clone());
775            }
776        }
777
778        let mut node = FailureNode {
779            failed_edges: failed.clone(),
780            is_cut,
781            probability: prob,
782            children: Vec::new(),
783        };
784
785        if !is_cut && remaining_depth > 1 {
786            failure_tree_expand_node(
787                &mut node.children,
788                &failed,
789                e + 1,
790                remaining_depth - 1,
791                n,
792                edges,
793                edge_probs,
794                m,
795                minimal_cuts,
796            );
797        }
798
799        children.push(node);
800    }
801}
802
803// ─────────────────────────────────────────────────────────────────────────────
804// Tests
805// ─────────────────────────────────────────────────────────────────────────────
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn triangle_adj(p: f64) -> Array2<f64> {
812        let mut adj = Array2::<f64>::zeros((3, 3));
813        adj[[0, 1]] = p;
814        adj[[1, 0]] = p;
815        adj[[1, 2]] = p;
816        adj[[2, 1]] = p;
817        adj[[0, 2]] = p;
818        adj[[2, 0]] = p;
819        adj
820    }
821
822    fn path_adj_p(n: usize, p: f64) -> Array2<f64> {
823        let mut adj = Array2::<f64>::zeros((n, n));
824        for i in 0..(n - 1) {
825            adj[[i, i + 1]] = p;
826            adj[[i + 1, i]] = p;
827        }
828        adj
829    }
830
831    #[test]
832    fn test_two_terminal_exact_vs_mc() {
833        // Path graph 0-1-2: two-terminal reliability (0 to 2) = p^2
834        let p = 0.8;
835        let adj = path_adj_p(3, p);
836        let rel = NetworkReliability::new(0, 2);
837        let exact = rel.exact(&adj).unwrap();
838        // For path 0-1-2, need both edges: p^2
839        assert!((exact - p * p).abs() < 1e-9, "Exact: {exact} vs {}", p * p);
840        let mc = rel.monte_carlo(&adj, 50000, Some(99)).unwrap();
841        assert!((mc - exact).abs() < 0.02, "MC: {mc} vs exact: {exact}");
842    }
843
844    #[test]
845    fn test_all_terminal_triangle_exact() {
846        let p = 0.9;
847        let adj = triangle_adj(p);
848        let rel = AllTerminalReliability::new();
849        let exact = rel.exact(&adj).unwrap();
850        // All-terminal reliability of triangle: at least 2 of 3 edges must be present
851        // = C(3,2)*p^2*(1-p) + C(3,3)*p^3 = 3p²(1-p) + p³
852        let expected = 3.0 * p * p * (1.0 - p) + p * p * p;
853        assert!(
854            (exact - expected).abs() < 1e-9,
855            "Exact: {exact} vs {expected}"
856        );
857        let mc = rel.monte_carlo(&adj, 50000, Some(7)).unwrap();
858        assert!((mc - exact).abs() < 0.02);
859    }
860
861    #[test]
862    fn test_reliability_polynomial() {
863        let adj = triangle_adj(1.0); // all edges present (weights = 1, so p=1 clamp)
864        let poly = ReliabilityPolynomial::compute(&adj).unwrap();
865        assert_eq!(poly.num_edges, 3);
866        // At p=1 all subsets with ≥2 edges should connect
867        let r1 = poly.evaluate(1.0);
868        assert!((r1 - 1.0).abs() < 1e-9, "R(1)={r1}");
869        let r0 = poly.evaluate(0.0);
870        assert!((r0 - 0.0).abs() < 1e-9, "R(0)={r0}");
871    }
872
873    #[test]
874    fn test_bdd_vs_exact() {
875        let p = 0.75;
876        let adj = triangle_adj(p);
877        let bdd = BDD::build_all_terminal(&adj).unwrap();
878        let probs = vec![p; 3];
879        let bdd_result = bdd.reliability(&probs).unwrap();
880        let exact = AllTerminalReliability::new().exact(&adj).unwrap();
881        assert!(
882            (bdd_result - exact).abs() < 1e-9,
883            "BDD: {bdd_result} vs exact: {exact}"
884        );
885    }
886
887    #[test]
888    fn test_component_failure_tree() {
889        let adj = triangle_adj(0.9);
890        let tree = ComponentFailureTree::build(&adj, 2).unwrap();
891        // A triangle has minimal cuts of size 2 (any 2 edges incident to a node)
892        assert!(!tree.minimal_cuts().is_empty());
893    }
894
895    #[test]
896    fn test_reliability_ci() {
897        let adj = path_adj_p(3, 0.9);
898        let rel = NetworkReliability::new(0, 2);
899        let (p_hat, hw) = rel.monte_carlo_with_ci(&adj, 10000, Some(1)).unwrap();
900        assert!((0.0..=1.0).contains(&p_hat));
901        assert!((0.0..=0.1).contains(&hw));
902    }
903}