Repository: xgi-org/xgi

Issue:
Consistency in dealing with empty edges
Currently, some XGI methods support empty edges and some do not.

Ones that do:
* `dual()`: the dual of a hypergraph with isolated nodes must have empty edges.
* `add_edges_from()` so long as the empty edges is not first and if it is, it raises an IndexError as it tries to access `list(first_edge)[0]` (in the case of a list of sets)

Ones that do not:
* `add_edge()`: this raises an XGIError for an empty edge.
* `remove_nodes_from()`, `remove_node()`, and `remove_node_from_edge()`: these functions delete edges if removing a node creates an empty edge.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/xgi-org/xgi/pull/565?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org) Report
Attention: Patch coverage is `93.75000%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 92.52%. Comparing base [(`40b2d9f`)](https://app.codecov.io/gh/xgi-org/xgi/commit/40b2d9f68ad588c187bc63d27ccd1c42dadeebeb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org) to head [(`9e8dfd4`)](https://app.codecov.io/gh/xgi-org/xgi/commit/9e8dfd4812bf8b2811620e72624d1b7a4ec1d199?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/xgi-org/xgi/pull/565?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org) | Patch % | Lines |
|---|---|---|
| [xgi/core/hypergraph.py](https://app.codecov.io/gh/xgi-org/xgi/pull/565?src=pr&el=tree&filepath=xgi%2Fcore%2Fhypergraph.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org#diff-eGdpL2NvcmUvaHlwZXJncmFwaC5weQ==) | 90.00% | [1 Missing :warning: ](https://app.codecov.io/gh/xgi-org/xgi/pull/565?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #565      +/-   ##
==========================================
+ Coverage   92.51%   92.52%   +0.01%     
==========================================
  Files          59       59              
  Lines        4393     4388       -5     
==========================================
- Hits         4064     4060       -4     
+ Misses        329      328       -1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/xgi-org/xgi/pull/565?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xgi-org).

nwlandry: @tlarock and @thomasrobiglio --- let me know if you have the bandwidth to review this. The URL workflow fails, but this is corrected in an already-merged PR.
thomasrobiglio: Hi @nwlandry, thank you for this. I am offline for these two weeks, I will review once I'm back.
nwlandry: Enjoy your vacation! That sounds good.
nwlandry: @thomasrobiglio --- are you still able to review this? No worries if not!

Patch:
diff --git a/xgi/core/dihypergraph.py b/xgi/core/dihypergraph.py
index 2c70879..1542c35 100644
--- a/xgi/core/dihypergraph.py
+++ b/xgi/core/dihypergraph.py
@@ -363,7 +363,7 @@ class DiHypergraph:
                 self._node_attr[n] = self._node_attr_dict_factory()
             self._node_attr[n].update(newdict)
 
-    def remove_node(self, n, strong=False):
+    def remove_node(self, n, strong=False, remove_empty=True):
         """Remove a single node.
 
         The removal may be weak (default) or strong.  In weak removal, the node is
@@ -375,9 +375,11 @@ class DiHypergraph:
         ----------
         n : node
             A node in the dihypergraph
-
         strong : bool (default False)
             Whether to execute weak or strong removal.
+        remove_empty : bool, optional
+            Whether to remove empty edges (0 members in both head and tail).
+            By default, True.
 
         Raises
         ------
@@ -406,17 +408,26 @@ class DiHypergraph:
 
             # remove empty edges
             for edge in edge_neighbors["in"].union(edge_neighbors["out"]):
-                if not self._edge[edge]["in"] and not self._edge[edge]["out"]:
+                if (
+                    not self._edge[edge]["in"]
+                    and not self._edge[edge]["out"]
+                    and remove_empty
+                ):
                     del self._edge[edge]
                     del self._edge_attr[edge]
 
-    def remove_nodes_from(self, nodes):
+    def remove_nodes_from(self, nodes, strong=False, remove_empty=True):
         """Remove multiple nodes.
 
         Parameters
         ----------
         nodes : iterable
             An iterable of nodes.
+        strong : bool (default False)
+            Whether to execute weak or strong removal.
+        remove_empty : bool, optional
+            Whether to remove empty edges (0 members in both head and tail).
+            By default, True.
 
         See Also
         --------
@@ -427,7 +438,7 @@ class DiHypergraph:
             if n not in self._node:
                 warn(f"Node {n} not in dihypergraph")
                 continue
-            self.remove_node(n)
+            self.remove_node(n, strong=strong, remove_empty=remove_empty)
 
     def set_node_attributes(self, values, name=None):
         """Sets node attributes from a given value or dictionary of values.
@@ -506,11 +517,6 @@ class DiHypergraph:
         **attr : dict, optional
             Attributes of the new edge.
 
-        Raises
-        -----
-        XGIError
-            If `members` is empty or is not a list or tuple.
-
         See Also
         --------
         add_edges_from : Add a collection of edges.
@@ -525,18 +531,12 @@ class DiHypergraph:
         >>> DH.add_edge(([1, 2, 3], [2, 3, 4]))
         >>> DH.add_edge(([3, 4], set()), id='myedge')
         """
-        if not members:
-            raise XGIError("Cannot add an empty edge")
-
         if isinstance(members, (tuple, list)):
             tail = members[0]
             head = members[1]
         else:
             raise XGIError("Directed edge must be a list or tuple!")
 
-        if not head and not tail:
-            raise XGIError("Cannot add an empty edge")
-
         uid = next(self._edge_uid) if id is None else id
 
         if id in self._edge.keys():  # check that uid is not present yet
@@ -969,11 +969,8 @@ class DiHypergraph:
         self.remove_nodes_from = frozen
         self.add_edge = frozen
         self.add_edges_from = frozen
-        self.add_weighted_edges_from = frozen
         self.remove_edge = frozen
         self.remove_edges_from = frozen
-        self.add_node_to_edge = frozen
-        self.remove_node_from_edge = frozen
         self.clear = frozen
         self.frozen = True
 
diff --git a/xgi/core/hypergraph.py b/xgi/core/hypergraph.py
index c0e2ff0..a712235 100644
--- a/xgi/core/hypergraph.py
+++ b/xgi/core/hypergraph.py
@@ -405,7 +405,7 @@ class Hypergraph:
                 self._node_attr[n] = self._node_attr_dict_factory()
             self._node_attr[n].update(newdict)
 
-    def remove_node(self, n, strong=False):
+    def remove_node(self, n, strong=False, remove_empty=True):
         """Remove a single node.
 
         The removal may be weak (default) or strong.  In weak removal, the node is
@@ -417,9 +417,10 @@ class Hypergraph:
         ----------
         n : node
             A node in the hypergraph
-
         strong : bool, optional
             Whether to execute weak or strong removal. By default, False.
+        remove_empty : bool, optional
+            Whether to remove empty edges. By default, True.
 
         Raises
         ------
@@ -445,17 +446,21 @@ class Hypergraph:
         else:  # weak removal
             for edge in edge_neighbors:
                 self._edge[edge].remove(n)
-                if not self._edge[edge]:
+                if not self._edge[edge] and remove_empty:
                     del self._edge[edge]
                     del self._edge_attr[edge]
 
-    def remove_nodes_from(self, nodes):
+    def remove_nodes_from(self, nodes, strong=False, remove_empty=True):
         """Remove multiple nodes.
 
         Parameters
         ----------
         nodes : iterable
             An iterable of nodes.
+        strong : bool, optional
+            Whether to execute weak or strong removal. By default, False.
+        remove_empty : bool, optional
+            Whether to remove empty edges. By default, True.
 
         See Also
         --------
@@ -466,7 +471,7 @@ class Hypergraph:
             if n not in self:
                 warn(f"Node {n} not in hypergraph")
                 continue
-            self.remove_node(n)
+            self.remove_node(n, strong=strong, remove_empty=remove_empty)
 
     def set_node_attributes(self, values, name=None):
         """Sets node attributes from a given value or dictionary of values.
@@ -577,8 +582,6 @@ class Hypergraph:
 
         """
         members = set(members)
-        if not members:
-            raise XGIError("Cannot add an empty edge")
 
         if id in self._edge.keys():  # check that uid is not present yet
             warn(f"uid {id} already exists, cannot add edge {members}")
@@ -1127,7 +1130,7 @@ class Hypergraph:
             del self._edge[id]
             del self._edge_attr[id]
 
-    def remove_node_from_edge(self, edge, node):
+    def remove_node_from_edge(self, edge, node, remove_empty=True):
         """Remove a node from an existing edge.
 
         Parameters
@@ -1136,6 +1139,8 @@ class Hypergraph:
             The edge ID
         node : hashable
             The node ID
+        remove_empty : bool, optional
+            Whether empty edges are removed. By default, True.
 
         Raises
         ------
@@ -1154,21 +1159,18 @@ class Hypergraph:
         removed.
 
         """
-        try:
-            self._node[node].remove(edge)
-        except KeyError as e:
-            raise XGIError(f"Node {node} not in the hypergraph") from e
-        except ValueError as e:
-            raise XGIError(f"Node {node} not in edge {edge}") from e
-
-        try:
+        if edge not in self._edge:
+            raise XGIError(f"Edge {edge} not in the hypergraph")
+        elif node not in self._node:
+            raise XGIError(f"Node {node} not in the hypergraph")
+        elif node not in self._edge[edge]:
+            raise XGIError(f"Edge {edge} does not contain node {node}")
+        else:
             self._edge[edge].remove(node)
-        except KeyError as e:
-            raise XGIError(f"Edge {edge} not in the hypergraph") from e
-        except ValueError as e:
-            raise XGIError(f"Edge {edge} does not contain node {node}") from e
 
-        if not self._edge[edge]:
+        self._node[node].remove(edge)
+
+        if not self._edge[edge] and remove_empty:
             del self._edge[edge]
             del self._edge_attr[edge]
 
diff --git a/xgi/core/simplicialcomplex.py b/xgi/core/simplicialcomplex.py
index ab9417c..6198099 100644
--- a/xgi/core/simplicialcomplex.py
+++ b/xgi/core/simplicialcomplex.py
@@ -268,11 +268,6 @@ class SimplicialComplex(Hypergraph):
         **attr : dict, optional
             Attributes of the new simplex.
 
-        Raises
-        -----
-        XGIError
-            If `members` is empty.
-
         See Also
         --------
         add_simplices_from : Add a collection of simplices.
@@ -313,9 +308,6 @@ class SimplicialComplex(Hypergraph):
         except TypeError:
             raise XGIError("The simplex cannot be cast to a frozenset.")
 
-        if not members:
-            raise XGIError("Cannot add an empty edge")
-
         if self.has_simplex(members):
             return
 
diff --git a/xgi/core/views.py b/xgi/core/views.py
index c0bb88a..e03a030 100644
--- a/xgi/core/views.py
+++ b/xgi/core/views.py
@@ -721,6 +721,20 @@ class EdgeView(IDView):
         """
         return self.filterby("size", 1)
 
+    def empty(self):
+        """Edges that contain no nodes.
+
+        Returns
+        -------
+        EdgeView containing the empty edges.
+
+        See Also
+        --------
+        :meth:`NodeView.isolates`
+
+        """
+        return self.filterby("size", 0)
+
     def maximal(self, strict=False):
         """Returns the maximal edges as an EdgeView.
 


Test patch:
diff --git a/tests/core/test_dihypergraph.py b/tests/core/test_dihypergraph.py
index c9c686f..f685ab6 100644
--- a/tests/core/test_dihypergraph.py
+++ b/tests/core/test_dihypergraph.py
@@ -127,15 +127,12 @@ def test_add_edge_accepts_different_types():
         assert H.edges.head(dtype=dict) == {0: {4}}
 
 
-def test_add_edge_raises_with_empty_edges():
+def test_add_empty_edges():
     H = xgi.DiHypergraph()
-    for edge in [[], {}, iter([])]:
-        with pytest.raises(XGIError):
-            H.add_edge(edge)
-
     for edge in [[[], []], (set(), set())]:
-        with pytest.raises(XGIError):
-            H.add_edge(edge)
+        H.add_edge(edge)
+
+    assert H.edges.size.asdict() == {0: 0, 1: 0}
 
 
 def test_add_edge_rejects_set():
@@ -415,7 +412,14 @@ def test_remove_node_weak(diedgelist1, diedgelist2):
     H.remove_node(3)
     H.remove_node(4)
 
-    assert 0 not in H.edges
+    # test keeping empty edges
+    H = xgi.DiHypergraph(diedgelist1)
+    H.remove_node(1)
+    H.remove_node(2)
+    H.remove_node(3)
+    H.remove_node(4, remove_empty=False)
+    assert 0 in H.edges
+    assert H.edges.size[0] == 0
 
     # test multiple edge removal with a single node.
     H = xgi.DiHypergraph(diedgelist2)
@@ -456,9 +460,9 @@ def test_remove_node_strong(diedgelist1):
     # node in both head and tail
     assert 6 in H
     H.remove_node(6, strong=True)
-    # assert 6 not in H
+    assert 6 not in H
 
-    # assert 1 not in H.edges
+    assert 1 not in H.edges
 
 
 def test_remove_nodes_from(diedgelist1):
@@ -470,6 +474,16 @@ def test_remove_nodes_from(diedgelist1):
     with pytest.warns(Warning):
         H.remove_nodes_from([1, 2, 3])
 
+    H = xgi.DiHypergraph(diedgelist1)
+
+    H.remove_nodes_from([1, 5], strong=True)
+    assert len(H.edges) == 0
+
+    H = xgi.DiHypergraph(diedgelist1)
+    H.remove_nodes_from([1, 2, 3, 4], remove_empty=False)
+    assert 0 in H.edges
+    assert H.edges.size[0] == 0
+
 
 def test_pickle(diedgelist1):
     _, filename = tempfile.mkstemp()
diff --git a/tests/core/test_hypergraph.py b/tests/core/test_hypergraph.py
index dfdaf65..3f55e15 100644
--- a/tests/core/test_hypergraph.py
+++ b/tests/core/test_hypergraph.py
@@ -181,10 +181,12 @@ def test_add_edge():
         assert {1, 2, 3} == H.edges.members(0)
         assert H.edges.members(dtype=dict) == {0: {1, 2, 3}}
 
+    # test adding empty edges
     H = xgi.Hypergraph()
     for edge in [[], set(), iter([])]:
-        with pytest.raises(XGIError):
-            H.add_edge(edge)
+        H.add_edge(edge)
+
+    assert H.edges.size.asdict() == {0: 0, 1: 0, 2: 0}
 
     # check that uid works correctly
     H1 = xgi.Hypergraph()
@@ -236,6 +238,41 @@ def test_add_node_to_edge():
     }
 
 
+def test_remove_node_from_edge(edgelist1):
+    H = xgi.Hypergraph(edgelist1)
+
+    # test non-existent node
+    with pytest.raises(XGIError):
+        H.remove_node_from_edge(1, 1000)
+
+    # test non-existent edge
+    with pytest.raises(XGIError):
+        H.remove_node_from_edge(1000, 1)
+
+    # test node which exists, but not in the edge
+    with pytest.raises(XGIError):
+        H.remove_node_from_edge(1, 1)
+
+    H.remove_node_from_edge(0, 1)
+    assert 1 not in H.edges.members(0)
+
+    with pytest.raises(XGIError):
+        H.remove_node_from_edge(0, 1)
+
+    H.remove_node_from_edge(0, 2)
+    H.remove_node_from_edge(0, 3)
+
+    assert 0 not in H.edges
+
+    # test leaving empty edges
+    H = xgi.Hypergraph(edgelist1)
+    H.remove_node_from_edge(0, 1)
+    H.remove_node_from_edge(0, 2)
+    H.remove_node_from_edge(0, 3, remove_empty=False)
+    assert 0 in H.edges
+    assert H.edges.members(0) == set()
+
+
 def test_add_edges_from_iterable_of_members():
     edges = [{0, 1}, {1, 2}, {2, 3, 4}]
     H = xgi.Hypergraph()
@@ -583,6 +620,15 @@ def test_remove_node_weak(edgelist1):
     with pytest.raises(IDNotFound):
         H.remove_node(10)
 
+    # test keeping empty edges
+    H = xgi.Hypergraph(edgelist1)
+    H.remove_node(1)
+    H.remove_node(2)
+    H.remove_node(3, remove_empty=False)
+    H.remove_node(4, remove_empty=False)
+    assert 0 in H.edges and 1 in H.edges
+    assert H.edges.size[0] == 0 and H.edges.size[1] == 0
+
 
 def test_remove_node_strong(edgelist1):
     H = xgi.Hypergraph(edgelist1)
@@ -592,6 +638,27 @@ def test_remove_node_strong(edgelist1):
     assert 0 not in H.edges
 
 
+def test_remove_nodes_from(edgelist1):
+    H = xgi.Hypergraph(edgelist1)
+
+    H.remove_nodes_from([1, 2, 3])
+    assert 1 not in H and 2 not in H and 3 not in H
+    assert 0 not in H.edges
+
+    with pytest.warns(Warning):
+        H.remove_nodes_from([1, 2, 3])
+
+    H = xgi.Hypergraph(edgelist1)
+
+    H.remove_nodes_from([1, 4], strong=True)
+    assert 0 not in H.edges and 1 not in H.edges
+
+    H = xgi.Hypergraph(edgelist1)
+    H.remove_nodes_from([1, 2, 3, 4], remove_empty=False)
+    assert 0 in H.edges and 1 in H.edges
+    assert H.edges.size[0] == 0 and H.edges.size[1] == 0
+
+
 def test_issue_445(edgelist1):
     H = xgi.Hypergraph(edgelist1)
     assert 1 in H