Repository: networkx/networkx

Issue:
Minimal d-separator function does not handle cases where no d-separating set exists
The minimal d-separator function does not return a special value when no valid d-separating set exists.

### Current Behavior

Currently the code returns:
```
>>> import networkx as nx
>>> G = nx.DiGraph()
>>> G.add_edge("A", "B")
>>> nx.minimal_d_separator(G, "A", "B")
set()
```
but this implies that A is marginally independent of B in G, which is false.

Compare with 
```
>>> import networkx as nx
>>> G = nx.DiGraph()
>>> G.add_edge("A", "B")
>>> G.add_edge("C", "B")
>>> nx.minimal_d_separator(G, "A", "C")
set()
```

and this empty set correctly implies that A is marginally independent of C.

### Expected Behavior
The function should return None, raise an error, or otherwise indicate that there is no valid d-separating set for A and B in the above graph, in order to distinguish from cases where the empty set is indeed a valid d-separating set.

### Steps to Reproduce
See above.

### Environment

Python version: 3.10.9
NetworkX version: 3.0

### Additional context

Related to discussions in #6247



Discussion:
@dschult I can also handle this by adding a return `None` in this edge-case, unless @jaron-lee wants to take the PR?
That seems relatively straightforward, I'm happy to take a stab over the weekend
I agree that returning `None` should indicate that there is no minimal separator. And returning an empty set means that the empty set is a d_separator.  

This changes (corrects) the current behavior, so a note should be added to the API changes section of `doc/release/release_dev.rst`.  I don't think it should need a deprecation, since it is correcting a faulty previous behavior. But I'd like @rossbar to verify that I'm not missing something.

Resolving patch:
diff --git a/networkx/algorithms/d_separation.py b/networkx/algorithms/d_separation.py
--- a/networkx/algorithms/d_separation.py
+++ b/networkx/algorithms/d_separation.py
@@ -11,88 +11,183 @@
 algorithm presented in [2]_.  Refer to [3]_, [4]_ for a couple of
 alternative algorithms.
 
-Here, we provide a brief overview of d-separation and related concepts that
-are relevant for understanding it:
-
-Blocking paths
---------------
+The functional interface in NetworkX consists of three functions:
 
-Before we overview, we introduce the following terminology to describe paths:
+- `find_minimal_d_separator` returns a minimal d-separator set ``z``.
+  That is, removing any node or nodes from it makes it no longer a d-separator.
+- `is_d_separator` checks if a given set is a d-separator.
+- `is_minimal_d_separator` checks if a given set is a minimal d-separator.
 
-- "open" path: A path between two nodes that can be traversed
-- "blocked" path: A path between two nodes that cannot be traversed
+D-separators
+------------
 
-A **collider** is a triplet of nodes along a path that is like the following:
-``... u -> c <- v ...``), where 'c' is a common successor of ``u`` and ``v``. A path
-through a collider is considered "blocked". When
-a node that is a collider, or a descendant of a collider is included in
-the d-separating set, then the path through that collider node is "open". If the
-path through the collider node is open, then we will call this node an open collider.
+Here, we provide a brief overview of d-separation and related concepts that
+are relevant for understanding it:
 
-The d-separation set blocks the paths between ``u`` and ``v``. If you include colliders,
-or their descendant nodes in the d-separation set, then those colliders will open up,
-enabling a path to be traversed if it is not blocked some other way.
+The ideas of d-separation and d-connection relate to paths being open or blocked.
+
+- A "path" is a sequence of nodes connected in order by edges. Unlike for most
+  graph theory analysis, the direction of the edges is ignored. Thus the path
+  can be thought of as a traditional path on the undirected version of the graph.
+- A "candidate d-separator" ``z`` is a set of nodes being considered as
+  possibly blocking all paths between two prescribed sets ``x`` and ``y`` of nodes.
+  We refer to each node in the candidate d-separator as "known".
+- A "collider" node on a path is a node that is a successor of its two neighbor
+  nodes on the path. That is, ``c`` is a collider if the edge directions
+  along the path look like ``... u -> c <- v ...``.
+- If a collider node or any of its descendants are "known", the collider
+  is called an "open collider". Otherwise it is a "blocking collider".
+- Any path can be "blocked" in two ways. If the path contains a "known" node
+  that is not a collider, the path is blocked. Also, if the path contains a
+  collider that is not a "known" node, the path is blocked.
+- A path is "open" if it is not blocked. That is, it is open if every node is
+  either an open collider or not a "known". Said another way, every
+  "known" in the path is a collider and every collider is open (has a
+  "known" as a inclusive descendant). The concept of "open path" is meant to
+  demonstrate a probabilistic conditional dependence between two nodes given
+  prescribed knowledge ("known" nodes).
+- Two sets ``x`` and ``y`` of nodes are "d-separated" by a set of nodes ``z``
+  if all paths between nodes in ``x`` and nodes in ``y`` are blocked. That is,
+  if there are no open paths from any node in ``x`` to any node in ``y``.
+  Such a set ``z`` is a "d-separator" of ``x`` and ``y``.
+- A "minimal d-separator" is a d-separator ``z`` for which no node or subset
+  of nodes can be removed with it still being a d-separator.
+
+The d-separator blocks some paths between ``x`` and ``y`` but opens others.
+Nodes in the d-separator block paths if the nodes are not colliders.
+But if a collider or its descendant nodes are in the d-separation set, the
+colliders are open, allowing a path through that collider.
 
 Illustration of D-separation with examples
 ------------------------------------------
 
-For a pair of two nodes, ``u`` and ``v``, all paths are considered open if
-there is a path between ``u`` and ``v`` that is not blocked. That means, there is an open
-path between ``u`` and ``v`` that does not encounter a collider, or a variable in the
-d-separating set.
+A pair of two nodes, ``u`` and ``v``, are d-connected if there is a path
+from ``u`` to ``v`` that is not blocked. That means, there is an open
+path from ``u`` to ``v``.
 
 For example, if the d-separating set is the empty set, then the following paths are
-unblocked between ``u`` and ``v``:
+open between ``u`` and ``v``:
 
-- u <- z -> v
-- u -> w -> ... -> z -> v
+- u <- n -> v
+- u -> w -> ... -> n -> v
 
-If for example, 'z' is in the d-separating set, then 'z' blocks those paths
-between ``u`` and ``v``.
+If  on the other hand, ``n`` is in the d-separating set, then ``n`` blocks
+those paths between ``u`` and ``v``.
 
-Colliders block a path by default if they and their descendants are not included
-in the d-separating set. An example of a path that is blocked when the d-separating
-set is empty is:
+Colliders block a path if they and their descendants are not included
+in the d-separating set. An example of a path that is blocked when the
+d-separating set is empty is:
 
-- u -> w -> ... -> z <- v
+- u -> w -> ... -> n <- v
 
-because 'z' is a collider in this path and 'z' is not in the d-separating set. However,
-if 'z' or a descendant of 'z' is included in the d-separating set, then the path through
-the collider at 'z' (... -> z <- ...) is now "open". 
+The node ``n`` is a collider in this path and is not in the d-separating set.
+So ``n`` blocks this path. However, if ``n`` or a descendant of ``n`` is
+included in the d-separating set, then the path through the collider
+at ``n`` (... -> n <- ...) is "open".
 
-D-separation is concerned with blocking all paths between u and v. Therefore, a
-d-separating set between ``u`` and ``v`` is one where all paths are blocked.
+D-separation is concerned with blocking all paths between nodes from ``x`` to ``y``.
+A d-separating set between ``x`` and ``y`` is one where all paths are blocked.
 
 D-separation and its applications in probability
 ------------------------------------------------
 
-D-separation is commonly used in probabilistic graphical models. D-separation
+D-separation is commonly used in probabilistic causal-graph models. D-separation
 connects the idea of probabilistic "dependence" with separation in a graph. If
-one assumes the causal Markov condition [5]_, then d-separation implies conditional
-independence in probability distributions.
+one assumes the causal Markov condition [5]_, (every node is conditionally
+independent of its non-descendants, given its parents) then d-separation implies
+conditional independence in probability distributions.
+Symmetrically, d-connection implies dependence.
+
+The intuition is as follows. The edges on a causal graph indicate which nodes
+influence the outcome of other nodes directly. An edge from u to v
+implies that the outcome of event ``u`` influences the probabilities for
+the outcome of event ``v``. Certainly knowing ``u`` changes predictions for ``v``.
+But also knowing ``v`` changes predictions for ``u``. The outcomes are dependent.
+Furthermore, an edge from ``v`` to ``w`` would mean that ``w`` and ``v`` are dependent
+and thus that ``u`` could indirectly influence ``w``.
+
+Without any knowledge about the system (candidate d-separating set is empty)
+a causal graph ``u -> v -> w`` allows all three nodes to be dependent. But
+if we know the outcome of ``v``, the conditional probabilities of outcomes for
+``u`` and ``w`` are independent of each other. That is, once we know the outcome
+for ```v`, the probabilities for ``w`` do not depend on the outcome for ``u``.
+This is the idea behind ``v`` blocking the path if it is "known" (in the candidate
+d-separating set).
+
+The same argument works whether the direction of the edges are both
+left-going and when both arrows head out from the middle. Having a "known"
+node on a path blocks the collider-free path because those relationships
+make the conditional probabilities independent.
+
+The direction of the causal edges does impact dependence precisely in the
+case of a collider e.g. ``u -> v <- w``. In that situation, both ``u`` and ``w``
+influence ``v```. But they do not directly influence each other. So without any
+knowledge of any outcomes, ``u`` and ``w`` are independent. That is the idea behind
+colliders blocking the path. But, if ``v`` is known, the conditional probabilities
+of ``u`` and ``w`` can be dependent. This is the heart of Berkson's Paradox [6]_.
+For example, suppose ``u`` and ``w`` are boolean events (they either happen or do not)
+and ``v`` represents the outcome "at least one of ``u`` and ``w`` occur". Then knowing
+``v`` is true makes the conditional probabilities of ``u`` and ``w`` dependent.
+Essentially, knowing that at least one of them is true raises the probability of
+each. But further knowledge that ``w`` is true (or false) change the conditional
+probability of ``u`` to either the original value or 1. So the conditional
+probability of ``u`` depends on the outcome of ``w`` even though there is no
+causal relationship between them. When a collider is known, dependence can
+occur across paths through that collider. This is the reason open colliders
+do not block paths.
+
+Furthermore, even if ``v`` is not "known", if one of its descendants is "known"
+we can use that information to know more about ``v`` which again makes
+``u`` and ``w`` potentially dependent. Suppose the chance of ``n`` occurring
+is much higher when ``v`` occurs ("at least one of ``u`` and ``w`` occur").
+Then if we know ``n`` occurred, it is more likely that ``v`` occurred and that
+makes the chance of ``u`` and ``w`` dependent. This is the idea behind why
+a collider does no block a path if any descendant of the collider is "known".
+
+When two sets of nodes ``x`` and ``y`` are d-separated by a set ``z``,
+it means that given the outcomes of the nodes in ``z``, the probabilities
+of outcomes of the nodes in ``x`` are independent of the outcomes of the
+nodes in ``y`` and vice versa.
 
 Examples
 --------
-
->>>
->>> # HMM graph with five states and observation nodes
-... g = nx.DiGraph()
->>> g.add_edges_from(
+A Hidden Markov Model with 5 observed states and 5 hidden states
+where the hidden states have causal relationships resulting in
+a path results in the following causal network. We check that
+early states along the path are separated from late state in
+the path by the d-separator of the middle hidden state.
+Thus if we condition on the middle hidden state, the early
+state probabilities are independent of the late state outcomes.
+
+>>> G = nx.DiGraph()
+>>> G.add_edges_from(
 ...     [
-...         ("S1", "S2"),
-...         ("S2", "S3"),
-...         ("S3", "S4"),
-...         ("S4", "S5"),
-...         ("S1", "O1"),
-...         ("S2", "O2"),
-...         ("S3", "O3"),
-...         ("S4", "O4"),
-...         ("S5", "O5"),
+...         ("H1", "H2"),
+...         ("H2", "H3"),
+...         ("H3", "H4"),
+...         ("H4", "H5"),
+...         ("H1", "O1"),
+...         ("H2", "O2"),
+...         ("H3", "O3"),
+...         ("H4", "O4"),
+...         ("H5", "O5"),
 ...     ]
 ... )
->>>
->>> # states/obs before 'S3' are d-separated from states/obs after 'S3'
-... nx.d_separated(g, {"S1", "S2", "O1", "O2"}, {"S4", "S5", "O4", "O5"}, {"S3"})
+>>> x, y, z = ({"H1", "O1"}, {"H5", "O5"}, {"H3"})
+>>> nx.is_d_separator(G, x, y, z)
+True
+>>> nx.is_minimal_d_separator(G, x, y, z)
+True
+>>> nx.is_minimal_d_separator(G, x, y, z | {"O3"})
+False
+>>> z = nx.find_minimal_d_separator(G, x | y, {"O2", "O3", "O4"})
+>>> z == {"H2", "H4"}
+True
+
+If no minimal_d_separator exists, `None` is returned
+
+>>> other_z = nx.find_minimal_d_separator(G, x | y, {"H2", "H3"})
+>>> other_z is None
 True
 
 
@@ -101,142 +196,192 @@
 
 .. [1] Pearl, J.  (2009).  Causality.  Cambridge: Cambridge University Press.
 
-.. [2] Darwiche, A.  (2009).  Modeling and reasoning with Bayesian networks. 
+.. [2] Darwiche, A.  (2009).  Modeling and reasoning with Bayesian networks.
    Cambridge: Cambridge University Press.
 
-.. [3] Shachter, R.  D.  (1998).
-   Bayes-ball: rational pastime (for determining irrelevance and requisite
-   information in belief networks and influence diagrams).
-   In , Proceedings of the Fourteenth Conference on Uncertainty in Artificial
-   Intelligence (pp.  480–487).
-   San Francisco, CA, USA: Morgan Kaufmann Publishers Inc.
+.. [3] Shachter, Ross D. "Bayes-ball: The rational pastime (for
+   determining irrelevance and requisite information in belief networks
+   and influence diagrams)." In Proceedings of the Fourteenth Conference
+   on Uncertainty in Artificial Intelligence (UAI), (pp. 480–487). 1998.
 
 .. [4] Koller, D., & Friedman, N. (2009).
    Probabilistic graphical models: principles and techniques. The MIT Press.
 
 .. [5] https://en.wikipedia.org/wiki/Causal_Markov_condition
 
+.. [6] https://en.wikipedia.org/wiki/Berkson%27s_paradox
+
 """
 
 from collections import deque
+from itertools import chain
 
 import networkx as nx
 from networkx.utils import UnionFind, not_implemented_for
 
-__all__ = ["d_separated", "minimal_d_separator", "is_minimal_d_separator"]
+__all__ = [
+    "is_d_separator",
+    "is_minimal_d_separator",
+    "find_minimal_d_separator",
+    "d_separated",
+    "minimal_d_separator",
+]
 
 
 @not_implemented_for("undirected")
 @nx._dispatch
-def d_separated(G, x, y, z):
-    """
-    Return whether node sets ``x`` and ``y`` are d-separated by ``z``.
+def is_d_separator(G, x, y, z):
+    """Return whether node sets `x` and `y` are d-separated by `z`.
 
     Parameters
     ----------
-    G : graph
+    G : nx.DiGraph
         A NetworkX DAG.
 
-    x : set
-        First set of nodes in ``G``.
+    x : node or set of nodes
+        First node or set of nodes in `G`.
 
-    y : set
-        Second set of nodes in ``G``.
+    y : node or set of nodes
+        Second node or set of nodes in `G`.
 
-    z : set
-        Set of conditioning nodes in ``G``. Can be empty set.
+    z : node or set of nodes
+        Potential separator (set of conditioning nodes in `G`). Can be empty set.
 
     Returns
     -------
     b : bool
-        A boolean that is true if ``x`` is d-separated from ``y`` given ``z`` in ``G``.
+        A boolean that is true if `x` is d-separated from `y` given `z` in `G`.
 
     Raises
     ------
     NetworkXError
-        The *d-separation* test is commonly used with directed
-        graphical models which are acyclic.  Accordingly, the algorithm
-        raises a :exc:`NetworkXError` if the input graph is not a DAG.
+        The *d-separation* test is commonly used on disjoint sets of
+        nodes in acyclic directed graphs.  Accordingly, the algorithm
+        raises a :exc:`NetworkXError` if the node sets are not
+        disjoint or if the input graph is not a DAG.
 
     NodeNotFound
         If any of the input nodes are not found in the graph,
-        a :exc:`NodeNotFound` exception is raised.
+        a :exc:`NodeNotFound` exception is raised
 
     Notes
     -----
     A d-separating set in a DAG is a set of nodes that
     blocks all paths between the two sets. Nodes in `z`
     block a path if they are part of the path and are not a collider,
-    or a descendant of a collider. A collider structure along a path
+    or a descendant of a collider. Also colliders that are not in `z`
+    block a path. A collider structure along a path
     is ``... -> c <- ...`` where ``c`` is the collider node.
 
     https://en.wikipedia.org/wiki/Bayesian_network#d-separation
     """
+    try:
+        x = {x} if x in G else x
+        y = {y} if y in G else y
+        z = {z} if z in G else z
+
+        intersection = x & y or x & z or y & z
+        if intersection:
+            raise nx.NetworkXError(
+                f"The sets are not disjoint, with intersection {intersection}"
+            )
+
+        set_v = x | y | z
+        if set_v - G.nodes:
+            raise nx.NodeNotFound(f"The node(s) {set_v - G.nodes} are not found in G")
+    except TypeError:
+        raise nx.NodeNotFound("One of x, y, or z is not a node or a set of nodes in G")
 
     if not nx.is_directed_acyclic_graph(G):
         raise nx.NetworkXError("graph should be directed acyclic")
 
-    union_xyz = x.union(y).union(z)
-
-    if any(n not in G.nodes for n in union_xyz):
-        raise nx.NodeNotFound("one or more specified nodes not found in the graph")
-
-    G_copy = G.copy()
-
-    # transform the graph by removing leaves that are not in x | y | z
-    # until no more leaves can be removed.
-    leaves = deque([n for n in G_copy.nodes if G_copy.out_degree[n] == 0])
-    while len(leaves) > 0:
-        leaf = leaves.popleft()
-        if leaf not in union_xyz:
-            for p in G_copy.predecessors(leaf):
-                if G_copy.out_degree[p] == 1:
-                    leaves.append(p)
-            G_copy.remove_node(leaf)
-
-    # transform the graph by removing outgoing edges from the
-    # conditioning set.
-    edges_to_remove = list(G_copy.out_edges(z))
-    G_copy.remove_edges_from(edges_to_remove)
-
-    # use disjoint-set data structure to check if any node in `x`
-    # occurs in the same weakly connected component as a node in `y`.
-    disjoint_set = UnionFind(G_copy.nodes())
-    for component in nx.weakly_connected_components(G_copy):
-        disjoint_set.union(*component)
-    disjoint_set.union(*x)
-    disjoint_set.union(*y)
-
-    if x and y and disjoint_set[next(iter(x))] == disjoint_set[next(iter(y))]:
-        return False
-    else:
-        return True
+    # contains -> and <-> edges from starting node T
+    forward_deque = deque([])
+    forward_visited = set()
+
+    # contains <- and - edges from starting node T
+    backward_deque = deque(x)
+    backward_visited = set()
+
+    ancestors_or_z = set().union(*[nx.ancestors(G, node) for node in x]) | z | x
+
+    while forward_deque or backward_deque:
+        if backward_deque:
+            node = backward_deque.popleft()
+            backward_visited.add(node)
+            if node in y:
+                return False
+            if node in z:
+                continue
+
+            # add <- edges to backward deque
+            backward_deque.extend(G.pred[node].keys() - backward_visited)
+            # add -> edges to forward deque
+            forward_deque.extend(G.succ[node].keys() - forward_visited)
+
+        if forward_deque:
+            node = forward_deque.popleft()
+            forward_visited.add(node)
+            if node in y:
+                return False
+
+            # Consider if -> node <- is opened due to ancestor of node in z
+            if node in ancestors_or_z:
+                # add <- edges to backward deque
+                backward_deque.extend(G.pred[node].keys() - backward_visited)
+            if node not in z:
+                # add -> edges to forward deque
+                forward_deque.extend(G.succ[node].keys() - forward_visited)
+
+    return True
 
 
 @not_implemented_for("undirected")
 @nx._dispatch
-def minimal_d_separator(G, u, v):
-    """Compute a minimal d-separating set between 'u' and 'v'.
+def find_minimal_d_separator(G, x, y, *, included=None, restricted=None):
+    """Returns a minimal d-separating set between `x` and `y` if possible
 
-    A d-separating set in a DAG is a set of nodes that blocks all paths
-    between the two nodes, 'u' and 'v'. This function
-    constructs a d-separating set that is "minimal", meaning it is the smallest
-    d-separating set for 'u' and 'v'. This is not necessarily
-    unique. For more details, see Notes.
+    A d-separating set in a DAG is a set of nodes that blocks all
+    paths between the two sets of nodes, `x` and `y`. This function
+    constructs a d-separating set that is "minimal", meaning no nodes can
+    be removed without it losing the d-separating property for `x` and `y`.
+    If no d-separating sets exist for `x` and `y`, this returns `None`.
+
+    In a DAG there may be more than one minimal d-separator between two
+    sets of nodes. Minimal d-separators are not always unique. This function
+    returns one minimal d-separator, or `None` if no d-separator exists.
+
+    Uses the algorithm presented in [1]_. The complexity of the algorithm
+    is :math:`O(m)`, where :math:`m` stands for the number of edges in
+    the subgraph of G consisting of only the ancestors of `x` and `y`.
+    For full details, see [1]_.
 
     Parameters
     ----------
     G : graph
         A networkx DAG.
-    u : node
-        A node in the graph, G.
-    v : node
-        A node in the graph, G.
+    x : set | node
+        A node or set of nodes in the graph.
+    y : set | node
+        A node or set of nodes in the graph.
+    included : set | node | None
+        A node or set of nodes which must be included in the found separating set,
+        default is None, which means the empty set.
+    restricted : set | node | None
+        Restricted node or set of nodes to consider. Only these nodes can be in
+        the found separating set, default is None meaning all nodes in ``G``.
+
+    Returns
+    -------
+    z : set | None
+        The minimal d-separating set, if at least one d-separating set exists,
+        otherwise None.
 
     Raises
     ------
     NetworkXError
-        Raises a :exc:`NetworkXError` if the input graph is not a DAG.
+        Raises a :exc:`NetworkXError` if the input graph is not a DAG
+        or if node sets `x`, `y`, and `included` are not disjoint.
 
     NodeNotFound
         If any of the input nodes are not found in the graph,
@@ -244,89 +389,98 @@ def minimal_d_separator(G, u, v):
 
     References
     ----------
-    .. [1] Tian, J., & Paz, A. (1998). Finding Minimal D-separators.
-
-    Notes
-    -----
-    This function only finds ``a`` minimal d-separator. It does not guarantee
-    uniqueness, since in a DAG there may be more than one minimal d-separator
-    between two nodes. Moreover, this only checks for minimal separators
-    between two nodes, not two sets. Finding minimal d-separators between
-    two sets of nodes is not supported.
-
-    Uses the algorithm presented in [1]_. The complexity of the algorithm
-    is :math:`O(|E_{An}^m|)`, where :math:`|E_{An}^m|` stands for the
-    number of edges in the moralized graph of the sub-graph consisting
-    of only the ancestors of 'u' and 'v'. For full details, see [1]_.
-
-    The algorithm works by constructing the moral graph consisting of just
-    the ancestors of `u` and `v`. Then it constructs a candidate for
-    a separating set  ``Z'`` from the predecessors of `u` and `v`.
-    Then BFS is run starting from `u` and marking nodes
-    found from ``Z'`` and calling those nodes ``Z''``.
-    Then BFS is run again starting from `v` and marking nodes if they are
-    present in ``Z''``. Those marked nodes are the returned minimal
-    d-separating set.
-
-    https://en.wikipedia.org/wiki/Bayesian_network#d-separation
+    .. [1] van der Zander, Benito, and Maciej Liśkiewicz. "Finding
+        minimal d-separators in linear time and applications." In
+        Uncertainty in Artificial Intelligence, pp. 637-647. PMLR, 2020.
     """
     if not nx.is_directed_acyclic_graph(G):
         raise nx.NetworkXError("graph should be directed acyclic")
 
-    union_uv = {u, v}
+    try:
+        x = {x} if x in G else x
+        y = {y} if y in G else y
 
-    if any(n not in G.nodes for n in union_uv):
-        raise nx.NodeNotFound("one or more specified nodes not found in the graph")
+        if included is None:
+            included = set()
+        elif included in G:
+            included = {included}
 
-    # first construct the set of ancestors of X and Y
-    x_anc = nx.ancestors(G, u)
-    y_anc = nx.ancestors(G, v)
-    D_anc_xy = x_anc.union(y_anc)
-    D_anc_xy.update((u, v))
+        if restricted is None:
+            restricted = set(G)
+        elif restricted in G:
+            restricted = {restricted}
 
-    # second, construct the moralization of the subgraph of Anc(X,Y)
-    moral_G = nx.moral_graph(G.subgraph(D_anc_xy))
+        set_y = x | y | included | restricted
+        if set_y - G.nodes:
+            raise nx.NodeNotFound(f"The node(s) {set_y - G.nodes} are not found in G")
+    except TypeError:
+        raise nx.NodeNotFound(
+            "One of x, y, included or restricted is not a node or set of nodes in G"
+        )
 
-    # find a separating set Z' in moral_G
-    Z_prime = set(G.predecessors(u)).union(set(G.predecessors(v)))
+    if not included <= restricted:
+        raise nx.NetworkXError(
+            f"Included nodes {included} must be in restricted nodes {restricted}"
+        )
 
-    # perform BFS on the graph from 'x' to mark
-    Z_dprime = _bfs_with_marks(moral_G, u, Z_prime)
-    Z = _bfs_with_marks(moral_G, v, Z_dprime)
-    return Z
+    intersection = x & y or x & included or y & included
+    if intersection:
+        raise nx.NetworkXError(
+            f"The sets x, y, included are not disjoint. Overlap: {intersection}"
+        )
+
+    nodeset = x | y | included
+    ancestors_x_y_included = nodeset.union(*[nx.ancestors(G, node) for node in nodeset])
+
+    z_init = restricted & (ancestors_x_y_included - (x | y))
+
+    x_closure = _reachable(G, x, ancestors_x_y_included, z_init)
+    if x_closure & y:
+        return None
+
+    z_updated = z_init & (x_closure | included)
+    y_closure = _reachable(G, y, ancestors_x_y_included, z_updated)
+    return z_updated & (y_closure | included)
 
 
 @not_implemented_for("undirected")
 @nx._dispatch
-def is_minimal_d_separator(G, u, v, z):
-    """Determine if a d-separating set is minimal.
+def is_minimal_d_separator(G, x, y, z, *, included=None, restricted=None):
+    """Determine if `z` is a minimal d-separator for `x` and `y`.
 
-    A d-separating set, `z`, in a DAG is a set of nodes that blocks
-    all paths between the two nodes, `u` and `v`. This function
-    verifies that a set is "minimal", meaning there is no smaller
-    d-separating set between the two nodes.
+    A d-separator, `z`, in a DAG is a set of nodes that blocks
+    all paths from nodes in set `x` to nodes in set `y`.
+    A minimal d-separator is a d-separator `z` such that removing
+    any subset of nodes makes it no longer a d-separator.
 
-    Note: This function checks whether `z` is a d-separator AND is minimal.
-    One can use the function `d_separated` to only check if `z` is a d-separator.
-    See examples below.
+    Note: This function checks whether `z` is a d-separator AND is
+    minimal. One can use the function `is_d_separator` to only check if
+    `z` is a d-separator. See examples below.
 
     Parameters
     ----------
     G : nx.DiGraph
-        The graph.
-    u : node
-        A node in the graph.
-    v : node
-        A node in the graph.
-    z : Set of nodes
-        The set of nodes to check if it is a minimal d-separating set.
-        The function :func:`d_separated` is called inside this function
+        A NetworkX DAG.
+    x : node | set
+        A node or set of nodes in the graph.
+    y : node | set
+        A node or set of nodes in the graph.
+    z : node | set
+        The node or set of nodes to check if it is a minimal d-separating set.
+        The function :func:`is_d_separator` is called inside this function
         to verify that `z` is in fact a d-separator.
+    included : set | node | None
+        A node or set of nodes which must be included in the found separating set,
+        default is ``None``, which means the empty set.
+    restricted : set | node | None
+        Restricted node or set of nodes to consider. Only these nodes can be in
+        the found separating set, default is ``None`` meaning all nodes in ``G``.
 
     Returns
     -------
     bool
-        Whether or not the set `z` is a d-separator and is also minimal.
+        Whether or not the set `z` is a minimal d-separator subject to
+        `restricted` nodes and `included` node constraints.
 
     Examples
     --------
@@ -338,7 +492,7 @@ def is_minimal_d_separator(G, u, v, z):
     >>> nx.is_minimal_d_separator(G, 0, 2, {1, 3, 4})
     False
     >>> # alternatively, if we only want to check that {1, 3, 4} is a d-separator
-    >>> nx.d_separated(G, {0}, {4}, {1, 3, 4})
+    >>> nx.is_d_separator(G, 0, 2, {1, 3, 4})
     True
 
     Raises
@@ -352,106 +506,217 @@ def is_minimal_d_separator(G, u, v, z):
 
     References
     ----------
-    .. [1] Tian, J., & Paz, A. (1998). Finding Minimal D-separators.
+    .. [1] van der Zander, Benito, and Maciej Liśkiewicz. "Finding
+        minimal d-separators in linear time and applications." In
+        Uncertainty in Artificial Intelligence, pp. 637-647. PMLR, 2020.
 
     Notes
     -----
-    This function only works on verifying a d-separating set is minimal
-    between two nodes. To verify that a d-separating set is minimal between
-    two sets of nodes is not supported.
-
-    Uses algorithm 2 presented in [1]_. The complexity of the algorithm
-    is :math:`O(|E_{An}^m|)`, where :math:`|E_{An}^m|` stands for the
-    number of edges in the moralized graph of the sub-graph consisting
-    of only the ancestors of ``u`` and ``v``.
-
-    The algorithm works by constructing the moral graph consisting of just
-    the ancestors of `u` and `v`. First, it performs BFS on the moral graph
-    starting from `u` and marking any nodes it encounters that are part of
-    the separating set, `z`. If a node is marked, then it does not continue
-    along that path. In the second stage, BFS with markings is repeated on the
-    moral graph starting from `v`. If at any stage, any node in `z` is
-    not marked, then `z` is considered not minimal. If the end of the algorithm
-    is reached, then `z` is minimal.
+    This function works on verifying that a set is minimal and
+    d-separating between two nodes. Uses criterion (a), (b), (c) on
+    page 4 of [1]_. a) closure(`x`) and `y` are disjoint. b) `z` contains
+    all nodes from `included` and is contained in the `restricted`
+    nodes and in the union of ancestors of `x`, `y`, and `included`.
+    c) the nodes in `z` not in `included` are contained in both
+    closure(x) and closure(y). The closure of a set is the set of nodes
+    connected to the set by a directed path in G.
+
+    The complexity is :math:`O(m)`, where :math:`m` stands for the
+    number of edges in the subgraph of G consisting of only the
+    ancestors of `x` and `y`.
 
     For full details, see [1]_.
-
-    https://en.wikipedia.org/wiki/Bayesian_network#d-separation
     """
-    if not nx.d_separated(G, {u}, {v}, z):
-        return False
-
-    x_anc = nx.ancestors(G, u)
-    y_anc = nx.ancestors(G, v)
-    xy_anc = x_anc.union(y_anc)
+    if not nx.is_directed_acyclic_graph(G):
+        raise nx.NetworkXError("graph should be directed acyclic")
 
-    # if Z contains any node which is not in ancestors of X or Y
-    # then it is definitely not minimal
-    if any(node not in xy_anc for node in z):
+    try:
+        x = {x} if x in G else x
+        y = {y} if y in G else y
+        z = {z} if z in G else z
+
+        if included is None:
+            included = set()
+        elif included in G:
+            included = {included}
+
+        if restricted is None:
+            restricted = set(G)
+        elif restricted in G:
+            restricted = {restricted}
+
+        set_y = x | y | included | restricted
+        if set_y - G.nodes:
+            raise nx.NodeNotFound(f"The node(s) {set_y - G.nodes} are not found in G")
+    except TypeError:
+        raise nx.NodeNotFound(
+            "One of x, y, z, included or restricted is not a node or set of nodes in G"
+        )
+
+    if not included <= z:
+        raise nx.NetworkXError(
+            f"Included nodes {included} must be in proposed separating set z {x}"
+        )
+    if not z <= restricted:
+        raise nx.NetworkXError(
+            f"Separating set {z} must be contained in restricted set {restricted}"
+        )
+
+    intersection = x.intersection(y) or x.intersection(z) or y.intersection(z)
+    if intersection:
+        raise nx.NetworkXError(
+            f"The sets are not disjoint, with intersection {intersection}"
+        )
+
+    nodeset = x | y | included
+    ancestors_x_y_included = nodeset.union(*[nx.ancestors(G, n) for n in nodeset])
+
+    # criterion (a) -- check that z is actually a separator
+    x_closure = _reachable(G, x, ancestors_x_y_included, z)
+    if x_closure & y:
         return False
 
-    D_anc_xy = x_anc.union(y_anc)
-    D_anc_xy.update((u, v))
-
-    # second, construct the moralization of the subgraph
-    moral_G = nx.moral_graph(G.subgraph(D_anc_xy))
-
-    # start BFS from X
-    marks = _bfs_with_marks(moral_G, u, z)
-
-    # if not all the Z is marked, then the set is not minimal
-    if any(node not in marks for node in z):
+    # criterion (b) -- basic constraint; included and restricted already checked above
+    if not (z <= ancestors_x_y_included):
         return False
 
-    # similarly, start BFS from Y and check the marks
-    marks = _bfs_with_marks(moral_G, v, z)
-    # if not all the Z is marked, then the set is not minimal
-    if any(node not in marks for node in z):
+    # criterion (c) -- check that z is minimal
+    y_closure = _reachable(G, y, ancestors_x_y_included, z)
+    if not ((z - included) <= (x_closure & y_closure)):
         return False
-
     return True
 
 
-@not_implemented_for("directed")
-def _bfs_with_marks(G, start_node, check_set):
-    """Breadth-first-search with markings.
+@not_implemented_for("undirected")
+def _reachable(G, x, a, z):
+    """Modified Bayes-Ball algorithm for finding d-connected nodes.
 
-    Performs BFS starting from ``start_node`` and whenever a node
-    inside ``check_set`` is met, it is "marked". Once a node is marked,
-    BFS does not continue along that path. The resulting marked nodes
-    are returned.
+    Find all nodes in `a` that are d-connected to those in `x` by
+    those in `z`. This is an implementation of the function
+    `REACHABLE` in [1]_ (which is itself a modification of the
+    Bayes-Ball algorithm [2]_) when restricted to DAGs.
 
     Parameters
     ----------
-    G : nx.Graph
-        An undirected graph.
-    start_node : node
-        The start of the BFS.
-    check_set : set
-        The set of nodes to check against.
+    G : nx.DiGraph
+        A NetworkX DAG.
+    x : node | set
+        A node in the DAG, or a set of nodes.
+    a : node | set
+        A (set of) node(s) in the DAG containing the ancestors of `x`.
+    z : node | set
+        The node or set of nodes conditioned on when checking d-connectedness.
 
     Returns
     -------
-    marked : set
-        A set of nodes that were marked.
+    w : set
+        The closure of `x` in `a` with respect to d-connectedness
+        given `z`.
+
+    References
+    ----------
+    .. [1] van der Zander, Benito, and Maciej Liśkiewicz. "Finding
+        minimal d-separators in linear time and applications." In
+        Uncertainty in Artificial Intelligence, pp. 637-647. PMLR, 2020.
+
+    .. [2] Shachter, Ross D. "Bayes-ball: The rational pastime
+       (for determining irrelevance and requisite information in
+       belief networks and influence diagrams)." In Proceedings of the
+       Fourteenth Conference on Uncertainty in Artificial Intelligence
+       (UAI), (pp. 480–487). 1998.
+    """
+
+    def _pass(e, v, f, n):
+        """Whether a ball entering node `v` along edge `e` passes to `n` along `f`.
+
+        Boolean function defined on page 6 of [1]_.
+
+        Parameters
+        ----------
+        e : bool
+            Directed edge by which the ball got to node `v`; `True` iff directed into `v`.
+        v : node
+            Node where the ball is.
+        f : bool
+            Directed edge connecting nodes `v` and `n`; `True` iff directed `n`.
+        n : node
+            Checking whether the ball passes to this node.
+
+        Returns
+        -------
+        b : bool
+            Whether the ball passes or not.
+
+        References
+        ----------
+        .. [1] van der Zander, Benito, and Maciej Liśkiewicz. "Finding
+           minimal d-separators in linear time and applications." In
+           Uncertainty in Artificial Intelligence, pp. 637-647. PMLR, 2020.
+        """
+        is_element_of_A = n in a
+        # almost_definite_status = True  # always true for DAGs; not so for RCGs
+        collider_if_in_Z = v not in z or (e and not f)
+        return is_element_of_A and collider_if_in_Z  # and almost_definite_status
+
+    queue = deque([])
+    for node in x:
+        if bool(G.pred[node]):
+            queue.append((True, node))
+        if bool(G.succ[node]):
+            queue.append((False, node))
+    processed = queue.copy()
+
+    while any(queue):
+        e, v = queue.popleft()
+        preds = ((False, n) for n in G.pred[v])
+        succs = ((True, n) for n in G.succ[v])
+        f_n_pairs = chain(preds, succs)
+        for f, n in f_n_pairs:
+            if (f, n) not in processed and _pass(e, v, f, n):
+                queue.append((f, n))
+                processed.append((f, n))
+
+    return {w for (_, w) in processed}
+
+
+# Deprecated functions:
+def d_separated(G, x, y, z):
+    """Return whether nodes sets ``x`` and ``y`` are d-separated by ``z``.
+
+    .. deprecated:: 3.3
+
+        This function is deprecated and will be removed in NetworkX v3.5.
+        Please use `is_d_separator(G, x, y, z)`.
+
+    """
+    import warnings
+
+    warnings.warn(
+        "d_separated is deprecated and will be removed in NetworkX v3.5."
+        "Please use `is_d_separator(G, x, y, z)`.",
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
+    return nx.is_d_separator(G, x, y, z)
+
+
+def minimal_d_separator(G, u, v):
+    """Returns a minimal_d-separating set between `x` and `y` if possible
+
+    .. deprecated:: 3.3
+
+        minimal_d_separator is deprecated and will be removed in NetworkX v3.5.
+        Please use `find_minimal_d_separator(G, x, y)`.
+
     """
-    visited = {}
-    marked = set()
-    queue = []
-
-    visited[start_node] = None
-    queue.append(start_node)
-    while queue:
-        m = queue.pop(0)
-
-        for nbr in G.neighbors(m):
-            if nbr not in visited:
-                # memoize where we visited so far
-                visited[nbr] = None
-
-                # mark the node in Z' and do not continue along that path
-                if nbr in check_set:
-                    marked.add(nbr)
-                else:
-                    queue.append(nbr)
-    return marked
+    import warnings
+
+    warnings.warn(
+        (
+            "This function is deprecated and will be removed in NetworkX v3.5."
+            "Please use `is_d_separator(G, x, y)`."
+        ),
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
+    return nx.find_minimal_d_separator(G, u, v)
diff --git a/networkx/conftest.py b/networkx/conftest.py
--- a/networkx/conftest.py
+++ b/networkx/conftest.py
@@ -133,6 +133,12 @@ def set_warnings():
     warnings.filterwarnings(
         "ignore", category=DeprecationWarning, message="\n\nrandom_triad"
     )
+    warnings.filterwarnings(
+        "ignore", category=DeprecationWarning, message="minimal_d_separator"
+    )
+    warnings.filterwarnings(
+        "ignore", category=DeprecationWarning, message="d_separated"
+    )
     warnings.filterwarnings("ignore", category=DeprecationWarning, message="\n\nk_core")
     warnings.filterwarnings(
         "ignore", category=DeprecationWarning, message="\n\nk_shell"


Test patch:
diff --git a/networkx/algorithms/tests/test_d_separation.py b/networkx/algorithms/tests/test_d_separation.py
--- a/networkx/algorithms/tests/test_d_separation.py
+++ b/networkx/algorithms/tests/test_d_separation.py
@@ -81,6 +81,41 @@ def asia_graph_fixture():
     return asia_graph()
 
 
+@pytest.fixture()
+def large_collider_graph():
+    edge_list = [("A", "B"), ("C", "B"), ("B", "D"), ("D", "E"), ("B", "F"), ("G", "E")]
+    G = nx.DiGraph(edge_list)
+    return G
+
+
+@pytest.fixture()
+def chain_and_fork_graph():
+    edge_list = [("A", "B"), ("B", "C"), ("B", "D"), ("D", "C")]
+    G = nx.DiGraph(edge_list)
+    return G
+
+
+@pytest.fixture()
+def no_separating_set_graph():
+    edge_list = [("A", "B")]
+    G = nx.DiGraph(edge_list)
+    return G
+
+
+@pytest.fixture()
+def large_no_separating_set_graph():
+    edge_list = [("A", "B"), ("C", "A"), ("C", "B")]
+    G = nx.DiGraph(edge_list)
+    return G
+
+
+@pytest.fixture()
+def collider_trek_graph():
+    edge_list = [("A", "B"), ("C", "B"), ("C", "D")]
+    G = nx.DiGraph(edge_list)
+    return G
+
+
 @pytest.mark.parametrize(
     "graph",
     [path_graph(), fork_graph(), collider_graph(), naive_bayes_graph(), asia_graph()],
@@ -90,40 +125,40 @@ def test_markov_condition(graph):
     for node in graph.nodes:
         parents = set(graph.predecessors(node))
         non_descendants = graph.nodes - nx.descendants(graph, node) - {node} - parents
-        assert nx.d_separated(graph, {node}, non_descendants, parents)
+        assert nx.is_d_separator(graph, {node}, non_descendants, parents)
 
 
 def test_path_graph_dsep(path_graph):
     """Example-based test of d-separation for path_graph."""
-    assert nx.d_separated(path_graph, {0}, {2}, {1})
-    assert not nx.d_separated(path_graph, {0}, {2}, {})
+    assert nx.is_d_separator(path_graph, {0}, {2}, {1})
+    assert not nx.is_d_separator(path_graph, {0}, {2}, set())
 
 
 def test_fork_graph_dsep(fork_graph):
     """Example-based test of d-separation for fork_graph."""
-    assert nx.d_separated(fork_graph, {1}, {2}, {0})
-    assert not nx.d_separated(fork_graph, {1}, {2}, {})
+    assert nx.is_d_separator(fork_graph, {1}, {2}, {0})
+    assert not nx.is_d_separator(fork_graph, {1}, {2}, set())
 
 
 def test_collider_graph_dsep(collider_graph):
     """Example-based test of d-separation for collider_graph."""
-    assert nx.d_separated(collider_graph, {0}, {1}, {})
-    assert not nx.d_separated(collider_graph, {0}, {1}, {2})
+    assert nx.is_d_separator(collider_graph, {0}, {1}, set())
+    assert not nx.is_d_separator(collider_graph, {0}, {1}, {2})
 
 
 def test_naive_bayes_dsep(naive_bayes_graph):
     """Example-based test of d-separation for naive_bayes_graph."""
     for u, v in combinations(range(1, 5), 2):
-        assert nx.d_separated(naive_bayes_graph, {u}, {v}, {0})
-        assert not nx.d_separated(naive_bayes_graph, {u}, {v}, {})
+        assert nx.is_d_separator(naive_bayes_graph, {u}, {v}, {0})
+        assert not nx.is_d_separator(naive_bayes_graph, {u}, {v}, set())
 
 
 def test_asia_graph_dsep(asia_graph):
     """Example-based test of d-separation for asia_graph."""
-    assert nx.d_separated(
+    assert nx.is_d_separator(
         asia_graph, {"asia", "smoking"}, {"dyspnea", "xray"}, {"bronchitis", "either"}
     )
-    assert nx.d_separated(
+    assert nx.is_d_separator(
         asia_graph, {"tuberculosis", "cancer"}, {"bronchitis"}, {"smoking", "xray"}
     )
 
@@ -137,11 +172,11 @@ def test_undirected_graphs_are_not_supported():
     """
     g = nx.path_graph(3, nx.Graph)
     with pytest.raises(nx.NetworkXNotImplemented):
-        nx.d_separated(g, {0}, {1}, {2})
+        nx.is_d_separator(g, {0}, {1}, {2})
     with pytest.raises(nx.NetworkXNotImplemented):
         nx.is_minimal_d_separator(g, {0}, {1}, {2})
     with pytest.raises(nx.NetworkXNotImplemented):
-        nx.minimal_d_separator(g, {0}, {1})
+        nx.find_minimal_d_separator(g, {0}, {1})
 
 
 def test_cyclic_graphs_raise_error():
@@ -152,60 +187,128 @@ def test_cyclic_graphs_raise_error():
     """
     g = nx.cycle_graph(3, nx.DiGraph)
     with pytest.raises(nx.NetworkXError):
-        nx.d_separated(g, {0}, {1}, {2})
+        nx.is_d_separator(g, {0}, {1}, {2})
     with pytest.raises(nx.NetworkXError):
-        nx.minimal_d_separator(g, 0, 1)
+        nx.find_minimal_d_separator(g, {0}, {1})
     with pytest.raises(nx.NetworkXError):
-        nx.is_minimal_d_separator(g, 0, 1, {2})
+        nx.is_minimal_d_separator(g, {0}, {1}, {2})
 
 
 def test_invalid_nodes_raise_error(asia_graph):
     """
     Test that graphs that have invalid nodes passed in raise errors.
     """
+    # Check both set and node arguments
+    with pytest.raises(nx.NodeNotFound):
+        nx.is_d_separator(asia_graph, {0}, {1}, {2})
+    with pytest.raises(nx.NodeNotFound):
+        nx.is_d_separator(asia_graph, 0, 1, 2)
+    with pytest.raises(nx.NodeNotFound):
+        nx.is_minimal_d_separator(asia_graph, {0}, {1}, {2})
     with pytest.raises(nx.NodeNotFound):
-        nx.d_separated(asia_graph, {0}, {1}, {2})
+        nx.is_minimal_d_separator(asia_graph, 0, 1, 2)
     with pytest.raises(nx.NodeNotFound):
-        nx.is_minimal_d_separator(asia_graph, 0, 1, {2})
+        nx.find_minimal_d_separator(asia_graph, {0}, {1})
     with pytest.raises(nx.NodeNotFound):
-        nx.minimal_d_separator(asia_graph, 0, 1)
+        nx.find_minimal_d_separator(asia_graph, 0, 1)
 
 
-def test_minimal_d_separator():
+def test_nondisjoint_node_sets_raise_error(collider_graph):
+    """
+    Test that error is raised when node sets aren't disjoint.
+    """
+    with pytest.raises(nx.NetworkXError):
+        nx.is_d_separator(collider_graph, 0, 1, 0)
+    with pytest.raises(nx.NetworkXError):
+        nx.is_d_separator(collider_graph, 0, 2, 0)
+    with pytest.raises(nx.NetworkXError):
+        nx.is_d_separator(collider_graph, 0, 0, 1)
+    with pytest.raises(nx.NetworkXError):
+        nx.is_d_separator(collider_graph, 1, 0, 0)
+    with pytest.raises(nx.NetworkXError):
+        nx.find_minimal_d_separator(collider_graph, 0, 0)
+    with pytest.raises(nx.NetworkXError):
+        nx.find_minimal_d_separator(collider_graph, 0, 1, included=0)
+    with pytest.raises(nx.NetworkXError):
+        nx.find_minimal_d_separator(collider_graph, 1, 0, included=0)
+    with pytest.raises(nx.NetworkXError):
+        nx.is_minimal_d_separator(collider_graph, 0, 0, set())
+    with pytest.raises(nx.NetworkXError):
+        nx.is_minimal_d_separator(collider_graph, 0, 1, set(), included=0)
+    with pytest.raises(nx.NetworkXError):
+        nx.is_minimal_d_separator(collider_graph, 1, 0, set(), included=0)
+
+
+def test_is_minimal_d_separator(
+    large_collider_graph,
+    chain_and_fork_graph,
+    no_separating_set_graph,
+    large_no_separating_set_graph,
+    collider_trek_graph,
+):
     # Case 1:
     # create a graph A -> B <- C
     # B -> D -> E;
     # B -> F;
     # G -> E;
-    edge_list = [("A", "B"), ("C", "B"), ("B", "D"), ("D", "E"), ("B", "F"), ("G", "E")]
-    G = nx.DiGraph(edge_list)
-    assert not nx.d_separated(G, {"B"}, {"E"}, set())
+    assert not nx.is_d_separator(large_collider_graph, {"B"}, {"E"}, set())
 
     # minimal set of the corresponding graph
     # for B and E should be (D,)
-    Zmin = nx.minimal_d_separator(G, "B", "E")
-
-    # the minimal separating set should pass the test for minimality
-    assert nx.is_minimal_d_separator(G, "B", "E", Zmin)
+    Zmin = nx.find_minimal_d_separator(large_collider_graph, "B", "E")
+    # check that the minimal d-separator is a d-separating set
+    assert nx.is_d_separator(large_collider_graph, "B", "E", Zmin)
+    # the minimal separating set should also pass the test for minimality
+    assert nx.is_minimal_d_separator(large_collider_graph, "B", "E", Zmin)
+    # function should also work with set arguments
+    assert nx.is_minimal_d_separator(large_collider_graph, {"A", "B"}, {"G", "E"}, Zmin)
     assert Zmin == {"D"}
 
     # Case 2:
     # create a graph A -> B -> C
     # B -> D -> C;
-    edge_list = [("A", "B"), ("B", "C"), ("B", "D"), ("D", "C")]
-    G = nx.DiGraph(edge_list)
-    assert not nx.d_separated(G, {"A"}, {"C"}, set())
-    Zmin = nx.minimal_d_separator(G, "A", "C")
+    assert not nx.is_d_separator(chain_and_fork_graph, {"A"}, {"C"}, set())
+    Zmin = nx.find_minimal_d_separator(chain_and_fork_graph, "A", "C")
 
     # the minimal separating set should pass the test for minimality
-    assert nx.is_minimal_d_separator(G, "A", "C", Zmin)
+    assert nx.is_minimal_d_separator(chain_and_fork_graph, "A", "C", Zmin)
     assert Zmin == {"B"}
-
     Znotmin = Zmin.union({"D"})
-    assert not nx.is_minimal_d_separator(G, "A", "C", Znotmin)
+    assert not nx.is_minimal_d_separator(chain_and_fork_graph, "A", "C", Znotmin)
+
+    # Case 3:
+    # create a graph A -> B
+
+    # there is no m-separating set between A and B at all, so
+    # no minimal m-separating set can exist
+    assert not nx.is_d_separator(no_separating_set_graph, {"A"}, {"B"}, set())
+    assert nx.find_minimal_d_separator(no_separating_set_graph, "A", "B") is None
+
+    # Case 4:
+    # create a graph A -> B with A <- C -> B
+
+    # there is no m-separating set between A and B at all, so
+    # no minimal m-separating set can exist
+    # however, the algorithm will initially propose C as a
+    # minimal (but invalid) separating set
+    assert not nx.is_d_separator(large_no_separating_set_graph, {"A"}, {"B"}, {"C"})
+    assert nx.find_minimal_d_separator(large_no_separating_set_graph, "A", "B") is None
+
+    # Test `included` and `excluded` args
+    # create graph A -> B <- C -> D
+    assert nx.find_minimal_d_separator(collider_trek_graph, "A", "D", included="B") == {
+        "B",
+        "C",
+    }
+    assert (
+        nx.find_minimal_d_separator(
+            collider_trek_graph, "A", "D", included="B", restricted="B"
+        )
+        is None
+    )
 
 
-def test_minimal_d_separator_checks_dsep():
+def test_is_minimal_d_separator_checks_dsep():
     """Test that is_minimal_d_separator checks for d-separation as well."""
     g = nx.DiGraph()
     g.add_edges_from(
@@ -221,8 +324,25 @@ def test_minimal_d_separator_checks_dsep():
         ]
     )
 
-    assert not nx.d_separated(g, {"C"}, {"F"}, {"D"})
+    assert not nx.is_d_separator(g, {"C"}, {"F"}, {"D"})
 
     # since {'D'} and {} are not d-separators, we return false
     assert not nx.is_minimal_d_separator(g, "C", "F", {"D"})
-    assert not nx.is_minimal_d_separator(g, "C", "F", {})
+    assert not nx.is_minimal_d_separator(g, "C", "F", set())
+
+
+def test__reachable(large_collider_graph):
+    reachable = nx.algorithms.d_separation._reachable
+    g = large_collider_graph
+    x = {"F", "D"}
+    ancestors = {"A", "B", "C", "D", "F"}
+    assert reachable(g, x, ancestors, {"B"}) == {"B", "F", "D"}
+    assert reachable(g, x, ancestors, set()) == ancestors
+
+
+def test_deprecations():
+    G = nx.DiGraph([(0, 1), (1, 2)])
+    with pytest.deprecated_call():
+        nx.d_separated(G, 0, 2, {1})
+    with pytest.deprecated_call():
+        z = nx.minimal_d_separator(G, 0, 2)