Repository: networkx/networkx

Issue:
nx.graph_edit_distance returns wrong value when one graph contains only one node and loops and the other graph is empty
### Current Behavior

Consider the graphs:
- `G1` with one node `0` and one loop `0 -> 0`
- `G2` the empty graph

The graph edit distance between `G1` and `G2` should be 2, but `nx.graph_edit_distance` returns 1.

Similarly, if `G1` is a `MultiDiGraph`, and it contains one node `0`, and two loops `0 -> 0`, `0 -> 0`, then the graph edit distance between `G1` and `G2` should be 3, but `nx.graph_edit_distance` returns 1.

### Expected Behavior

Given:
- `G1` with one node `0` and one loop `0 -> 0`
- `G2` the empty graph

`nx.graph_edit_distance` returns 2.

Given:
- `G1` with one node `0` and two loop `0 -> 0`, `0 -> 0`
- `G2` the empty graph

`nx.graph_edit_distance` returns 3.

### Steps to Reproduce

Consider the following test case:

```python
import networkx

def test_one_node_one_loop_and_empty_graph():
    g1 = networkx.DiGraph()
    g1.add_nodes_from([0])
    g1.add_edges_from([(0, 0)])
    g2 = networkx.DiGraph()
    ged = networkx.graph_edit_distance(g1, g2)
    assert ged == 2

def test_one_node_two_loops_and_empty_graph():
    g1 = networkx.MultiDiGraph()
    g1.add_nodes_from([0])
    g1.add_edges_from([(0, 0), (0, 0)])
    g2 = networkx.DiGraph()
    ged = networkx.graph_edit_distance(g1, g2)
    assert ged == 3
```

Running the code with pytest I get:

```
========================================================== test session starts ===========================================================
platform darwin -- Python 3.13.5, pytest-8.4.1, pluggy-1.6.0
rootdir: /Users/marco/code/trying_packages/trying_networkx_graph_edit_distance
configfile: pyproject.toml
collected 2 items                                                                                                                        

tests/test_graph_edit_distance.py FF                                                                                               [100%]

================================================================ FAILURES ================================================================
_________________________________________________ test_one_node_one_loop_and_empty_graph _________________________________________________

    def test_one_node_one_loop_and_empty_graph():
        g1 = networkx.DiGraph()
        g1.add_nodes_from([0])
        g1.add_edges_from([(0, 0)])
        g2 = networkx.DiGraph()
        ged = networkx.graph_edit_distance(g1, g2)
>       assert ged == 2
E       assert 1.0 == 2

tests/test_graph_edit_distance.py:18: AssertionError
________________________________________________ test_one_node_two_loops_and_empty_graph _________________________________________________

    def test_one_node_two_loops_and_empty_graph():
        g1 = networkx.MultiDiGraph()
        g1.add_nodes_from([0])
        g1.add_edges_from([(0, 0), (0, 0)])
        g2 = networkx.DiGraph()
        ged = networkx.graph_edit_distance(g1, g2)
>       assert ged == 3
E       assert 1.0 == 3

tests/test_graph_edit_distance.py:34: AssertionError
======================================================== short test summary info =========================================================
FAILED tests/test_graph_edit_distance.py::test_one_node_one_loop_and_empty_graph - assert 1.0 == 2
FAILED tests/test_graph_edit_distance.py::test_one_node_two_loops_and_empty_graph - assert 1.0 == 3
=========================================================== 2 failed in 0.35s ============================================================
```

**Note** The same errors occur when `G1` and `G2` are not directed graphs.

### Environment

Python version: 3.13
NetworkX version: 3.5

`uv tree` returns
```
├── networkx v3.5
├── numpy v2.3.2
├── pytest v8.4.1
│   ├── iniconfig v2.1.0
│   ├── packaging v25.0
│   ├── pluggy v1.6.0
│   └── pygments v2.19.2
└── scipy v1.16.1
    └── numpy v2.3.2
```

### Additional context

None



Discussion:
IIUC the edit distance counts removal of a node as distance 1.
Removal of the node gets edge removal for free. Said another way, the node does not need to be an isolated node in order to be removed.  So removing a node and its self-loops is a single step and thus distance 1.

@dschult thank you for your answer. I think that is a possible interpretation, but I do not think that is a correct distance.

Also, I have tried this:

```
import networkx

def test_2_loops():
    g1 = networkx.DiGraph()
    g1.add_nodes_from([0, 1])
    g1.add_edges_from([(0, 0), (1, 1)])
    g2 = networkx.DiGraph()
    ged = networkx.graph_edit_distance(g1, g2)
    assert ged == 4
```

And this test passes. If your interpretation (removing a node and its self-loops is a single step and thus distance 1) was correct, then the result in this case should be 2 and the test should fail ... but it does not fail.
There is definitely something strange going on here. I prefaced my previous comment with IIUC. It seems I don't understand correctly. :/   It looks like even when nodes are deleted, the connected edges need to be deleted too. That is, the edit distance includes both. 

I found a comment in the match_edges helper function inside `similarity.py` that says:
```
        # only attempt to match edges after one node match has been made
        # this will stop self-edges on the first node being automatically deleted
        # even when a substitution is the better option
```
But that doesn't say when self-loops **are** deleted relative to when nodes are deleted. 

Maybe @sheldonkhall or @aparamon have a useful perspective.
I also notice that there are packages specifically for graph edit distance named GED4py and GMatch4py that might be helpful in tracking this down.

Thanks @marcosbodio for the report of this issue!



Patch:
diff --git a/networkx/algorithms/similarity.py b/networkx/algorithms/similarity.py
index 245bc8913c5..34131d1ce54 100644
--- a/networkx/algorithms/similarity.py
+++ b/networkx/algorithms/similarity.py
@@ -757,7 +757,10 @@ def match_edges(u, v, pending_g, pending_h, Ce, matched_uv=None):
         # only attempt to match edges after one node match has been made
         # this will stop self-edges on the first node being automatically deleted
         # even when a substitution is the better option
-        if matched_uv is None or len(matched_uv) == 0:
+
+        substitution_possible = M and N
+        at_least_one_node_match = matched_uv is None or len(matched_uv) == 0
+        if at_least_one_node_match and substitution_possible:
             g_ind = []
             h_ind = []
         else:


Test patch:
diff --git a/networkx/algorithms/tests/test_similarity.py b/networkx/algorithms/tests/test_similarity.py
index 81d870a51ff..e3f0eab1c3e 100644
--- a/networkx/algorithms/tests/test_similarity.py
+++ b/networkx/algorithms/tests/test_similarity.py
@@ -954,6 +954,20 @@ def test_generate_random_paths_weighted(self):
         assert expected_paths == list(paths)
         assert expected_map == index_map
 
+    def test_one_node_one_loop_and_empty_graph(self):
+        G1 = nx.DiGraph([(0, 0)])
+        G2 = nx.DiGraph()
+        assert nx.graph_edit_distance(G1, G2) == 2
+
+    def test_one_node_two_loops_and_empty_graph(self):
+        G1 = nx.MultiDiGraph([(0, 0), (0, 0)])
+        assert nx.graph_edit_distance(G1, nx.DiGraph()) == 3
+        assert nx.graph_edit_distance(G1, nx.MultiDiGraph()) == 3
+
+    def test_two_directed_loops(self):
+        G = nx.DiGraph([(0, 0), (1, 1)])
+        assert nx.graph_edit_distance(G, nx.DiGraph()) == 4
+
     def test_symmetry_with_custom_matching(self):
         """G2 has edge (a,b) and G3 has edge (a,a) but node order for G2 is (a,b)
         while for G3 it is (b,a)"""