Repository: networkx/networkx

Issue:
all_simple_paths returns empty generator when the source is one of the possible targets
### Current Behavior

Due to this conditional:

https://github.com/networkx/networkx/blob/5f2f88f6afc385892d91c20676d7dc6aa80d52bb/networkx/algorithms/simple_paths.py#L253-L254

whenever the `source` node appears as one of the possible targets, `all_simple_paths` always generates an empty list of paths.

For example:

```python
import networkx as nx

graph = nx.Graph([(1, 2), (1, 3)])
```
```python
>>> list(nx.all_simple_paths(graph, source=1, target={1, 2, 3}))
[]
```


### Expected Behavior

Apologies if I have misunderstood what the function is meant to do. Here's what I understand.

The function generates a list of simple paths from the given source to (any of the) given target(s). The docstring says:
> Pass an iterable of nodes as target to generate all paths ending in **any** of several nodes

So the fact that the source node is in the set of target nodes does not necessarily exclude _all_ paths, there could be some other target that works. For instance, in the example above the function missed out on two perfectly reasonable paths: `[1, 2]` and `[1, 3]`. As far as I understand they both satisfy the description above: they're simple paths from the source (1) to any of the targets (1, 2, 3).

So why does it return an empty generator? It should return the same as `list(nx.all_simple_paths(graph, source=1, target={2, 3}))`, that is, `[[1, 2], [1, 3]]`.

I understand that any path from source to itself wouldn't be simple, but again this doesn't automatically exclude paths to any of the other specified targets.

### Steps to Reproduce

```python
import networkx as nx

graph = nx.Graph([(1, 2), (1, 3)])
list(nx.all_simple_paths(graph, source=1, target={1, 2, 3}))
```


### Environment

<!--- Please provide details about your local environment -->

Python version: 3.11
NetworkX version: 5f2f88f6afc385892d91c20676d7dc6aa80d52bb (3.1)


all_simple_paths on MultiGraph fails when multiple targets are reached exactly at the cutoff
<!-- If you have a general question about NetworkX, please use the discussions tab to create a new discussion -->

<!--- Provide a general summary of the issue in the Title above -->

### Steps to Reproduce

```python
import networkx as nx

G = nx.MultiGraph([(0, 1), (0, 2)])
list(nx.all_simple_paths(G, 0, {1, 2}, cutoff=1))
```


### Current Behavior

```python
>>> list(nx.all_simple_paths(G, 0, {1, 2}, cutoff=1))
[[0, 1]]
```

### Expected Behavior

```python
>>> list(nx.all_simple_paths(G, 0, {1, 2}, cutoff=1))
[[0, 1], [0, 2]]
```

### Environment

<!--- Please provide details about your local environment -->

Python version: 3.11
NetworkX version: 3.2rc0.dev0

### Additional context

This is a bug I encountered while working on #6694 . The cause of the bug is here:

https://github.com/networkx/networkx/blob/5fcf01b9a43a097c4f579486023d1279b2b88619/networkx/algorithms/simple_paths.py#L315-L318

Since `children` is an iterator, the first time `list(children)` is computed, the iterator is consumed, and it returns an empty list afterwards.

I will fix it in #6694 because I need to refactor this function in any case.



Discussion:
It looks like handling multiple targets was introduced in #3138 and at that time the if-structure was changed from
```python
if source == target:
    return []
```
to 
```python
if source in target:
    return []
```
The current post's corner case was not discussed at the time.  The previous behavior is different because with only one target, when that target equals the source, you don't get a simple path. It is a cycle. But with multiple targets, we now have the case in the OP -- what about paths to the other targets?

A workaround is to call the helper function directly:
```python
list(nx.algorithms.simple_paths._all_simple_paths_graph(G, source=1, targets={1, 2, 3}, cutoff=10))
```
or to call with `target=set(target) - {source}`.

But I think the fix is pretty easy. We can let the code do it's thing with the full set of targets.  But we could retain the spirit of the original code (avoiding extra computation in a corner case) by making the check:
```python
targets.discard(source)
if not targets:
    return _empty_generator()
```

Does this make sense?
> We can let the code do it's thing with the full set of targets. 

That was my first thought too.

> But we could retain the spirit of the original code (avoiding extra computation in a corner case) by making the check:
> 
> ```python
> targets.discard(source)
> if not targets:
>     return _empty_generator()
> ```
> 
> Does this make sense?

Seems like the best fix. I can't do it right now, but if in a while nobody has submitted a PR I'll do it.


Actually, isn't the trivial path `[source]` always a simple path from `source` to `source`? It seems kind of arbitrary to exclude this case too.
Indeed -- at least based on a definition that a path is a sequence of nodes.  But I guess that also means that a list of no nodes `[]` is also a path.  Isn't the empty sequence a sequence?  These corner cases are rarely described in a satisfying manner in textbooks/references.

But `[source]` is not a path if we go by [the definition e.g. wikipedia](https://en.wikipedia.org/wiki/Path_(graph_theory)), that `In graph theory, a path in a graph is a finite or infinite sequence of edges which joins a sequence of vertices.`

So, I think we should decide based on whether users will be more troubled by having to remove the single node paths (and zero length paths) when they don't want them vs by having to add the single node paths when they do want them.

Backward compatibility suggests we should not report these paths with less than 2 nodes in this PR.  But I am open to discussion about whether we should have another PR that deprecates the current behavior and adds single node (or less than 2 node) paths in a future version.  I'm -0.5 on adding them because I believe more users will want to remove them than users who will need to add them.

In the meantime, we should add a sentence or note to the doc_string that this function does not yield paths with less than 2 nodes, so those should be manually added by the user if desired.
I agree with your reasoning overall.

I would say that in all settings where you need to "do X for every Y in an arbitrary object Z", in this case "do X for every simple path in an arbitrary graph", it helps to be as general as possible with your definition of Y (simple path). Like including the empty set when computing the subsets of a set, or in this case returning the singleton path.

For example, a simple routine to find Hamiltonian paths could just use `all_simple_paths` with every possible source and specifying the entire set of vertices as possible targets, and then filter to only return paths that include all vertices. If the singleton path is excluded, this would not work for graphs of size one, and the routine would have to handle it manually. (Furthermore, in fact, if the empty path is excluded, this would also break this routine for empty graphs.)

In general I think that the more general the method is, the less corner cases the user will have to deal with manually themselves. So I am in favour of adding this change in the future.

But I think we can reasonably draw the line at non-empty paths: the method gives all simple paths that start from the given `source`, which the empty path does not.
By the way, `shortest_simple_paths` does currently include the singleton path, so consistency is another reason to  make the change to `all_simple_paths`:
```python
>>> G = nx.cycle_graph(5)
>>> list(nx.shortest_simple_paths(G, 0, 0))
[[0]]
```
You are sooo right...  And the `is_simple_path` also recognizes a single node path. So in our simple_path functions we have:
- `list(nx.shortest_simple_paths(G, 0, 0))` returns [[0]]
- `nx.is_simple_path(G, [0])` returns True
- `list(nx.all_simple_paths(G, 0, 0))` returns []
- `list(nx.all_simple_edge_paths(G, 0, 0))` returns []
- `nx.simple_cycles(G)` yields single node paths when a self-loop exists.  

So, the evidence for yielding single node paths is much stronger than I realized.

@MartinPJorge do you have an opinion about returning single node paths?

I think I am now +0.5 on reporting single node simple paths.

If we do change the `all_simple_paths` and `all_simple_edge_paths` to yield the single node paths, I think we would need to deprecate the current skipping of those edges. Does anyone think we should fix this without deprecation (e.g. because the current behavior is wrong)?

Also, looking briefly at the current code, this might require that we change how the `cutoff < 1` check is done. And maybe we could get rid of `empty_generator` now and make the functions be generators which use `yield from ...` instead of `return ...`.
>  Does anyone think we should fix this without deprecation (e.g. because the current behavior is wrong)?

Given the above discussion and the fact that other simple path functions include singletons, I would say this is a bug that needs to be fixed. But, on a pragmatical side, who knows if someone somewhere is relying on this behaviour...

> If we do change the all_simple_paths and all_simple_edge_paths to yield the single node paths, I think we would need to deprecate the current skipping of those edges. Does anyone think we should fix this without deprecation (e.g. because the current behavior is wrong)?

Without having any real sense of how disruptive this would be in user code, my general feeling is that deprecating/warning would be overkill. IIUC, there is already an inconsistency in behavior between various functions, so user code would be getting different answers depending on which functions were used. Within this context, modifying behavior to improve consistency between functions feels more like a fix than an unexpected change!
Two more cents from me: I think the best way to discuss/build consensus on this would be to have a PR that adds tests for the desired behavior across all relevant functions.
The debate over path length is as old as time itself -- is it the number of nodes or the number of edges?  Is a path a sequence of edges, or a sequence of nodes with edges between them?  Throughout our library, we have used the latter.   I think that it's important to remain consistent on this, and that strongly implies that we should treat `[0]` as a sequence of nodes starting at `0` and ending at `0` where all consecutive pairs of nodes are joined by an edge -- a path.
More supporting evidence: `len(nx.path_graph(1))` is `1`.
Great -- I think we have consensus on changing #6694 
1. report the single node paths (and show this in doc_string examples)
2. add a test that these functions report single node paths.
3. If you want (otherwise we will do it in another PR) add tests to the other functions mentioned above to ensure that they treat single node paths as paths. Those tests may already exist. But if they don't it'd be good to add them. 

@plammens Can you do 1) and 2) and let me know if you want us to do 3)?
Thanks!!
More evidence:
```python
G = nx.empty_graph(0)  # or any graph with 0 as a node
nx.has_path(G, 0)
```
outputs   `True`
> @plammens Can you do 1) and 2) and let me know if you want us to do 3)? Thanks!!

I'm now working on 1) and 2), I think I can also do 3) but I'll let you now if not.


Resolving patch:
diff --git a/networkx/algorithms/simple_paths.py b/networkx/algorithms/simple_paths.py
--- a/networkx/algorithms/simple_paths.py
+++ b/networkx/algorithms/simple_paths.py
@@ -169,6 +169,17 @@ def all_simple_paths(G, source, target, cutoff=None):
         [0, 3, 1, 2]
         [0, 3, 2]
 
+    The singleton path from ``source`` to itself is considered a simple path and is
+    included in the results:
+
+        >>> G = nx.empty_graph(5)
+        >>> list(nx.all_simple_paths(G, source=0, target=0))
+        [[0]]
+
+        >>> G = nx.path_graph(3)
+        >>> list(nx.all_simple_paths(G, source=0, target={0, 1, 2}))
+        [[0], [0, 1], [0, 1, 2]]
+
     Iterate over each path from the root nodes to the leaf nodes in a
     directed acyclic graph using a functional programming approach::
 
@@ -242,83 +253,8 @@ def all_simple_paths(G, source, target, cutoff=None):
     all_shortest_paths, shortest_path, has_path
 
     """
-    if source not in G:
-        raise nx.NodeNotFound(f"source node {source} not in graph")
-    if target in G:
-        targets = {target}
-    else:
-        try:
-            targets = set(target)
-        except TypeError as err:
-            raise nx.NodeNotFound(f"target node {target} not in graph") from err
-    if source in targets:
-        return _empty_generator()
-    if cutoff is None:
-        cutoff = len(G) - 1
-    if cutoff < 1:
-        return _empty_generator()
-    if G.is_multigraph():
-        return _all_simple_paths_multigraph(G, source, targets, cutoff)
-    else:
-        return _all_simple_paths_graph(G, source, targets, cutoff)
-
-
-def _empty_generator():
-    yield from ()
-
-
-def _all_simple_paths_graph(G, source, targets, cutoff):
-    visited = {source: True}
-    stack = [iter(G[source])]
-    while stack:
-        children = stack[-1]
-        child = next(children, None)
-        if child is None:
-            stack.pop()
-            visited.popitem()
-        elif len(visited) < cutoff:
-            if child in visited:
-                continue
-            if child in targets:
-                yield list(visited) + [child]
-            visited[child] = True
-            if targets - set(visited.keys()):  # expand stack until find all targets
-                stack.append(iter(G[child]))
-            else:
-                visited.popitem()  # maybe other ways to child
-        else:  # len(visited) == cutoff:
-            for target in (targets & (set(children) | {child})) - set(visited.keys()):
-                yield list(visited) + [target]
-            stack.pop()
-            visited.popitem()
-
-
-def _all_simple_paths_multigraph(G, source, targets, cutoff):
-    visited = {source: True}
-    stack = [(v for u, v in G.edges(source))]
-    while stack:
-        children = stack[-1]
-        child = next(children, None)
-        if child is None:
-            stack.pop()
-            visited.popitem()
-        elif len(visited) < cutoff:
-            if child in visited:
-                continue
-            if child in targets:
-                yield list(visited) + [child]
-            visited[child] = True
-            if targets - set(visited.keys()):
-                stack.append((v for u, v in G.edges(child)))
-            else:
-                visited.popitem()
-        else:  # len(visited) == cutoff:
-            for target in targets - set(visited.keys()):
-                count = ([child] + list(children)).count(target)
-                for i in range(count):
-                    yield list(visited) + [target]
-            stack.pop()
-            visited.popitem()
+    for edge_path in all_simple_edge_paths(G, source, target, cutoff):
+        yield [source] + [edge[1] for edge in edge_path]
 
 
 @nx._dispatch
@@ -375,6 +311,19 @@ def all_simple_edge_paths(G, source, target, cutoff=None):
         [(1, 2, 'k0'), (2, 3, 'k0')]
         [(1, 2, 'k1'), (2, 3, 'k0')]
 
+    When ``source`` is one of the targets, the empty path starting and ending at
+    ``source`` without traversing any edge is considered a valid simple edge path
+    and is included in the results:
+
+        >>> G = nx.Graph()
+        >>> G.add_node(0)
+        >>> paths = list(nx.all_simple_edge_paths(G, 0, 0))
+        >>> for path in paths:
+        ...     print (path)
+        []
+        >>> len(paths)
+        1
+
 
     Notes
     -----
@@ -394,52 +343,62 @@ def all_simple_edge_paths(G, source, target, cutoff=None):
 
     """
     if source not in G:
-        raise nx.NodeNotFound("source node %s not in graph" % source)
+        raise nx.NodeNotFound(f"source node {source} not in graph")
+
     if target in G:
         targets = {target}
     else:
         try:
             targets = set(target)
-        except TypeError:
-            raise nx.NodeNotFound("target node %s not in graph" % target)
-    if source in targets:
-        return []
-    if cutoff is None:
-        cutoff = len(G) - 1
-    if cutoff < 1:
-        return []
-    if G.is_multigraph():
-        for simp_path in _all_simple_edge_paths_multigraph(G, source, targets, cutoff):
-            yield simp_path
-    else:
-        for simp_path in _all_simple_paths_graph(G, source, targets, cutoff):
-            yield list(zip(simp_path[:-1], simp_path[1:]))
+        except TypeError as err:
+            raise nx.NodeNotFound(f"target node {target} not in graph") from err
+
+    cutoff = cutoff if cutoff is not None else len(G) - 1
+
+    if cutoff >= 0 and targets:
+        yield from _all_simple_edge_paths(G, source, targets, cutoff)
 
 
-def _all_simple_edge_paths_multigraph(G, source, targets, cutoff):
-    if not cutoff or cutoff < 1:
-        return []
-    visited = [source]
-    stack = [iter(G.edges(source, keys=True))]
+def _all_simple_edge_paths(G, source, targets, cutoff):
+    # We simulate recursion with a stack, keeping the current path being explored
+    # and the outgoing edge iterators at each point in the stack.
+    # To avoid unnecessary checks, the loop is structured in a way such that a path
+    # is considered for yielding only after a new node/edge is added.
+    # We bootstrap the search by adding a dummy iterator to the stack that only yields
+    # a dummy edge to source (so that the trivial path has a chance of being included).
+
+    get_edges = (
+        (lambda node: G.edges(node, keys=True))
+        if G.is_multigraph()
+        else (lambda node: G.edges(node))
+    )
+
+    # The current_path is a dictionary that maps nodes in the path to the edge that was
+    # used to enter that node (instead of a list of edges) because we want both a fast
+    # membership test for nodes in the path and the preservation of insertion order.
+    current_path = {None: None}
+    stack = [iter([(None, source)])]
 
     while stack:
-        children = stack[-1]
-        child = next(children, None)
-        if child is None:
+        # 1. Try to extend the current path.
+        next_edge = next((e for e in stack[-1] if e[1] not in current_path), None)
+        if next_edge is None:
+            # All edges of the last node in the current path have been explored.
             stack.pop()
-            visited.pop()
-        elif len(visited) < cutoff:
-            if child[1] in targets:
-                yield visited[1:] + [child]
-            elif child[1] not in [v[0] for v in visited[1:]]:
-                visited.append(child)
-                stack.append(iter(G.edges(child[1], keys=True)))
-        else:  # len(visited) == cutoff:
-            for u, v, k in [child] + list(children):
-                if v in targets:
-                    yield visited[1:] + [(u, v, k)]
-            stack.pop()
-            visited.pop()
+            current_path.popitem()
+            continue
+        previous_node, next_node, *_ = next_edge
+
+        # 2. Check if we've reached a target.
+        if next_node in targets:
+            yield (list(current_path.values()) + [next_edge])[2:]  # remove dummy edge
+
+        # 3. Only expand the search through the next node if it makes sense.
+        if len(current_path) - 1 < cutoff and (
+            targets - current_path.keys() - {next_node}
+        ):
+            current_path[next_node] = next_edge
+            stack.append(iter(get_edges(next_node)))
 
 
 @not_implemented_for("multigraph")


Test patch:
diff --git a/networkx/algorithms/shortest_paths/tests/test_generic.py b/networkx/algorithms/shortest_paths/tests/test_generic.py
--- a/networkx/algorithms/shortest_paths/tests/test_generic.py
+++ b/networkx/algorithms/shortest_paths/tests/test_generic.py
@@ -278,6 +278,10 @@ def test_has_path(self):
         assert nx.has_path(G, 0, 2)
         assert not nx.has_path(G, 0, 4)
 
+    def test_has_path_singleton(self):
+        G = nx.empty_graph(1)
+        assert nx.has_path(G, 0, 0)
+
     def test_all_shortest_paths(self):
         G = nx.Graph()
         nx.add_path(G, [0, 1, 2, 3])
diff --git a/networkx/algorithms/tests/test_cycles.py b/networkx/algorithms/tests/test_cycles.py
--- a/networkx/algorithms/tests/test_cycles.py
+++ b/networkx/algorithms/tests/test_cycles.py
@@ -97,6 +97,10 @@ def test_simple_cycles(self):
         for c in cc:
             assert any(self.is_cyclic_permutation(c, rc) for rc in ca)
 
+    def test_simple_cycles_singleton(self):
+        G = nx.Graph([(0, 0)])  # self-loop
+        assert list(nx.simple_cycles(G)) == [[0]]
+
     def test_unsortable(self):
         # this test ensures that graphs whose nodes without an intrinsic
         # ordering do not cause issues
diff --git a/networkx/algorithms/tests/test_simple_paths.py b/networkx/algorithms/tests/test_simple_paths.py
--- a/networkx/algorithms/tests/test_simple_paths.py
+++ b/networkx/algorithms/tests/test_simple_paths.py
@@ -140,8 +140,7 @@ def test_all_simple_paths_with_two_targets_inside_cycle_emits_two_paths():
 
 def test_all_simple_paths_source_target():
     G = nx.path_graph(4)
-    paths = nx.all_simple_paths(G, 1, 1)
-    assert list(paths) == []
+    assert list(nx.all_simple_paths(G, 1, 1)) == [[1]]
 
 
 def test_all_simple_paths_cutoff():
@@ -181,8 +180,7 @@ def test_all_simple_paths_on_non_trivial_graph():
 
 def test_all_simple_paths_multigraph():
     G = nx.MultiGraph([(1, 2), (1, 2)])
-    paths = nx.all_simple_paths(G, 1, 1)
-    assert list(paths) == []
+    assert list(nx.all_simple_paths(G, 1, 1)) == [[1]]
     nx.add_path(G, [3, 1, 10, 2])
     paths = list(nx.all_simple_paths(G, 1, 2))
     assert len(paths) == 3
@@ -195,6 +193,10 @@ def test_all_simple_paths_multigraph_with_cutoff():
     assert len(paths) == 2
     assert {tuple(p) for p in paths} == {(1, 2), (1, 2)}
 
+    # See GitHub issue #6732.
+    G = nx.MultiGraph([(0, 1), (0, 2)])
+    assert list(nx.all_simple_paths(G, 0, {1, 2}, cutoff=1)) == [[0, 1], [0, 2]]
+
 
 def test_all_simple_paths_directed():
     G = nx.DiGraph()
@@ -211,11 +213,17 @@ def test_all_simple_paths_empty():
 
 
 def test_all_simple_paths_corner_cases():
-    assert list(nx.all_simple_paths(nx.empty_graph(2), 0, 0)) == []
+    assert list(nx.all_simple_paths(nx.empty_graph(2), 0, 0)) == [[0]]
     assert list(nx.all_simple_paths(nx.empty_graph(2), 0, 1)) == []
     assert list(nx.all_simple_paths(nx.path_graph(9), 0, 8, 0)) == []
 
 
+def test_all_simple_paths_source_in_targets():
+    # See GitHub issue #6690.
+    G = nx.path_graph(3)
+    assert list(nx.all_simple_paths(G, 0, {0, 1, 2})) == [[0], [0, 1], [0, 1, 2]]
+
+
 def hamiltonian_path(G, source):
     source = arbitrary_element(G)
     neighbors = set(G[source]) - {source}
@@ -264,6 +272,11 @@ def test_all_simple_edge_paths():
     assert {tuple(p) for p in paths} == {((0, 1), (1, 2), (2, 3))}
 
 
+def test_all_simple_edge_paths_empty_path():
+    G = nx.empty_graph(1)
+    assert list(nx.all_simple_edge_paths(G, 0, 0)) == [[]]
+
+
 def test_all_simple_edge_paths_with_two_targets_emits_two_paths():
     G = nx.path_graph(4)
     G.add_edge(2, 4)
@@ -327,7 +340,7 @@ def test_all_simple_edge_paths_with_two_targets_inside_cycle_emits_two_paths():
 def test_all_simple_edge_paths_source_target():
     G = nx.path_graph(4)
     paths = nx.all_simple_edge_paths(G, 1, 1)
-    assert list(paths) == []
+    assert list(paths) == [[]]
 
 
 def test_all_simple_edge_paths_cutoff():
@@ -368,7 +381,7 @@ def test_all_simple_edge_paths_on_non_trivial_graph():
 def test_all_simple_edge_paths_multigraph():
     G = nx.MultiGraph([(1, 2), (1, 2)])
     paths = nx.all_simple_edge_paths(G, 1, 1)
-    assert list(paths) == []
+    assert list(paths) == [[]]
     nx.add_path(G, [3, 1, 10, 2])
     paths = list(nx.all_simple_edge_paths(G, 1, 2))
     assert len(paths) == 3
@@ -401,11 +414,16 @@ def test_all_simple_edge_paths_empty():
 
 
 def test_all_simple_edge_paths_corner_cases():
-    assert list(nx.all_simple_edge_paths(nx.empty_graph(2), 0, 0)) == []
+    assert list(nx.all_simple_edge_paths(nx.empty_graph(2), 0, 0)) == [[]]
     assert list(nx.all_simple_edge_paths(nx.empty_graph(2), 0, 1)) == []
     assert list(nx.all_simple_edge_paths(nx.path_graph(9), 0, 8, 0)) == []
 
 
+def test_all_simple_edge_paths_ignores_self_loop():
+    G = nx.Graph([(0, 0), (0, 1), (1, 1), (1, 2)])
+    assert list(nx.all_simple_edge_paths(G, 0, 2)) == [[(0, 1), (1, 2)]]
+
+
 def hamiltonian_edge_path(G, source):
     source = arbitrary_element(G)
     neighbors = set(G[source]) - {source}
@@ -458,6 +476,11 @@ def test_shortest_simple_paths():
     )
 
 
+def test_shortest_simple_paths_singleton_path():
+    G = nx.empty_graph(3)
+    assert list(nx.shortest_simple_paths(G, 0, 0)) == [[0]]
+
+
 def test_shortest_simple_paths_directed():
     G = nx.cycle_graph(7, create_using=nx.DiGraph())
     paths = nx.shortest_simple_paths(G, 0, 3)