Skip to content

Commit

Permalink
Allow empty edges (#565)
Browse files Browse the repository at this point in the history
* add the ability to retain empty edges with node removal.

* allow empty edges in dihypergraphs

* add strong keyword

* Allow SCs to have empty edges

* Update simplicialcomplex.py

* fix tests

* added empty edges to cleanup

* fix failing tests

* fix failing test

* added unit tests

* Response to review.

* Fix logic

* add extra case

* added unit tests
  • Loading branch information
nwlandry authored Aug 20, 2024
1 parent 0475563 commit 5d67d6b
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 60 deletions.
34 changes: 24 additions & 10 deletions tests/core/test_dihypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand Down
71 changes: 69 additions & 2 deletions tests/core/test_hypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
35 changes: 16 additions & 19 deletions xgi/core/dihypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def add_nodes_from(self, nodes_for_adding, **attr):
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
Expand All @@ -375,9 +375,11 @@ def remove_node(self, n, strong=False):
----------
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
------
Expand Down Expand Up @@ -406,17 +408,26 @@ def remove_node(self, n, strong=False):

# 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
--------
Expand All @@ -427,7 +438,7 @@ def remove_nodes_from(self, nodes):
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.
Expand Down Expand Up @@ -506,11 +517,6 @@ def add_edge(self, members, id=None, **attr):
**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.
Expand All @@ -525,18 +531,12 @@ def add_edge(self, members, id=None, **attr):
>>> 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
Expand Down Expand Up @@ -969,11 +969,8 @@ def freeze(self):
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

Expand Down
44 changes: 23 additions & 21 deletions xgi/core/hypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def add_nodes_from(self, nodes_for_adding, **attr):
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
Expand All @@ -417,9 +417,10 @@ def remove_node(self, n, strong=False):
----------
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
------
Expand All @@ -445,17 +446,21 @@ def remove_node(self, n, strong=False):
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
--------
Expand All @@ -466,7 +471,7 @@ def remove_nodes_from(self, nodes):
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.
Expand Down Expand Up @@ -577,8 +582,6 @@ def add_edge(self, members, id=None, **attr):
"""
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}")
Expand Down Expand Up @@ -1127,7 +1130,7 @@ def remove_edges_from(self, ebunch):
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
Expand All @@ -1136,6 +1139,8 @@ def remove_node_from_edge(self, edge, node):
The edge ID
node : hashable
The node ID
remove_empty : bool, optional
Whether empty edges are removed. By default, True.
Raises
------
Expand All @@ -1154,21 +1159,18 @@ def remove_node_from_edge(self, edge, node):
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]

Expand Down
Loading

0 comments on commit 5d67d6b

Please sign in to comment.