Skip to main content

rust_igraph/algorithms/
feedback_arc_set.rs

1//! Feedback arc set (ALGO-CY-002).
2//!
3//! A feedback arc set (FAS) is a set of edges whose removal makes the
4//! graph acyclic. For undirected graphs the minimum FAS equals the set
5//! of edges not in any spanning tree. For directed graphs this is
6//! NP-hard; we implement the Eades-Lin-Smyth (1993) heuristic which
7//! is guaranteed to find a FAS smaller than |E|/2 - |V|/6 in O(|E|).
8//!
9//! Counterpart of `igraph_feedback_arc_set`.
10
11use std::collections::VecDeque;
12
13use crate::algorithms::spanning::mst::{MstAlgorithm, minimum_spanning_tree};
14use crate::core::graph::EdgeId;
15use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
16
17/// Algorithm choice for computing the feedback arc set.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FasAlgorithm {
20    /// For undirected graphs: non-tree edges of a maximum-weight
21    /// spanning tree. For directed graphs: Eades-Lin-Smyth heuristic.
22    EadesLinSmyth,
23}
24
25/// Compute a feedback arc set — a set of edge IDs whose removal
26/// makes the graph acyclic.
27///
28/// For undirected graphs, the result is a minimum FAS (non-tree edges
29/// of a maximum-weight spanning forest). For directed graphs, the
30/// Eades-Lin-Smyth heuristic provides a good approximation.
31///
32/// # Errors
33///
34/// - Returns an error if `weights` length does not match `ecount`.
35/// - Returns an error if weights contain non-finite values.
36///
37/// # Examples
38///
39/// ```
40/// use rust_igraph::{Graph, feedback_arc_set, FasAlgorithm};
41///
42/// // Directed cycle 0 → 1 → 2 → 0: removing one edge breaks it.
43/// let mut g = Graph::new(3, true).unwrap();
44/// g.add_edge(0, 1).unwrap();
45/// g.add_edge(1, 2).unwrap();
46/// g.add_edge(2, 0).unwrap();
47/// let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
48/// assert!(!fas.is_empty());
49/// assert!(fas.len() <= 2);
50///
51/// // DAG: no feedback edges needed.
52/// let mut g = Graph::new(3, true).unwrap();
53/// g.add_edge(0, 1).unwrap();
54/// g.add_edge(1, 2).unwrap();
55/// let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
56/// assert!(fas.is_empty());
57/// ```
58pub fn feedback_arc_set(
59    graph: &Graph,
60    weights: Option<&[f64]>,
61    _algo: FasAlgorithm,
62) -> IgraphResult<Vec<EdgeId>> {
63    if let Some(w) = weights {
64        if w.len() != graph.ecount() {
65            return Err(IgraphError::InvalidArgument(format!(
66                "weights length {} does not match ecount {}",
67                w.len(),
68                graph.ecount()
69            )));
70        }
71        if !w.iter().all(|x| x.is_finite()) {
72            return Err(IgraphError::InvalidArgument(
73                "weights must be finite".into(),
74            ));
75        }
76    }
77
78    if graph.ecount() == 0 {
79        return Ok(Vec::new());
80    }
81
82    if graph.is_directed() {
83        fas_eades(graph, weights)
84    } else {
85        fas_undirected(graph, weights)
86    }
87}
88
89fn fas_undirected(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<EdgeId>> {
90    let negated: Vec<f64>;
91    let mst_weights = match weights {
92        Some(w) => {
93            negated = w.iter().map(|x| -x).collect();
94            Some(negated.as_slice())
95        }
96        None => None,
97    };
98
99    let tree_edges = minimum_spanning_tree(graph, mst_weights, MstAlgorithm::Automatic)?;
100
101    let mut in_tree = vec![false; graph.ecount()];
102    for &eid in &tree_edges {
103        in_tree[eid as usize] = true;
104    }
105
106    let mut result = Vec::new();
107    for (eid, &is_tree) in in_tree.iter().enumerate() {
108        if !is_tree {
109            #[allow(clippy::cast_possible_truncation)]
110            result.push(eid as EdgeId);
111        }
112    }
113    Ok(result)
114}
115
116#[allow(clippy::too_many_lines)]
117fn fas_eades(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<EdgeId>> {
118    let n = graph.vcount() as usize;
119    let edge_count = graph.ecount();
120
121    let mut indegrees: Vec<i32> = vec![0; n];
122    let mut outdegrees: Vec<i32> = vec![0; n];
123    let mut instrengths: Vec<f64> = vec![0.0; n];
124    let mut outstrengths: Vec<f64> = vec![0.0; n];
125
126    for eid in 0..edge_count {
127        #[allow(clippy::cast_possible_truncation)]
128        let (from, to) = graph.edge(eid as EdgeId)?;
129        if from == to {
130            continue;
131        }
132        let w = weights.map_or(1.0, |ws| ws[eid]);
133        outdegrees[from as usize] += 1;
134        outstrengths[from as usize] += w;
135        indegrees[to as usize] += 1;
136        instrengths[to as usize] += w;
137    }
138
139    let mut ordering: Vec<i32> = vec![0; n];
140    let mut order_next_pos: i32 = 0;
141    let mut order_next_neg: i32 = -1;
142
143    let mut sources: VecDeque<VertexId> = VecDeque::new();
144    let mut sinks: VecDeque<VertexId> = VecDeque::new();
145
146    let mut nodes_left: usize = n;
147
148    for u in 0..n {
149        if indegrees[u] == 0 {
150            if outdegrees[u] == 0 {
151                #[allow(clippy::cast_possible_truncation)]
152                {
153                    ordering[u] = order_next_pos;
154                    order_next_pos += 1;
155                    indegrees[u] = -1;
156                    outdegrees[u] = -1;
157                    nodes_left -= 1;
158                }
159            } else {
160                #[allow(clippy::cast_possible_truncation)]
161                sources.push_back(u as VertexId);
162            }
163        } else if outdegrees[u] == 0 {
164            #[allow(clippy::cast_possible_truncation)]
165            sinks.push_back(u as VertexId);
166        }
167    }
168
169    while nodes_left > 0 {
170        while let Some(u) = sources.pop_front() {
171            let ui = u as usize;
172            ordering[ui] = order_next_pos;
173            order_next_pos += 1;
174            indegrees[ui] = -1;
175            outdegrees[ui] = -1;
176            nodes_left -= 1;
177
178            let out_edges = graph.incident(u)?;
179            for &eid in &out_edges {
180                let to = graph.edge_target(eid)?;
181                if to == u {
182                    continue;
183                }
184                let wi = to as usize;
185                if indegrees[wi] <= 0 {
186                    continue;
187                }
188                indegrees[wi] -= 1;
189                instrengths[wi] -= weights.map_or(1.0, |ws| ws[eid as usize]);
190                if indegrees[wi] == 0 {
191                    sources.push_back(to);
192                }
193            }
194        }
195
196        while let Some(u) = sinks.pop_front() {
197            let ui = u as usize;
198            if indegrees[ui] < 0 {
199                continue;
200            }
201            ordering[ui] = order_next_neg;
202            order_next_neg -= 1;
203            indegrees[ui] = -1;
204            outdegrees[ui] = -1;
205            nodes_left -= 1;
206
207            let in_edges = graph.incident_in(u)?;
208            for &eid in &in_edges {
209                let from = graph.edge_source(eid)?;
210                if from == u {
211                    continue;
212                }
213                let wi = from as usize;
214                if outdegrees[wi] <= 0 {
215                    continue;
216                }
217                outdegrees[wi] -= 1;
218                outstrengths[wi] -= weights.map_or(1.0, |ws| ws[eid as usize]);
219                if outdegrees[wi] == 0 {
220                    sinks.push_back(from);
221                }
222            }
223        }
224
225        let mut best_v: Option<usize> = None;
226        let mut max_diff = f64::NEG_INFINITY;
227        for u in 0..n {
228            if outdegrees[u] < 0 {
229                continue;
230            }
231            let diff = outstrengths[u] - instrengths[u];
232            if diff > max_diff {
233                max_diff = diff;
234                best_v = Some(u);
235            }
236        }
237
238        if let Some(v) = best_v {
239            ordering[v] = order_next_pos;
240            order_next_pos += 1;
241
242            #[allow(clippy::cast_possible_truncation)]
243            let vv = v as VertexId;
244
245            let out_edges = graph.incident(vv)?;
246            for &eid in &out_edges {
247                let to = graph.edge_target(eid)?;
248                if to == vv {
249                    continue;
250                }
251                let wi = to as usize;
252                if indegrees[wi] <= 0 {
253                    continue;
254                }
255                indegrees[wi] -= 1;
256                instrengths[wi] -= weights.map_or(1.0, |ws| ws[eid as usize]);
257                if indegrees[wi] == 0 {
258                    sources.push_back(to);
259                }
260            }
261
262            let in_edges = graph.incident_in(vv)?;
263            for &eid in &in_edges {
264                let from = graph.edge_source(eid)?;
265                if from == vv {
266                    continue;
267                }
268                let wi = from as usize;
269                if outdegrees[wi] <= 0 {
270                    continue;
271                }
272                outdegrees[wi] -= 1;
273                outstrengths[wi] -= weights.map_or(1.0, |ws| ws[eid as usize]);
274                if outdegrees[wi] == 0 && indegrees[wi] > 0 {
275                    sinks.push_back(from);
276                }
277            }
278
279            indegrees[v] = -1;
280            outdegrees[v] = -1;
281            nodes_left -= 1;
282        }
283    }
284
285    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
286    let n_i32 = n as i32;
287    for pos in &mut ordering {
288        if *pos < 0 {
289            *pos += n_i32;
290        }
291    }
292
293    let mut result = Vec::new();
294    for eid in 0..edge_count {
295        #[allow(clippy::cast_possible_truncation)]
296        let (from, to) = graph.edge(eid as EdgeId)?;
297        if from == to || ordering[from as usize] > ordering[to as usize] {
298            #[allow(clippy::cast_possible_truncation)]
299            result.push(eid as EdgeId);
300        }
301    }
302
303    Ok(result)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::algorithms::cycles::{CycleMode, find_cycle};
310
311    #[test]
312    fn empty_graph() {
313        let g = Graph::with_vertices(0);
314        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
315        assert!(fas.is_empty());
316    }
317
318    #[test]
319    fn dag_no_feedback() {
320        let mut g = Graph::new(4, true).unwrap();
321        g.add_edge(0, 1).unwrap();
322        g.add_edge(1, 2).unwrap();
323        g.add_edge(2, 3).unwrap();
324        g.add_edge(0, 3).unwrap();
325        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
326        assert!(fas.is_empty());
327    }
328
329    #[test]
330    fn simple_directed_cycle() {
331        let mut g = Graph::new(3, true).unwrap();
332        g.add_edge(0, 1).unwrap();
333        g.add_edge(1, 2).unwrap();
334        g.add_edge(2, 0).unwrap();
335        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
336        assert!(!fas.is_empty());
337        assert!(fas.len() <= 2);
338    }
339
340    #[test]
341    fn directed_cycle_4() {
342        let mut g = Graph::new(4, true).unwrap();
343        g.add_edge(0, 1).unwrap();
344        g.add_edge(1, 2).unwrap();
345        g.add_edge(2, 3).unwrap();
346        g.add_edge(3, 0).unwrap();
347        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
348        assert!(!fas.is_empty());
349        assert!(fas.len() <= 2);
350    }
351
352    #[test]
353    fn undirected_tree_no_feedback() {
354        let mut g = Graph::with_vertices(5);
355        g.add_edge(0, 1).unwrap();
356        g.add_edge(1, 2).unwrap();
357        g.add_edge(1, 3).unwrap();
358        g.add_edge(3, 4).unwrap();
359        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
360        assert!(fas.is_empty());
361    }
362
363    #[test]
364    fn undirected_single_cycle() {
365        let mut g = Graph::with_vertices(3);
366        g.add_edge(0, 1).unwrap();
367        g.add_edge(1, 2).unwrap();
368        g.add_edge(2, 0).unwrap();
369        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
370        assert_eq!(fas.len(), 1);
371    }
372
373    #[test]
374    fn undirected_k4() {
375        let mut g = Graph::with_vertices(4);
376        for u in 0..4u32 {
377            for v in (u + 1)..4 {
378                g.add_edge(u, v).unwrap();
379            }
380        }
381        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
382        // K4 has 6 edges, spanning tree has 3 edges → FAS = 3
383        assert_eq!(fas.len(), 3);
384    }
385
386    #[test]
387    fn self_loops_in_directed() {
388        let mut g = Graph::new(3, true).unwrap();
389        g.add_edge(0, 0).unwrap(); // self-loop
390        g.add_edge(0, 1).unwrap();
391        g.add_edge(1, 2).unwrap();
392        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
393        assert!(fas.contains(&0)); // self-loop is always a feedback edge
394    }
395
396    #[test]
397    fn weighted_undirected() {
398        let mut g = Graph::with_vertices(3);
399        g.add_edge(0, 1).unwrap(); // eid 0, weight 1.0
400        g.add_edge(1, 2).unwrap(); // eid 1, weight 10.0
401        g.add_edge(2, 0).unwrap(); // eid 2, weight 10.0
402        let weights = vec![1.0, 10.0, 10.0];
403        let fas = feedback_arc_set(&g, Some(&weights), FasAlgorithm::EadesLinSmyth).unwrap();
404        // MST should pick the heaviest edges (1,2) and (2,0), leaving (0,1) out
405        assert_eq!(fas.len(), 1);
406        assert_eq!(fas[0], 0); // lightest edge not in max-weight spanning tree
407    }
408
409    #[test]
410    fn weighted_directed() {
411        let mut g = Graph::new(3, true).unwrap();
412        g.add_edge(0, 1).unwrap(); // eid 0
413        g.add_edge(1, 2).unwrap(); // eid 1
414        g.add_edge(2, 0).unwrap(); // eid 2
415        let weights = vec![1.0, 1.0, 100.0];
416        let fas = feedback_arc_set(&g, Some(&weights), FasAlgorithm::EadesLinSmyth).unwrap();
417        assert!(!fas.is_empty());
418    }
419
420    #[test]
421    fn invalid_weights_length() {
422        let g = Graph::with_vertices(3);
423        let weights = vec![1.0]; // wrong length
424        assert!(feedback_arc_set(&g, Some(&weights), FasAlgorithm::EadesLinSmyth).is_err());
425    }
426
427    #[test]
428    fn nan_weights_rejected() {
429        let mut g = Graph::with_vertices(2);
430        g.add_edge(0, 1).unwrap();
431        let weights = vec![f64::NAN];
432        assert!(feedback_arc_set(&g, Some(&weights), FasAlgorithm::EadesLinSmyth).is_err());
433    }
434
435    #[test]
436    fn two_directed_cycles_sharing_vertex() {
437        // 0→1→2→0, 0→3→4→0
438        let mut g = Graph::new(5, true).unwrap();
439        g.add_edge(0, 1).unwrap();
440        g.add_edge(1, 2).unwrap();
441        g.add_edge(2, 0).unwrap();
442        g.add_edge(0, 3).unwrap();
443        g.add_edge(3, 4).unwrap();
444        g.add_edge(4, 0).unwrap();
445        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
446        assert!(fas.len() >= 2);
447        assert!(fas.len() <= 4);
448    }
449
450    #[test]
451    fn bidirectional_edges() {
452        // 0↔1 — both directions
453        let mut g = Graph::new(2, true).unwrap();
454        g.add_edge(0, 1).unwrap();
455        g.add_edge(1, 0).unwrap();
456        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
457        assert_eq!(fas.len(), 1);
458    }
459
460    #[test]
461    fn isolated_vertices_directed() {
462        let g = Graph::new(5, true).unwrap();
463        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
464        assert!(fas.is_empty());
465    }
466
467    #[test]
468    fn removes_all_cycles() {
469        // Verify the FAS actually removes all cycles by checking
470        // the remaining graph is a DAG
471        let mut g = Graph::new(5, true).unwrap();
472        g.add_edge(0, 1).unwrap();
473        g.add_edge(1, 2).unwrap();
474        g.add_edge(2, 0).unwrap();
475        g.add_edge(2, 3).unwrap();
476        g.add_edge(3, 4).unwrap();
477        g.add_edge(4, 2).unwrap();
478
479        let fas = feedback_arc_set(&g, None, FasAlgorithm::EadesLinSmyth).unwrap();
480
481        // Rebuild graph without FAS edges and check it's acyclic
482        let fas_set: std::collections::HashSet<EdgeId> = fas.into_iter().collect();
483        let mut g2 = Graph::new(5, true).unwrap();
484        for eid in 0..g.ecount() {
485            #[allow(clippy::cast_possible_truncation)]
486            let eid32 = eid as EdgeId;
487            if !fas_set.contains(&eid32) {
488                let (from, to) = g.edge(eid32).unwrap();
489                g2.add_edge(from, to).unwrap();
490            }
491        }
492        // The remaining graph should be a DAG
493        let cycle = find_cycle(&g2, CycleMode::Out);
494        assert!(
495            cycle.is_ok() && cycle.unwrap().vertices.is_empty(),
496            "FAS did not break all cycles"
497        );
498    }
499}