From 7d4eade902243be085ebc5f36086a461b34a9cde Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 15:16:09 +0000 Subject: [PATCH 1/8] Add plan for #213: MinimumFeedbackArcSet model --- .../2026-03-12-minimum-feedback-arc-set.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/plans/2026-03-12-minimum-feedback-arc-set.md diff --git a/docs/plans/2026-03-12-minimum-feedback-arc-set.md b/docs/plans/2026-03-12-minimum-feedback-arc-set.md new file mode 100644 index 000000000..c99199f07 --- /dev/null +++ b/docs/plans/2026-03-12-minimum-feedback-arc-set.md @@ -0,0 +1,85 @@ +# Plan: Add MinimumFeedbackArcSet Model (#213) + +## Overview + +Add the `MinimumFeedbackArcSet` problem model. This requires first adding a `DirectedGraph` topology type since the codebase currently only has undirected graph types. + +## Prerequisite: DirectedGraph topology type + +### Step P1: Create `src/topology/directed_graph.rs` + +- Create `DirectedGraph` struct wrapping petgraph's `DiGraph<(), ()>` +- Constructor: `new(num_vertices, arcs)` where arcs are `(usize, usize)` directed pairs +- Methods: `num_vertices()`, `num_arcs()`, `arcs()`, `has_arc(u, v)`, `successors(v)`, `predecessors(v)` +- Implement `Serialize`/`Deserialize`, `Clone`, `Debug`, `PartialEq`, `Eq` +- Register as `VariantParam` with category `"graph"` (no parent — directed graphs are not a subtype of undirected) +- NOTE: Do NOT implement the `Graph` trait (which is for undirected graphs). DirectedGraph has its own API. + +### Step P2: Register DirectedGraph in topology/mod.rs + +- Add `mod directed_graph;` and `pub use directed_graph::DirectedGraph;` + +### Step P3: Write unit tests for DirectedGraph + +- Create `src/unit_tests/topology/directed_graph.rs` +- Test construction, arc queries, successor/predecessor queries, serialization round-trip +- Link via `#[cfg(test)] #[path]` in the source file + +## Model Implementation (add-model steps) + +### Step 1: Create `src/models/graph/minimum_feedback_arc_set.rs` + +**Problem definition:** +- `MinimumFeedbackArcSet` where `G` is a directed graph type +- Single field: `graph: G` +- Optimization (Minimize), `Metric = SolutionSize` +- Variables: m = num_arcs binary variables (one per arc) +- `dims()` -> `vec![2; self.graph.num_arcs()]` +- `evaluate()`: check if selected arcs form a valid FAS (removing them makes DAG), return count +- Feasibility check: for each arc, if x_a=1 it's removed; verify remaining graph is acyclic (DFS-based cycle detection) +- `direction()` -> `Direction::Minimize` +- Getter methods: `num_vertices()`, `num_arcs()` + +**Complexity:** `2^num_vertices` (best known exact: DP over vertex subsets, O*(2^n)) + +### Step 2: Register in module hierarchy + +- `src/models/graph/mod.rs`: add `pub(crate) mod minimum_feedback_arc_set;` and `pub use` +- `src/models/mod.rs`: add to graph re-exports +- `src/lib.rs` prelude: add `MinimumFeedbackArcSet` + +### Step 3: Register variant complexity + +```rust +crate::declare_variants! { + MinimumFeedbackArcSet => "2^num_vertices", +} +``` + +### Step 4: Register in CLI + +- `problemreductions-cli/src/dispatch.rs`: add match arms in `load_problem()` and `serialize_any_problem()` +- `problemreductions-cli/src/problem_name.rs`: add `"minimumfeedbackarcset"` alias in `resolve_alias()` +- `problemreductions-cli/src/commands/create.rs`: add creation handler using `--arcs` flag for directed arc list +- `problemreductions-cli/src/cli.rs`: add `--arcs` flag to `CreateArgs`, update `all_data_flags_empty()`, update help table + +### Step 5: Write unit tests + +Create `src/unit_tests/models/graph/minimum_feedback_arc_set.rs`: +- `test_minimum_feedback_arc_set_creation`: construct instance, verify dimensions +- `test_minimum_feedback_arc_set_evaluation`: verify evaluate on valid/invalid configs +- `test_minimum_feedback_arc_set_direction`: verify Minimize +- `test_minimum_feedback_arc_set_solver`: verify brute-force finds correct solution +- Use the example from the issue: 6 vertices, 9 arcs, optimal FAS = {(0->1), (3->4)}, size 2 + +### Step 6: Document in paper + +Add problem-def entry to `docs/paper/reductions.typ`: +- Add `"MinimumFeedbackArcSet"` to `display-name` dict +- Write `#problem-def("MinimumFeedbackArcSet")[...]` with formal definition + +### Step 7: Verify + +```bash +make check # fmt + clippy + test +``` From d7c397daf8d3241ce63ed24f7ea980a82b813dab Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 15:25:40 +0000 Subject: [PATCH 2/8] feat: add MinimumFeedbackArcSet model with DirectedGraph topology (#213) Add the MinimumFeedbackArcSet problem model for finding minimum-size arc subsets whose removal makes a directed graph acyclic. This includes: - New DirectedGraph topology type (wrapping petgraph DiGraph) - MinimumFeedbackArcSet model with BruteForce solver support - Full CLI support (create, solve, serialize) with --arcs flag - Paper documentation with formal definition and example - Comprehensive unit tests (12 tests covering creation, evaluation, solver, serialization, and edge cases) Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 9 + docs/paper/references.bib | 22 +++ examples/detect_isolated_problems.rs | 3 +- problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 47 ++++- problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/problem_name.rs | 1 + src/lib.rs | 2 +- src/models/graph/minimum_feedback_arc_set.rs | 135 +++++++++++++ src/models/graph/mod.rs | 3 + src/models/mod.rs | 4 +- src/topology/directed_graph.rs | 173 +++++++++++++++++ src/topology/mod.rs | 3 + .../models/graph/minimum_feedback_arc_set.rs | 177 ++++++++++++++++++ src/unit_tests/topology/directed_graph.rs | 105 +++++++++++ 15 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 src/models/graph/minimum_feedback_arc_set.rs create mode 100644 src/topology/directed_graph.rs create mode 100644 src/unit_tests/models/graph/minimum_feedback_arc_set.rs create mode 100644 src/unit_tests/topology/directed_graph.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a30d1b73b..f973fa401 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -54,6 +54,7 @@ "BinPacking": [Bin Packing], "ClosestVectorProblem": [Closest Vector Problem], "SubsetSum": [Subset Sum], + "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], ) // Definition label: "def:" — each definition block must have a matching label @@ -956,6 +957,14 @@ Biclique Cover is equivalent to factoring the biadjacency matrix $M$ of the bipa *Example.* Let $A = {3, 7, 1, 8, 2, 4}$ ($n = 6$) and target $B = 11$. Selecting $A' = {3, 8}$ gives sum $3 + 8 = 11 = B$. Another solution: $A' = {7, 4}$ with sum $7 + 4 = 11 = B$. ] +#problem-def("MinimumFeedbackArcSet")[ + Given a directed graph $G = (V, A)$, find a minimum-size subset $A' subset.eq A$ such that $G - A'$ is a directed acyclic graph (DAG). Equivalently, $A'$ must contain at least one arc from every directed cycle in $G$. +][ + Feedback Arc Set (FAS) is a classical NP-complete problem from Karp's original list @karp1972 (via transformation from Vertex Cover, as presented in Garey & Johnson GT8). The problem arises in ranking aggregation, sports scheduling, deadlock avoidance, and causal inference. Unlike the undirected analogue (which is trivially polynomial --- the number of non-tree edges in a spanning forest), the directed version is NP-hard due to the richer structure of directed cycles. The best known exact algorithm uses dynamic programming over vertex subsets in $O^*(2^n)$ time (Held--Karp style DP over vertex orderings). FAS is fixed-parameter tractable with parameter $k = |A'|$: an $O(4^k dot k! dot n^(O(1)))$ algorithm exists via iterative compression @chen2008. Polynomial-time solvable for planar digraphs via the Lucchesi--Younger theorem @lucchesi1978. + + *Example.* Consider $G$ with $V = {0, 1, 2, 3, 4, 5}$ and arcs $(0 arrow 1), (1 arrow 2), (2 arrow 0), (1 arrow 3), (3 arrow 4), (4 arrow 1), (2 arrow 5), (5 arrow 3), (3 arrow 0)$. This graph contains four directed cycles: $0 arrow 1 arrow 2 arrow 0$, $1 arrow 3 arrow 4 arrow 1$, $0 arrow 1 arrow 3 arrow 0$, and $2 arrow 5 arrow 3 arrow 0 arrow 1 arrow 2$. Removing $A' = {(0 arrow 1), (3 arrow 4)}$ breaks all four cycles (vertex 0 becomes a sink in the residual graph), giving a minimum FAS of size 2. +] + // Completeness check: warn about problem types in JSON but missing from paper #{ let json-models = { diff --git a/docs/paper/references.bib b/docs/paper/references.bib index a68de5c4b..59754c615 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -437,3 +437,25 @@ @article{cygan2014 note = {Conference version: STOC 2014}, doi = {10.1137/140990255} } + +@article{chen2008, + author = {Jianer Chen and Yang Liu and Songjian Lu and Barry O'Sullivan and Igor Razgon}, + title = {A Fixed-Parameter Algorithm for the Directed Feedback Vertex Set Problem}, + journal = {Journal of the ACM}, + volume = {55}, + number = {5}, + pages = {1--19}, + year = {2008}, + doi = {10.1145/1411509.1411511} +} + +@article{lucchesi1978, + author = {Cl\'audio L. Lucchesi and Daniel H. Younger}, + title = {A Minimax Theorem for Directed Graphs}, + journal = {Journal of the London Mathematical Society}, + volume = {s2-17}, + number = {3}, + pages = {369--374}, + year = {1978}, + doi = {10.1112/jlms/s2-17.3.369} +} diff --git a/examples/detect_isolated_problems.rs b/examples/detect_isolated_problems.rs index 209b61b17..5e6c5c1e4 100644 --- a/examples/detect_isolated_problems.rs +++ b/examples/detect_isolated_problems.rs @@ -107,8 +107,7 @@ fn main() { let label = if v.is_empty() { name.to_string() } else { - let parts: Vec = - v.iter().map(|(k, val)| format!("{k}: {val}")).collect(); + let parts: Vec = v.iter().map(|(k, val)| format!("{k}: {val}")).collect(); format!("{name} {{{}}}", parts.join(", ")) }; if let Some(c) = graph.variant_complexity(name, v) { diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 44dfa163a..1a05817c8 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -217,6 +217,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] + MinimumFeedbackArcSet --arcs ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -327,6 +328,9 @@ pub struct CreateArgs { /// Variable bounds for CVP as "lower,upper" (e.g., "-10,10") [default: -10,10] #[arg(long, allow_hyphen_values = true)] pub bounds: Option, + /// Directed arcs for MinimumFeedbackArcSet (e.g., "0>1,1>2,2>0") + #[arg(long)] + pub arcs: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 06d460a08..ebcbad1de 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -10,7 +10,8 @@ use problemreductions::models::misc::{BinPacking, PaintShop}; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ - BipartiteGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, + BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, + UnitDiskGraph, }; use serde::Serialize; use std::collections::BTreeMap; @@ -46,6 +47,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.basis.is_none() && args.target_vec.is_none() && args.bounds.is_none() + && args.arcs.is_none() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -85,6 +87,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "Factoring" => "--target 15 --m 4 --n 4", + "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", _ => "", } } @@ -457,6 +460,28 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumFeedbackArcSet + "MinimumFeedbackArcSet" => { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MinimumFeedbackArcSet requires --arcs\n\n\ + Usage: pred create MinimumFeedbackArcSet --arcs \"0>1,1>2,2>0\"" + ) + })?; + let arcs = parse_directed_arcs(arcs_str)?; + let num_vertices = arcs + .iter() + .flat_map(|(u, v)| [*u, *v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let graph = DirectedGraph::new(num_vertices, arcs); + ( + ser(MinimumFeedbackArcSet::new(graph))?, + resolved_variant.clone(), + ) + } + _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), }; @@ -838,6 +863,26 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { .collect() } +/// Parse `--arcs` as directed arc pairs separated by commas, using `>` as separator. +/// E.g., "0>1,1>2,2>0" +fn parse_directed_arcs(arcs_str: &str) -> Result> { + arcs_str + .split(',') + .map(|pair| { + let parts: Vec<&str> = pair.trim().split('>').collect(); + if parts.len() != 2 { + bail!( + "Invalid arc '{}': expected format u>v (e.g., 0>1)", + pair.trim() + ); + } + let u: usize = parts[0].parse()?; + let v: usize = parts[1].parse()?; + Ok((u, v)) + }) + .collect() +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 75bce502d..6c6100143 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -247,6 +247,7 @@ pub fn load_problem( }, "Knapsack" => deser_opt::(data), "SubsetSum" => deser_sat::(data), + "MinimumFeedbackArcSet" => deser_opt::(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } @@ -309,6 +310,7 @@ pub fn serialize_any_problem( }, "Knapsack" => try_ser::(any), "SubsetSum" => try_ser::(any), + "MinimumFeedbackArcSet" => try_ser::(any), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 6baef3117..8974c1f7b 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -54,6 +54,7 @@ pub fn resolve_alias(input: &str) -> String { "cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(), "knapsack" => "Knapsack".to_string(), "subsetsum" => "SubsetSum".to_string(), + "minimumfeedbackarcset" => "MinimumFeedbackArcSet".to_string(), _ => input.to_string(), // pass-through for exact names } } diff --git a/src/lib.rs b/src/lib.rs index 599feefa5..e45c3342d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackArcSet, MinimumVertexCover, TravelingSalesman, }; pub use crate::models::misc::{BinPacking, Factoring, Knapsack, PaintShop, SubsetSum}; pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs new file mode 100644 index 000000000..306d05071 --- /dev/null +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -0,0 +1,135 @@ +//! Minimum Feedback Arc Set problem implementation. +//! +//! The Feedback Arc Set problem asks for a minimum-size subset of arcs +//! whose removal makes a directed graph acyclic (a DAG). + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumFeedbackArcSet", + module_path: module_path!(), + description: "Find minimum feedback arc set in a directed graph", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + ], + } +} + +/// The Minimum Feedback Arc Set problem. +/// +/// Given a directed graph G = (V, A), find a minimum-size subset A' ⊆ A +/// such that removing A' makes G acyclic (i.e., G - A' is a DAG). +/// +/// # Variables +/// +/// One binary variable per arc: x_a = 1 means arc a is in the feedback arc set (removed). +/// The configuration space has dimension m = |A|. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MinimumFeedbackArcSet; +/// use problemreductions::topology::DirectedGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Directed cycle: 0->1->2->0 +/// let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); +/// let problem = MinimumFeedbackArcSet::new(graph); +/// +/// // Solve with brute force +/// let solver = BruteForce::new(); +/// let solution = solver.find_best(&problem).unwrap(); +/// +/// // Minimum FAS has size 1 (remove any single arc to break the cycle) +/// assert_eq!(solution.iter().sum::(), 1); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumFeedbackArcSet { + /// The directed graph. + graph: DirectedGraph, +} + +impl MinimumFeedbackArcSet { + /// Create a Minimum Feedback Arc Set problem from a directed graph. + pub fn new(graph: DirectedGraph) -> Self { + Self { graph } + } + + /// Get a reference to the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the number of vertices in the directed graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the directed graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Check if a configuration is a valid feedback arc set. + /// + /// A configuration is valid if removing the selected arcs makes the graph acyclic. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_fas(&self.graph, config) + } +} + +impl Problem for MinimumFeedbackArcSet { + const NAME: &'static str = "MinimumFeedbackArcSet"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_arcs()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + if !is_valid_fas(&self.graph, config) { + return SolutionSize::Invalid; + } + let count = config.iter().filter(|&&x| x == 1).count() as i32; + SolutionSize::Valid(count) + } +} + +impl OptimizationProblem for MinimumFeedbackArcSet { + type Value = i32; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +/// Check if a configuration forms a valid feedback arc set. +/// +/// config[i] = 1 means arc i is selected for removal. +/// The remaining arcs must form a DAG. +fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { + let num_arcs = graph.num_arcs(); + if config.len() != num_arcs { + return false; + } + // kept_arcs[i] = true means arc i is NOT removed (kept in the graph) + let kept_arcs: Vec = config.iter().map(|&x| x == 0).collect(); + graph.is_acyclic_subgraph(&kept_arcs) +} + +crate::declare_variants! { + MinimumFeedbackArcSet => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/minimum_feedback_arc_set.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 2e7cb23e5..4e27170b5 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -13,6 +13,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs pub(crate) mod biclique_cover; pub(crate) mod graph_partitioning; @@ -23,6 +24,7 @@ pub(crate) mod maximum_clique; pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; +pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_vertex_cover; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -36,6 +38,7 @@ pub use maximum_clique::MaximumClique; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; +pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_vertex_cover::MinimumVertexCover; pub use spin_glass::SpinGlass; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/mod.rs b/src/models/mod.rs index ee441c20e..a28e7ab8a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,8 +13,8 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, - MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, - TravelingSalesman, + MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, + MinimumVertexCover, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs new file mode 100644 index 000000000..68be62ce7 --- /dev/null +++ b/src/topology/directed_graph.rs @@ -0,0 +1,173 @@ +//! Directed graph type for problems on digraphs. +//! +//! [`DirectedGraph`] wraps petgraph's `DiGraph` and provides a directed-graph API +//! with arcs (directed edges), successors, and predecessors. + +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::EdgeRef; +use serde::{Deserialize, Serialize}; + +/// A simple directed graph (digraph). +/// +/// Wraps petgraph's `DiGraph` and exposes a directed-edge API. +/// Unlike [`SimpleGraph`](super::SimpleGraph) which is undirected, +/// `DirectedGraph` distinguishes between arc (u, v) and arc (v, u). +/// +/// # Example +/// +/// ``` +/// use problemreductions::topology::DirectedGraph; +/// +/// let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); +/// assert_eq!(graph.num_vertices(), 3); +/// assert_eq!(graph.num_arcs(), 3); +/// assert!(graph.has_arc(0, 1)); +/// assert!(!graph.has_arc(1, 0)); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectedGraph { + inner: DiGraph<(), ()>, +} + +impl DirectedGraph { + /// Creates a new directed graph with the given vertices and arcs. + /// + /// # Arguments + /// + /// * `num_vertices` - Number of vertices in the graph + /// * `arcs` - List of directed arcs as (source, target) pairs + /// + /// # Panics + /// + /// Panics if any arc references a vertex index >= num_vertices. + pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>) -> Self { + let mut inner = DiGraph::new(); + for _ in 0..num_vertices { + inner.add_node(()); + } + for (u, v) in arcs { + assert!( + u < num_vertices && v < num_vertices, + "arc ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + inner.add_edge(NodeIndex::new(u), NodeIndex::new(v), ()); + } + Self { inner } + } + + /// Returns the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.inner.node_count() + } + + /// Returns the number of arcs (directed edges) in the graph. + pub fn num_arcs(&self) -> usize { + self.inner.edge_count() + } + + /// Returns all arcs as a list of (source, target) pairs. + pub fn arcs(&self) -> Vec<(usize, usize)> { + self.inner + .edge_references() + .map(|e| (e.source().index(), e.target().index())) + .collect() + } + + /// Checks if a directed arc exists from `u` to `v`. + pub fn has_arc(&self, u: usize, v: usize) -> bool { + self.inner + .find_edge(NodeIndex::new(u), NodeIndex::new(v)) + .is_some() + } + + /// Returns all successors of vertex `v` (vertices reachable by a single arc from `v`). + pub fn successors(&self, v: usize) -> Vec { + self.inner + .neighbors_directed(NodeIndex::new(v), petgraph::Direction::Outgoing) + .map(|n| n.index()) + .collect() + } + + /// Returns all predecessors of vertex `v` (vertices with an arc to `v`). + pub fn predecessors(&self, v: usize) -> Vec { + self.inner + .neighbors_directed(NodeIndex::new(v), petgraph::Direction::Incoming) + .map(|n| n.index()) + .collect() + } + + /// Returns the out-degree of vertex `v`. + pub fn out_degree(&self, v: usize) -> usize { + self.successors(v).len() + } + + /// Returns the in-degree of vertex `v`. + pub fn in_degree(&self, v: usize) -> usize { + self.predecessors(v).len() + } + + /// Returns true if the graph has no vertices. + pub fn is_empty(&self) -> bool { + self.num_vertices() == 0 + } + + /// Check if the subgraph induced by keeping only the given arcs is acyclic (a DAG). + /// + /// `kept_arcs` is a boolean slice of length `num_arcs()`, where `true` means the arc is kept. + pub fn is_acyclic_subgraph(&self, kept_arcs: &[bool]) -> bool { + let n = self.num_vertices(); + let arcs = self.arcs(); + + // Build adjacency list for the subgraph + let mut adj = vec![vec![]; n]; + let mut in_degree = vec![0usize; n]; + for (i, &(u, v)) in arcs.iter().enumerate() { + if kept_arcs[i] { + adj[u].push(v); + in_degree[v] += 1; + } + } + + // Kahn's algorithm (topological sort) + let mut queue: Vec = (0..n).filter(|&v| in_degree[v] == 0).collect(); + let mut visited = 0; + while let Some(u) = queue.pop() { + visited += 1; + for &v in &adj[u] { + in_degree[v] -= 1; + if in_degree[v] == 0 { + queue.push(v); + } + } + } + visited == n + } +} + +impl PartialEq for DirectedGraph { + fn eq(&self, other: &Self) -> bool { + if self.num_vertices() != other.num_vertices() { + return false; + } + if self.num_arcs() != other.num_arcs() { + return false; + } + let mut self_arcs = self.arcs(); + let mut other_arcs = other.arcs(); + self_arcs.sort(); + other_arcs.sort(); + self_arcs == other_arcs + } +} + +impl Eq for DirectedGraph {} + +use crate::impl_variant_param; +impl_variant_param!(DirectedGraph, "graph"); + +#[cfg(test)] +#[path = "../unit_tests/topology/directed_graph.rs"] +mod tests; diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 3e4e64b34..123b60a35 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -3,11 +3,13 @@ //! - [`SimpleGraph`]: Standard unweighted graph (default for most problems) //! - [`PlanarGraph`]: Planar graph //! - [`BipartiteGraph`]: Bipartite graph +//! - [`DirectedGraph`]: Directed graph (digraph) //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph mod bipartite_graph; +mod directed_graph; mod graph; mod kings_subgraph; mod planar_graph; @@ -16,6 +18,7 @@ mod triangular_subgraph; mod unit_disk_graph; pub use bipartite_graph::BipartiteGraph; +pub use directed_graph::DirectedGraph; pub use graph::{Graph, GraphCast, SimpleGraph}; pub use kings_subgraph::KingsSubgraph; pub use planar_graph::PlanarGraph; diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs new file mode 100644 index 000000000..549bf6ebf --- /dev/null +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +#[test] +fn test_minimum_feedback_arc_set_creation() { + // 6 vertices, 9 arcs (example from issue) + let graph = DirectedGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (1, 3), + (3, 4), + (4, 1), + (2, 5), + (5, 3), + (3, 0), + ], + ); + let problem = MinimumFeedbackArcSet::new(graph); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 9); + assert_eq!(problem.dims().len(), 9); + assert!(problem.dims().iter().all(|&d| d == 2)); +} + +#[test] +fn test_minimum_feedback_arc_set_direction() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_minimum_feedback_arc_set_evaluation_valid() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + + // Remove arc 2->0 (index 2) -> breaks the cycle + let config = vec![0, 0, 1]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); + + // Remove arc 0->1 (index 0) -> also breaks the cycle + let config = vec![1, 0, 0]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); + + // Remove all arcs -> valid (trivially acyclic), size 3 + let config = vec![1, 1, 1]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_evaluation_invalid() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + + // Remove no arcs -> cycle remains -> invalid + let config = vec![0, 0, 0]; + let result = problem.evaluate(&config); + assert!(!result.is_valid()); +} + +#[test] +fn test_minimum_feedback_arc_set_dag() { + // Already a DAG: 0->1->2 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumFeedbackArcSet::new(graph); + + // Remove no arcs -> already acyclic + let config = vec![0, 0]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 0); +} + +#[test] +fn test_minimum_feedback_arc_set_solver_simple_cycle() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + + let solutions = BruteForce::new().find_all_best(&problem); + // Minimum FAS has size 1 (remove any one arc) + for sol in &solutions { + assert_eq!(sol.iter().sum::(), 1); + } + // There are 3 optimal solutions (one for each arc) + assert_eq!(solutions.len(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_solver_issue_example() { + // Example from issue #213: 6 vertices, 9 arcs + let graph = DirectedGraph::new( + 6, + vec![ + (0, 1), // a0 + (1, 2), // a1 + (2, 0), // a2 + (1, 3), // a3 + (3, 4), // a4 + (4, 1), // a5 + (2, 5), // a6 + (5, 3), // a7 + (3, 0), // a8 + ], + ); + let problem = MinimumFeedbackArcSet::new(graph); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + // The optimal FAS has size 2 + let fas_size: usize = solution.iter().sum(); + assert_eq!(fas_size, 2); + + // Verify the solution is valid + assert!(problem.is_valid_solution(&solution)); +} + +#[test] +fn test_minimum_feedback_arc_set_is_valid_solution() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + + // Valid: remove one arc from the cycle + assert!(problem.is_valid_solution(&[0, 0, 1])); + // Invalid: keep all arcs (cycle remains) + assert!(!problem.is_valid_solution(&[0, 0, 0])); +} + +#[test] +fn test_minimum_feedback_arc_set_problem_name() { + assert_eq!( + ::NAME, + "MinimumFeedbackArcSet" + ); +} + +#[test] +fn test_minimum_feedback_arc_set_serialization() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: MinimumFeedbackArcSet = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.num_vertices(), 3); + assert_eq!(deserialized.num_arcs(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_two_disjoint_cycles() { + // Two disjoint cycles: 0->1->0 and 2->3->2 + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 0), (2, 3), (3, 2)]); + let problem = MinimumFeedbackArcSet::new(graph); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + // Need to remove at least one arc from each cycle -> size 2 + assert_eq!(solution.iter().sum::(), 2); +} + +#[test] +fn test_minimum_feedback_arc_set_size_getters() { + let graph = DirectedGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); + let problem = MinimumFeedbackArcSet::new(graph); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_arcs(), 5); +} diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs new file mode 100644 index 000000000..9bd987eec --- /dev/null +++ b/src/unit_tests/topology/directed_graph.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn test_directed_graph_creation() { + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + assert_eq!(graph.num_vertices(), 4); + assert_eq!(graph.num_arcs(), 3); +} + +#[test] +fn test_directed_graph_empty() { + let graph = DirectedGraph::new(3, vec![]); + assert_eq!(graph.num_vertices(), 3); + assert_eq!(graph.num_arcs(), 0); + assert!(!graph.is_empty()); + + let empty = DirectedGraph::new(0, vec![]); + assert!(empty.is_empty()); +} + +#[test] +fn test_directed_graph_has_arc() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + assert!(graph.has_arc(0, 1)); + assert!(graph.has_arc(1, 2)); + // Direction matters + assert!(!graph.has_arc(1, 0)); + assert!(!graph.has_arc(2, 1)); + assert!(!graph.has_arc(0, 2)); +} + +#[test] +fn test_directed_graph_arcs() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let mut arcs = graph.arcs(); + arcs.sort(); + assert_eq!(arcs, vec![(0, 1), (1, 2), (2, 0)]); +} + +#[test] +fn test_directed_graph_successors() { + let graph = DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3)]); + let mut succ = graph.successors(0); + succ.sort(); + assert_eq!(succ, vec![1, 2]); + assert_eq!(graph.successors(1), vec![3]); + assert!(graph.successors(3).is_empty()); +} + +#[test] +fn test_directed_graph_predecessors() { + let graph = DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 2)]); + let mut preds = graph.predecessors(2); + preds.sort(); + assert_eq!(preds, vec![0, 1]); + assert_eq!(graph.predecessors(0), Vec::::new()); +} + +#[test] +fn test_directed_graph_degrees() { + let graph = DirectedGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + assert_eq!(graph.out_degree(0), 2); + assert_eq!(graph.out_degree(1), 1); + assert_eq!(graph.out_degree(2), 0); + assert_eq!(graph.in_degree(0), 0); + assert_eq!(graph.in_degree(1), 1); + assert_eq!(graph.in_degree(2), 2); +} + +#[test] +fn test_directed_graph_is_acyclic_subgraph() { + // Cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + // Keep all arcs -> has cycle + assert!(!graph.is_acyclic_subgraph(&[true, true, true])); + // Remove arc 2->0 -> acyclic + assert!(graph.is_acyclic_subgraph(&[true, true, false])); + // Remove arc 0->1 -> acyclic + assert!(graph.is_acyclic_subgraph(&[false, true, true])); + // Keep no arcs -> trivially acyclic + assert!(graph.is_acyclic_subgraph(&[false, false, false])); +} + +#[test] +fn test_directed_graph_serialization() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let json = serde_json::to_string(&graph).unwrap(); + let deserialized: DirectedGraph = serde_json::from_str(&json).unwrap(); + assert_eq!(graph, deserialized); +} + +#[test] +fn test_directed_graph_equality() { + let g1 = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let g2 = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let g3 = DirectedGraph::new(3, vec![(1, 0), (1, 2)]); + assert_eq!(g1, g2); + assert_ne!(g1, g3); +} + +#[test] +#[should_panic(expected = "arc (3, 0) references vertex >= num_vertices (3)")] +fn test_directed_graph_invalid_arc() { + DirectedGraph::new(3, vec![(3, 0)]); +} From 18a0817e2ecf15b07c90d85ac59fcd75a8caca13 Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 15:25:45 +0000 Subject: [PATCH 3/8] chore: remove plan file after implementation --- .../2026-03-12-minimum-feedback-arc-set.md | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 docs/plans/2026-03-12-minimum-feedback-arc-set.md diff --git a/docs/plans/2026-03-12-minimum-feedback-arc-set.md b/docs/plans/2026-03-12-minimum-feedback-arc-set.md deleted file mode 100644 index c99199f07..000000000 --- a/docs/plans/2026-03-12-minimum-feedback-arc-set.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan: Add MinimumFeedbackArcSet Model (#213) - -## Overview - -Add the `MinimumFeedbackArcSet` problem model. This requires first adding a `DirectedGraph` topology type since the codebase currently only has undirected graph types. - -## Prerequisite: DirectedGraph topology type - -### Step P1: Create `src/topology/directed_graph.rs` - -- Create `DirectedGraph` struct wrapping petgraph's `DiGraph<(), ()>` -- Constructor: `new(num_vertices, arcs)` where arcs are `(usize, usize)` directed pairs -- Methods: `num_vertices()`, `num_arcs()`, `arcs()`, `has_arc(u, v)`, `successors(v)`, `predecessors(v)` -- Implement `Serialize`/`Deserialize`, `Clone`, `Debug`, `PartialEq`, `Eq` -- Register as `VariantParam` with category `"graph"` (no parent — directed graphs are not a subtype of undirected) -- NOTE: Do NOT implement the `Graph` trait (which is for undirected graphs). DirectedGraph has its own API. - -### Step P2: Register DirectedGraph in topology/mod.rs - -- Add `mod directed_graph;` and `pub use directed_graph::DirectedGraph;` - -### Step P3: Write unit tests for DirectedGraph - -- Create `src/unit_tests/topology/directed_graph.rs` -- Test construction, arc queries, successor/predecessor queries, serialization round-trip -- Link via `#[cfg(test)] #[path]` in the source file - -## Model Implementation (add-model steps) - -### Step 1: Create `src/models/graph/minimum_feedback_arc_set.rs` - -**Problem definition:** -- `MinimumFeedbackArcSet` where `G` is a directed graph type -- Single field: `graph: G` -- Optimization (Minimize), `Metric = SolutionSize` -- Variables: m = num_arcs binary variables (one per arc) -- `dims()` -> `vec![2; self.graph.num_arcs()]` -- `evaluate()`: check if selected arcs form a valid FAS (removing them makes DAG), return count -- Feasibility check: for each arc, if x_a=1 it's removed; verify remaining graph is acyclic (DFS-based cycle detection) -- `direction()` -> `Direction::Minimize` -- Getter methods: `num_vertices()`, `num_arcs()` - -**Complexity:** `2^num_vertices` (best known exact: DP over vertex subsets, O*(2^n)) - -### Step 2: Register in module hierarchy - -- `src/models/graph/mod.rs`: add `pub(crate) mod minimum_feedback_arc_set;` and `pub use` -- `src/models/mod.rs`: add to graph re-exports -- `src/lib.rs` prelude: add `MinimumFeedbackArcSet` - -### Step 3: Register variant complexity - -```rust -crate::declare_variants! { - MinimumFeedbackArcSet => "2^num_vertices", -} -``` - -### Step 4: Register in CLI - -- `problemreductions-cli/src/dispatch.rs`: add match arms in `load_problem()` and `serialize_any_problem()` -- `problemreductions-cli/src/problem_name.rs`: add `"minimumfeedbackarcset"` alias in `resolve_alias()` -- `problemreductions-cli/src/commands/create.rs`: add creation handler using `--arcs` flag for directed arc list -- `problemreductions-cli/src/cli.rs`: add `--arcs` flag to `CreateArgs`, update `all_data_flags_empty()`, update help table - -### Step 5: Write unit tests - -Create `src/unit_tests/models/graph/minimum_feedback_arc_set.rs`: -- `test_minimum_feedback_arc_set_creation`: construct instance, verify dimensions -- `test_minimum_feedback_arc_set_evaluation`: verify evaluate on valid/invalid configs -- `test_minimum_feedback_arc_set_direction`: verify Minimize -- `test_minimum_feedback_arc_set_solver`: verify brute-force finds correct solution -- Use the example from the issue: 6 vertices, 9 arcs, optimal FAS = {(0->1), (3->4)}, size 2 - -### Step 6: Document in paper - -Add problem-def entry to `docs/paper/reductions.typ`: -- Add `"MinimumFeedbackArcSet"` to `display-name` dict -- Write `#problem-def("MinimumFeedbackArcSet")[...]` with formal definition - -### Step 7: Verify - -```bash -make check # fmt + clippy + test -``` From 5c8b66f39071a95164f34bad714e2976a748d836 Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 15:29:53 +0000 Subject: [PATCH 4/8] test: add MinimumFeedbackArcSet to trait consistency tests Co-Authored-By: Claude Opus 4.6 --- src/unit_tests/trait_consistency.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 7eef06601..cbf8e7e83 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -3,7 +3,7 @@ use crate::models::formula::*; use crate::models::graph::*; use crate::models::misc::*; use crate::models::set::*; -use crate::topology::{BipartiteGraph, SimpleGraph}; +use crate::topology::{BipartiteGraph, DirectedGraph, SimpleGraph}; use crate::traits::Problem; use crate::variant::K3; @@ -83,6 +83,10 @@ fn test_all_problems_implement_trait_correctly() { BooleanExpr::constant(true), )]); check_problem_trait(&CircuitSAT::new(circuit), "CircuitSAT"); + check_problem_trait( + &MinimumFeedbackArcSet::new(DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])), + "MinimumFeedbackArcSet", + ); } #[test] @@ -124,6 +128,10 @@ fn test_direction() { BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1).direction(), Direction::Minimize ); + assert_eq!( + MinimumFeedbackArcSet::new(DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])).direction(), + Direction::Minimize + ); // Maximization problems assert_eq!( From 2d00d3820cf55f208f8b505386ca99525c485382 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 22:09:14 +0800 Subject: [PATCH 5/8] Address Copilot review comments - Fix evaluate/is_valid_fas consistency: use x != 0 for counting removed arcs - Fix complexity from 2^num_vertices to 2^num_arcs (matches config space) - Add length validation assert to is_acyclic_subgraph - Add DirectedGraph to type_format_hint and show --arcs in schema help Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 5 +++++ src/models/graph/minimum_feedback_arc_set.rs | 4 ++-- src/topology/directed_graph.rs | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 5e93b56c9..c91583b81 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -65,6 +65,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "u64" => "integer", "i64" => "integer", "Vec" => "comma-separated integers: 3,7,1,8", + "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", _ => "value", } } @@ -115,6 +116,10 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { if graph_type == Some("UnitDiskGraph") { eprintln!(" --{:<16} Distance threshold [default: 1.0]", "radius"); } + } else if field.type_name == "DirectedGraph" { + // DirectedGraph fields use --arcs, not --graph + let hint = type_format_hint(&field.type_name, graph_type); + eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); } else { let hint = type_format_hint(&field.type_name, graph_type); eprintln!( diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 306d05071..3ec31f9ae 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -99,7 +99,7 @@ impl Problem for MinimumFeedbackArcSet { if !is_valid_fas(&self.graph, config) { return SolutionSize::Invalid; } - let count = config.iter().filter(|&&x| x == 1).count() as i32; + let count = config.iter().filter(|&&x| x != 0).count() as i32; SolutionSize::Valid(count) } } @@ -127,7 +127,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - MinimumFeedbackArcSet => "2^num_vertices", + MinimumFeedbackArcSet => "2^num_arcs", } #[cfg(test)] diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs index 85fc4bd83..3fe0a011b 100644 --- a/src/topology/directed_graph.rs +++ b/src/topology/directed_graph.rs @@ -146,7 +146,16 @@ impl DirectedGraph { /// Check if the subgraph induced by keeping only the given arcs is acyclic (a DAG). /// /// `kept_arcs` is a boolean slice of length `num_arcs()`, where `true` means the arc is kept. + /// + /// # Panics + /// + /// Panics if `kept_arcs.len() != self.num_arcs()`. pub fn is_acyclic_subgraph(&self, kept_arcs: &[bool]) -> bool { + assert_eq!( + kept_arcs.len(), + self.num_arcs(), + "kept_arcs slice length must equal num_arcs" + ); let n = self.num_vertices(); let arcs = self.arcs(); From c804e5c3b1220811ef427f8cfa05fb39849179eb Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 22:17:12 +0800 Subject: [PATCH 6/8] Add FAS alias and --num-vertices support for MinimumFeedbackArcSet CLI - Add 'FAS' as a short alias for MinimumFeedbackArcSet (matching FVS pattern) - Support --num-vertices flag for creating graphs with isolated vertices - Update CLI help text Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 17 +++++++++++++++-- problemreductions-cli/src/problem_name.rs | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index e0014cce4..21c07888c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -219,7 +219,7 @@ Flags by problem type: BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] LCS --strings - MinimumFeedbackArcSet --arcs + FAS --arcs [--num-vertices] FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index c91583b81..2b5310bfe 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -513,16 +513,29 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let arcs_str = args.arcs.as_deref().ok_or_else(|| { anyhow::anyhow!( "MinimumFeedbackArcSet requires --arcs\n\n\ - Usage: pred create MinimumFeedbackArcSet --arcs \"0>1,1>2,2>0\"" + Usage: pred create FAS --arcs \"0>1,1>2,2>0\" [--num-vertices N]" ) })?; let arcs = parse_directed_arcs(arcs_str)?; - let num_vertices = arcs + let inferred_num_v = arcs .iter() .flat_map(|(u, v)| [*u, *v]) .max() .map(|m| m + 1) .unwrap_or(0); + let num_vertices = match args.num_vertices { + Some(user_num_v) => { + anyhow::ensure!( + user_num_v >= inferred_num_v, + "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", + user_num_v, + inferred_num_v, + inferred_num_v.saturating_sub(1), + ); + user_num_v + } + None => inferred_num_v, + }; let graph = DirectedGraph::new(num_vertices, arcs); ( ser(MinimumFeedbackArcSet::new(graph))?, diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 905f164fd..472d50c71 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -23,6 +23,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("LCS", "LongestCommonSubsequence"), ("MaxMatching", "MaximumMatching"), ("FVS", "MinimumFeedbackVertexSet"), + ("FAS", "MinimumFeedbackArcSet"), ]; /// Resolve a short alias to the canonical problem name. @@ -57,8 +58,8 @@ pub fn resolve_alias(input: &str) -> String { "knapsack" => "Knapsack".to_string(), "lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), + "fas" | "minimumfeedbackarcset" => "MinimumFeedbackArcSet".to_string(), "subsetsum" => "SubsetSum".to_string(), - "minimumfeedbackarcset" => "MinimumFeedbackArcSet".to_string(), _ => input.to_string(), // pass-through for exact names } } From c29bd4de6b5c7ad4fc2241f0197d1a4f8ed60886 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 23:28:11 +0800 Subject: [PATCH 7/8] Fix FAS complexity: 2^num_arcs -> 2^num_vertices (Bodlaender et al. 2012) The best known exact algorithm for Minimum Feedback Arc Set uses DP over vertex subsets in O*(2^n) time, not brute force over arcs. Add citation to Bodlaender, Fomin, Koster, Kratsch, Thilikos (2012) in both the paper and references.bib. Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 2 +- docs/paper/references.bib | 11 +++++++++++ src/models/graph/minimum_feedback_arc_set.rs | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 28f77ebb3..3f276f83a 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -1001,7 +1001,7 @@ Biclique Cover is equivalent to factoring the biadjacency matrix $M$ of the bipa #problem-def("MinimumFeedbackArcSet")[ Given a directed graph $G = (V, A)$, find a minimum-size subset $A' subset.eq A$ such that $G - A'$ is a directed acyclic graph (DAG). Equivalently, $A'$ must contain at least one arc from every directed cycle in $G$. ][ - Feedback Arc Set (FAS) is a classical NP-complete problem from Karp's original list @karp1972 (via transformation from Vertex Cover, as presented in Garey & Johnson GT8). The problem arises in ranking aggregation, sports scheduling, deadlock avoidance, and causal inference. Unlike the undirected analogue (which is trivially polynomial --- the number of non-tree edges in a spanning forest), the directed version is NP-hard due to the richer structure of directed cycles. The best known exact algorithm uses dynamic programming over vertex subsets in $O^*(2^n)$ time (Held--Karp style DP over vertex orderings). FAS is fixed-parameter tractable with parameter $k = |A'|$: an $O(4^k dot k! dot n^(O(1)))$ algorithm exists via iterative compression @chen2008. Polynomial-time solvable for planar digraphs via the Lucchesi--Younger theorem @lucchesi1978. + Feedback Arc Set (FAS) is a classical NP-complete problem from Karp's original list @karp1972 (via transformation from Vertex Cover, as presented in Garey & Johnson GT8). The problem arises in ranking aggregation, sports scheduling, deadlock avoidance, and causal inference. Unlike the undirected analogue (which is trivially polynomial --- the number of non-tree edges in a spanning forest), the directed version is NP-hard due to the richer structure of directed cycles. The best known exact algorithm uses dynamic programming over vertex subsets in $O^*(2^n)$ time, generalizing the Held--Karp TSP technique to vertex ordering problems @bodlaender2012. FAS is fixed-parameter tractable with parameter $k = |A'|$: an $O(4^k dot k! dot n^(O(1)))$ algorithm exists via iterative compression @chen2008. Polynomial-time solvable for planar digraphs via the Lucchesi--Younger theorem @lucchesi1978. *Example.* Consider $G$ with $V = {0, 1, 2, 3, 4, 5}$ and arcs $(0 arrow 1), (1 arrow 2), (2 arrow 0), (1 arrow 3), (3 arrow 4), (4 arrow 1), (2 arrow 5), (5 arrow 3), (3 arrow 0)$. This graph contains four directed cycles: $0 arrow 1 arrow 2 arrow 0$, $1 arrow 3 arrow 4 arrow 1$, $0 arrow 1 arrow 3 arrow 0$, and $2 arrow 5 arrow 3 arrow 0 arrow 1 arrow 2$. Removing $A' = {(0 arrow 1), (3 arrow 4)}$ breaks all four cycles (vertex 0 becomes a sink in the residual graph), giving a minimum FAS of size 2. ] diff --git a/docs/paper/references.bib b/docs/paper/references.bib index cc0dbb790..94dcd02f1 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -478,6 +478,17 @@ @article{cygan2014 doi = {10.1137/140990255} } +@article{bodlaender2012, + author = {Hans L. Bodlaender and Fedor V. Fomin and Arie M. C. A. Koster and Dieter Kratsch and Dimitrios M. Thilikos}, + title = {A Note on Exact Algorithms for Vertex Ordering Problems on Graphs}, + journal = {Theory of Computing Systems}, + volume = {50}, + number = {3}, + pages = {420--432}, + year = {2012}, + doi = {10.1007/s00224-011-9312-0} +} + @article{chen2008, author = {Jianer Chen and Yang Liu and Songjian Lu and Barry O'Sullivan and Igor Razgon}, title = {A Fixed-Parameter Algorithm for the Directed Feedback Vertex Set Problem}, diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 3ec31f9ae..6d5d2d01d 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -127,7 +127,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - MinimumFeedbackArcSet => "2^num_arcs", + MinimumFeedbackArcSet => "2^num_vertices", } #[cfg(test)] From c71f49c3a2821146ccc459259c7489cc4ca314ac Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 23:41:02 +0800 Subject: [PATCH 8/8] Add weight type parameter to MinimumFeedbackArcSet - Add generic W parameter matching FVS pattern (WeightElement + VariantParam) - Evaluate sums arc weights instead of counting (supports weighted FAS) - Add weights(), set_weights(), is_weighted() accessors - Deduplicate arc parsing: extract parse_directed_graph() shared by FAS/FVS - Add parse_arc_weights() for FAS --weights CLI support - Add weighted test case (high-weight arc avoided by solver) - Register MinimumFeedbackArcSet variant in declare_variants! - Update dispatch.rs to use MinimumFeedbackArcSet Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 122 +++++++++--------- problemreductions-cli/src/dispatch.rs | 4 +- src/lib.rs | 4 +- src/models/graph/minimum_feedback_arc_set.rs | 96 ++++++++++---- src/models/mod.rs | 3 +- .../models/graph/minimum_feedback_arc_set.rs | 55 ++++++-- src/unit_tests/trait_consistency.rs | 11 +- 8 files changed, 186 insertions(+), 111 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 21c07888c..6154557b1 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -219,7 +219,7 @@ Flags by problem type: BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] LCS --strings - FAS --arcs [--num-vertices] + FAS --arcs [--weights] [--num-vertices] FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2b5310bfe..692b3296b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -513,79 +513,27 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let arcs_str = args.arcs.as_deref().ok_or_else(|| { anyhow::anyhow!( "MinimumFeedbackArcSet requires --arcs\n\n\ - Usage: pred create FAS --arcs \"0>1,1>2,2>0\" [--num-vertices N]" + Usage: pred create FAS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" ) })?; - let arcs = parse_directed_arcs(arcs_str)?; - let inferred_num_v = arcs - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_vertices = match args.num_vertices { - Some(user_num_v) => { - anyhow::ensure!( - user_num_v >= inferred_num_v, - "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", - user_num_v, - inferred_num_v, - inferred_num_v.saturating_sub(1), - ); - user_num_v - } - None => inferred_num_v, - }; - let graph = DirectedGraph::new(num_vertices, arcs); + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let weights = parse_arc_weights(args, num_arcs)?; ( - ser(MinimumFeedbackArcSet::new(graph))?, + ser(MinimumFeedbackArcSet::new(graph, weights))?, resolved_variant.clone(), ) } // MinimumFeedbackVertexSet "MinimumFeedbackVertexSet" => { - let arcs_str = args.arcs.as_ref().ok_or_else(|| { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { anyhow::anyhow!( "MinimumFeedbackVertexSet requires --arcs\n\n\ Usage: pred create FVS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" ) })?; - let arcs: Vec<(usize, usize)> = arcs_str - .split(',') - .map(|s| { - let parts: Vec<&str> = s.split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid arc format '{}', expected 'u>v'", - s - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?; - let inferred_num_v = arcs - .iter() - .flat_map(|&(u, v)| [u, v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_v = match args.num_vertices { - Some(user_num_v) => { - anyhow::ensure!( - user_num_v >= inferred_num_v, - "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", - user_num_v, - inferred_num_v, - inferred_num_v.saturating_sub(1), - ); - user_num_v - } - None => inferred_num_v, - }; - let graph = DirectedGraph::new(num_v, arcs); + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + let num_v = graph.num_vertices(); let weights = parse_vertex_weights(args, num_v)?; ( ser(MinimumFeedbackVertexSet::new(graph, weights))?, @@ -974,10 +922,16 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { .collect() } -/// Parse `--arcs` as directed arc pairs separated by commas, using `>` as separator. +/// Parse `--arcs` as directed arc pairs and build a `DirectedGraph`. +/// +/// Returns `(graph, num_arcs)`. Infers vertex count from arc endpoints +/// unless `num_vertices` is provided (which must be >= inferred count). /// E.g., "0>1,1>2,2>0" -fn parse_directed_arcs(arcs_str: &str) -> Result> { - arcs_str +fn parse_directed_graph( + arcs_str: &str, + num_vertices: Option, +) -> Result<(DirectedGraph, usize)> { + let arcs: Vec<(usize, usize)> = arcs_str .split(',') .map(|pair| { let parts: Vec<&str> = pair.trim().split('>').collect(); @@ -991,7 +945,49 @@ fn parse_directed_arcs(arcs_str: &str) -> Result> { let v: usize = parts[1].parse()?; Ok((u, v)) }) - .collect() + .collect::>>()?; + let inferred_num_v = arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let num_v = match num_vertices { + Some(user_num_v) => { + anyhow::ensure!( + user_num_v >= inferred_num_v, + "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", + user_num_v, + inferred_num_v, + inferred_num_v.saturating_sub(1), + ); + user_num_v + } + None => inferred_num_v, + }; + let num_arcs = arcs.len(); + Ok((DirectedGraph::new(num_v, arcs), num_arcs)) +} + +/// Parse `--weights` as arc weights (i32), defaulting to all 1s. +fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.weights { + Some(w) => { + let weights: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if weights.len() != num_arcs { + bail!( + "Expected {} arc weights but got {}", + num_arcs, + weights.len() + ); + } + Ok(weights) + } + None => Ok(vec![1i32; num_arcs]), + } } /// Handle `pred create --random ...` diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 03ec5f9f0..82a995d94 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -249,7 +249,7 @@ pub fn load_problem( "LongestCommonSubsequence" => deser_opt::(data), "MinimumFeedbackVertexSet" => deser_opt::>(data), "SubsetSum" => deser_sat::(data), - "MinimumFeedbackArcSet" => deser_opt::(data), + "MinimumFeedbackArcSet" => deser_opt::>(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } @@ -314,7 +314,7 @@ pub fn serialize_any_problem( "LongestCommonSubsequence" => try_ser::(any), "MinimumFeedbackVertexSet" => try_ser::>(any), "SubsetSum" => try_ser::(any), - "MinimumFeedbackArcSet" => try_ser::(any), + "MinimumFeedbackArcSet" => try_ser::>(any), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } diff --git a/src/lib.rs b/src/lib.rs index fa99ffbfd..f0f487281 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,8 +43,8 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, - MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumVertexCover, + TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum, diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 6d5d2d01d..d7c92085f 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -1,29 +1,33 @@ //! Minimum Feedback Arc Set problem implementation. //! -//! The Feedback Arc Set problem asks for a minimum-size subset of arcs +//! The Feedback Arc Set problem asks for a minimum-weight subset of arcs //! whose removal makes a directed graph acyclic (a DAG). use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::{OptimizationProblem, Problem}; -use crate::types::{Direction, SolutionSize}; +use crate::types::{Direction, SolutionSize, WeightElement}; +use num_traits::Zero; use serde::{Deserialize, Serialize}; inventory::submit! { ProblemSchemaEntry { name: "MinimumFeedbackArcSet", module_path: module_path!(), - description: "Find minimum feedback arc set in a directed graph", + description: "Find minimum weight feedback arc set in a directed graph", fields: &[ FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w: A -> R" }, ], } } /// The Minimum Feedback Arc Set problem. /// -/// Given a directed graph G = (V, A), find a minimum-size subset A' ⊆ A -/// such that removing A' makes G acyclic (i.e., G - A' is a DAG). +/// Given a directed graph G = (V, A) and weights w_a for each arc, +/// find a subset A' ⊆ A such that: +/// - Removing A' from G yields a directed acyclic graph (DAG) +/// - The total weight Σ_{a ∈ A'} w_a is minimized /// /// # Variables /// @@ -39,7 +43,7 @@ inventory::submit! { /// /// // Directed cycle: 0->1->2->0 /// let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); -/// let problem = MinimumFeedbackArcSet::new(graph); +/// let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); /// /// // Solve with brute force /// let solver = BruteForce::new(); @@ -49,15 +53,22 @@ inventory::submit! { /// assert_eq!(solution.iter().sum::(), 1); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MinimumFeedbackArcSet { +pub struct MinimumFeedbackArcSet { /// The directed graph. graph: DirectedGraph, + /// Weights for each arc. + weights: Vec, } -impl MinimumFeedbackArcSet { - /// Create a Minimum Feedback Arc Set problem from a directed graph. - pub fn new(graph: DirectedGraph) -> Self { - Self { graph } +impl MinimumFeedbackArcSet { + /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. + pub fn new(graph: DirectedGraph, weights: Vec) -> Self { + assert_eq!( + weights.len(), + graph.num_arcs(), + "weights length must match graph num_arcs" + ); + Self { graph, weights } } /// Get a reference to the underlying directed graph. @@ -65,14 +76,19 @@ impl MinimumFeedbackArcSet { &self.graph } - /// Get the number of vertices in the directed graph. - pub fn num_vertices(&self) -> usize { - self.graph.num_vertices() + /// Get a reference to the weights slice. + pub fn weights(&self) -> &[W] { + &self.weights } - /// Get the number of arcs in the directed graph. - pub fn num_arcs(&self) -> usize { - self.graph.num_arcs() + /// Set arc weights. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!( + weights.len(), + self.graph.num_arcs(), + "weights length must match graph num_arcs" + ); + self.weights = weights; } /// Check if a configuration is a valid feedback arc set. @@ -83,29 +99,57 @@ impl MinimumFeedbackArcSet { } } -impl Problem for MinimumFeedbackArcSet { +impl MinimumFeedbackArcSet { + /// Check if the problem has non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices in the directed graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the directed graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } +} + +impl Problem for MinimumFeedbackArcSet +where + W: WeightElement + crate::variant::VariantParam, +{ const NAME: &'static str = "MinimumFeedbackArcSet"; - type Metric = SolutionSize; + type Metric = SolutionSize; fn variant() -> Vec<(&'static str, &'static str)> { - crate::variant_params![] + crate::variant_params![W] } fn dims(&self) -> Vec { vec![2; self.graph.num_arcs()] } - fn evaluate(&self, config: &[usize]) -> SolutionSize { + fn evaluate(&self, config: &[usize]) -> SolutionSize { if !is_valid_fas(&self.graph, config) { return SolutionSize::Invalid; } - let count = config.iter().filter(|&&x| x != 0).count() as i32; - SolutionSize::Valid(count) + let mut total = W::Sum::zero(); + for (i, &selected) in config.iter().enumerate() { + if selected != 0 { + total += self.weights[i].to_sum(); + } + } + SolutionSize::Valid(total) } } -impl OptimizationProblem for MinimumFeedbackArcSet { - type Value = i32; +impl OptimizationProblem for MinimumFeedbackArcSet +where + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; fn direction(&self) -> Direction { Direction::Minimize @@ -127,7 +171,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - MinimumFeedbackArcSet => "2^num_vertices", + MinimumFeedbackArcSet => "2^num_vertices", } #[cfg(test)] diff --git a/src/models/mod.rs b/src/models/mod.rs index 5f38415bd..742b92872 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,8 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, - MinimumFeedbackVertexSet, - MinimumVertexCover, SpinGlass, TravelingSalesman, + MinimumFeedbackVertexSet, MinimumVertexCover, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs index 549bf6ebf..09dd95f75 100644 --- a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -21,7 +21,7 @@ fn test_minimum_feedback_arc_set_creation() { (3, 0), ], ); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 9]); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 9); assert_eq!(problem.dims().len(), 9); @@ -31,7 +31,7 @@ fn test_minimum_feedback_arc_set_creation() { #[test] fn test_minimum_feedback_arc_set_direction() { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); assert_eq!(problem.direction(), Direction::Minimize); } @@ -39,7 +39,7 @@ fn test_minimum_feedback_arc_set_direction() { fn test_minimum_feedback_arc_set_evaluation_valid() { // Simple cycle: 0->1->2->0 let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); // Remove arc 2->0 (index 2) -> breaks the cycle let config = vec![0, 0, 1]; @@ -64,7 +64,7 @@ fn test_minimum_feedback_arc_set_evaluation_valid() { fn test_minimum_feedback_arc_set_evaluation_invalid() { // Simple cycle: 0->1->2->0 let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); // Remove no arcs -> cycle remains -> invalid let config = vec![0, 0, 0]; @@ -76,7 +76,7 @@ fn test_minimum_feedback_arc_set_evaluation_invalid() { fn test_minimum_feedback_arc_set_dag() { // Already a DAG: 0->1->2 let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 2]); // Remove no arcs -> already acyclic let config = vec![0, 0]; @@ -89,7 +89,7 @@ fn test_minimum_feedback_arc_set_dag() { fn test_minimum_feedback_arc_set_solver_simple_cycle() { // Simple cycle: 0->1->2->0 let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); let solutions = BruteForce::new().find_all_best(&problem); // Minimum FAS has size 1 (remove any one arc) @@ -117,7 +117,7 @@ fn test_minimum_feedback_arc_set_solver_issue_example() { (3, 0), // a8 ], ); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 9]); let solution = BruteForce::new().find_best(&problem).unwrap(); // The optimal FAS has size 2 @@ -128,10 +128,27 @@ fn test_minimum_feedback_arc_set_solver_issue_example() { assert!(problem.is_valid_solution(&solution)); } +#[test] +fn test_minimum_feedback_arc_set_weighted() { + // Cycle: 0->1->2->0 with weights [10, 1, 1] + // Arc 0 (0->1) costs 10, arcs 1,2 cost 1 each + // Optimal: remove arc 1 or arc 2 (cost 1), NOT arc 0 (cost 10) + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![10i32, 1, 1]); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + let result = problem.evaluate(&solution); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); // should pick a cheap arc + + // Arc 0 should NOT be selected (too expensive) + assert_eq!(solution[0], 0); +} + #[test] fn test_minimum_feedback_arc_set_is_valid_solution() { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); // Valid: remove one arc from the cycle assert!(problem.is_valid_solution(&[0, 0, 1])); @@ -142,7 +159,7 @@ fn test_minimum_feedback_arc_set_is_valid_solution() { #[test] fn test_minimum_feedback_arc_set_problem_name() { assert_eq!( - ::NAME, + as Problem>::NAME, "MinimumFeedbackArcSet" ); } @@ -150,9 +167,9 @@ fn test_minimum_feedback_arc_set_problem_name() { #[test] fn test_minimum_feedback_arc_set_serialization() { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); let json = serde_json::to_string(&problem).unwrap(); - let deserialized: MinimumFeedbackArcSet = serde_json::from_str(&json).unwrap(); + let deserialized: MinimumFeedbackArcSet = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.num_vertices(), 3); assert_eq!(deserialized.num_arcs(), 3); } @@ -161,7 +178,7 @@ fn test_minimum_feedback_arc_set_serialization() { fn test_minimum_feedback_arc_set_two_disjoint_cycles() { // Two disjoint cycles: 0->1->0 and 2->3->2 let graph = DirectedGraph::new(4, vec![(0, 1), (1, 0), (2, 3), (3, 2)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 4]); let solution = BruteForce::new().find_best(&problem).unwrap(); // Need to remove at least one arc from each cycle -> size 2 @@ -171,7 +188,19 @@ fn test_minimum_feedback_arc_set_two_disjoint_cycles() { #[test] fn test_minimum_feedback_arc_set_size_getters() { let graph = DirectedGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); - let problem = MinimumFeedbackArcSet::new(graph); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 5]); assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_arcs(), 5); } + +#[test] +fn test_minimum_feedback_arc_set_accessors() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let mut problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + assert!(problem.is_weighted()); // i32 type → true + assert_eq!(problem.weights(), &[1, 1, 1]); + + problem.set_weights(vec![2, 3, 4]); + assert_eq!(problem.weights(), &[2, 3, 4]); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index cbf8e7e83..a90872ca8 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -84,7 +84,10 @@ fn test_all_problems_implement_trait_correctly() { )]); check_problem_trait(&CircuitSAT::new(circuit), "CircuitSAT"); check_problem_trait( - &MinimumFeedbackArcSet::new(DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])), + &MinimumFeedbackArcSet::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3], + ), "MinimumFeedbackArcSet", ); } @@ -129,7 +132,11 @@ fn test_direction() { Direction::Minimize ); assert_eq!( - MinimumFeedbackArcSet::new(DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])).direction(), + MinimumFeedbackArcSet::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3] + ) + .direction(), Direction::Minimize );