about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite')
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/__init__.py87
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/basic.py322
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/centrality.py290
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/cluster.py278
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/covering.py57
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/edgelist.py360
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/extendability.py105
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/generators.py604
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matching.py590
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matrix.py168
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/projection.py526
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/redundancy.py112
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/spectral.py69
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_basic.py125
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_centrality.py192
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_cluster.py84
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_covering.py33
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_edgelist.py240
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_extendability.py334
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_generators.py409
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matching.py327
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matrix.py84
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_project.py407
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_redundancy.py35
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_spectral_bipartivity.py80
26 files changed, 5918 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/__init__.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/__init__.py
new file mode 100644
index 00000000..7839db96
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/__init__.py
@@ -0,0 +1,87 @@
+r"""This module provides functions and operations for bipartite
+graphs.  Bipartite graphs `B = (U, V, E)` have two node sets `U,V` and edges in
+`E` that only connect nodes from opposite sets. It is common in the literature
+to use an spatial analogy referring to the two node sets as top and bottom nodes.
+
+The bipartite algorithms are not imported into the networkx namespace
+at the top level so the easiest way to use them is with:
+
+>>> from networkx.algorithms import bipartite
+
+NetworkX does not have a custom bipartite graph class but the Graph()
+or DiGraph() classes can be used to represent bipartite graphs. However,
+you have to keep track of which set each node belongs to, and make
+sure that there is no edge between nodes of the same set. The convention used
+in NetworkX is to use a node attribute named `bipartite` with values 0 or 1 to
+identify the sets each node belongs to. This convention is not enforced in
+the source code of bipartite functions, it's only a recommendation.
+
+For example:
+
+>>> B = nx.Graph()
+>>> # Add nodes with the node attribute "bipartite"
+>>> B.add_nodes_from([1, 2, 3, 4], bipartite=0)
+>>> B.add_nodes_from(["a", "b", "c"], bipartite=1)
+>>> # Add edges only between nodes of opposite node sets
+>>> B.add_edges_from([(1, "a"), (1, "b"), (2, "b"), (2, "c"), (3, "c"), (4, "a")])
+
+Many algorithms of the bipartite module of NetworkX require, as an argument, a
+container with all the nodes that belong to one set, in addition to the bipartite
+graph `B`. The functions in the bipartite package do not check that the node set
+is actually correct nor that the input graph is actually bipartite.
+If `B` is connected, you can find the two node sets using a two-coloring
+algorithm:
+
+>>> nx.is_connected(B)
+True
+>>> bottom_nodes, top_nodes = bipartite.sets(B)
+
+However, if the input graph is not connected, there are more than one possible
+colorations. This is the reason why we require the user to pass a container
+with all nodes of one bipartite node set as an argument to most bipartite
+functions. In the face of ambiguity, we refuse the temptation to guess and
+raise an :exc:`AmbiguousSolution <networkx.AmbiguousSolution>`
+Exception if the input graph for
+:func:`bipartite.sets <networkx.algorithms.bipartite.basic.sets>`
+is disconnected.
+
+Using the `bipartite` node attribute, you can easily get the two node sets:
+
+>>> top_nodes = {n for n, d in B.nodes(data=True) if d["bipartite"] == 0}
+>>> bottom_nodes = set(B) - top_nodes
+
+So you can easily use the bipartite algorithms that require, as an argument, a
+container with all nodes that belong to one node set:
+
+>>> print(round(bipartite.density(B, bottom_nodes), 2))
+0.5
+>>> G = bipartite.projected_graph(B, top_nodes)
+
+All bipartite graph generators in NetworkX build bipartite graphs with the
+`bipartite` node attribute. Thus, you can use the same approach:
+
+>>> RB = bipartite.random_graph(5, 7, 0.2)
+>>> RB_top = {n for n, d in RB.nodes(data=True) if d["bipartite"] == 0}
+>>> RB_bottom = set(RB) - RB_top
+>>> list(RB_top)
+[0, 1, 2, 3, 4]
+>>> list(RB_bottom)
+[5, 6, 7, 8, 9, 10, 11]
+
+For other bipartite graph generators see
+:mod:`Generators <networkx.algorithms.bipartite.generators>`.
+
+"""
+
+from networkx.algorithms.bipartite.basic import *
+from networkx.algorithms.bipartite.centrality import *
+from networkx.algorithms.bipartite.cluster import *
+from networkx.algorithms.bipartite.covering import *
+from networkx.algorithms.bipartite.edgelist import *
+from networkx.algorithms.bipartite.matching import *
+from networkx.algorithms.bipartite.matrix import *
+from networkx.algorithms.bipartite.projection import *
+from networkx.algorithms.bipartite.redundancy import *
+from networkx.algorithms.bipartite.spectral import *
+from networkx.algorithms.bipartite.generators import *
+from networkx.algorithms.bipartite.extendability import *
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/basic.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/basic.py
new file mode 100644
index 00000000..8d9a4d5b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/basic.py
@@ -0,0 +1,322 @@
+"""
+==========================
+Bipartite Graph Algorithms
+==========================
+"""
+
+import networkx as nx
+from networkx.algorithms.components import connected_components
+from networkx.exception import AmbiguousSolution
+
+__all__ = [
+    "is_bipartite",
+    "is_bipartite_node_set",
+    "color",
+    "sets",
+    "density",
+    "degrees",
+]
+
+
+@nx._dispatchable
+def color(G):
+    """Returns a two-coloring of the graph.
+
+    Raises an exception if the graph is not bipartite.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+    Returns
+    -------
+    color : dictionary
+        A dictionary keyed by node with a 1 or 0 as data for each node color.
+
+    Raises
+    ------
+    NetworkXError
+        If the graph is not two-colorable.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> c = bipartite.color(G)
+    >>> print(c)
+    {0: 1, 1: 0, 2: 1, 3: 0}
+
+    You can use this to set a node attribute indicating the bipartite set:
+
+    >>> nx.set_node_attributes(G, c, "bipartite")
+    >>> print(G.nodes[0]["bipartite"])
+    1
+    >>> print(G.nodes[1]["bipartite"])
+    0
+    """
+    if G.is_directed():
+        import itertools
+
+        def neighbors(v):
+            return itertools.chain.from_iterable([G.predecessors(v), G.successors(v)])
+
+    else:
+        neighbors = G.neighbors
+
+    color = {}
+    for n in G:  # handle disconnected graphs
+        if n in color or len(G[n]) == 0:  # skip isolates
+            continue
+        queue = [n]
+        color[n] = 1  # nodes seen with color (1 or 0)
+        while queue:
+            v = queue.pop()
+            c = 1 - color[v]  # opposite color of node v
+            for w in neighbors(v):
+                if w in color:
+                    if color[w] == color[v]:
+                        raise nx.NetworkXError("Graph is not bipartite.")
+                else:
+                    color[w] = c
+                    queue.append(w)
+    # color isolates with 0
+    color.update(dict.fromkeys(nx.isolates(G), 0))
+    return color
+
+
+@nx._dispatchable
+def is_bipartite(G):
+    """Returns True if graph G is bipartite, False if not.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> print(bipartite.is_bipartite(G))
+    True
+
+    See Also
+    --------
+    color, is_bipartite_node_set
+    """
+    try:
+        color(G)
+        return True
+    except nx.NetworkXError:
+        return False
+
+
+@nx._dispatchable
+def is_bipartite_node_set(G, nodes):
+    """Returns True if nodes and G/nodes are a bipartition of G.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+    nodes: list or container
+      Check if nodes are a one of a bipartite set.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> X = set([1, 3])
+    >>> bipartite.is_bipartite_node_set(G, X)
+    True
+
+    Notes
+    -----
+    An exception is raised if the input nodes are not distinct, because in this
+    case some bipartite algorithms will yield incorrect results.
+    For connected graphs the bipartite sets are unique.  This function handles
+    disconnected graphs.
+    """
+    S = set(nodes)
+
+    if len(S) < len(nodes):
+        # this should maybe just return False?
+        raise AmbiguousSolution(
+            "The input node set contains duplicates.\n"
+            "This may lead to incorrect results when using it in bipartite algorithms.\n"
+            "Consider using set(nodes) as the input"
+        )
+
+    for CC in (G.subgraph(c).copy() for c in connected_components(G)):
+        X, Y = sets(CC)
+        if not (
+            (X.issubset(S) and Y.isdisjoint(S)) or (Y.issubset(S) and X.isdisjoint(S))
+        ):
+            return False
+    return True
+
+
+@nx._dispatchable
+def sets(G, top_nodes=None):
+    """Returns bipartite node sets of graph G.
+
+    Raises an exception if the graph is not bipartite or if the input
+    graph is disconnected and thus more than one valid solution exists.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+    top_nodes : container, optional
+      Container with all nodes in one bipartite node set. If not supplied
+      it will be computed. But if more than one solution exists an exception
+      will be raised.
+
+    Returns
+    -------
+    X : set
+      Nodes from one side of the bipartite graph.
+    Y : set
+      Nodes from the other side.
+
+    Raises
+    ------
+    AmbiguousSolution
+      Raised if the input bipartite graph is disconnected and no container
+      with all nodes in one bipartite set is provided. When determining
+      the nodes in each bipartite set more than one valid solution is
+      possible if the input graph is disconnected.
+    NetworkXError
+      Raised if the input graph is not bipartite.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> X, Y = bipartite.sets(G)
+    >>> list(X)
+    [0, 2]
+    >>> list(Y)
+    [1, 3]
+
+    See Also
+    --------
+    color
+
+    """
+    if G.is_directed():
+        is_connected = nx.is_weakly_connected
+    else:
+        is_connected = nx.is_connected
+    if top_nodes is not None:
+        X = set(top_nodes)
+        Y = set(G) - X
+    else:
+        if not is_connected(G):
+            msg = "Disconnected graph: Ambiguous solution for bipartite sets."
+            raise nx.AmbiguousSolution(msg)
+        c = color(G)
+        X = {n for n, is_top in c.items() if is_top}
+        Y = {n for n, is_top in c.items() if not is_top}
+    return (X, Y)
+
+
+@nx._dispatchable(graphs="B")
+def density(B, nodes):
+    """Returns density of bipartite graph B.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+
+    nodes: list or container
+      Nodes in one node set of the bipartite graph.
+
+    Returns
+    -------
+    d : float
+       The bipartite density
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.complete_bipartite_graph(3, 2)
+    >>> X = set([0, 1, 2])
+    >>> bipartite.density(G, X)
+    1.0
+    >>> Y = set([3, 4])
+    >>> bipartite.density(G, Y)
+    1.0
+
+    Notes
+    -----
+    The container of nodes passed as argument must contain all nodes
+    in one of the two bipartite node sets to avoid ambiguity in the
+    case of disconnected graphs.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    color
+    """
+    n = len(B)
+    m = nx.number_of_edges(B)
+    nb = len(nodes)
+    nt = n - nb
+    if m == 0:  # includes cases n==0 and n==1
+        d = 0.0
+    else:
+        if B.is_directed():
+            d = m / (2 * nb * nt)
+        else:
+            d = m / (nb * nt)
+    return d
+
+
+@nx._dispatchable(graphs="B", edge_attrs="weight")
+def degrees(B, nodes, weight=None):
+    """Returns the degrees of the two node sets in the bipartite graph B.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+
+    nodes: list or container
+      Nodes in one node set of the bipartite graph.
+
+    weight : string or None, optional (default=None)
+       The edge attribute that holds the numerical value used as a weight.
+       If None, then each edge has weight 1.
+       The degree is the sum of the edge weights adjacent to the node.
+
+    Returns
+    -------
+    (degX,degY) : tuple of dictionaries
+       The degrees of the two bipartite sets as dictionaries keyed by node.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.complete_bipartite_graph(3, 2)
+    >>> Y = set([3, 4])
+    >>> degX, degY = bipartite.degrees(G, Y)
+    >>> dict(degX)
+    {0: 2, 1: 2, 2: 2}
+
+    Notes
+    -----
+    The container of nodes passed as argument must contain all nodes
+    in one of the two bipartite node sets to avoid ambiguity in the
+    case of disconnected graphs.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    color, density
+    """
+    bottom = set(nodes)
+    top = set(B) - bottom
+    return (B.degree(top, weight), B.degree(bottom, weight))
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/centrality.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/centrality.py
new file mode 100644
index 00000000..42d7270e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/centrality.py
@@ -0,0 +1,290 @@
+import networkx as nx
+
+__all__ = ["degree_centrality", "betweenness_centrality", "closeness_centrality"]
+
+
+@nx._dispatchable(name="bipartite_degree_centrality")
+def degree_centrality(G, nodes):
+    r"""Compute the degree centrality for nodes in a bipartite network.
+
+    The degree centrality for a node `v` is the fraction of nodes
+    connected to it.
+
+    Parameters
+    ----------
+    G : graph
+       A bipartite network
+
+    nodes : list or container
+      Container with all nodes in one bipartite node set.
+
+    Returns
+    -------
+    centrality : dictionary
+       Dictionary keyed by node with bipartite degree centrality as the value.
+
+    Examples
+    --------
+    >>> G = nx.wheel_graph(5)
+    >>> top_nodes = {0, 1, 2}
+    >>> nx.bipartite.degree_centrality(G, nodes=top_nodes)
+    {0: 2.0, 1: 1.5, 2: 1.5, 3: 1.0, 4: 1.0}
+
+    See Also
+    --------
+    betweenness_centrality
+    closeness_centrality
+    :func:`~networkx.algorithms.bipartite.basic.sets`
+    :func:`~networkx.algorithms.bipartite.basic.is_bipartite`
+
+    Notes
+    -----
+    The nodes input parameter must contain all nodes in one bipartite node set,
+    but the dictionary returned contains all nodes from both bipartite node
+    sets. See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    For unipartite networks, the degree centrality values are
+    normalized by dividing by the maximum possible degree (which is
+    `n-1` where `n` is the number of nodes in G).
+
+    In the bipartite case, the maximum possible degree of a node in a
+    bipartite node set is the number of nodes in the opposite node set
+    [1]_.  The degree centrality for a node `v` in the bipartite
+    sets `U` with `n` nodes and `V` with `m` nodes is
+
+    .. math::
+
+        d_{v} = \frac{deg(v)}{m}, \mbox{for} v \in U ,
+
+        d_{v} = \frac{deg(v)}{n}, \mbox{for} v \in V ,
+
+
+    where `deg(v)` is the degree of node `v`.
+
+    References
+    ----------
+    .. [1] Borgatti, S.P. and Halgin, D. In press. "Analyzing Affiliation
+        Networks". In Carrington, P. and Scott, J. (eds) The Sage Handbook
+        of Social Network Analysis. Sage Publications.
+        https://dx.doi.org/10.4135/9781446294413.n28
+    """
+    top = set(nodes)
+    bottom = set(G) - top
+    s = 1.0 / len(bottom)
+    centrality = {n: d * s for n, d in G.degree(top)}
+    s = 1.0 / len(top)
+    centrality.update({n: d * s for n, d in G.degree(bottom)})
+    return centrality
+
+
+@nx._dispatchable(name="bipartite_betweenness_centrality")
+def betweenness_centrality(G, nodes):
+    r"""Compute betweenness centrality for nodes in a bipartite network.
+
+    Betweenness centrality of a node `v` is the sum of the
+    fraction of all-pairs shortest paths that pass through `v`.
+
+    Values of betweenness are normalized by the maximum possible
+    value which for bipartite graphs is limited by the relative size
+    of the two node sets [1]_.
+
+    Let `n` be the number of nodes in the node set `U` and
+    `m` be the number of nodes in the node set `V`, then
+    nodes in `U` are normalized by dividing by
+
+    .. math::
+
+       \frac{1}{2} [m^2 (s + 1)^2 + m (s + 1)(2t - s - 1) - t (2s - t + 3)] ,
+
+    where
+
+    .. math::
+
+        s = (n - 1) \div m , t = (n - 1) \mod m ,
+
+    and nodes in `V` are normalized by dividing by
+
+    .. math::
+
+        \frac{1}{2} [n^2 (p + 1)^2 + n (p + 1)(2r - p - 1) - r (2p - r + 3)] ,
+
+    where,
+
+    .. math::
+
+        p = (m - 1) \div n , r = (m - 1) \mod n .
+
+    Parameters
+    ----------
+    G : graph
+        A bipartite graph
+
+    nodes : list or container
+        Container with all nodes in one bipartite node set.
+
+    Returns
+    -------
+    betweenness : dictionary
+        Dictionary keyed by node with bipartite betweenness centrality
+        as the value.
+
+    Examples
+    --------
+    >>> G = nx.cycle_graph(4)
+    >>> top_nodes = {1, 2}
+    >>> nx.bipartite.betweenness_centrality(G, nodes=top_nodes)
+    {0: 0.25, 1: 0.25, 2: 0.25, 3: 0.25}
+
+    See Also
+    --------
+    degree_centrality
+    closeness_centrality
+    :func:`~networkx.algorithms.bipartite.basic.sets`
+    :func:`~networkx.algorithms.bipartite.basic.is_bipartite`
+
+    Notes
+    -----
+    The nodes input parameter must contain all nodes in one bipartite node set,
+    but the dictionary returned contains all nodes from both node sets.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+
+    References
+    ----------
+    .. [1] Borgatti, S.P. and Halgin, D. In press. "Analyzing Affiliation
+        Networks". In Carrington, P. and Scott, J. (eds) The Sage Handbook
+        of Social Network Analysis. Sage Publications.
+        https://dx.doi.org/10.4135/9781446294413.n28
+    """
+    top = set(nodes)
+    bottom = set(G) - top
+    n = len(top)
+    m = len(bottom)
+    s, t = divmod(n - 1, m)
+    bet_max_top = (
+        ((m**2) * ((s + 1) ** 2))
+        + (m * (s + 1) * (2 * t - s - 1))
+        - (t * ((2 * s) - t + 3))
+    ) / 2.0
+    p, r = divmod(m - 1, n)
+    bet_max_bot = (
+        ((n**2) * ((p + 1) ** 2))
+        + (n * (p + 1) * (2 * r - p - 1))
+        - (r * ((2 * p) - r + 3))
+    ) / 2.0
+    betweenness = nx.betweenness_centrality(G, normalized=False, weight=None)
+    for node in top:
+        betweenness[node] /= bet_max_top
+    for node in bottom:
+        betweenness[node] /= bet_max_bot
+    return betweenness
+
+
+@nx._dispatchable(name="bipartite_closeness_centrality")
+def closeness_centrality(G, nodes, normalized=True):
+    r"""Compute the closeness centrality for nodes in a bipartite network.
+
+    The closeness of a node is the distance to all other nodes in the
+    graph or in the case that the graph is not connected to all other nodes
+    in the connected component containing that node.
+
+    Parameters
+    ----------
+    G : graph
+        A bipartite network
+
+    nodes : list or container
+        Container with all nodes in one bipartite node set.
+
+    normalized : bool, optional
+      If True (default) normalize by connected component size.
+
+    Returns
+    -------
+    closeness : dictionary
+        Dictionary keyed by node with bipartite closeness centrality
+        as the value.
+
+    Examples
+    --------
+    >>> G = nx.wheel_graph(5)
+    >>> top_nodes = {0, 1, 2}
+    >>> nx.bipartite.closeness_centrality(G, nodes=top_nodes)
+    {0: 1.5, 1: 1.2, 2: 1.2, 3: 1.0, 4: 1.0}
+
+    See Also
+    --------
+    betweenness_centrality
+    degree_centrality
+    :func:`~networkx.algorithms.bipartite.basic.sets`
+    :func:`~networkx.algorithms.bipartite.basic.is_bipartite`
+
+    Notes
+    -----
+    The nodes input parameter must contain all nodes in one bipartite node set,
+    but the dictionary returned contains all nodes from both node sets.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+
+    Closeness centrality is normalized by the minimum distance possible.
+    In the bipartite case the minimum distance for a node in one bipartite
+    node set is 1 from all nodes in the other node set and 2 from all
+    other nodes in its own set [1]_. Thus the closeness centrality
+    for node `v`  in the two bipartite sets `U` with
+    `n` nodes and `V` with `m` nodes is
+
+    .. math::
+
+        c_{v} = \frac{m + 2(n - 1)}{d}, \mbox{for} v \in U,
+
+        c_{v} = \frac{n + 2(m - 1)}{d}, \mbox{for} v \in V,
+
+    where `d` is the sum of the distances from `v` to all
+    other nodes.
+
+    Higher values of closeness  indicate higher centrality.
+
+    As in the unipartite case, setting normalized=True causes the
+    values to normalized further to n-1 / size(G)-1 where n is the
+    number of nodes in the connected part of graph containing the
+    node.  If the graph is not completely connected, this algorithm
+    computes the closeness centrality for each connected part
+    separately.
+
+    References
+    ----------
+    .. [1] Borgatti, S.P. and Halgin, D. In press. "Analyzing Affiliation
+        Networks". In Carrington, P. and Scott, J. (eds) The Sage Handbook
+        of Social Network Analysis. Sage Publications.
+        https://dx.doi.org/10.4135/9781446294413.n28
+    """
+    closeness = {}
+    path_length = nx.single_source_shortest_path_length
+    top = set(nodes)
+    bottom = set(G) - top
+    n = len(top)
+    m = len(bottom)
+    for node in top:
+        sp = dict(path_length(G, node))
+        totsp = sum(sp.values())
+        if totsp > 0.0 and len(G) > 1:
+            closeness[node] = (m + 2 * (n - 1)) / totsp
+            if normalized:
+                s = (len(sp) - 1) / (len(G) - 1)
+                closeness[node] *= s
+        else:
+            closeness[node] = 0.0
+    for node in bottom:
+        sp = dict(path_length(G, node))
+        totsp = sum(sp.values())
+        if totsp > 0.0 and len(G) > 1:
+            closeness[node] = (n + 2 * (m - 1)) / totsp
+            if normalized:
+                s = (len(sp) - 1) / (len(G) - 1)
+                closeness[node] *= s
+        else:
+            closeness[node] = 0.0
+    return closeness
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/cluster.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/cluster.py
new file mode 100644
index 00000000..5b66b280
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/cluster.py
@@ -0,0 +1,278 @@
+"""Functions for computing clustering of pairs"""
+
+import itertools
+
+import networkx as nx
+
+__all__ = [
+    "clustering",
+    "average_clustering",
+    "latapy_clustering",
+    "robins_alexander_clustering",
+]
+
+
+def cc_dot(nu, nv):
+    return len(nu & nv) / len(nu | nv)
+
+
+def cc_max(nu, nv):
+    return len(nu & nv) / max(len(nu), len(nv))
+
+
+def cc_min(nu, nv):
+    return len(nu & nv) / min(len(nu), len(nv))
+
+
+modes = {"dot": cc_dot, "min": cc_min, "max": cc_max}
+
+
+@nx._dispatchable
+def latapy_clustering(G, nodes=None, mode="dot"):
+    r"""Compute a bipartite clustering coefficient for nodes.
+
+    The bipartite clustering coefficient is a measure of local density
+    of connections defined as [1]_:
+
+    .. math::
+
+       c_u = \frac{\sum_{v \in N(N(u))} c_{uv} }{|N(N(u))|}
+
+    where `N(N(u))` are the second order neighbors of `u` in `G` excluding `u`,
+    and `c_{uv}` is the pairwise clustering coefficient between nodes
+    `u` and `v`.
+
+    The mode selects the function for `c_{uv}` which can be:
+
+    `dot`:
+
+    .. math::
+
+       c_{uv}=\frac{|N(u)\cap N(v)|}{|N(u) \cup N(v)|}
+
+    `min`:
+
+    .. math::
+
+       c_{uv}=\frac{|N(u)\cap N(v)|}{min(|N(u)|,|N(v)|)}
+
+    `max`:
+
+    .. math::
+
+       c_{uv}=\frac{|N(u)\cap N(v)|}{max(|N(u)|,|N(v)|)}
+
+
+    Parameters
+    ----------
+    G : graph
+        A bipartite graph
+
+    nodes : list or iterable (optional)
+        Compute bipartite clustering for these nodes. The default
+        is all nodes in G.
+
+    mode : string
+        The pairwise bipartite clustering method to be used in the computation.
+        It must be "dot", "max", or "min".
+
+    Returns
+    -------
+    clustering : dictionary
+        A dictionary keyed by node with the clustering coefficient value.
+
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)  # path graphs are bipartite
+    >>> c = bipartite.clustering(G)
+    >>> c[0]
+    0.5
+    >>> c = bipartite.clustering(G, mode="min")
+    >>> c[0]
+    1.0
+
+    See Also
+    --------
+    robins_alexander_clustering
+    average_clustering
+    networkx.algorithms.cluster.square_clustering
+
+    References
+    ----------
+    .. [1] Latapy, Matthieu, Clémence Magnien, and Nathalie Del Vecchio (2008).
+       Basic notions for the analysis of large two-mode networks.
+       Social Networks 30(1), 31--48.
+    """
+    if not nx.algorithms.bipartite.is_bipartite(G):
+        raise nx.NetworkXError("Graph is not bipartite")
+
+    try:
+        cc_func = modes[mode]
+    except KeyError as err:
+        raise nx.NetworkXError(
+            "Mode for bipartite clustering must be: dot, min or max"
+        ) from err
+
+    if nodes is None:
+        nodes = G
+    ccs = {}
+    for v in nodes:
+        cc = 0.0
+        nbrs2 = {u for nbr in G[v] for u in G[nbr]} - {v}
+        for u in nbrs2:
+            cc += cc_func(set(G[u]), set(G[v]))
+        if cc > 0.0:  # len(nbrs2)>0
+            cc /= len(nbrs2)
+        ccs[v] = cc
+    return ccs
+
+
+clustering = latapy_clustering
+
+
+@nx._dispatchable(name="bipartite_average_clustering")
+def average_clustering(G, nodes=None, mode="dot"):
+    r"""Compute the average bipartite clustering coefficient.
+
+    A clustering coefficient for the whole graph is the average,
+
+    .. math::
+
+       C = \frac{1}{n}\sum_{v \in G} c_v,
+
+    where `n` is the number of nodes in `G`.
+
+    Similar measures for the two bipartite sets can be defined [1]_
+
+    .. math::
+
+       C_X = \frac{1}{|X|}\sum_{v \in X} c_v,
+
+    where `X` is a bipartite set of `G`.
+
+    Parameters
+    ----------
+    G : graph
+        a bipartite graph
+
+    nodes : list or iterable, optional
+        A container of nodes to use in computing the average.
+        The nodes should be either the entire graph (the default) or one of the
+        bipartite sets.
+
+    mode : string
+        The pairwise bipartite clustering method.
+        It must be "dot", "max", or "min"
+
+    Returns
+    -------
+    clustering : float
+       The average bipartite clustering for the given set of nodes or the
+       entire graph if no nodes are specified.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.star_graph(3)  # star graphs are bipartite
+    >>> bipartite.average_clustering(G)
+    0.75
+    >>> X, Y = bipartite.sets(G)
+    >>> bipartite.average_clustering(G, X)
+    0.0
+    >>> bipartite.average_clustering(G, Y)
+    1.0
+
+    See Also
+    --------
+    clustering
+
+    Notes
+    -----
+    The container of nodes passed to this function must contain all of the nodes
+    in one of the bipartite sets ("top" or "bottom") in order to compute
+    the correct average bipartite clustering coefficients.
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+
+    References
+    ----------
+    .. [1] Latapy, Matthieu, Clémence Magnien, and Nathalie Del Vecchio (2008).
+        Basic notions for the analysis of large two-mode networks.
+        Social Networks 30(1), 31--48.
+    """
+    if nodes is None:
+        nodes = G
+    ccs = latapy_clustering(G, nodes=nodes, mode=mode)
+    return sum(ccs[v] for v in nodes) / len(nodes)
+
+
+@nx._dispatchable
+def robins_alexander_clustering(G):
+    r"""Compute the bipartite clustering of G.
+
+    Robins and Alexander [1]_ defined bipartite clustering coefficient as
+    four times the number of four cycles `C_4` divided by the number of
+    three paths `L_3` in a bipartite graph:
+
+    .. math::
+
+       CC_4 = \frac{4 * C_4}{L_3}
+
+    Parameters
+    ----------
+    G : graph
+        a bipartite graph
+
+    Returns
+    -------
+    clustering : float
+       The Robins and Alexander bipartite clustering for the input graph.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.davis_southern_women_graph()
+    >>> print(round(bipartite.robins_alexander_clustering(G), 3))
+    0.468
+
+    See Also
+    --------
+    latapy_clustering
+    networkx.algorithms.cluster.square_clustering
+
+    References
+    ----------
+    .. [1] Robins, G. and M. Alexander (2004). Small worlds among interlocking
+           directors: Network structure and distance in bipartite graphs.
+           Computational & Mathematical Organization Theory 10(1), 69–94.
+
+    """
+    if G.order() < 4 or G.size() < 3:
+        return 0
+    L_3 = _threepaths(G)
+    if L_3 == 0:
+        return 0
+    C_4 = _four_cycles(G)
+    return (4.0 * C_4) / L_3
+
+
+def _four_cycles(G):
+    cycles = 0
+    for v in G:
+        for u, w in itertools.combinations(G[v], 2):
+            cycles += len((set(G[u]) & set(G[w])) - {v})
+    return cycles / 4
+
+
+def _threepaths(G):
+    paths = 0
+    for v in G:
+        for u in G[v]:
+            for w in set(G[u]) - {v}:
+                paths += len(set(G[w]) - {v, u})
+    # Divide by two because we count each three path twice
+    # one for each possible starting point
+    return paths / 2
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/covering.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/covering.py
new file mode 100644
index 00000000..f937903e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/covering.py
@@ -0,0 +1,57 @@
+"""Functions related to graph covers."""
+
+import networkx as nx
+from networkx.algorithms.bipartite.matching import hopcroft_karp_matching
+from networkx.algorithms.covering import min_edge_cover as _min_edge_cover
+from networkx.utils import not_implemented_for
+
+__all__ = ["min_edge_cover"]
+
+
+@not_implemented_for("directed")
+@not_implemented_for("multigraph")
+@nx._dispatchable(name="bipartite_min_edge_cover")
+def min_edge_cover(G, matching_algorithm=None):
+    """Returns a set of edges which constitutes
+    the minimum edge cover of the graph.
+
+    The smallest edge cover can be found in polynomial time by finding
+    a maximum matching and extending it greedily so that all nodes
+    are covered.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+        An undirected bipartite graph.
+
+    matching_algorithm : function
+        A function that returns a maximum cardinality matching in a
+        given bipartite graph. The function must take one input, the
+        graph ``G``, and return a dictionary mapping each node to its
+        mate. If not specified,
+        :func:`~networkx.algorithms.bipartite.matching.hopcroft_karp_matching`
+        will be used. Other possibilities include
+        :func:`~networkx.algorithms.bipartite.matching.eppstein_matching`,
+
+    Returns
+    -------
+    set
+        A set of the edges in a minimum edge cover of the graph, given as
+        pairs of nodes. It contains both the edges `(u, v)` and `(v, u)`
+        for given nodes `u` and `v` among the edges of minimum edge cover.
+
+    Notes
+    -----
+    An edge cover of a graph is a set of edges such that every node of
+    the graph is incident to at least one edge of the set.
+    A minimum edge cover is an edge covering of smallest cardinality.
+
+    Due to its implementation, the worst-case running time of this algorithm
+    is bounded by the worst-case running time of the function
+    ``matching_algorithm``.
+    """
+    if G.order() == 0:  # Special case for the empty graph
+        return set()
+    if matching_algorithm is None:
+        matching_algorithm = hopcroft_karp_matching
+    return _min_edge_cover(G, matching_algorithm=matching_algorithm)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/edgelist.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/edgelist.py
new file mode 100644
index 00000000..db6ef9d8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/edgelist.py
@@ -0,0 +1,360 @@
+"""
+********************
+Bipartite Edge Lists
+********************
+Read and write NetworkX graphs as bipartite edge lists.
+
+Format
+------
+You can read or write three formats of edge lists with these functions.
+
+Node pairs with no data::
+
+ 1 2
+
+Python dictionary as data::
+
+ 1 2 {'weight':7, 'color':'green'}
+
+Arbitrary data::
+
+ 1 2 7 green
+
+For each edge (u, v) the node u is assigned to part 0 and the node v to part 1.
+"""
+
+__all__ = ["generate_edgelist", "write_edgelist", "parse_edgelist", "read_edgelist"]
+
+import networkx as nx
+from networkx.utils import not_implemented_for, open_file
+
+
+@open_file(1, mode="wb")
+def write_edgelist(G, path, comments="#", delimiter=" ", data=True, encoding="utf-8"):
+    """Write a bipartite graph as a list of edges.
+
+    Parameters
+    ----------
+    G : Graph
+       A NetworkX bipartite graph
+    path : file or string
+       File or filename to write. If a file is provided, it must be
+       opened in 'wb' mode. Filenames ending in .gz or .bz2 will be compressed.
+    comments : string, optional
+       The character used to indicate the start of a comment
+    delimiter : string, optional
+       The string used to separate values.  The default is whitespace.
+    data : bool or list, optional
+       If False write no edge data.
+       If True write a string representation of the edge data dictionary..
+       If a list (or other iterable) is provided, write the  keys specified
+       in the list.
+    encoding: string, optional
+       Specify which encoding to use when writing file.
+
+    Examples
+    --------
+    >>> G = nx.path_graph(4)
+    >>> G.add_nodes_from([0, 2], bipartite=0)
+    >>> G.add_nodes_from([1, 3], bipartite=1)
+    >>> nx.write_edgelist(G, "test.edgelist")
+    >>> fh = open("test.edgelist", "wb")
+    >>> nx.write_edgelist(G, fh)
+    >>> nx.write_edgelist(G, "test.edgelist.gz")
+    >>> nx.write_edgelist(G, "test.edgelist.gz", data=False)
+
+    >>> G = nx.Graph()
+    >>> G.add_edge(1, 2, weight=7, color="red")
+    >>> nx.write_edgelist(G, "test.edgelist", data=False)
+    >>> nx.write_edgelist(G, "test.edgelist", data=["color"])
+    >>> nx.write_edgelist(G, "test.edgelist", data=["color", "weight"])
+
+    See Also
+    --------
+    write_edgelist
+    generate_edgelist
+    """
+    for line in generate_edgelist(G, delimiter, data):
+        line += "\n"
+        path.write(line.encode(encoding))
+
+
+@not_implemented_for("directed")
+def generate_edgelist(G, delimiter=" ", data=True):
+    """Generate a single line of the bipartite graph G in edge list format.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+       The graph is assumed to have node attribute `part` set to 0,1 representing
+       the two graph parts
+
+    delimiter : string, optional
+       Separator for node labels
+
+    data : bool or list of keys
+       If False generate no edge data.  If True use a dictionary
+       representation of edge data.  If a list of keys use a list of data
+       values corresponding to the keys.
+
+    Returns
+    -------
+    lines : string
+        Lines of data in adjlist format.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> G.add_nodes_from([0, 2], bipartite=0)
+    >>> G.add_nodes_from([1, 3], bipartite=1)
+    >>> G[1][2]["weight"] = 3
+    >>> G[2][3]["capacity"] = 12
+    >>> for line in bipartite.generate_edgelist(G, data=False):
+    ...     print(line)
+    0 1
+    2 1
+    2 3
+
+    >>> for line in bipartite.generate_edgelist(G):
+    ...     print(line)
+    0 1 {}
+    2 1 {'weight': 3}
+    2 3 {'capacity': 12}
+
+    >>> for line in bipartite.generate_edgelist(G, data=["weight"]):
+    ...     print(line)
+    0 1
+    2 1 3
+    2 3
+    """
+    try:
+        part0 = [n for n, d in G.nodes.items() if d["bipartite"] == 0]
+    except BaseException as err:
+        raise AttributeError("Missing node attribute `bipartite`") from err
+    if data is True or data is False:
+        for n in part0:
+            for edge in G.edges(n, data=data):
+                yield delimiter.join(map(str, edge))
+    else:
+        for n in part0:
+            for u, v, d in G.edges(n, data=True):
+                edge = [u, v]
+                try:
+                    edge.extend(d[k] for k in data)
+                except KeyError:
+                    pass  # missing data for this edge, should warn?
+                yield delimiter.join(map(str, edge))
+
+
+@nx._dispatchable(name="bipartite_parse_edgelist", graphs=None, returns_graph=True)
+def parse_edgelist(
+    lines, comments="#", delimiter=None, create_using=None, nodetype=None, data=True
+):
+    """Parse lines of an edge list representation of a bipartite graph.
+
+    Parameters
+    ----------
+    lines : list or iterator of strings
+        Input data in edgelist format
+    comments : string, optional
+       Marker for comment lines
+    delimiter : string, optional
+       Separator for node labels
+    create_using: NetworkX graph container, optional
+       Use given NetworkX graph for holding nodes or edges.
+    nodetype : Python type, optional
+       Convert nodes to this type.
+    data : bool or list of (label,type) tuples
+       If False generate no edge data or if True use a dictionary
+       representation of edge data or a list tuples specifying dictionary
+       key names and types for edge data.
+
+    Returns
+    -------
+    G: NetworkX Graph
+        The bipartite graph corresponding to lines
+
+    Examples
+    --------
+    Edgelist with no data:
+
+    >>> from networkx.algorithms import bipartite
+    >>> lines = ["1 2", "2 3", "3 4"]
+    >>> G = bipartite.parse_edgelist(lines, nodetype=int)
+    >>> sorted(G.nodes())
+    [1, 2, 3, 4]
+    >>> sorted(G.nodes(data=True))
+    [(1, {'bipartite': 0}), (2, {'bipartite': 0}), (3, {'bipartite': 0}), (4, {'bipartite': 1})]
+    >>> sorted(G.edges())
+    [(1, 2), (2, 3), (3, 4)]
+
+    Edgelist with data in Python dictionary representation:
+
+    >>> lines = ["1 2 {'weight':3}", "2 3 {'weight':27}", "3 4 {'weight':3.0}"]
+    >>> G = bipartite.parse_edgelist(lines, nodetype=int)
+    >>> sorted(G.nodes())
+    [1, 2, 3, 4]
+    >>> sorted(G.edges(data=True))
+    [(1, 2, {'weight': 3}), (2, 3, {'weight': 27}), (3, 4, {'weight': 3.0})]
+
+    Edgelist with data in a list:
+
+    >>> lines = ["1 2 3", "2 3 27", "3 4 3.0"]
+    >>> G = bipartite.parse_edgelist(lines, nodetype=int, data=(("weight", float),))
+    >>> sorted(G.nodes())
+    [1, 2, 3, 4]
+    >>> sorted(G.edges(data=True))
+    [(1, 2, {'weight': 3.0}), (2, 3, {'weight': 27.0}), (3, 4, {'weight': 3.0})]
+
+    See Also
+    --------
+    """
+    from ast import literal_eval
+
+    G = nx.empty_graph(0, create_using)
+    for line in lines:
+        p = line.find(comments)
+        if p >= 0:
+            line = line[:p]
+        if not len(line):
+            continue
+        # split line, should have 2 or more
+        s = line.rstrip("\n").split(delimiter)
+        if len(s) < 2:
+            continue
+        u = s.pop(0)
+        v = s.pop(0)
+        d = s
+        if nodetype is not None:
+            try:
+                u = nodetype(u)
+                v = nodetype(v)
+            except BaseException as err:
+                raise TypeError(
+                    f"Failed to convert nodes {u},{v} to type {nodetype}."
+                ) from err
+
+        if len(d) == 0 or data is False:
+            # no data or data type specified
+            edgedata = {}
+        elif data is True:
+            # no edge types specified
+            try:  # try to evaluate as dictionary
+                edgedata = dict(literal_eval(" ".join(d)))
+            except BaseException as err:
+                raise TypeError(
+                    f"Failed to convert edge data ({d}) to dictionary."
+                ) from err
+        else:
+            # convert edge data to dictionary with specified keys and type
+            if len(d) != len(data):
+                raise IndexError(
+                    f"Edge data {d} and data_keys {data} are not the same length"
+                )
+            edgedata = {}
+            for (edge_key, edge_type), edge_value in zip(data, d):
+                try:
+                    edge_value = edge_type(edge_value)
+                except BaseException as err:
+                    raise TypeError(
+                        f"Failed to convert {edge_key} data "
+                        f"{edge_value} to type {edge_type}."
+                    ) from err
+                edgedata.update({edge_key: edge_value})
+        G.add_node(u, bipartite=0)
+        G.add_node(v, bipartite=1)
+        G.add_edge(u, v, **edgedata)
+    return G
+
+
+@open_file(0, mode="rb")
+@nx._dispatchable(name="bipartite_read_edgelist", graphs=None, returns_graph=True)
+def read_edgelist(
+    path,
+    comments="#",
+    delimiter=None,
+    create_using=None,
+    nodetype=None,
+    data=True,
+    edgetype=None,
+    encoding="utf-8",
+):
+    """Read a bipartite graph from a list of edges.
+
+    Parameters
+    ----------
+    path : file or string
+       File or filename to read. If a file is provided, it must be
+       opened in 'rb' mode.
+       Filenames ending in .gz or .bz2 will be uncompressed.
+    comments : string, optional
+       The character used to indicate the start of a comment.
+    delimiter : string, optional
+       The string used to separate values.  The default is whitespace.
+    create_using : Graph container, optional,
+       Use specified container to build graph.  The default is networkx.Graph,
+       an undirected graph.
+    nodetype : int, float, str, Python type, optional
+       Convert node data from strings to specified type
+    data : bool or list of (label,type) tuples
+       Tuples specifying dictionary key names and types for edge data
+    edgetype : int, float, str, Python type, optional OBSOLETE
+       Convert edge data from strings to specified type and use as 'weight'
+    encoding: string, optional
+       Specify which encoding to use when reading file.
+
+    Returns
+    -------
+    G : graph
+       A networkx Graph or other type specified with create_using
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> G.add_nodes_from([0, 2], bipartite=0)
+    >>> G.add_nodes_from([1, 3], bipartite=1)
+    >>> bipartite.write_edgelist(G, "test.edgelist")
+    >>> G = bipartite.read_edgelist("test.edgelist")
+
+    >>> fh = open("test.edgelist", "rb")
+    >>> G = bipartite.read_edgelist(fh)
+    >>> fh.close()
+
+    >>> G = bipartite.read_edgelist("test.edgelist", nodetype=int)
+
+    Edgelist with data in a list:
+
+    >>> textline = "1 2 3"
+    >>> fh = open("test.edgelist", "w")
+    >>> d = fh.write(textline)
+    >>> fh.close()
+    >>> G = bipartite.read_edgelist(
+    ...     "test.edgelist", nodetype=int, data=(("weight", float),)
+    ... )
+    >>> list(G)
+    [1, 2]
+    >>> list(G.edges(data=True))
+    [(1, 2, {'weight': 3.0})]
+
+    See parse_edgelist() for more examples of formatting.
+
+    See Also
+    --------
+    parse_edgelist
+
+    Notes
+    -----
+    Since nodes must be hashable, the function nodetype must return hashable
+    types (e.g. int, float, str, frozenset - or tuples of those, etc.)
+    """
+    lines = (line.decode(encoding) for line in path)
+    return parse_edgelist(
+        lines,
+        comments=comments,
+        delimiter=delimiter,
+        create_using=create_using,
+        nodetype=nodetype,
+        data=data,
+    )
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/extendability.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/extendability.py
new file mode 100644
index 00000000..61d8d067
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/extendability.py
@@ -0,0 +1,105 @@
+"""Provides a function for computing the extendability of a graph which is
+undirected, simple, connected and bipartite and contains at least one perfect matching."""
+
+import networkx as nx
+from networkx.utils import not_implemented_for
+
+__all__ = ["maximal_extendability"]
+
+
+@not_implemented_for("directed")
+@not_implemented_for("multigraph")
+@nx._dispatchable
+def maximal_extendability(G):
+    """Computes the extendability of a graph.
+
+    The extendability of a graph is defined as the maximum $k$ for which `G`
+    is $k$-extendable. Graph `G` is $k$-extendable if and only if `G` has a
+    perfect matching and every set of $k$ independent edges can be extended
+    to a perfect matching in `G`.
+
+    Parameters
+    ----------
+    G : NetworkX Graph
+        A fully-connected bipartite graph without self-loops
+
+    Returns
+    -------
+    extendability : int
+
+    Raises
+    ------
+    NetworkXError
+       If the graph `G` is disconnected.
+       If the graph `G` is not bipartite.
+       If the graph `G` does not contain a perfect matching.
+       If the residual graph of `G` is not strongly connected.
+
+    Notes
+    -----
+    Definition:
+    Let `G` be a simple, connected, undirected and bipartite graph with a perfect
+    matching M and bipartition (U,V). The residual graph of `G`, denoted by $G_M$,
+    is the graph obtained from G by directing the edges of M from V to U and the
+    edges that do not belong to M from U to V.
+
+    Lemma [1]_ :
+    Let M be a perfect matching of `G`. `G` is $k$-extendable if and only if its residual
+    graph $G_M$ is strongly connected and there are $k$ vertex-disjoint directed
+    paths between every vertex of U and every vertex of V.
+
+    Assuming that input graph `G` is undirected, simple, connected, bipartite and contains
+    a perfect matching M, this function constructs the residual graph $G_M$ of G and
+    returns the minimum value among the maximum vertex-disjoint directed paths between
+    every vertex of U and every vertex of V in $G_M$. By combining the definitions
+    and the lemma, this value represents the extendability of the graph `G`.
+
+    Time complexity O($n^3$ $m^2$)) where $n$ is the number of vertices
+    and $m$ is the number of edges.
+
+    References
+    ----------
+    .. [1] "A polynomial algorithm for the extendability problem in bipartite graphs",
+          J. Lakhal, L. Litzler, Information Processing Letters, 1998.
+    .. [2] "On n-extendible graphs", M. D. Plummer, Discrete Mathematics, 31:201–210, 1980
+          https://doi.org/10.1016/0012-365X(80)90037-0
+
+    """
+    if not nx.is_connected(G):
+        raise nx.NetworkXError("Graph G is not connected")
+
+    if not nx.bipartite.is_bipartite(G):
+        raise nx.NetworkXError("Graph G is not bipartite")
+
+    U, V = nx.bipartite.sets(G)
+
+    maximum_matching = nx.bipartite.hopcroft_karp_matching(G)
+
+    if not nx.is_perfect_matching(G, maximum_matching):
+        raise nx.NetworkXError("Graph G does not contain a perfect matching")
+
+    # list of edges in perfect matching, directed from V to U
+    pm = [(node, maximum_matching[node]) for node in V & maximum_matching.keys()]
+
+    # Direct all the edges of G, from V to U if in matching, else from U to V
+    directed_edges = [
+        (x, y) if (x in V and (x, y) in pm) or (x in U and (y, x) not in pm) else (y, x)
+        for x, y in G.edges
+    ]
+
+    # Construct the residual graph of G
+    residual_G = nx.DiGraph()
+    residual_G.add_nodes_from(G)
+    residual_G.add_edges_from(directed_edges)
+
+    if not nx.is_strongly_connected(residual_G):
+        raise nx.NetworkXError("The residual graph of G is not strongly connected")
+
+    # For node-pairs between V & U, keep min of max number of node-disjoint paths
+    # Variable $k$ stands for the extendability of graph G
+    k = float("inf")
+    for u in U:
+        for v in V:
+            num_paths = sum(1 for _ in nx.node_disjoint_paths(residual_G, u, v))
+            k = k if k < num_paths else num_paths
+    return k
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/generators.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/generators.py
new file mode 100644
index 00000000..e8428f6b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/generators.py
@@ -0,0 +1,604 @@
+"""
+Generators and functions for bipartite graphs.
+"""
+
+import math
+import numbers
+from functools import reduce
+
+import networkx as nx
+from networkx.utils import nodes_or_number, py_random_state
+
+__all__ = [
+    "configuration_model",
+    "havel_hakimi_graph",
+    "reverse_havel_hakimi_graph",
+    "alternating_havel_hakimi_graph",
+    "preferential_attachment_graph",
+    "random_graph",
+    "gnmk_random_graph",
+    "complete_bipartite_graph",
+]
+
+
+@nx._dispatchable(graphs=None, returns_graph=True)
+@nodes_or_number([0, 1])
+def complete_bipartite_graph(n1, n2, create_using=None):
+    """Returns the complete bipartite graph `K_{n_1,n_2}`.
+
+    The graph is composed of two partitions with nodes 0 to (n1 - 1)
+    in the first and nodes n1 to (n1 + n2 - 1) in the second.
+    Each node in the first is connected to each node in the second.
+
+    Parameters
+    ----------
+    n1, n2 : integer or iterable container of nodes
+        If integers, nodes are from `range(n1)` and `range(n1, n1 + n2)`.
+        If a container, the elements are the nodes.
+    create_using : NetworkX graph instance, (default: nx.Graph)
+       Return graph of this type.
+
+    Notes
+    -----
+    Nodes are the integers 0 to `n1 + n2 - 1` unless either n1 or n2 are
+    containers of nodes. If only one of n1 or n2 are integers, that
+    integer is replaced by `range` of that integer.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.complete_bipartite_graph
+    """
+    G = nx.empty_graph(0, create_using)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    n1, top = n1
+    n2, bottom = n2
+    if isinstance(n1, numbers.Integral) and isinstance(n2, numbers.Integral):
+        bottom = [n1 + i for i in bottom]
+    G.add_nodes_from(top, bipartite=0)
+    G.add_nodes_from(bottom, bipartite=1)
+    if len(G) != len(top) + len(bottom):
+        raise nx.NetworkXError("Inputs n1 and n2 must contain distinct nodes")
+    G.add_edges_from((u, v) for u in top for v in bottom)
+    G.graph["name"] = f"complete_bipartite_graph({len(top)}, {len(bottom)})"
+    return G
+
+
+@py_random_state(3)
+@nx._dispatchable(name="bipartite_configuration_model", graphs=None, returns_graph=True)
+def configuration_model(aseq, bseq, create_using=None, seed=None):
+    """Returns a random bipartite graph from two given degree sequences.
+
+    Parameters
+    ----------
+    aseq : list
+       Degree sequence for node set A.
+    bseq : list
+       Degree sequence for node set B.
+    create_using : NetworkX graph instance, optional
+       Return graph of this type.
+    seed : integer, random_state, or None (default)
+        Indicator of random number generation state.
+        See :ref:`Randomness<randomness>`.
+
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (len(aseq) - 1) and set B has nodes len(aseq) to (len(bseq) - 1).
+    Nodes from set A are connected to nodes in set B by choosing
+    randomly from the possible free stubs, one in A and one in B.
+
+    Notes
+    -----
+    The sum of the two sequences must be equal: sum(aseq)=sum(bseq)
+    If no graph type is specified use MultiGraph with parallel edges.
+    If you want a graph with no parallel edges use create_using=Graph()
+    but then the resulting degree sequences might not be exact.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.configuration_model
+    """
+    G = nx.empty_graph(0, create_using, default=nx.MultiGraph)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    # length and sum of each sequence
+    lena = len(aseq)
+    lenb = len(bseq)
+    suma = sum(aseq)
+    sumb = sum(bseq)
+
+    if not suma == sumb:
+        raise nx.NetworkXError(
+            f"invalid degree sequences, sum(aseq)!=sum(bseq),{suma},{sumb}"
+        )
+
+    G = _add_nodes_with_bipartite_label(G, lena, lenb)
+
+    if len(aseq) == 0 or max(aseq) == 0:
+        return G  # done if no edges
+
+    # build lists of degree-repeated vertex numbers
+    stubs = [[v] * aseq[v] for v in range(lena)]
+    astubs = [x for subseq in stubs for x in subseq]
+
+    stubs = [[v] * bseq[v - lena] for v in range(lena, lena + lenb)]
+    bstubs = [x for subseq in stubs for x in subseq]
+
+    # shuffle lists
+    seed.shuffle(astubs)
+    seed.shuffle(bstubs)
+
+    G.add_edges_from([astubs[i], bstubs[i]] for i in range(suma))
+
+    G.name = "bipartite_configuration_model"
+    return G
+
+
+@nx._dispatchable(name="bipartite_havel_hakimi_graph", graphs=None, returns_graph=True)
+def havel_hakimi_graph(aseq, bseq, create_using=None):
+    """Returns a bipartite graph from two given degree sequences using a
+    Havel-Hakimi style construction.
+
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (len(aseq) - 1) and set B has nodes len(aseq) to (len(bseq) - 1).
+    Nodes from the set A are connected to nodes in the set B by
+    connecting the highest degree nodes in set A to the highest degree
+    nodes in set B until all stubs are connected.
+
+    Parameters
+    ----------
+    aseq : list
+       Degree sequence for node set A.
+    bseq : list
+       Degree sequence for node set B.
+    create_using : NetworkX graph instance, optional
+       Return graph of this type.
+
+    Notes
+    -----
+    The sum of the two sequences must be equal: sum(aseq)=sum(bseq)
+    If no graph type is specified use MultiGraph with parallel edges.
+    If you want a graph with no parallel edges use create_using=Graph()
+    but then the resulting degree sequences might not be exact.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.havel_hakimi_graph
+    """
+    G = nx.empty_graph(0, create_using, default=nx.MultiGraph)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    # length of the each sequence
+    naseq = len(aseq)
+    nbseq = len(bseq)
+
+    suma = sum(aseq)
+    sumb = sum(bseq)
+
+    if not suma == sumb:
+        raise nx.NetworkXError(
+            f"invalid degree sequences, sum(aseq)!=sum(bseq),{suma},{sumb}"
+        )
+
+    G = _add_nodes_with_bipartite_label(G, naseq, nbseq)
+
+    if len(aseq) == 0 or max(aseq) == 0:
+        return G  # done if no edges
+
+    # build list of degree-repeated vertex numbers
+    astubs = [[aseq[v], v] for v in range(naseq)]
+    bstubs = [[bseq[v - naseq], v] for v in range(naseq, naseq + nbseq)]
+    astubs.sort()
+    while astubs:
+        (degree, u) = astubs.pop()  # take of largest degree node in the a set
+        if degree == 0:
+            break  # done, all are zero
+        # connect the source to largest degree nodes in the b set
+        bstubs.sort()
+        for target in bstubs[-degree:]:
+            v = target[1]
+            G.add_edge(u, v)
+            target[0] -= 1  # note this updates bstubs too.
+            if target[0] == 0:
+                bstubs.remove(target)
+
+    G.name = "bipartite_havel_hakimi_graph"
+    return G
+
+
+@nx._dispatchable(graphs=None, returns_graph=True)
+def reverse_havel_hakimi_graph(aseq, bseq, create_using=None):
+    """Returns a bipartite graph from two given degree sequences using a
+    Havel-Hakimi style construction.
+
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (len(aseq) - 1) and set B has nodes len(aseq) to (len(bseq) - 1).
+    Nodes from set A are connected to nodes in the set B by connecting
+    the highest degree nodes in set A to the lowest degree nodes in
+    set B until all stubs are connected.
+
+    Parameters
+    ----------
+    aseq : list
+       Degree sequence for node set A.
+    bseq : list
+       Degree sequence for node set B.
+    create_using : NetworkX graph instance, optional
+       Return graph of this type.
+
+    Notes
+    -----
+    The sum of the two sequences must be equal: sum(aseq)=sum(bseq)
+    If no graph type is specified use MultiGraph with parallel edges.
+    If you want a graph with no parallel edges use create_using=Graph()
+    but then the resulting degree sequences might not be exact.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.reverse_havel_hakimi_graph
+    """
+    G = nx.empty_graph(0, create_using, default=nx.MultiGraph)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    # length of the each sequence
+    lena = len(aseq)
+    lenb = len(bseq)
+    suma = sum(aseq)
+    sumb = sum(bseq)
+
+    if not suma == sumb:
+        raise nx.NetworkXError(
+            f"invalid degree sequences, sum(aseq)!=sum(bseq),{suma},{sumb}"
+        )
+
+    G = _add_nodes_with_bipartite_label(G, lena, lenb)
+
+    if len(aseq) == 0 or max(aseq) == 0:
+        return G  # done if no edges
+
+    # build list of degree-repeated vertex numbers
+    astubs = [[aseq[v], v] for v in range(lena)]
+    bstubs = [[bseq[v - lena], v] for v in range(lena, lena + lenb)]
+    astubs.sort()
+    bstubs.sort()
+    while astubs:
+        (degree, u) = astubs.pop()  # take of largest degree node in the a set
+        if degree == 0:
+            break  # done, all are zero
+        # connect the source to the smallest degree nodes in the b set
+        for target in bstubs[0:degree]:
+            v = target[1]
+            G.add_edge(u, v)
+            target[0] -= 1  # note this updates bstubs too.
+            if target[0] == 0:
+                bstubs.remove(target)
+
+    G.name = "bipartite_reverse_havel_hakimi_graph"
+    return G
+
+
+@nx._dispatchable(graphs=None, returns_graph=True)
+def alternating_havel_hakimi_graph(aseq, bseq, create_using=None):
+    """Returns a bipartite graph from two given degree sequences using
+    an alternating Havel-Hakimi style construction.
+
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (len(aseq) - 1) and set B has nodes len(aseq) to (len(bseq) - 1).
+    Nodes from the set A are connected to nodes in the set B by
+    connecting the highest degree nodes in set A to alternatively the
+    highest and the lowest degree nodes in set B until all stubs are
+    connected.
+
+    Parameters
+    ----------
+    aseq : list
+       Degree sequence for node set A.
+    bseq : list
+       Degree sequence for node set B.
+    create_using : NetworkX graph instance, optional
+       Return graph of this type.
+
+    Notes
+    -----
+    The sum of the two sequences must be equal: sum(aseq)=sum(bseq)
+    If no graph type is specified use MultiGraph with parallel edges.
+    If you want a graph with no parallel edges use create_using=Graph()
+    but then the resulting degree sequences might not be exact.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.alternating_havel_hakimi_graph
+    """
+    G = nx.empty_graph(0, create_using, default=nx.MultiGraph)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    # length of the each sequence
+    naseq = len(aseq)
+    nbseq = len(bseq)
+    suma = sum(aseq)
+    sumb = sum(bseq)
+
+    if not suma == sumb:
+        raise nx.NetworkXError(
+            f"invalid degree sequences, sum(aseq)!=sum(bseq),{suma},{sumb}"
+        )
+
+    G = _add_nodes_with_bipartite_label(G, naseq, nbseq)
+
+    if len(aseq) == 0 or max(aseq) == 0:
+        return G  # done if no edges
+    # build list of degree-repeated vertex numbers
+    astubs = [[aseq[v], v] for v in range(naseq)]
+    bstubs = [[bseq[v - naseq], v] for v in range(naseq, naseq + nbseq)]
+    while astubs:
+        astubs.sort()
+        (degree, u) = astubs.pop()  # take of largest degree node in the a set
+        if degree == 0:
+            break  # done, all are zero
+        bstubs.sort()
+        small = bstubs[0 : degree // 2]  # add these low degree targets
+        large = bstubs[(-degree + degree // 2) :]  # now high degree targets
+        stubs = [x for z in zip(large, small) for x in z]  # combine, sorry
+        if len(stubs) < len(small) + len(large):  # check for zip truncation
+            stubs.append(large.pop())
+        for target in stubs:
+            v = target[1]
+            G.add_edge(u, v)
+            target[0] -= 1  # note this updates bstubs too.
+            if target[0] == 0:
+                bstubs.remove(target)
+
+    G.name = "bipartite_alternating_havel_hakimi_graph"
+    return G
+
+
+@py_random_state(3)
+@nx._dispatchable(graphs=None, returns_graph=True)
+def preferential_attachment_graph(aseq, p, create_using=None, seed=None):
+    """Create a bipartite graph with a preferential attachment model from
+    a given single degree sequence.
+
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (len(aseq) - 1) and set B has nodes starting with node len(aseq).
+    The number of nodes in set B is random.
+
+    Parameters
+    ----------
+    aseq : list
+       Degree sequence for node set A.
+    p :  float
+       Probability that a new bottom node is added.
+    create_using : NetworkX graph instance, optional
+       Return graph of this type.
+    seed : integer, random_state, or None (default)
+        Indicator of random number generation state.
+        See :ref:`Randomness<randomness>`.
+
+    References
+    ----------
+    .. [1] Guillaume, J.L. and Latapy, M.,
+       Bipartite graphs as models of complex networks.
+       Physica A: Statistical Mechanics and its Applications,
+       2006, 371(2), pp.795-813.
+    .. [2] Jean-Loup Guillaume and Matthieu Latapy,
+       Bipartite structure of all complex networks,
+       Inf. Process. Lett. 90, 2004, pg. 215-221
+       https://doi.org/10.1016/j.ipl.2004.03.007
+
+    Notes
+    -----
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.preferential_attachment_graph
+    """
+    G = nx.empty_graph(0, create_using, default=nx.MultiGraph)
+    if G.is_directed():
+        raise nx.NetworkXError("Directed Graph not supported")
+
+    if p > 1:
+        raise nx.NetworkXError(f"probability {p} > 1")
+
+    naseq = len(aseq)
+    G = _add_nodes_with_bipartite_label(G, naseq, 0)
+    vv = [[v] * aseq[v] for v in range(naseq)]
+    while vv:
+        while vv[0]:
+            source = vv[0][0]
+            vv[0].remove(source)
+            if seed.random() < p or len(G) == naseq:
+                target = len(G)
+                G.add_node(target, bipartite=1)
+                G.add_edge(source, target)
+            else:
+                bb = [[b] * G.degree(b) for b in range(naseq, len(G))]
+                # flatten the list of lists into a list.
+                bbstubs = reduce(lambda x, y: x + y, bb)
+                # choose preferentially a bottom node.
+                target = seed.choice(bbstubs)
+                G.add_node(target, bipartite=1)
+                G.add_edge(source, target)
+        vv.remove(vv[0])
+    G.name = "bipartite_preferential_attachment_model"
+    return G
+
+
+@py_random_state(3)
+@nx._dispatchable(graphs=None, returns_graph=True)
+def random_graph(n, m, p, seed=None, directed=False):
+    """Returns a bipartite random graph.
+
+    This is a bipartite version of the binomial (Erdős-Rényi) graph.
+    The graph is composed of two partitions. Set A has nodes 0 to
+    (n - 1) and set B has nodes n to (n + m - 1).
+
+    Parameters
+    ----------
+    n : int
+        The number of nodes in the first bipartite set.
+    m : int
+        The number of nodes in the second bipartite set.
+    p : float
+        Probability for edge creation.
+    seed : integer, random_state, or None (default)
+        Indicator of random number generation state.
+        See :ref:`Randomness<randomness>`.
+    directed : bool, optional (default=False)
+        If True return a directed graph
+
+    Notes
+    -----
+    The bipartite random graph algorithm chooses each of the n*m (undirected)
+    or 2*nm (directed) possible edges with probability p.
+
+    This algorithm is $O(n+m)$ where $m$ is the expected number of edges.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.random_graph
+
+    See Also
+    --------
+    gnp_random_graph, configuration_model
+
+    References
+    ----------
+    .. [1] Vladimir Batagelj and Ulrik Brandes,
+       "Efficient generation of large random networks",
+       Phys. Rev. E, 71, 036113, 2005.
+    """
+    G = nx.Graph()
+    G = _add_nodes_with_bipartite_label(G, n, m)
+    if directed:
+        G = nx.DiGraph(G)
+    G.name = f"fast_gnp_random_graph({n},{m},{p})"
+
+    if p <= 0:
+        return G
+    if p >= 1:
+        return nx.complete_bipartite_graph(n, m)
+
+    lp = math.log(1.0 - p)
+
+    v = 0
+    w = -1
+    while v < n:
+        lr = math.log(1.0 - seed.random())
+        w = w + 1 + int(lr / lp)
+        while w >= m and v < n:
+            w = w - m
+            v = v + 1
+        if v < n:
+            G.add_edge(v, n + w)
+
+    if directed:
+        # use the same algorithm to
+        # add edges from the "m" to "n" set
+        v = 0
+        w = -1
+        while v < n:
+            lr = math.log(1.0 - seed.random())
+            w = w + 1 + int(lr / lp)
+            while w >= m and v < n:
+                w = w - m
+                v = v + 1
+            if v < n:
+                G.add_edge(n + w, v)
+
+    return G
+
+
+@py_random_state(3)
+@nx._dispatchable(graphs=None, returns_graph=True)
+def gnmk_random_graph(n, m, k, seed=None, directed=False):
+    """Returns a random bipartite graph G_{n,m,k}.
+
+    Produces a bipartite graph chosen randomly out of the set of all graphs
+    with n top nodes, m bottom nodes, and k edges.
+    The graph is composed of two sets of nodes.
+    Set A has nodes 0 to (n - 1) and set B has nodes n to (n + m - 1).
+
+    Parameters
+    ----------
+    n : int
+        The number of nodes in the first bipartite set.
+    m : int
+        The number of nodes in the second bipartite set.
+    k : int
+        The number of edges
+    seed : integer, random_state, or None (default)
+        Indicator of random number generation state.
+        See :ref:`Randomness<randomness>`.
+    directed : bool, optional (default=False)
+        If True return a directed graph
+
+    Examples
+    --------
+    from nx.algorithms import bipartite
+    G = bipartite.gnmk_random_graph(10,20,50)
+
+    See Also
+    --------
+    gnm_random_graph
+
+    Notes
+    -----
+    If k > m * n then a complete bipartite graph is returned.
+
+    This graph is a bipartite version of the `G_{nm}` random graph model.
+
+    The nodes are assigned the attribute 'bipartite' with the value 0 or 1
+    to indicate which bipartite set the node belongs to.
+
+    This function is not imported in the main namespace.
+    To use it use nx.bipartite.gnmk_random_graph
+    """
+    G = nx.Graph()
+    G = _add_nodes_with_bipartite_label(G, n, m)
+    if directed:
+        G = nx.DiGraph(G)
+    G.name = f"bipartite_gnm_random_graph({n},{m},{k})"
+    if n == 1 or m == 1:
+        return G
+    max_edges = n * m  # max_edges for bipartite networks
+    if k >= max_edges:  # Maybe we should raise an exception here
+        return nx.complete_bipartite_graph(n, m, create_using=G)
+
+    top = [n for n, d in G.nodes(data=True) if d["bipartite"] == 0]
+    bottom = list(set(G) - set(top))
+    edge_count = 0
+    while edge_count < k:
+        # generate random edge,u,v
+        u = seed.choice(top)
+        v = seed.choice(bottom)
+        if v in G[u]:
+            continue
+        else:
+            G.add_edge(u, v)
+            edge_count += 1
+    return G
+
+
+def _add_nodes_with_bipartite_label(G, lena, lenb):
+    G.add_nodes_from(range(lena + lenb))
+    b = dict(zip(range(lena), [0] * lena))
+    b.update(dict(zip(range(lena, lena + lenb), [1] * lenb)))
+    nx.set_node_attributes(G, b, "bipartite")
+    return G
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matching.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matching.py
new file mode 100644
index 00000000..38a17478
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matching.py
@@ -0,0 +1,590 @@
+# This module uses material from the Wikipedia article Hopcroft--Karp algorithm
+# <https://en.wikipedia.org/wiki/Hopcroft%E2%80%93Karp_algorithm>, accessed on
+# January 3, 2015, which is released under the Creative Commons
+# Attribution-Share-Alike License 3.0
+# <http://creativecommons.org/licenses/by-sa/3.0/>. That article includes
+# pseudocode, which has been translated into the corresponding Python code.
+#
+# Portions of this module use code from David Eppstein's Python Algorithms and
+# Data Structures (PADS) library, which is dedicated to the public domain (for
+# proof, see <http://www.ics.uci.edu/~eppstein/PADS/ABOUT-PADS.txt>).
+"""Provides functions for computing maximum cardinality matchings and minimum
+weight full matchings in a bipartite graph.
+
+If you don't care about the particular implementation of the maximum matching
+algorithm, simply use the :func:`maximum_matching`. If you do care, you can
+import one of the named maximum matching algorithms directly.
+
+For example, to find a maximum matching in the complete bipartite graph with
+two vertices on the left and three vertices on the right:
+
+>>> G = nx.complete_bipartite_graph(2, 3)
+>>> left, right = nx.bipartite.sets(G)
+>>> list(left)
+[0, 1]
+>>> list(right)
+[2, 3, 4]
+>>> nx.bipartite.maximum_matching(G)
+{0: 2, 1: 3, 2: 0, 3: 1}
+
+The dictionary returned by :func:`maximum_matching` includes a mapping for
+vertices in both the left and right vertex sets.
+
+Similarly, :func:`minimum_weight_full_matching` produces, for a complete
+weighted bipartite graph, a matching whose cardinality is the cardinality of
+the smaller of the two partitions, and for which the sum of the weights of the
+edges included in the matching is minimal.
+
+"""
+
+import collections
+import itertools
+
+import networkx as nx
+from networkx.algorithms.bipartite import sets as bipartite_sets
+from networkx.algorithms.bipartite.matrix import biadjacency_matrix
+
+__all__ = [
+    "maximum_matching",
+    "hopcroft_karp_matching",
+    "eppstein_matching",
+    "to_vertex_cover",
+    "minimum_weight_full_matching",
+]
+
+INFINITY = float("inf")
+
+
+@nx._dispatchable
+def hopcroft_karp_matching(G, top_nodes=None):
+    """Returns the maximum cardinality matching of the bipartite graph `G`.
+
+    A matching is a set of edges that do not share any nodes. A maximum
+    cardinality matching is a matching with the most edges possible. It
+    is not always unique. Finding a matching in a bipartite graph can be
+    treated as a networkx flow problem.
+
+    The functions ``hopcroft_karp_matching`` and ``maximum_matching``
+    are aliases of the same function.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+      Undirected bipartite graph
+
+    top_nodes : container of nodes
+
+      Container with all nodes in one bipartite node set. If not supplied
+      it will be computed. But if more than one solution exists an exception
+      will be raised.
+
+    Returns
+    -------
+    matches : dictionary
+
+      The matching is returned as a dictionary, `matches`, such that
+      ``matches[v] == w`` if node `v` is matched to node `w`. Unmatched
+      nodes do not occur as a key in `matches`.
+
+    Raises
+    ------
+    AmbiguousSolution
+      Raised if the input bipartite graph is disconnected and no container
+      with all nodes in one bipartite set is provided. When determining
+      the nodes in each bipartite set more than one valid solution is
+      possible if the input graph is disconnected.
+
+    Notes
+    -----
+    This function is implemented with the `Hopcroft--Karp matching algorithm
+    <https://en.wikipedia.org/wiki/Hopcroft%E2%80%93Karp_algorithm>`_ for
+    bipartite graphs.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    maximum_matching
+    hopcroft_karp_matching
+    eppstein_matching
+
+    References
+    ----------
+    .. [1] John E. Hopcroft and Richard M. Karp. "An n^{5 / 2} Algorithm for
+       Maximum Matchings in Bipartite Graphs" In: **SIAM Journal of Computing**
+       2.4 (1973), pp. 225--231. <https://doi.org/10.1137/0202019>.
+
+    """
+
+    # First we define some auxiliary search functions.
+    #
+    # If you are a human reading these auxiliary search functions, the "global"
+    # variables `leftmatches`, `rightmatches`, `distances`, etc. are defined
+    # below the functions, so that they are initialized close to the initial
+    # invocation of the search functions.
+    def breadth_first_search():
+        for v in left:
+            if leftmatches[v] is None:
+                distances[v] = 0
+                queue.append(v)
+            else:
+                distances[v] = INFINITY
+        distances[None] = INFINITY
+        while queue:
+            v = queue.popleft()
+            if distances[v] < distances[None]:
+                for u in G[v]:
+                    if distances[rightmatches[u]] is INFINITY:
+                        distances[rightmatches[u]] = distances[v] + 1
+                        queue.append(rightmatches[u])
+        return distances[None] is not INFINITY
+
+    def depth_first_search(v):
+        if v is not None:
+            for u in G[v]:
+                if distances[rightmatches[u]] == distances[v] + 1:
+                    if depth_first_search(rightmatches[u]):
+                        rightmatches[u] = v
+                        leftmatches[v] = u
+                        return True
+            distances[v] = INFINITY
+            return False
+        return True
+
+    # Initialize the "global" variables that maintain state during the search.
+    left, right = bipartite_sets(G, top_nodes)
+    leftmatches = {v: None for v in left}
+    rightmatches = {v: None for v in right}
+    distances = {}
+    queue = collections.deque()
+
+    # Implementation note: this counter is incremented as pairs are matched but
+    # it is currently not used elsewhere in the computation.
+    num_matched_pairs = 0
+    while breadth_first_search():
+        for v in left:
+            if leftmatches[v] is None:
+                if depth_first_search(v):
+                    num_matched_pairs += 1
+
+    # Strip the entries matched to `None`.
+    leftmatches = {k: v for k, v in leftmatches.items() if v is not None}
+    rightmatches = {k: v for k, v in rightmatches.items() if v is not None}
+
+    # At this point, the left matches and the right matches are inverses of one
+    # another. In other words,
+    #
+    #     leftmatches == {v, k for k, v in rightmatches.items()}
+    #
+    # Finally, we combine both the left matches and right matches.
+    return dict(itertools.chain(leftmatches.items(), rightmatches.items()))
+
+
+@nx._dispatchable
+def eppstein_matching(G, top_nodes=None):
+    """Returns the maximum cardinality matching of the bipartite graph `G`.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+      Undirected bipartite graph
+
+    top_nodes : container
+
+      Container with all nodes in one bipartite node set. If not supplied
+      it will be computed. But if more than one solution exists an exception
+      will be raised.
+
+    Returns
+    -------
+    matches : dictionary
+
+      The matching is returned as a dictionary, `matching`, such that
+      ``matching[v] == w`` if node `v` is matched to node `w`. Unmatched
+      nodes do not occur as a key in `matching`.
+
+    Raises
+    ------
+    AmbiguousSolution
+      Raised if the input bipartite graph is disconnected and no container
+      with all nodes in one bipartite set is provided. When determining
+      the nodes in each bipartite set more than one valid solution is
+      possible if the input graph is disconnected.
+
+    Notes
+    -----
+    This function is implemented with David Eppstein's version of the algorithm
+    Hopcroft--Karp algorithm (see :func:`hopcroft_karp_matching`), which
+    originally appeared in the `Python Algorithms and Data Structures library
+    (PADS) <http://www.ics.uci.edu/~eppstein/PADS/ABOUT-PADS.txt>`_.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+
+    hopcroft_karp_matching
+
+    """
+    # Due to its original implementation, a directed graph is needed
+    # so that the two sets of bipartite nodes can be distinguished
+    left, right = bipartite_sets(G, top_nodes)
+    G = nx.DiGraph(G.edges(left))
+    # initialize greedy matching (redundant, but faster than full search)
+    matching = {}
+    for u in G:
+        for v in G[u]:
+            if v not in matching:
+                matching[v] = u
+                break
+    while True:
+        # structure residual graph into layers
+        # pred[u] gives the neighbor in the previous layer for u in U
+        # preds[v] gives a list of neighbors in the previous layer for v in V
+        # unmatched gives a list of unmatched vertices in final layer of V,
+        # and is also used as a flag value for pred[u] when u is in the first
+        # layer
+        preds = {}
+        unmatched = []
+        pred = {u: unmatched for u in G}
+        for v in matching:
+            del pred[matching[v]]
+        layer = list(pred)
+
+        # repeatedly extend layering structure by another pair of layers
+        while layer and not unmatched:
+            newLayer = {}
+            for u in layer:
+                for v in G[u]:
+                    if v not in preds:
+                        newLayer.setdefault(v, []).append(u)
+            layer = []
+            for v in newLayer:
+                preds[v] = newLayer[v]
+                if v in matching:
+                    layer.append(matching[v])
+                    pred[matching[v]] = v
+                else:
+                    unmatched.append(v)
+
+        # did we finish layering without finding any alternating paths?
+        if not unmatched:
+            # TODO - The lines between --- were unused and were thus commented
+            # out. This whole commented chunk should be reviewed to determine
+            # whether it should be built upon or completely removed.
+            # ---
+            # unlayered = {}
+            # for u in G:
+            #     # TODO Why is extra inner loop necessary?
+            #     for v in G[u]:
+            #         if v not in preds:
+            #             unlayered[v] = None
+            # ---
+            # TODO Originally, this function returned a three-tuple:
+            #
+            #     return (matching, list(pred), list(unlayered))
+            #
+            # For some reason, the documentation for this function
+            # indicated that the second and third elements of the returned
+            # three-tuple would be the vertices in the left and right vertex
+            # sets, respectively, that are also in the maximum independent set.
+            # However, what I think the author meant was that the second
+            # element is the list of vertices that were unmatched and the third
+            # element was the list of vertices that were matched. Since that
+            # seems to be the case, they don't really need to be returned,
+            # since that information can be inferred from the matching
+            # dictionary.
+
+            # All the matched nodes must be a key in the dictionary
+            for key in matching.copy():
+                matching[matching[key]] = key
+            return matching
+
+        # recursively search backward through layers to find alternating paths
+        # recursion returns true if found path, false otherwise
+        def recurse(v):
+            if v in preds:
+                L = preds.pop(v)
+                for u in L:
+                    if u in pred:
+                        pu = pred.pop(u)
+                        if pu is unmatched or recurse(pu):
+                            matching[v] = u
+                            return True
+            return False
+
+        for v in unmatched:
+            recurse(v)
+
+
+def _is_connected_by_alternating_path(G, v, matched_edges, unmatched_edges, targets):
+    """Returns True if and only if the vertex `v` is connected to one of
+    the target vertices by an alternating path in `G`.
+
+    An *alternating path* is a path in which every other edge is in the
+    specified maximum matching (and the remaining edges in the path are not in
+    the matching). An alternating path may have matched edges in the even
+    positions or in the odd positions, as long as the edges alternate between
+    'matched' and 'unmatched'.
+
+    `G` is an undirected bipartite NetworkX graph.
+
+    `v` is a vertex in `G`.
+
+    `matched_edges` is a set of edges present in a maximum matching in `G`.
+
+    `unmatched_edges` is a set of edges not present in a maximum
+    matching in `G`.
+
+    `targets` is a set of vertices.
+
+    """
+
+    def _alternating_dfs(u, along_matched=True):
+        """Returns True if and only if `u` is connected to one of the
+        targets by an alternating path.
+
+        `u` is a vertex in the graph `G`.
+
+        If `along_matched` is True, this step of the depth-first search
+        will continue only through edges in the given matching. Otherwise, it
+        will continue only through edges *not* in the given matching.
+
+        """
+        visited = set()
+        # Follow matched edges when depth is even,
+        # and follow unmatched edges when depth is odd.
+        initial_depth = 0 if along_matched else 1
+        stack = [(u, iter(G[u]), initial_depth)]
+        while stack:
+            parent, children, depth = stack[-1]
+            valid_edges = matched_edges if depth % 2 else unmatched_edges
+            try:
+                child = next(children)
+                if child not in visited:
+                    if (parent, child) in valid_edges or (child, parent) in valid_edges:
+                        if child in targets:
+                            return True
+                        visited.add(child)
+                        stack.append((child, iter(G[child]), depth + 1))
+            except StopIteration:
+                stack.pop()
+        return False
+
+    # Check for alternating paths starting with edges in the matching, then
+    # check for alternating paths starting with edges not in the
+    # matching.
+    return _alternating_dfs(v, along_matched=True) or _alternating_dfs(
+        v, along_matched=False
+    )
+
+
+def _connected_by_alternating_paths(G, matching, targets):
+    """Returns the set of vertices that are connected to one of the target
+    vertices by an alternating path in `G` or are themselves a target.
+
+    An *alternating path* is a path in which every other edge is in the
+    specified maximum matching (and the remaining edges in the path are not in
+    the matching). An alternating path may have matched edges in the even
+    positions or in the odd positions, as long as the edges alternate between
+    'matched' and 'unmatched'.
+
+    `G` is an undirected bipartite NetworkX graph.
+
+    `matching` is a dictionary representing a maximum matching in `G`, as
+    returned by, for example, :func:`maximum_matching`.
+
+    `targets` is a set of vertices.
+
+    """
+    # Get the set of matched edges and the set of unmatched edges. Only include
+    # one version of each undirected edge (for example, include edge (1, 2) but
+    # not edge (2, 1)). Using frozensets as an intermediary step we do not
+    # require nodes to be orderable.
+    edge_sets = {frozenset((u, v)) for u, v in matching.items()}
+    matched_edges = {tuple(edge) for edge in edge_sets}
+    unmatched_edges = {
+        (u, v) for (u, v) in G.edges() if frozenset((u, v)) not in edge_sets
+    }
+
+    return {
+        v
+        for v in G
+        if v in targets
+        or _is_connected_by_alternating_path(
+            G, v, matched_edges, unmatched_edges, targets
+        )
+    }
+
+
+@nx._dispatchable
+def to_vertex_cover(G, matching, top_nodes=None):
+    """Returns the minimum vertex cover corresponding to the given maximum
+    matching of the bipartite graph `G`.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+      Undirected bipartite graph
+
+    matching : dictionary
+
+      A dictionary whose keys are vertices in `G` and whose values are the
+      distinct neighbors comprising the maximum matching for `G`, as returned
+      by, for example, :func:`maximum_matching`. The dictionary *must*
+      represent the maximum matching.
+
+    top_nodes : container
+
+      Container with all nodes in one bipartite node set. If not supplied
+      it will be computed. But if more than one solution exists an exception
+      will be raised.
+
+    Returns
+    -------
+    vertex_cover : :class:`set`
+
+      The minimum vertex cover in `G`.
+
+    Raises
+    ------
+    AmbiguousSolution
+      Raised if the input bipartite graph is disconnected and no container
+      with all nodes in one bipartite set is provided. When determining
+      the nodes in each bipartite set more than one valid solution is
+      possible if the input graph is disconnected.
+
+    Notes
+    -----
+    This function is implemented using the procedure guaranteed by `Konig's
+    theorem
+    <https://en.wikipedia.org/wiki/K%C3%B6nig%27s_theorem_%28graph_theory%29>`_,
+    which proves an equivalence between a maximum matching and a minimum vertex
+    cover in bipartite graphs.
+
+    Since a minimum vertex cover is the complement of a maximum independent set
+    for any graph, one can compute the maximum independent set of a bipartite
+    graph this way:
+
+    >>> G = nx.complete_bipartite_graph(2, 3)
+    >>> matching = nx.bipartite.maximum_matching(G)
+    >>> vertex_cover = nx.bipartite.to_vertex_cover(G, matching)
+    >>> independent_set = set(G) - vertex_cover
+    >>> print(list(independent_set))
+    [2, 3, 4]
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    """
+    # This is a Python implementation of the algorithm described at
+    # <https://en.wikipedia.org/wiki/K%C3%B6nig%27s_theorem_%28graph_theory%29#Proof>.
+    L, R = bipartite_sets(G, top_nodes)
+    # Let U be the set of unmatched vertices in the left vertex set.
+    unmatched_vertices = set(G) - set(matching)
+    U = unmatched_vertices & L
+    # Let Z be the set of vertices that are either in U or are connected to U
+    # by alternating paths.
+    Z = _connected_by_alternating_paths(G, matching, U)
+    # At this point, every edge either has a right endpoint in Z or a left
+    # endpoint not in Z. This gives us the vertex cover.
+    return (L - Z) | (R & Z)
+
+
+#: Returns the maximum cardinality matching in the given bipartite graph.
+#:
+#: This function is simply an alias for :func:`hopcroft_karp_matching`.
+maximum_matching = hopcroft_karp_matching
+
+
+@nx._dispatchable(edge_attrs="weight")
+def minimum_weight_full_matching(G, top_nodes=None, weight="weight"):
+    r"""Returns a minimum weight full matching of the bipartite graph `G`.
+
+    Let :math:`G = ((U, V), E)` be a weighted bipartite graph with real weights
+    :math:`w : E \to \mathbb{R}`. This function then produces a matching
+    :math:`M \subseteq E` with cardinality
+
+    .. math::
+       \lvert M \rvert = \min(\lvert U \rvert, \lvert V \rvert),
+
+    which minimizes the sum of the weights of the edges included in the
+    matching, :math:`\sum_{e \in M} w(e)`, or raises an error if no such
+    matching exists.
+
+    When :math:`\lvert U \rvert = \lvert V \rvert`, this is commonly
+    referred to as a perfect matching; here, since we allow
+    :math:`\lvert U \rvert` and :math:`\lvert V \rvert` to differ, we
+    follow Karp [1]_ and refer to the matching as *full*.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+      Undirected bipartite graph
+
+    top_nodes : container
+
+      Container with all nodes in one bipartite node set. If not supplied
+      it will be computed.
+
+    weight : string, optional (default='weight')
+
+       The edge data key used to provide each value in the matrix.
+       If None, then each edge has weight 1.
+
+    Returns
+    -------
+    matches : dictionary
+
+      The matching is returned as a dictionary, `matches`, such that
+      ``matches[v] == w`` if node `v` is matched to node `w`. Unmatched
+      nodes do not occur as a key in `matches`.
+
+    Raises
+    ------
+    ValueError
+      Raised if no full matching exists.
+
+    ImportError
+      Raised if SciPy is not available.
+
+    Notes
+    -----
+    The problem of determining a minimum weight full matching is also known as
+    the rectangular linear assignment problem. This implementation defers the
+    calculation of the assignment to SciPy.
+
+    References
+    ----------
+    .. [1] Richard Manning Karp:
+       An algorithm to Solve the m x n Assignment Problem in Expected Time
+       O(mn log n).
+       Networks, 10(2):143–152, 1980.
+
+    """
+    import numpy as np
+    import scipy as sp
+
+    left, right = nx.bipartite.sets(G, top_nodes)
+    U = list(left)
+    V = list(right)
+    # We explicitly create the biadjacency matrix having infinities
+    # where edges are missing (as opposed to zeros, which is what one would
+    # get by using toarray on the sparse matrix).
+    weights_sparse = biadjacency_matrix(
+        G, row_order=U, column_order=V, weight=weight, format="coo"
+    )
+    weights = np.full(weights_sparse.shape, np.inf)
+    weights[weights_sparse.row, weights_sparse.col] = weights_sparse.data
+    left_matches = sp.optimize.linear_sum_assignment(weights)
+    d = {U[u]: V[v] for u, v in zip(*left_matches)}
+    # d will contain the matching from edges in left to right; we need to
+    # add the ones from right to left as well.
+    d.update({v: u for u, v in d.items()})
+    return d
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matrix.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matrix.py
new file mode 100644
index 00000000..bbfa47c7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/matrix.py
@@ -0,0 +1,168 @@
+"""
+====================
+Biadjacency matrices
+====================
+"""
+
+import itertools
+
+import networkx as nx
+from networkx.convert_matrix import _generate_weighted_edges
+
+__all__ = ["biadjacency_matrix", "from_biadjacency_matrix"]
+
+
+@nx._dispatchable(edge_attrs="weight")
+def biadjacency_matrix(
+    G, row_order, column_order=None, dtype=None, weight="weight", format="csr"
+):
+    r"""Returns the biadjacency matrix of the bipartite graph G.
+
+    Let `G = (U, V, E)` be a bipartite graph with node sets
+    `U = u_{1},...,u_{r}` and `V = v_{1},...,v_{s}`. The biadjacency
+    matrix [1]_ is the `r` x `s` matrix `B` in which `b_{i,j} = 1`
+    if, and only if, `(u_i, v_j) \in E`. If the parameter `weight` is
+    not `None` and matches the name of an edge attribute, its value is
+    used instead of 1.
+
+    Parameters
+    ----------
+    G : graph
+       A NetworkX graph
+
+    row_order : list of nodes
+       The rows of the matrix are ordered according to the list of nodes.
+
+    column_order : list, optional
+       The columns of the matrix are ordered according to the list of nodes.
+       If column_order is None, then the ordering of columns is arbitrary.
+
+    dtype : NumPy data-type, optional
+        A valid NumPy dtype used to initialize the array. If None, then the
+        NumPy default is used.
+
+    weight : string or None, optional (default='weight')
+       The edge data key used to provide each value in the matrix.
+       If None, then each edge has weight 1.
+
+    format : str in {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'}
+        The type of the matrix to be returned (default 'csr').  For
+        some algorithms different implementations of sparse matrices
+        can perform better.  See [2]_ for details.
+
+    Returns
+    -------
+    M : SciPy sparse array
+        Biadjacency matrix representation of the bipartite graph G.
+
+    Notes
+    -----
+    No attempt is made to check that the input graph is bipartite.
+
+    For directed bipartite graphs only successors are considered as neighbors.
+    To obtain an adjacency matrix with ones (or weight values) for both
+    predecessors and successors you have to generate two biadjacency matrices
+    where the rows of one of them are the columns of the other, and then add
+    one to the transpose of the other.
+
+    See Also
+    --------
+    adjacency_matrix
+    from_biadjacency_matrix
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Adjacency_matrix#Adjacency_matrix_of_a_bipartite_graph
+    .. [2] Scipy Dev. References, "Sparse Matrices",
+       https://docs.scipy.org/doc/scipy/reference/sparse.html
+    """
+    import scipy as sp
+
+    nlen = len(row_order)
+    if nlen == 0:
+        raise nx.NetworkXError("row_order is empty list")
+    if len(row_order) != len(set(row_order)):
+        msg = "Ambiguous ordering: `row_order` contained duplicates."
+        raise nx.NetworkXError(msg)
+    if column_order is None:
+        column_order = list(set(G) - set(row_order))
+    mlen = len(column_order)
+    if len(column_order) != len(set(column_order)):
+        msg = "Ambiguous ordering: `column_order` contained duplicates."
+        raise nx.NetworkXError(msg)
+
+    row_index = dict(zip(row_order, itertools.count()))
+    col_index = dict(zip(column_order, itertools.count()))
+
+    if G.number_of_edges() == 0:
+        row, col, data = [], [], []
+    else:
+        row, col, data = zip(
+            *(
+                (row_index[u], col_index[v], d.get(weight, 1))
+                for u, v, d in G.edges(row_order, data=True)
+                if u in row_index and v in col_index
+            )
+        )
+    A = sp.sparse.coo_array((data, (row, col)), shape=(nlen, mlen), dtype=dtype)
+    try:
+        return A.asformat(format)
+    except ValueError as err:
+        raise nx.NetworkXError(f"Unknown sparse array format: {format}") from err
+
+
+@nx._dispatchable(graphs=None, returns_graph=True)
+def from_biadjacency_matrix(A, create_using=None, edge_attribute="weight"):
+    r"""Creates a new bipartite graph from a biadjacency matrix given as a
+    SciPy sparse array.
+
+    Parameters
+    ----------
+    A: scipy sparse array
+      A biadjacency matrix representation of a graph
+
+    create_using: NetworkX graph
+       Use specified graph for result.  The default is Graph()
+
+    edge_attribute: string
+       Name of edge attribute to store matrix numeric value. The data will
+       have the same type as the matrix entry (int, float, (real,imag)).
+
+    Notes
+    -----
+    The nodes are labeled with the attribute `bipartite` set to an integer
+    0 or 1 representing membership in part 0 or part 1 of the bipartite graph.
+
+    If `create_using` is an instance of :class:`networkx.MultiGraph` or
+    :class:`networkx.MultiDiGraph` and the entries of `A` are of
+    type :class:`int`, then this function returns a multigraph (of the same
+    type as `create_using`) with parallel edges. In this case, `edge_attribute`
+    will be ignored.
+
+    See Also
+    --------
+    biadjacency_matrix
+    from_numpy_array
+
+    References
+    ----------
+    [1] https://en.wikipedia.org/wiki/Adjacency_matrix#Adjacency_matrix_of_a_bipartite_graph
+    """
+    G = nx.empty_graph(0, create_using)
+    n, m = A.shape
+    # Make sure we get even the isolated nodes of the graph.
+    G.add_nodes_from(range(n), bipartite=0)
+    G.add_nodes_from(range(n, n + m), bipartite=1)
+    # Create an iterable over (u, v, w) triples and for each triple, add an
+    # edge from u to v with weight w.
+    triples = ((u, n + v, d) for (u, v, d) in _generate_weighted_edges(A))
+    # If the entries in the adjacency matrix are integers and the graph is a
+    # multigraph, then create parallel edges, each with weight 1, for each
+    # entry in the adjacency matrix. Otherwise, create one edge for each
+    # positive entry in the adjacency matrix and set the weight of that edge to
+    # be the entry in the matrix.
+    if A.dtype.kind in ("i", "u") and G.is_multigraph():
+        chain = itertools.chain.from_iterable
+        triples = chain(((u, v, 1) for d in range(w)) for (u, v, w) in triples)
+    G.add_weighted_edges_from(triples, weight=edge_attribute)
+    return G
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/projection.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/projection.py
new file mode 100644
index 00000000..7c2a26cf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/projection.py
@@ -0,0 +1,526 @@
+"""One-mode (unipartite) projections of bipartite graphs."""
+
+import networkx as nx
+from networkx.exception import NetworkXAlgorithmError
+from networkx.utils import not_implemented_for
+
+__all__ = [
+    "projected_graph",
+    "weighted_projected_graph",
+    "collaboration_weighted_projected_graph",
+    "overlap_weighted_projected_graph",
+    "generic_weighted_projected_graph",
+]
+
+
+@nx._dispatchable(
+    graphs="B", preserve_node_attrs=True, preserve_graph_attrs=True, returns_graph=True
+)
+def projected_graph(B, nodes, multigraph=False):
+    r"""Returns the projection of B onto one of its node sets.
+
+    Returns the graph G that is the projection of the bipartite graph B
+    onto the specified nodes. They retain their attributes and are connected
+    in G if they have a common neighbor in B.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+      The input graph should be bipartite.
+
+    nodes : list or iterable
+      Nodes to project onto (the "bottom" nodes).
+
+    multigraph: bool (default=False)
+       If True return a multigraph where the multiple edges represent multiple
+       shared neighbors.  They edge key in the multigraph is assigned to the
+       label of the neighbor.
+
+    Returns
+    -------
+    Graph : NetworkX graph or multigraph
+       A graph that is the projection onto the given nodes.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> B = nx.path_graph(4)
+    >>> G = bipartite.projected_graph(B, [1, 3])
+    >>> list(G)
+    [1, 3]
+    >>> list(G.edges())
+    [(1, 3)]
+
+    If nodes `a`, and `b` are connected through both nodes 1 and 2 then
+    building a multigraph results in two edges in the projection onto
+    [`a`, `b`]:
+
+    >>> B = nx.Graph()
+    >>> B.add_edges_from([("a", 1), ("b", 1), ("a", 2), ("b", 2)])
+    >>> G = bipartite.projected_graph(B, ["a", "b"], multigraph=True)
+    >>> print([sorted((u, v)) for u, v in G.edges()])
+    [['a', 'b'], ['a', 'b']]
+
+    Notes
+    -----
+    No attempt is made to verify that the input graph B is bipartite.
+    Returns a simple graph that is the projection of the bipartite graph B
+    onto the set of nodes given in list nodes.  If multigraph=True then
+    a multigraph is returned with an edge for every shared neighbor.
+
+    Directed graphs are allowed as input.  The output will also then
+    be a directed graph with edges if there is a directed path between
+    the nodes.
+
+    The graph and node properties are (shallow) copied to the projected graph.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    is_bipartite,
+    is_bipartite_node_set,
+    sets,
+    weighted_projected_graph,
+    collaboration_weighted_projected_graph,
+    overlap_weighted_projected_graph,
+    generic_weighted_projected_graph
+    """
+    if B.is_multigraph():
+        raise nx.NetworkXError("not defined for multigraphs")
+    if B.is_directed():
+        directed = True
+        if multigraph:
+            G = nx.MultiDiGraph()
+        else:
+            G = nx.DiGraph()
+    else:
+        directed = False
+        if multigraph:
+            G = nx.MultiGraph()
+        else:
+            G = nx.Graph()
+    G.graph.update(B.graph)
+    G.add_nodes_from((n, B.nodes[n]) for n in nodes)
+    for u in nodes:
+        nbrs2 = {v for nbr in B[u] for v in B[nbr] if v != u}
+        if multigraph:
+            for n in nbrs2:
+                if directed:
+                    links = set(B[u]) & set(B.pred[n])
+                else:
+                    links = set(B[u]) & set(B[n])
+                for l in links:
+                    if not G.has_edge(u, n, l):
+                        G.add_edge(u, n, key=l)
+        else:
+            G.add_edges_from((u, n) for n in nbrs2)
+    return G
+
+
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs="B", returns_graph=True)
+def weighted_projected_graph(B, nodes, ratio=False):
+    r"""Returns a weighted projection of B onto one of its node sets.
+
+    The weighted projected graph is the projection of the bipartite
+    network B onto the specified nodes with weights representing the
+    number of shared neighbors or the ratio between actual shared
+    neighbors and possible shared neighbors if ``ratio is True`` [1]_.
+    The nodes retain their attributes and are connected in the resulting
+    graph if they have an edge to a common node in the original graph.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+        The input graph should be bipartite.
+
+    nodes : list or iterable
+        Distinct nodes to project onto (the "bottom" nodes).
+
+    ratio: Bool (default=False)
+        If True, edge weight is the ratio between actual shared neighbors
+        and maximum possible shared neighbors (i.e., the size of the other
+        node set). If False, edges weight is the number of shared neighbors.
+
+    Returns
+    -------
+    Graph : NetworkX graph
+       A graph that is the projection onto the given nodes.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> B = nx.path_graph(4)
+    >>> G = bipartite.weighted_projected_graph(B, [1, 3])
+    >>> list(G)
+    [1, 3]
+    >>> list(G.edges(data=True))
+    [(1, 3, {'weight': 1})]
+    >>> G = bipartite.weighted_projected_graph(B, [1, 3], ratio=True)
+    >>> list(G.edges(data=True))
+    [(1, 3, {'weight': 0.5})]
+
+    Notes
+    -----
+    No attempt is made to verify that the input graph B is bipartite, or that
+    the input nodes are distinct. However, if the length of the input nodes is
+    greater than or equal to the nodes in the graph B, an exception is raised.
+    If the nodes are not distinct but don't raise this error, the output weights
+    will be incorrect.
+    The graph and node properties are (shallow) copied to the projected graph.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    is_bipartite,
+    is_bipartite_node_set,
+    sets,
+    collaboration_weighted_projected_graph,
+    overlap_weighted_projected_graph,
+    generic_weighted_projected_graph
+    projected_graph
+
+    References
+    ----------
+    .. [1] Borgatti, S.P. and Halgin, D. In press. "Analyzing Affiliation
+        Networks". In Carrington, P. and Scott, J. (eds) The Sage Handbook
+        of Social Network Analysis. Sage Publications.
+    """
+    if B.is_directed():
+        pred = B.pred
+        G = nx.DiGraph()
+    else:
+        pred = B.adj
+        G = nx.Graph()
+    G.graph.update(B.graph)
+    G.add_nodes_from((n, B.nodes[n]) for n in nodes)
+    n_top = len(B) - len(nodes)
+
+    if n_top < 1:
+        raise NetworkXAlgorithmError(
+            f"the size of the nodes to project onto ({len(nodes)}) is >= the graph size ({len(B)}).\n"
+            "They are either not a valid bipartite partition or contain duplicates"
+        )
+
+    for u in nodes:
+        unbrs = set(B[u])
+        nbrs2 = {n for nbr in unbrs for n in B[nbr]} - {u}
+        for v in nbrs2:
+            vnbrs = set(pred[v])
+            common = unbrs & vnbrs
+            if not ratio:
+                weight = len(common)
+            else:
+                weight = len(common) / n_top
+            G.add_edge(u, v, weight=weight)
+    return G
+
+
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs="B", returns_graph=True)
+def collaboration_weighted_projected_graph(B, nodes):
+    r"""Newman's weighted projection of B onto one of its node sets.
+
+    The collaboration weighted projection is the projection of the
+    bipartite network B onto the specified nodes with weights assigned
+    using Newman's collaboration model [1]_:
+
+    .. math::
+
+        w_{u, v} = \sum_k \frac{\delta_{u}^{k} \delta_{v}^{k}}{d_k - 1}
+
+    where `u` and `v` are nodes from the bottom bipartite node set,
+    and `k` is a node of the top node set.
+    The value `d_k` is the degree of node `k` in the bipartite
+    network and `\delta_{u}^{k}` is 1 if node `u` is
+    linked to node `k` in the original bipartite graph or 0 otherwise.
+
+    The nodes retain their attributes and are connected in the resulting
+    graph if have an edge to a common node in the original bipartite
+    graph.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+      The input graph should be bipartite.
+
+    nodes : list or iterable
+      Nodes to project onto (the "bottom" nodes).
+
+    Returns
+    -------
+    Graph : NetworkX graph
+       A graph that is the projection onto the given nodes.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> B = nx.path_graph(5)
+    >>> B.add_edge(1, 5)
+    >>> G = bipartite.collaboration_weighted_projected_graph(B, [0, 2, 4, 5])
+    >>> list(G)
+    [0, 2, 4, 5]
+    >>> for edge in sorted(G.edges(data=True)):
+    ...     print(edge)
+    (0, 2, {'weight': 0.5})
+    (0, 5, {'weight': 0.5})
+    (2, 4, {'weight': 1.0})
+    (2, 5, {'weight': 0.5})
+
+    Notes
+    -----
+    No attempt is made to verify that the input graph B is bipartite.
+    The graph and node properties are (shallow) copied to the projected graph.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    is_bipartite,
+    is_bipartite_node_set,
+    sets,
+    weighted_projected_graph,
+    overlap_weighted_projected_graph,
+    generic_weighted_projected_graph,
+    projected_graph
+
+    References
+    ----------
+    .. [1] Scientific collaboration networks: II.
+        Shortest paths, weighted networks, and centrality,
+        M. E. J. Newman, Phys. Rev. E 64, 016132 (2001).
+    """
+    if B.is_directed():
+        pred = B.pred
+        G = nx.DiGraph()
+    else:
+        pred = B.adj
+        G = nx.Graph()
+    G.graph.update(B.graph)
+    G.add_nodes_from((n, B.nodes[n]) for n in nodes)
+    for u in nodes:
+        unbrs = set(B[u])
+        nbrs2 = {n for nbr in unbrs for n in B[nbr] if n != u}
+        for v in nbrs2:
+            vnbrs = set(pred[v])
+            common_degree = (len(B[n]) for n in unbrs & vnbrs)
+            weight = sum(1.0 / (deg - 1) for deg in common_degree if deg > 1)
+            G.add_edge(u, v, weight=weight)
+    return G
+
+
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs="B", returns_graph=True)
+def overlap_weighted_projected_graph(B, nodes, jaccard=True):
+    r"""Overlap weighted projection of B onto one of its node sets.
+
+    The overlap weighted projection is the projection of the bipartite
+    network B onto the specified nodes with weights representing
+    the Jaccard index between the neighborhoods of the two nodes in the
+    original bipartite network [1]_:
+
+    .. math::
+
+        w_{v, u} = \frac{|N(u) \cap N(v)|}{|N(u) \cup N(v)|}
+
+    or if the parameter 'jaccard' is False, the fraction of common
+    neighbors by minimum of both nodes degree in the original
+    bipartite graph [1]_:
+
+    .. math::
+
+        w_{v, u} = \frac{|N(u) \cap N(v)|}{min(|N(u)|, |N(v)|)}
+
+    The nodes retain their attributes and are connected in the resulting
+    graph if have an edge to a common node in the original bipartite graph.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+        The input graph should be bipartite.
+
+    nodes : list or iterable
+        Nodes to project onto (the "bottom" nodes).
+
+    jaccard: Bool (default=True)
+
+    Returns
+    -------
+    Graph : NetworkX graph
+       A graph that is the projection onto the given nodes.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> B = nx.path_graph(5)
+    >>> nodes = [0, 2, 4]
+    >>> G = bipartite.overlap_weighted_projected_graph(B, nodes)
+    >>> list(G)
+    [0, 2, 4]
+    >>> list(G.edges(data=True))
+    [(0, 2, {'weight': 0.5}), (2, 4, {'weight': 0.5})]
+    >>> G = bipartite.overlap_weighted_projected_graph(B, nodes, jaccard=False)
+    >>> list(G.edges(data=True))
+    [(0, 2, {'weight': 1.0}), (2, 4, {'weight': 1.0})]
+
+    Notes
+    -----
+    No attempt is made to verify that the input graph B is bipartite.
+    The graph and node properties are (shallow) copied to the projected graph.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    is_bipartite,
+    is_bipartite_node_set,
+    sets,
+    weighted_projected_graph,
+    collaboration_weighted_projected_graph,
+    generic_weighted_projected_graph,
+    projected_graph
+
+    References
+    ----------
+    .. [1] Borgatti, S.P. and Halgin, D. In press. Analyzing Affiliation
+        Networks. In Carrington, P. and Scott, J. (eds) The Sage Handbook
+        of Social Network Analysis. Sage Publications.
+
+    """
+    if B.is_directed():
+        pred = B.pred
+        G = nx.DiGraph()
+    else:
+        pred = B.adj
+        G = nx.Graph()
+    G.graph.update(B.graph)
+    G.add_nodes_from((n, B.nodes[n]) for n in nodes)
+    for u in nodes:
+        unbrs = set(B[u])
+        nbrs2 = {n for nbr in unbrs for n in B[nbr]} - {u}
+        for v in nbrs2:
+            vnbrs = set(pred[v])
+            if jaccard:
+                wt = len(unbrs & vnbrs) / len(unbrs | vnbrs)
+            else:
+                wt = len(unbrs & vnbrs) / min(len(unbrs), len(vnbrs))
+            G.add_edge(u, v, weight=wt)
+    return G
+
+
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs="B", preserve_all_attrs=True, returns_graph=True)
+def generic_weighted_projected_graph(B, nodes, weight_function=None):
+    r"""Weighted projection of B with a user-specified weight function.
+
+    The bipartite network B is projected on to the specified nodes
+    with weights computed by a user-specified function.  This function
+    must accept as a parameter the neighborhood sets of two nodes and
+    return an integer or a float.
+
+    The nodes retain their attributes and are connected in the resulting graph
+    if they have an edge to a common node in the original graph.
+
+    Parameters
+    ----------
+    B : NetworkX graph
+        The input graph should be bipartite.
+
+    nodes : list or iterable
+        Nodes to project onto (the "bottom" nodes).
+
+    weight_function : function
+        This function must accept as parameters the same input graph
+        that this function, and two nodes; and return an integer or a float.
+        The default function computes the number of shared neighbors.
+
+    Returns
+    -------
+    Graph : NetworkX graph
+       A graph that is the projection onto the given nodes.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> # Define some custom weight functions
+    >>> def jaccard(G, u, v):
+    ...     unbrs = set(G[u])
+    ...     vnbrs = set(G[v])
+    ...     return float(len(unbrs & vnbrs)) / len(unbrs | vnbrs)
+    >>> def my_weight(G, u, v, weight="weight"):
+    ...     w = 0
+    ...     for nbr in set(G[u]) & set(G[v]):
+    ...         w += G[u][nbr].get(weight, 1) + G[v][nbr].get(weight, 1)
+    ...     return w
+    >>> # A complete bipartite graph with 4 nodes and 4 edges
+    >>> B = nx.complete_bipartite_graph(2, 2)
+    >>> # Add some arbitrary weight to the edges
+    >>> for i, (u, v) in enumerate(B.edges()):
+    ...     B.edges[u, v]["weight"] = i + 1
+    >>> for edge in B.edges(data=True):
+    ...     print(edge)
+    (0, 2, {'weight': 1})
+    (0, 3, {'weight': 2})
+    (1, 2, {'weight': 3})
+    (1, 3, {'weight': 4})
+    >>> # By default, the weight is the number of shared neighbors
+    >>> G = bipartite.generic_weighted_projected_graph(B, [0, 1])
+    >>> print(list(G.edges(data=True)))
+    [(0, 1, {'weight': 2})]
+    >>> # To specify a custom weight function use the weight_function parameter
+    >>> G = bipartite.generic_weighted_projected_graph(
+    ...     B, [0, 1], weight_function=jaccard
+    ... )
+    >>> print(list(G.edges(data=True)))
+    [(0, 1, {'weight': 1.0})]
+    >>> G = bipartite.generic_weighted_projected_graph(
+    ...     B, [0, 1], weight_function=my_weight
+    ... )
+    >>> print(list(G.edges(data=True)))
+    [(0, 1, {'weight': 10})]
+
+    Notes
+    -----
+    No attempt is made to verify that the input graph B is bipartite.
+    The graph and node properties are (shallow) copied to the projected graph.
+
+    See :mod:`bipartite documentation <networkx.algorithms.bipartite>`
+    for further details on how bipartite graphs are handled in NetworkX.
+
+    See Also
+    --------
+    is_bipartite,
+    is_bipartite_node_set,
+    sets,
+    weighted_projected_graph,
+    collaboration_weighted_projected_graph,
+    overlap_weighted_projected_graph,
+    projected_graph
+
+    """
+    if B.is_directed():
+        pred = B.pred
+        G = nx.DiGraph()
+    else:
+        pred = B.adj
+        G = nx.Graph()
+    if weight_function is None:
+
+        def weight_function(G, u, v):
+            # Notice that we use set(pred[v]) for handling the directed case.
+            return len(set(G[u]) & set(pred[v]))
+
+    G.graph.update(B.graph)
+    G.add_nodes_from((n, B.nodes[n]) for n in nodes)
+    for u in nodes:
+        nbrs2 = {n for nbr in set(B[u]) for n in B[nbr]} - {u}
+        for v in nbrs2:
+            weight = weight_function(B, u, v)
+            G.add_edge(u, v, weight=weight)
+    return G
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/redundancy.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/redundancy.py
new file mode 100644
index 00000000..b622b975
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/redundancy.py
@@ -0,0 +1,112 @@
+"""Node redundancy for bipartite graphs."""
+
+from itertools import combinations
+
+import networkx as nx
+from networkx import NetworkXError
+
+__all__ = ["node_redundancy"]
+
+
+@nx._dispatchable
+def node_redundancy(G, nodes=None):
+    r"""Computes the node redundancy coefficients for the nodes in the bipartite
+    graph `G`.
+
+    The redundancy coefficient of a node `v` is the fraction of pairs of
+    neighbors of `v` that are both linked to other nodes. In a one-mode
+    projection these nodes would be linked together even if `v` were
+    not there.
+
+    More formally, for any vertex `v`, the *redundancy coefficient of `v`* is
+    defined by
+
+    .. math::
+
+        rc(v) = \frac{|\{\{u, w\} \subseteq N(v),
+        \: \exists v' \neq  v,\: (v',u) \in E\:
+        \mathrm{and}\: (v',w) \in E\}|}{ \frac{|N(v)|(|N(v)|-1)}{2}},
+
+    where `N(v)` is the set of neighbors of `v` in `G`.
+
+    Parameters
+    ----------
+    G : graph
+        A bipartite graph
+
+    nodes : list or iterable (optional)
+        Compute redundancy for these nodes. The default is all nodes in G.
+
+    Returns
+    -------
+    redundancy : dictionary
+        A dictionary keyed by node with the node redundancy value.
+
+    Examples
+    --------
+    Compute the redundancy coefficient of each node in a graph::
+
+        >>> from networkx.algorithms import bipartite
+        >>> G = nx.cycle_graph(4)
+        >>> rc = bipartite.node_redundancy(G)
+        >>> rc[0]
+        1.0
+
+    Compute the average redundancy for the graph::
+
+        >>> from networkx.algorithms import bipartite
+        >>> G = nx.cycle_graph(4)
+        >>> rc = bipartite.node_redundancy(G)
+        >>> sum(rc.values()) / len(G)
+        1.0
+
+    Compute the average redundancy for a set of nodes::
+
+        >>> from networkx.algorithms import bipartite
+        >>> G = nx.cycle_graph(4)
+        >>> rc = bipartite.node_redundancy(G)
+        >>> nodes = [0, 2]
+        >>> sum(rc[n] for n in nodes) / len(nodes)
+        1.0
+
+    Raises
+    ------
+    NetworkXError
+        If any of the nodes in the graph (or in `nodes`, if specified) has
+        (out-)degree less than two (which would result in division by zero,
+        according to the definition of the redundancy coefficient).
+
+    References
+    ----------
+    .. [1] Latapy, Matthieu, Clémence Magnien, and Nathalie Del Vecchio (2008).
+       Basic notions for the analysis of large two-mode networks.
+       Social Networks 30(1), 31--48.
+
+    """
+    if nodes is None:
+        nodes = G
+    if any(len(G[v]) < 2 for v in nodes):
+        raise NetworkXError(
+            "Cannot compute redundancy coefficient for a node"
+            " that has fewer than two neighbors."
+        )
+    # TODO This can be trivially parallelized.
+    return {v: _node_redundancy(G, v) for v in nodes}
+
+
+def _node_redundancy(G, v):
+    """Returns the redundancy of the node `v` in the bipartite graph `G`.
+
+    If `G` is a graph with `n` nodes, the redundancy of a node is the ratio
+    of the "overlap" of `v` to the maximum possible overlap of `v`
+    according to its degree. The overlap of `v` is the number of pairs of
+    neighbors that have mutual neighbors themselves, other than `v`.
+
+    `v` must have at least two neighbors in `G`.
+
+    """
+    n = len(G[v])
+    overlap = sum(
+        1 for (u, w) in combinations(G[v], 2) if (set(G[u]) & set(G[w])) - {v}
+    )
+    return (2 * overlap) / (n * (n - 1))
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/spectral.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/spectral.py
new file mode 100644
index 00000000..cb9388f6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/spectral.py
@@ -0,0 +1,69 @@
+"""
+Spectral bipartivity measure.
+"""
+
+import networkx as nx
+
+__all__ = ["spectral_bipartivity"]
+
+
+@nx._dispatchable(edge_attrs="weight")
+def spectral_bipartivity(G, nodes=None, weight="weight"):
+    """Returns the spectral bipartivity.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+
+    nodes : list or container  optional(default is all nodes)
+      Nodes to return value of spectral bipartivity contribution.
+
+    weight : string or None  optional (default = 'weight')
+      Edge data key to use for edge weights. If None, weights set to 1.
+
+    Returns
+    -------
+    sb : float or dict
+       A single number if the keyword nodes is not specified, or
+       a dictionary keyed by node with the spectral bipartivity contribution
+       of that node as the value.
+
+    Examples
+    --------
+    >>> from networkx.algorithms import bipartite
+    >>> G = nx.path_graph(4)
+    >>> bipartite.spectral_bipartivity(G)
+    1.0
+
+    Notes
+    -----
+    This implementation uses Numpy (dense) matrices which are not efficient
+    for storing large sparse graphs.
+
+    See Also
+    --------
+    color
+
+    References
+    ----------
+    .. [1] E. Estrada and J. A. Rodríguez-Velázquez, "Spectral measures of
+       bipartivity in complex networks", PhysRev E 72, 046105 (2005)
+    """
+    import scipy as sp
+
+    nodelist = list(G)  # ordering of nodes in matrix
+    A = nx.to_numpy_array(G, nodelist, weight=weight)
+    expA = sp.linalg.expm(A)
+    expmA = sp.linalg.expm(-A)
+    coshA = 0.5 * (expA + expmA)
+    if nodes is None:
+        # return single number for entire graph
+        return float(coshA.diagonal().sum() / expA.diagonal().sum())
+    else:
+        # contribution for individual nodes
+        index = dict(zip(nodelist, range(len(nodelist))))
+        sb = {}
+        for n in nodes:
+            i = index[n]
+            sb[n] = coshA.item(i, i) / expA.item(i, i)
+        return sb
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/__init__.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/__init__.py
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_basic.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_basic.py
new file mode 100644
index 00000000..655506b4
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_basic.py
@@ -0,0 +1,125 @@
+import pytest
+
+import networkx as nx
+from networkx.algorithms import bipartite
+
+
+class TestBipartiteBasic:
+    def test_is_bipartite(self):
+        assert bipartite.is_bipartite(nx.path_graph(4))
+        assert bipartite.is_bipartite(nx.DiGraph([(1, 0)]))
+        assert not bipartite.is_bipartite(nx.complete_graph(3))
+
+    def test_bipartite_color(self):
+        G = nx.path_graph(4)
+        c = bipartite.color(G)
+        assert c == {0: 1, 1: 0, 2: 1, 3: 0}
+
+    def test_not_bipartite_color(self):
+        with pytest.raises(nx.NetworkXError):
+            c = bipartite.color(nx.complete_graph(4))
+
+    def test_bipartite_directed(self):
+        G = bipartite.random_graph(10, 10, 0.1, directed=True)
+        assert bipartite.is_bipartite(G)
+
+    def test_bipartite_sets(self):
+        G = nx.path_graph(4)
+        X, Y = bipartite.sets(G)
+        assert X == {0, 2}
+        assert Y == {1, 3}
+
+    def test_bipartite_sets_directed(self):
+        G = nx.path_graph(4)
+        D = G.to_directed()
+        X, Y = bipartite.sets(D)
+        assert X == {0, 2}
+        assert Y == {1, 3}
+
+    def test_bipartite_sets_given_top_nodes(self):
+        G = nx.path_graph(4)
+        top_nodes = [0, 2]
+        X, Y = bipartite.sets(G, top_nodes)
+        assert X == {0, 2}
+        assert Y == {1, 3}
+
+    def test_bipartite_sets_disconnected(self):
+        with pytest.raises(nx.AmbiguousSolution):
+            G = nx.path_graph(4)
+            G.add_edges_from([(5, 6), (6, 7)])
+            X, Y = bipartite.sets(G)
+
+    def test_is_bipartite_node_set(self):
+        G = nx.path_graph(4)
+
+        with pytest.raises(nx.AmbiguousSolution):
+            bipartite.is_bipartite_node_set(G, [1, 1, 2, 3])
+
+        assert bipartite.is_bipartite_node_set(G, [0, 2])
+        assert bipartite.is_bipartite_node_set(G, [1, 3])
+        assert not bipartite.is_bipartite_node_set(G, [1, 2])
+        G.add_edge(10, 20)
+        assert bipartite.is_bipartite_node_set(G, [0, 2, 10])
+        assert bipartite.is_bipartite_node_set(G, [0, 2, 20])
+        assert bipartite.is_bipartite_node_set(G, [1, 3, 10])
+        assert bipartite.is_bipartite_node_set(G, [1, 3, 20])
+
+    def test_bipartite_density(self):
+        G = nx.path_graph(5)
+        X, Y = bipartite.sets(G)
+        density = len(list(G.edges())) / (len(X) * len(Y))
+        assert bipartite.density(G, X) == density
+        D = nx.DiGraph(G.edges())
+        assert bipartite.density(D, X) == density / 2.0
+        assert bipartite.density(nx.Graph(), {}) == 0.0
+
+    def test_bipartite_degrees(self):
+        G = nx.path_graph(5)
+        X = {1, 3}
+        Y = {0, 2, 4}
+        u, d = bipartite.degrees(G, Y)
+        assert dict(u) == {1: 2, 3: 2}
+        assert dict(d) == {0: 1, 2: 2, 4: 1}
+
+    def test_bipartite_weighted_degrees(self):
+        G = nx.path_graph(5)
+        G.add_edge(0, 1, weight=0.1, other=0.2)
+        X = {1, 3}
+        Y = {0, 2, 4}
+        u, d = bipartite.degrees(G, Y, weight="weight")
+        assert dict(u) == {1: 1.1, 3: 2}
+        assert dict(d) == {0: 0.1, 2: 2, 4: 1}
+        u, d = bipartite.degrees(G, Y, weight="other")
+        assert dict(u) == {1: 1.2, 3: 2}
+        assert dict(d) == {0: 0.2, 2: 2, 4: 1}
+
+    def test_biadjacency_matrix_weight(self):
+        pytest.importorskip("scipy")
+        G = nx.path_graph(5)
+        G.add_edge(0, 1, weight=2, other=4)
+        X = [1, 3]
+        Y = [0, 2, 4]
+        M = bipartite.biadjacency_matrix(G, X, weight="weight")
+        assert M[0, 0] == 2
+        M = bipartite.biadjacency_matrix(G, X, weight="other")
+        assert M[0, 0] == 4
+
+    def test_biadjacency_matrix(self):
+        pytest.importorskip("scipy")
+        tops = [2, 5, 10]
+        bots = [5, 10, 15]
+        for i in range(len(tops)):
+            G = bipartite.random_graph(tops[i], bots[i], 0.2)
+            top = [n for n, d in G.nodes(data=True) if d["bipartite"] == 0]
+            M = bipartite.biadjacency_matrix(G, top)
+            assert M.shape[0] == tops[i]
+            assert M.shape[1] == bots[i]
+
+    def test_biadjacency_matrix_order(self):
+        pytest.importorskip("scipy")
+        G = nx.path_graph(5)
+        G.add_edge(0, 1, weight=2)
+        X = [3, 1]
+        Y = [4, 2, 0]
+        M = bipartite.biadjacency_matrix(G, X, Y, weight="weight")
+        assert M[1, 2] == 2
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_centrality.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_centrality.py
new file mode 100644
index 00000000..19fb5d11
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_centrality.py
@@ -0,0 +1,192 @@
+import pytest
+
+import networkx as nx
+from networkx.algorithms import bipartite
+
+
+class TestBipartiteCentrality:
+    @classmethod
+    def setup_class(cls):
+        cls.P4 = nx.path_graph(4)
+        cls.K3 = nx.complete_bipartite_graph(3, 3)
+        cls.C4 = nx.cycle_graph(4)
+        cls.davis = nx.davis_southern_women_graph()
+        cls.top_nodes = [
+            n for n, d in cls.davis.nodes(data=True) if d["bipartite"] == 0
+        ]
+
+    def test_degree_centrality(self):
+        d = bipartite.degree_centrality(self.P4, [1, 3])
+        answer = {0: 0.5, 1: 1.0, 2: 1.0, 3: 0.5}
+        assert d == answer
+        d = bipartite.degree_centrality(self.K3, [0, 1, 2])
+        answer = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0, 5: 1.0}
+        assert d == answer
+        d = bipartite.degree_centrality(self.C4, [0, 2])
+        answer = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0}
+        assert d == answer
+
+    def test_betweenness_centrality(self):
+        c = bipartite.betweenness_centrality(self.P4, [1, 3])
+        answer = {0: 0.0, 1: 1.0, 2: 1.0, 3: 0.0}
+        assert c == answer
+        c = bipartite.betweenness_centrality(self.K3, [0, 1, 2])
+        answer = {0: 0.125, 1: 0.125, 2: 0.125, 3: 0.125, 4: 0.125, 5: 0.125}
+        assert c == answer
+        c = bipartite.betweenness_centrality(self.C4, [0, 2])
+        answer = {0: 0.25, 1: 0.25, 2: 0.25, 3: 0.25}
+        assert c == answer
+
+    def test_closeness_centrality(self):
+        c = bipartite.closeness_centrality(self.P4, [1, 3])
+        answer = {0: 2.0 / 3, 1: 1.0, 2: 1.0, 3: 2.0 / 3}
+        assert c == answer
+        c = bipartite.closeness_centrality(self.K3, [0, 1, 2])
+        answer = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0, 5: 1.0}
+        assert c == answer
+        c = bipartite.closeness_centrality(self.C4, [0, 2])
+        answer = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0}
+        assert c == answer
+        G = nx.Graph()
+        G.add_node(0)
+        G.add_node(1)
+        c = bipartite.closeness_centrality(G, [0])
+        assert c == {0: 0.0, 1: 0.0}
+        c = bipartite.closeness_centrality(G, [1])
+        assert c == {0: 0.0, 1: 0.0}
+
+    def test_bipartite_closeness_centrality_unconnected(self):
+        G = nx.complete_bipartite_graph(3, 3)
+        G.add_edge(6, 7)
+        c = bipartite.closeness_centrality(G, [0, 2, 4, 6], normalized=False)
+        answer = {
+            0: 10.0 / 7,
+            2: 10.0 / 7,
+            4: 10.0 / 7,
+            6: 10.0,
+            1: 10.0 / 7,
+            3: 10.0 / 7,
+            5: 10.0 / 7,
+            7: 10.0,
+        }
+        assert c == answer
+
+    def test_davis_degree_centrality(self):
+        G = self.davis
+        deg = bipartite.degree_centrality(G, self.top_nodes)
+        answer = {
+            "E8": 0.78,
+            "E9": 0.67,
+            "E7": 0.56,
+            "Nora Fayette": 0.57,
+            "Evelyn Jefferson": 0.57,
+            "Theresa Anderson": 0.57,
+            "E6": 0.44,
+            "Sylvia Avondale": 0.50,
+            "Laura Mandeville": 0.50,
+            "Brenda Rogers": 0.50,
+            "Katherina Rogers": 0.43,
+            "E5": 0.44,
+            "Helen Lloyd": 0.36,
+            "E3": 0.33,
+            "Ruth DeSand": 0.29,
+            "Verne Sanderson": 0.29,
+            "E12": 0.33,
+            "Myra Liddel": 0.29,
+            "E11": 0.22,
+            "Eleanor Nye": 0.29,
+            "Frances Anderson": 0.29,
+            "Pearl Oglethorpe": 0.21,
+            "E4": 0.22,
+            "Charlotte McDowd": 0.29,
+            "E10": 0.28,
+            "Olivia Carleton": 0.14,
+            "Flora Price": 0.14,
+            "E2": 0.17,
+            "E1": 0.17,
+            "Dorothy Murchison": 0.14,
+            "E13": 0.17,
+            "E14": 0.17,
+        }
+        for node, value in answer.items():
+            assert value == pytest.approx(deg[node], abs=1e-2)
+
+    def test_davis_betweenness_centrality(self):
+        G = self.davis
+        bet = bipartite.betweenness_centrality(G, self.top_nodes)
+        answer = {
+            "E8": 0.24,
+            "E9": 0.23,
+            "E7": 0.13,
+            "Nora Fayette": 0.11,
+            "Evelyn Jefferson": 0.10,
+            "Theresa Anderson": 0.09,
+            "E6": 0.07,
+            "Sylvia Avondale": 0.07,
+            "Laura Mandeville": 0.05,
+            "Brenda Rogers": 0.05,
+            "Katherina Rogers": 0.05,
+            "E5": 0.04,
+            "Helen Lloyd": 0.04,
+            "E3": 0.02,
+            "Ruth DeSand": 0.02,
+            "Verne Sanderson": 0.02,
+            "E12": 0.02,
+            "Myra Liddel": 0.02,
+            "E11": 0.02,
+            "Eleanor Nye": 0.01,
+            "Frances Anderson": 0.01,
+            "Pearl Oglethorpe": 0.01,
+            "E4": 0.01,
+            "Charlotte McDowd": 0.01,
+            "E10": 0.01,
+            "Olivia Carleton": 0.01,
+            "Flora Price": 0.01,
+            "E2": 0.00,
+            "E1": 0.00,
+            "Dorothy Murchison": 0.00,
+            "E13": 0.00,
+            "E14": 0.00,
+        }
+        for node, value in answer.items():
+            assert value == pytest.approx(bet[node], abs=1e-2)
+
+    def test_davis_closeness_centrality(self):
+        G = self.davis
+        clos = bipartite.closeness_centrality(G, self.top_nodes)
+        answer = {
+            "E8": 0.85,
+            "E9": 0.79,
+            "E7": 0.73,
+            "Nora Fayette": 0.80,
+            "Evelyn Jefferson": 0.80,
+            "Theresa Anderson": 0.80,
+            "E6": 0.69,
+            "Sylvia Avondale": 0.77,
+            "Laura Mandeville": 0.73,
+            "Brenda Rogers": 0.73,
+            "Katherina Rogers": 0.73,
+            "E5": 0.59,
+            "Helen Lloyd": 0.73,
+            "E3": 0.56,
+            "Ruth DeSand": 0.71,
+            "Verne Sanderson": 0.71,
+            "E12": 0.56,
+            "Myra Liddel": 0.69,
+            "E11": 0.54,
+            "Eleanor Nye": 0.67,
+            "Frances Anderson": 0.67,
+            "Pearl Oglethorpe": 0.67,
+            "E4": 0.54,
+            "Charlotte McDowd": 0.60,
+            "E10": 0.55,
+            "Olivia Carleton": 0.59,
+            "Flora Price": 0.59,
+            "E2": 0.52,
+            "E1": 0.52,
+            "Dorothy Murchison": 0.65,
+            "E13": 0.52,
+            "E14": 0.52,
+        }
+        for node, value in answer.items():
+            assert value == pytest.approx(clos[node], abs=1e-2)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_cluster.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_cluster.py
new file mode 100644
index 00000000..72e2dbad
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_cluster.py
@@ -0,0 +1,84 @@
+import pytest
+
+import networkx as nx
+from networkx.algorithms import bipartite
+from networkx.algorithms.bipartite.cluster import cc_dot, cc_max, cc_min
+
+
+def test_pairwise_bipartite_cc_functions():
+    # Test functions for different kinds of bipartite clustering coefficients
+    # between pairs of nodes using 3 example graphs from figure 5 p. 40
+    # Latapy et al (2008)
+    G1 = nx.Graph([(0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (1, 5), (1, 6), (1, 7)])
+    G2 = nx.Graph([(0, 2), (0, 3), (0, 4), (1, 3), (1, 4), (1, 5)])
+    G3 = nx.Graph(
+        [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9)]
+    )
+    result = {
+        0: [1 / 3.0, 2 / 3.0, 2 / 5.0],
+        1: [1 / 2.0, 2 / 3.0, 2 / 3.0],
+        2: [2 / 8.0, 2 / 5.0, 2 / 5.0],
+    }
+    for i, G in enumerate([G1, G2, G3]):
+        assert bipartite.is_bipartite(G)
+        assert cc_dot(set(G[0]), set(G[1])) == result[i][0]
+        assert cc_min(set(G[0]), set(G[1])) == result[i][1]
+        assert cc_max(set(G[0]), set(G[1])) == result[i][2]
+
+
+def test_star_graph():
+    G = nx.star_graph(3)
+    # all modes are the same
+    answer = {0: 0, 1: 1, 2: 1, 3: 1}
+    assert bipartite.clustering(G, mode="dot") == answer
+    assert bipartite.clustering(G, mode="min") == answer
+    assert bipartite.clustering(G, mode="max") == answer
+
+
+def test_not_bipartite():
+    with pytest.raises(nx.NetworkXError):
+        bipartite.clustering(nx.complete_graph(4))
+
+
+def test_bad_mode():
+    with pytest.raises(nx.NetworkXError):
+        bipartite.clustering(nx.path_graph(4), mode="foo")
+
+
+def test_path_graph():
+    G = nx.path_graph(4)
+    answer = {0: 0.5, 1: 0.5, 2: 0.5, 3: 0.5}
+    assert bipartite.clustering(G, mode="dot") == answer
+    assert bipartite.clustering(G, mode="max") == answer
+    answer = {0: 1, 1: 1, 2: 1, 3: 1}
+    assert bipartite.clustering(G, mode="min") == answer
+
+
+def test_average_path_graph():
+    G = nx.path_graph(4)
+    assert bipartite.average_clustering(G, mode="dot") == 0.5
+    assert bipartite.average_clustering(G, mode="max") == 0.5
+    assert bipartite.average_clustering(G, mode="min") == 1
+
+
+def test_ra_clustering_davis():
+    G = nx.davis_southern_women_graph()
+    cc4 = round(bipartite.robins_alexander_clustering(G), 3)
+    assert cc4 == 0.468
+
+
+def test_ra_clustering_square():
+    G = nx.path_graph(4)
+    G.add_edge(0, 3)
+    assert bipartite.robins_alexander_clustering(G) == 1.0
+
+
+def test_ra_clustering_zero():
+    G = nx.Graph()
+    assert bipartite.robins_alexander_clustering(G) == 0
+    G.add_nodes_from(range(4))
+    assert bipartite.robins_alexander_clustering(G) == 0
+    G.add_edges_from([(0, 1), (2, 3), (3, 4)])
+    assert bipartite.robins_alexander_clustering(G) == 0
+    G.add_edge(1, 2)
+    assert bipartite.robins_alexander_clustering(G) == 0
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_covering.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_covering.py
new file mode 100644
index 00000000..9507e134
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_covering.py
@@ -0,0 +1,33 @@
+import networkx as nx
+from networkx.algorithms import bipartite
+
+
+class TestMinEdgeCover:
+    """Tests for :func:`networkx.algorithms.bipartite.min_edge_cover`"""
+
+    def test_empty_graph(self):
+        G = nx.Graph()
+        assert bipartite.min_edge_cover(G) == set()
+
+    def test_graph_single_edge(self):
+        G = nx.Graph()
+        G.add_edge(0, 1)
+        assert bipartite.min_edge_cover(G) == {(0, 1), (1, 0)}
+
+    def test_bipartite_default(self):
+        G = nx.Graph()
+        G.add_nodes_from([1, 2, 3, 4], bipartite=0)
+        G.add_nodes_from(["a", "b", "c"], bipartite=1)
+        G.add_edges_from([(1, "a"), (1, "b"), (2, "b"), (2, "c"), (3, "c"), (4, "a")])
+        min_cover = bipartite.min_edge_cover(G)
+        assert nx.is_edge_cover(G, min_cover)
+        assert len(min_cover) == 8
+
+    def test_bipartite_explicit(self):
+        G = nx.Graph()
+        G.add_nodes_from([1, 2, 3, 4], bipartite=0)
+        G.add_nodes_from(["a", "b", "c"], bipartite=1)
+        G.add_edges_from([(1, "a"), (1, "b"), (2, "b"), (2, "c"), (3, "c"), (4, "a")])
+        min_cover = bipartite.min_edge_cover(G, bipartite.eppstein_matching)
+        assert nx.is_edge_cover(G, min_cover)
+        assert len(min_cover) == 8
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_edgelist.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_edgelist.py
new file mode 100644
index 00000000..66be8a2f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_edgelist.py
@@ -0,0 +1,240 @@
+"""
+Unit tests for bipartite edgelists.
+"""
+
+import io
+
+import pytest
+
+import networkx as nx
+from networkx.algorithms import bipartite
+from networkx.utils import edges_equal, graphs_equal, nodes_equal
+
+
+class TestEdgelist:
+    @classmethod
+    def setup_class(cls):
+        cls.G = nx.Graph(name="test")
+        e = [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e"), ("e", "f"), ("a", "f")]
+        cls.G.add_edges_from(e)
+        cls.G.add_nodes_from(["a", "c", "e"], bipartite=0)
+        cls.G.add_nodes_from(["b", "d", "f"], bipartite=1)
+        cls.G.add_node("g", bipartite=0)
+        cls.DG = nx.DiGraph(cls.G)
+        cls.MG = nx.MultiGraph()
+        cls.MG.add_edges_from([(1, 2), (1, 2), (1, 2)])
+        cls.MG.add_node(1, bipartite=0)
+        cls.MG.add_node(2, bipartite=1)
+
+    def test_read_edgelist_1(self):
+        s = b"""\
+# comment line
+1 2
+# comment line
+2 3
+"""
+        bytesIO = io.BytesIO(s)
+        G = bipartite.read_edgelist(bytesIO, nodetype=int)
+        assert edges_equal(G.edges(), [(1, 2), (2, 3)])
+
+    def test_read_edgelist_3(self):
+        s = b"""\
+# comment line
+1 2 {'weight':2.0}
+# comment line
+2 3 {'weight':3.0}
+"""
+        bytesIO = io.BytesIO(s)
+        G = bipartite.read_edgelist(bytesIO, nodetype=int, data=False)
+        assert edges_equal(G.edges(), [(1, 2), (2, 3)])
+
+        bytesIO = io.BytesIO(s)
+        G = bipartite.read_edgelist(bytesIO, nodetype=int, data=True)
+        assert edges_equal(
+            G.edges(data=True), [(1, 2, {"weight": 2.0}), (2, 3, {"weight": 3.0})]
+        )
+
+    def test_write_edgelist_1(self):
+        fh = io.BytesIO()
+        G = nx.Graph()
+        G.add_edges_from([(1, 2), (2, 3)])
+        G.add_node(1, bipartite=0)
+        G.add_node(2, bipartite=1)
+        G.add_node(3, bipartite=0)
+        bipartite.write_edgelist(G, fh, data=False)
+        fh.seek(0)
+        assert fh.read() == b"1 2\n3 2\n"
+
+    def test_write_edgelist_2(self):
+        fh = io.BytesIO()
+        G = nx.Graph()
+        G.add_edges_from([(1, 2), (2, 3)])
+        G.add_node(1, bipartite=0)
+        G.add_node(2, bipartite=1)
+        G.add_node(3, bipartite=0)
+        bipartite.write_edgelist(G, fh, data=True)
+        fh.seek(0)
+        assert fh.read() == b"1 2 {}\n3 2 {}\n"
+
+    def test_write_edgelist_3(self):
+        fh = io.BytesIO()
+        G = nx.Graph()
+        G.add_edge(1, 2, weight=2.0)
+        G.add_edge(2, 3, weight=3.0)
+        G.add_node(1, bipartite=0)
+        G.add_node(2, bipartite=1)
+        G.add_node(3, bipartite=0)
+        bipartite.write_edgelist(G, fh, data=True)
+        fh.seek(0)
+        assert fh.read() == b"1 2 {'weight': 2.0}\n3 2 {'weight': 3.0}\n"
+
+    def test_write_edgelist_4(self):
+        fh = io.BytesIO()
+        G = nx.Graph()
+        G.add_edge(1, 2, weight=2.0)
+        G.add_edge(2, 3, weight=3.0)
+        G.add_node(1, bipartite=0)
+        G.add_node(2, bipartite=1)
+        G.add_node(3, bipartite=0)
+        bipartite.write_edgelist(G, fh, data=[("weight")])
+        fh.seek(0)
+        assert fh.read() == b"1 2 2.0\n3 2 3.0\n"
+
+    def test_unicode(self, tmp_path):
+        G = nx.Graph()
+        name1 = chr(2344) + chr(123) + chr(6543)
+        name2 = chr(5543) + chr(1543) + chr(324)
+        G.add_edge(name1, "Radiohead", **{name2: 3})
+        G.add_node(name1, bipartite=0)
+        G.add_node("Radiohead", bipartite=1)
+
+        fname = tmp_path / "edgelist.txt"
+        bipartite.write_edgelist(G, fname)
+        H = bipartite.read_edgelist(fname)
+        assert graphs_equal(G, H)
+
+    def test_latin1_issue(self, tmp_path):
+        G = nx.Graph()
+        name1 = chr(2344) + chr(123) + chr(6543)
+        name2 = chr(5543) + chr(1543) + chr(324)
+        G.add_edge(name1, "Radiohead", **{name2: 3})
+        G.add_node(name1, bipartite=0)
+        G.add_node("Radiohead", bipartite=1)
+
+        fname = tmp_path / "edgelist.txt"
+        with pytest.raises(UnicodeEncodeError):
+            bipartite.write_edgelist(G, fname, encoding="latin-1")
+
+    def test_latin1(self, tmp_path):
+        G = nx.Graph()
+        name1 = "Bj" + chr(246) + "rk"
+        name2 = chr(220) + "ber"
+        G.add_edge(name1, "Radiohead", **{name2: 3})
+        G.add_node(name1, bipartite=0)
+        G.add_node("Radiohead", bipartite=1)
+
+        fname = tmp_path / "edgelist.txt"
+        bipartite.write_edgelist(G, fname, encoding="latin-1")
+        H = bipartite.read_edgelist(fname, encoding="latin-1")
+        assert graphs_equal(G, H)
+
+    def test_edgelist_graph(self, tmp_path):
+        G = self.G
+        fname = tmp_path / "edgelist.txt"
+        bipartite.write_edgelist(G, fname)
+        H = bipartite.read_edgelist(fname)
+        H2 = bipartite.read_edgelist(fname)
+        assert H is not H2  # they should be different graphs
+        G.remove_node("g")  # isolated nodes are not written in edgelist
+        assert nodes_equal(list(H), list(G))
+        assert edges_equal(list(H.edges()), list(G.edges()))
+
+    def test_edgelist_integers(self, tmp_path):
+        G = nx.convert_node_labels_to_integers(self.G)
+        fname = tmp_path / "edgelist.txt"
+        bipartite.write_edgelist(G, fname)
+        H = bipartite.read_edgelist(fname, nodetype=int)
+        # isolated nodes are not written in edgelist
+        G.remove_nodes_from(list(nx.isolates(G)))
+        assert nodes_equal(list(H), list(G))
+        assert edges_equal(list(H.edges()), list(G.edges()))
+
+    def test_edgelist_multigraph(self, tmp_path):
+        G = self.MG
+        fname = tmp_path / "edgelist.txt"
+        bipartite.write_edgelist(G, fname)
+        H = bipartite.read_edgelist(fname, nodetype=int, create_using=nx.MultiGraph())
+        H2 = bipartite.read_edgelist(fname, nodetype=int, create_using=nx.MultiGraph())
+        assert H is not H2  # they should be different graphs
+        assert nodes_equal(list(H), list(G))
+        assert edges_equal(list(H.edges()), list(G.edges()))
+
+    def test_empty_digraph(self):
+        with pytest.raises(nx.NetworkXNotImplemented):
+            bytesIO = io.BytesIO()
+            bipartite.write_edgelist(nx.DiGraph(), bytesIO)
+
+    def test_raise_attribute(self):
+        with pytest.raises(AttributeError):
+            G = nx.path_graph(4)
+            bytesIO = io.BytesIO()
+            bipartite.write_edgelist(G, bytesIO)
+
+    def test_parse_edgelist(self):
+        """Tests for conditions specific to
+        parse_edge_list method"""
+
+        # ignore strings of length less than 2
+        lines = ["1 2", "2 3", "3 1", "4", " "]
+        G = bipartite.parse_edgelist(lines, nodetype=int)
+        assert list(G.nodes) == [1, 2, 3]
+
+        # Exception raised when node is not convertible
+        # to specified data type
+        with pytest.raises(TypeError, match=".*Failed to convert nodes"):
+            lines = ["a b", "b c", "c a"]
+            G = bipartite.parse_edgelist(lines, nodetype=int)
+
+        # Exception raised when format of data is not
+        # convertible to dictionary object
+        with pytest.raises(TypeError, match=".*Failed to convert edge data"):
+            lines = ["1 2 3", "2 3 4", "3 1 2"]
+            G = bipartite.parse_edgelist(lines, nodetype=int)
+
+        # Exception raised when edge data and data
+        # keys are not of same length
+        with pytest.raises(IndexError):
+            lines = ["1 2 3 4", "2 3 4"]
+            G = bipartite.parse_edgelist(
+                lines, nodetype=int, data=[("weight", int), ("key", int)]
+            )
+
+        # Exception raised when edge data is not
+        # convertible to specified data type
+        with pytest.raises(TypeError, match=".*Failed to convert key data"):
+            lines = ["1 2 3 a", "2 3 4 b"]
+            G = bipartite.parse_edgelist(
+                lines, nodetype=int, data=[("weight", int), ("key", int)]
+            )
+
+
+def test_bipartite_edgelist_consistent_strip_handling():
+    """See gh-7462
+
+    Input when printed looks like:
+
+        A       B       interaction     2
+        B       C       interaction     4
+        C       A       interaction
+
+    Note the trailing \\t in the last line, which indicates the existence of
+    an empty data field.
+    """
+    lines = io.StringIO(
+        "A\tB\tinteraction\t2\nB\tC\tinteraction\t4\nC\tA\tinteraction\t"
+    )
+    descr = [("type", str), ("weight", str)]
+    # Should not raise
+    G = nx.bipartite.parse_edgelist(lines, delimiter="\t", data=descr)
+    expected = [("A", "B", "2"), ("A", "C", ""), ("B", "C", "4")]
+    assert sorted(G.edges(data="weight")) == expected
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_extendability.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_extendability.py
new file mode 100644
index 00000000..17b71243
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_extendability.py
@@ -0,0 +1,334 @@
+import pytest
+
+import networkx as nx
+
+
+def test_selfloops_raises():
+    G = nx.ladder_graph(3)
+    G.add_edge(0, 0)
+    with pytest.raises(nx.NetworkXError, match=".*not bipartite"):
+        nx.bipartite.maximal_extendability(G)
+
+
+def test_disconnected_raises():
+    G = nx.ladder_graph(3)
+    G.add_node("a")
+    with pytest.raises(nx.NetworkXError, match=".*not connected"):
+        nx.bipartite.maximal_extendability(G)
+
+
+def test_not_bipartite_raises():
+    G = nx.complete_graph(5)
+    with pytest.raises(nx.NetworkXError, match=".*not bipartite"):
+        nx.bipartite.maximal_extendability(G)
+
+
+def test_no_perfect_matching_raises():
+    G = nx.Graph([(0, 1), (0, 2)])
+    with pytest.raises(nx.NetworkXError, match=".*not contain a perfect matching"):
+        nx.bipartite.maximal_extendability(G)
+
+
+def test_residual_graph_not_strongly_connected_raises():
+    G = nx.Graph([(1, 2), (2, 3), (3, 4)])
+    with pytest.raises(
+        nx.NetworkXError, match="The residual graph of G is not strongly connected"
+    ):
+        nx.bipartite.maximal_extendability(G)
+
+
+def test_ladder_graph_is_1():
+    G = nx.ladder_graph(3)
+    assert nx.bipartite.maximal_extendability(G) == 1
+
+
+def test_cubical_graph_is_2():
+    G = nx.cubical_graph()
+    assert nx.bipartite.maximal_extendability(G) == 2
+
+
+def test_k_is_3():
+    G = nx.Graph(
+        [
+            (1, 6),
+            (1, 7),
+            (1, 8),
+            (1, 9),
+            (2, 6),
+            (2, 7),
+            (2, 8),
+            (2, 10),
+            (3, 6),
+            (3, 8),
+            (3, 9),
+            (3, 10),
+            (4, 7),
+            (4, 8),
+            (4, 9),
+            (4, 10),
+            (5, 6),
+            (5, 7),
+            (5, 9),
+            (5, 10),
+        ]
+    )
+    assert nx.bipartite.maximal_extendability(G) == 3
+
+
+def test_k_is_4():
+    G = nx.Graph(
+        [
+            (8, 1),
+            (8, 2),
+            (8, 3),
+            (8, 4),
+            (8, 5),
+            (9, 1),
+            (9, 2),
+            (9, 3),
+            (9, 4),
+            (9, 7),
+            (10, 1),
+            (10, 2),
+            (10, 3),
+            (10, 4),
+            (10, 6),
+            (11, 1),
+            (11, 2),
+            (11, 5),
+            (11, 6),
+            (11, 7),
+            (12, 1),
+            (12, 3),
+            (12, 5),
+            (12, 6),
+            (12, 7),
+            (13, 2),
+            (13, 4),
+            (13, 5),
+            (13, 6),
+            (13, 7),
+            (14, 3),
+            (14, 4),
+            (14, 5),
+            (14, 6),
+            (14, 7),
+        ]
+    )
+    assert nx.bipartite.maximal_extendability(G) == 4
+
+
+def test_k_is_5():
+    G = nx.Graph(
+        [
+            (8, 1),
+            (8, 2),
+            (8, 3),
+            (8, 4),
+            (8, 5),
+            (8, 6),
+            (9, 1),
+            (9, 2),
+            (9, 3),
+            (9, 4),
+            (9, 5),
+            (9, 7),
+            (10, 1),
+            (10, 2),
+            (10, 3),
+            (10, 4),
+            (10, 6),
+            (10, 7),
+            (11, 1),
+            (11, 2),
+            (11, 3),
+            (11, 5),
+            (11, 6),
+            (11, 7),
+            (12, 1),
+            (12, 2),
+            (12, 4),
+            (12, 5),
+            (12, 6),
+            (12, 7),
+            (13, 1),
+            (13, 3),
+            (13, 4),
+            (13, 5),
+            (13, 6),
+            (13, 7),
+            (14, 2),
+            (14, 3),
+            (14, 4),
+            (14, 5),
+            (14, 6),
+            (14, 7),
+        ]
+    )
+    assert nx.bipartite.maximal_extendability(G) == 5
+
+
+def test_k_is_6():
+    G = nx.Graph(
+        [
+            (9, 1),
+            (9, 2),
+            (9, 3),
+            (9, 4),
+            (9, 5),
+            (9, 6),
+            (9, 7),
+            (10, 1),
+            (10, 2),
+            (10, 3),
+            (10, 4),
+            (10, 5),
+            (10, 6),
+            (10, 8),
+            (11, 1),
+            (11, 2),
+            (11, 3),
+            (11, 4),
+            (11, 5),
+            (11, 7),
+            (11, 8),
+            (12, 1),
+            (12, 2),
+            (12, 3),
+            (12, 4),
+            (12, 6),
+            (12, 7),
+            (12, 8),
+            (13, 1),
+            (13, 2),
+            (13, 3),
+            (13, 5),
+            (13, 6),
+            (13, 7),
+            (13, 8),
+            (14, 1),
+            (14, 2),
+            (14, 4),
+            (14, 5),
+            (14, 6),
+            (14, 7),
+            (14, 8),
+            (15, 1),
+            (15, 3),
+            (15, 4),
+            (15, 5),
+            (15, 6),
+            (15, 7),
+            (15, 8),
+            (16, 2),
+            (16, 3),
+            (16, 4),
+            (16, 5),
+            (16, 6),
+            (16, 7),
+            (16, 8),
+        ]
+    )
+    assert nx.bipartite.maximal_extendability(G) == 6
+
+
+def test_k_is_7():
+    G = nx.Graph(
+        [
+            (1, 11),
+            (1, 12),
+            (1, 13),
+            (1, 14),
+            (1, 15),
+            (1, 16),
+            (1, 17),
+            (1, 18),
+            (2, 11),
+            (2, 12),
+            (2, 13),
+            (2, 14),
+            (2, 15),
+            (2, 16),
+            (2, 17),
+            (2, 19),
+            (3, 11),
+            (3, 12),
+            (3, 13),
+            (3, 14),
+            (3, 15),
+            (3, 16),
+            (3, 17),
+            (3, 20),
+            (4, 11),
+            (4, 12),
+            (4, 13),
+            (4, 14),
+            (4, 15),
+            (4, 16),
+            (4, 17),
+            (4, 18),
+            (4, 19),
+            (4, 20),
+            (5, 11),
+            (5, 12),
+            (5, 13),
+            (5, 14),
+            (5, 15),
+            (5, 16),
+            (5, 17),
+            (5, 18),
+            (5, 19),
+            (5, 20),
+            (6, 11),
+            (6, 12),
+            (6, 13),
+            (6, 14),
+            (6, 15),
+            (6, 16),
+            (6, 17),
+            (6, 18),
+            (6, 19),
+            (6, 20),
+            (7, 11),
+            (7, 12),
+            (7, 13),
+            (7, 14),
+            (7, 15),
+            (7, 16),
+            (7, 17),
+            (7, 18),
+            (7, 19),
+            (7, 20),
+            (8, 11),
+            (8, 12),
+            (8, 13),
+            (8, 14),
+            (8, 15),
+            (8, 16),
+            (8, 17),
+            (8, 18),
+            (8, 19),
+            (8, 20),
+            (9, 11),
+            (9, 12),
+            (9, 13),
+            (9, 14),
+            (9, 15),
+            (9, 16),
+            (9, 17),
+            (9, 18),
+            (9, 19),
+            (9, 20),
+            (10, 11),
+            (10, 12),
+            (10, 13),
+            (10, 14),
+            (10, 15),
+            (10, 16),
+            (10, 17),
+            (10, 18),
+            (10, 19),
+            (10, 20),
+        ]
+    )
+    assert nx.bipartite.maximal_extendability(G) == 7
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_generators.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_generators.py
new file mode 100644
index 00000000..8b1e7793
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_generators.py
@@ -0,0 +1,409 @@
+import numbers
+
+import pytest
+
+import networkx as nx
+
+from ..generators import (
+    alternating_havel_hakimi_graph,
+    complete_bipartite_graph,
+    configuration_model,
+    gnmk_random_graph,
+    havel_hakimi_graph,
+    preferential_attachment_graph,
+    random_graph,
+    reverse_havel_hakimi_graph,
+)
+
+"""
+Generators - Bipartite
+----------------------
+"""
+
+
+class TestGeneratorsBipartite:
+    def test_complete_bipartite_graph(self):
+        G = complete_bipartite_graph(0, 0)
+        assert nx.is_isomorphic(G, nx.null_graph())
+
+        for i in [1, 5]:
+            G = complete_bipartite_graph(i, 0)
+            assert nx.is_isomorphic(G, nx.empty_graph(i))
+            G = complete_bipartite_graph(0, i)
+            assert nx.is_isomorphic(G, nx.empty_graph(i))
+
+        G = complete_bipartite_graph(2, 2)
+        assert nx.is_isomorphic(G, nx.cycle_graph(4))
+
+        G = complete_bipartite_graph(1, 5)
+        assert nx.is_isomorphic(G, nx.star_graph(5))
+
+        G = complete_bipartite_graph(5, 1)
+        assert nx.is_isomorphic(G, nx.star_graph(5))
+
+        # complete_bipartite_graph(m1,m2) is a connected graph with
+        # m1+m2 nodes and  m1*m2 edges
+        for m1, m2 in [(5, 11), (7, 3)]:
+            G = complete_bipartite_graph(m1, m2)
+            assert nx.number_of_nodes(G) == m1 + m2
+            assert nx.number_of_edges(G) == m1 * m2
+
+        with pytest.raises(nx.NetworkXError):
+            complete_bipartite_graph(7, 3, create_using=nx.DiGraph)
+        with pytest.raises(nx.NetworkXError):
+            complete_bipartite_graph(7, 3, create_using=nx.MultiDiGraph)
+
+        mG = complete_bipartite_graph(7, 3, create_using=nx.MultiGraph)
+        assert mG.is_multigraph()
+        assert sorted(mG.edges()) == sorted(G.edges())
+
+        mG = complete_bipartite_graph(7, 3, create_using=nx.MultiGraph)
+        assert mG.is_multigraph()
+        assert sorted(mG.edges()) == sorted(G.edges())
+
+        mG = complete_bipartite_graph(7, 3)  # default to Graph
+        assert sorted(mG.edges()) == sorted(G.edges())
+        assert not mG.is_multigraph()
+        assert not mG.is_directed()
+
+        # specify nodes rather than number of nodes
+        for n1, n2 in [([1, 2], "ab"), (3, 2), (3, "ab"), ("ab", 3)]:
+            G = complete_bipartite_graph(n1, n2)
+            if isinstance(n1, numbers.Integral):
+                if isinstance(n2, numbers.Integral):
+                    n2 = range(n1, n1 + n2)
+                n1 = range(n1)
+            elif isinstance(n2, numbers.Integral):
+                n2 = range(n2)
+            edges = {(u, v) for u in n1 for v in n2}
+            assert edges == set(G.edges)
+            assert G.size() == len(edges)
+
+        # raise when node sets are not distinct
+        for n1, n2 in [([1, 2], 3), (3, [1, 2]), ("abc", "bcd")]:
+            pytest.raises(nx.NetworkXError, complete_bipartite_graph, n1, n2)
+
+    def test_configuration_model(self):
+        aseq = []
+        bseq = []
+        G = configuration_model(aseq, bseq)
+        assert len(G) == 0
+
+        aseq = [0, 0]
+        bseq = [0, 0]
+        G = configuration_model(aseq, bseq)
+        assert len(G) == 4
+        assert G.number_of_edges() == 0
+
+        aseq = [3, 3, 3, 3]
+        bseq = [2, 2, 2, 2, 2]
+        pytest.raises(nx.NetworkXError, configuration_model, aseq, bseq)
+
+        aseq = [3, 3, 3, 3]
+        bseq = [2, 2, 2, 2, 2, 2]
+        G = configuration_model(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 2, 2, 2]
+        bseq = [3, 3, 3, 3]
+        G = configuration_model(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 1, 1, 1]
+        bseq = [3, 3, 3]
+        G = configuration_model(aseq, bseq)
+        assert G.is_multigraph()
+        assert not G.is_directed()
+        assert sorted(d for n, d in G.degree()) == [1, 1, 1, 2, 2, 2, 3, 3, 3]
+
+        GU = nx.projected_graph(nx.Graph(G), range(len(aseq)))
+        assert GU.number_of_nodes() == 6
+
+        GD = nx.projected_graph(nx.Graph(G), range(len(aseq), len(aseq) + len(bseq)))
+        assert GD.number_of_nodes() == 3
+
+        G = reverse_havel_hakimi_graph(aseq, bseq, create_using=nx.Graph)
+        assert not G.is_multigraph()
+        assert not G.is_directed()
+
+        pytest.raises(
+            nx.NetworkXError, configuration_model, aseq, bseq, create_using=nx.DiGraph()
+        )
+        pytest.raises(
+            nx.NetworkXError, configuration_model, aseq, bseq, create_using=nx.DiGraph
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            configuration_model,
+            aseq,
+            bseq,
+            create_using=nx.MultiDiGraph,
+        )
+
+    def test_havel_hakimi_graph(self):
+        aseq = []
+        bseq = []
+        G = havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 0
+
+        aseq = [0, 0]
+        bseq = [0, 0]
+        G = havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 4
+        assert G.number_of_edges() == 0
+
+        aseq = [3, 3, 3, 3]
+        bseq = [2, 2, 2, 2, 2]
+        pytest.raises(nx.NetworkXError, havel_hakimi_graph, aseq, bseq)
+
+        bseq = [2, 2, 2, 2, 2, 2]
+        G = havel_hakimi_graph(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 2, 2, 2]
+        bseq = [3, 3, 3, 3]
+        G = havel_hakimi_graph(aseq, bseq)
+        assert G.is_multigraph()
+        assert not G.is_directed()
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        GU = nx.projected_graph(nx.Graph(G), range(len(aseq)))
+        assert GU.number_of_nodes() == 6
+
+        GD = nx.projected_graph(nx.Graph(G), range(len(aseq), len(aseq) + len(bseq)))
+        assert GD.number_of_nodes() == 4
+
+        G = reverse_havel_hakimi_graph(aseq, bseq, create_using=nx.Graph)
+        assert not G.is_multigraph()
+        assert not G.is_directed()
+
+        pytest.raises(
+            nx.NetworkXError, havel_hakimi_graph, aseq, bseq, create_using=nx.DiGraph
+        )
+        pytest.raises(
+            nx.NetworkXError, havel_hakimi_graph, aseq, bseq, create_using=nx.DiGraph
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.MultiDiGraph,
+        )
+
+    def test_reverse_havel_hakimi_graph(self):
+        aseq = []
+        bseq = []
+        G = reverse_havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 0
+
+        aseq = [0, 0]
+        bseq = [0, 0]
+        G = reverse_havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 4
+        assert G.number_of_edges() == 0
+
+        aseq = [3, 3, 3, 3]
+        bseq = [2, 2, 2, 2, 2]
+        pytest.raises(nx.NetworkXError, reverse_havel_hakimi_graph, aseq, bseq)
+
+        bseq = [2, 2, 2, 2, 2, 2]
+        G = reverse_havel_hakimi_graph(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 2, 2, 2]
+        bseq = [3, 3, 3, 3]
+        G = reverse_havel_hakimi_graph(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 1, 1, 1]
+        bseq = [3, 3, 3]
+        G = reverse_havel_hakimi_graph(aseq, bseq)
+        assert G.is_multigraph()
+        assert not G.is_directed()
+        assert sorted(d for n, d in G.degree()) == [1, 1, 1, 2, 2, 2, 3, 3, 3]
+
+        GU = nx.projected_graph(nx.Graph(G), range(len(aseq)))
+        assert GU.number_of_nodes() == 6
+
+        GD = nx.projected_graph(nx.Graph(G), range(len(aseq), len(aseq) + len(bseq)))
+        assert GD.number_of_nodes() == 3
+
+        G = reverse_havel_hakimi_graph(aseq, bseq, create_using=nx.Graph)
+        assert not G.is_multigraph()
+        assert not G.is_directed()
+
+        pytest.raises(
+            nx.NetworkXError,
+            reverse_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.DiGraph,
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            reverse_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.DiGraph,
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            reverse_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.MultiDiGraph,
+        )
+
+    def test_alternating_havel_hakimi_graph(self):
+        aseq = []
+        bseq = []
+        G = alternating_havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 0
+
+        aseq = [0, 0]
+        bseq = [0, 0]
+        G = alternating_havel_hakimi_graph(aseq, bseq)
+        assert len(G) == 4
+        assert G.number_of_edges() == 0
+
+        aseq = [3, 3, 3, 3]
+        bseq = [2, 2, 2, 2, 2]
+        pytest.raises(nx.NetworkXError, alternating_havel_hakimi_graph, aseq, bseq)
+
+        bseq = [2, 2, 2, 2, 2, 2]
+        G = alternating_havel_hakimi_graph(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 2, 2, 2]
+        bseq = [3, 3, 3, 3]
+        G = alternating_havel_hakimi_graph(aseq, bseq)
+        assert sorted(d for n, d in G.degree()) == [2, 2, 2, 2, 2, 2, 3, 3, 3, 3]
+
+        aseq = [2, 2, 2, 1, 1, 1]
+        bseq = [3, 3, 3]
+        G = alternating_havel_hakimi_graph(aseq, bseq)
+        assert G.is_multigraph()
+        assert not G.is_directed()
+        assert sorted(d for n, d in G.degree()) == [1, 1, 1, 2, 2, 2, 3, 3, 3]
+
+        GU = nx.projected_graph(nx.Graph(G), range(len(aseq)))
+        assert GU.number_of_nodes() == 6
+
+        GD = nx.projected_graph(nx.Graph(G), range(len(aseq), len(aseq) + len(bseq)))
+        assert GD.number_of_nodes() == 3
+
+        G = reverse_havel_hakimi_graph(aseq, bseq, create_using=nx.Graph)
+        assert not G.is_multigraph()
+        assert not G.is_directed()
+
+        pytest.raises(
+            nx.NetworkXError,
+            alternating_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.DiGraph,
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            alternating_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.DiGraph,
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            alternating_havel_hakimi_graph,
+            aseq,
+            bseq,
+            create_using=nx.MultiDiGraph,
+        )
+
+    def test_preferential_attachment(self):
+        aseq = [3, 2, 1, 1]
+        G = preferential_attachment_graph(aseq, 0.5)
+        assert G.is_multigraph()
+        assert not G.is_directed()
+
+        G = preferential_attachment_graph(aseq, 0.5, create_using=nx.Graph)
+        assert not G.is_multigraph()
+        assert not G.is_directed()
+
+        pytest.raises(
+            nx.NetworkXError,
+            preferential_attachment_graph,
+            aseq,
+            0.5,
+            create_using=nx.DiGraph(),
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            preferential_attachment_graph,
+            aseq,
+            0.5,
+            create_using=nx.DiGraph(),
+        )
+        pytest.raises(
+            nx.NetworkXError,
+            preferential_attachment_graph,
+            aseq,
+            0.5,
+            create_using=nx.DiGraph(),
+        )
+
+    def test_random_graph(self):
+        n = 10
+        m = 20
+        G = random_graph(n, m, 0.9)
+        assert len(G) == 30
+        assert nx.is_bipartite(G)
+        X, Y = nx.algorithms.bipartite.sets(G)
+        assert set(range(n)) == X
+        assert set(range(n, n + m)) == Y
+
+    def test_random_digraph(self):
+        n = 10
+        m = 20
+        G = random_graph(n, m, 0.9, directed=True)
+        assert len(G) == 30
+        assert nx.is_bipartite(G)
+        X, Y = nx.algorithms.bipartite.sets(G)
+        assert set(range(n)) == X
+        assert set(range(n, n + m)) == Y
+
+    def test_gnmk_random_graph(self):
+        n = 10
+        m = 20
+        edges = 100
+        # set seed because sometimes it is not connected
+        # which raises an error in bipartite.sets(G) below.
+        G = gnmk_random_graph(n, m, edges, seed=1234)
+        assert len(G) == n + m
+        assert nx.is_bipartite(G)
+        X, Y = nx.algorithms.bipartite.sets(G)
+        # print(X)
+        assert set(range(n)) == X
+        assert set(range(n, n + m)) == Y
+        assert edges == len(list(G.edges()))
+
+    def test_gnmk_random_graph_complete(self):
+        n = 10
+        m = 20
+        edges = 200
+        G = gnmk_random_graph(n, m, edges)
+        assert len(G) == n + m
+        assert nx.is_bipartite(G)
+        X, Y = nx.algorithms.bipartite.sets(G)
+        # print(X)
+        assert set(range(n)) == X
+        assert set(range(n, n + m)) == Y
+        assert edges == len(list(G.edges()))
+
+    @pytest.mark.parametrize("n", (4, range(4), {0, 1, 2, 3}))
+    @pytest.mark.parametrize("m", (range(4, 7), {4, 5, 6}))
+    def test_complete_bipartite_graph_str(self, n, m):
+        """Ensure G.name is consistent for all inputs accepted by nodes_or_number.
+        See gh-7396"""
+        G = nx.complete_bipartite_graph(n, m)
+        ans = "Graph named 'complete_bipartite_graph(4, 3)' with 7 nodes and 12 edges"
+        assert str(G) == ans
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matching.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matching.py
new file mode 100644
index 00000000..c24659ea
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matching.py
@@ -0,0 +1,327 @@
+"""Unit tests for the :mod:`networkx.algorithms.bipartite.matching` module."""
+
+import itertools
+
+import pytest
+
+import networkx as nx
+from networkx.algorithms.bipartite.matching import (
+    eppstein_matching,
+    hopcroft_karp_matching,
+    maximum_matching,
+    minimum_weight_full_matching,
+    to_vertex_cover,
+)
+
+
+class TestMatching:
+    """Tests for bipartite matching algorithms."""
+
+    def setup_method(self):
+        """Creates a bipartite graph for use in testing matching algorithms.
+
+        The bipartite graph has a maximum cardinality matching that leaves
+        vertex 1 and vertex 10 unmatched. The first six numbers are the left
+        vertices and the next six numbers are the right vertices.
+
+        """
+        self.simple_graph = nx.complete_bipartite_graph(2, 3)
+        self.simple_solution = {0: 2, 1: 3, 2: 0, 3: 1}
+
+        edges = [(0, 7), (0, 8), (2, 6), (2, 9), (3, 8), (4, 8), (4, 9), (5, 11)]
+        self.top_nodes = set(range(6))
+        self.graph = nx.Graph()
+        self.graph.add_nodes_from(range(12))
+        self.graph.add_edges_from(edges)
+
+        # Example bipartite graph from issue 2127
+        G = nx.Graph()
+        G.add_nodes_from(
+            [
+                (1, "C"),
+                (1, "B"),
+                (0, "G"),
+                (1, "F"),
+                (1, "E"),
+                (0, "C"),
+                (1, "D"),
+                (1, "I"),
+                (0, "A"),
+                (0, "D"),
+                (0, "F"),
+                (0, "E"),
+                (0, "H"),
+                (1, "G"),
+                (1, "A"),
+                (0, "I"),
+                (0, "B"),
+                (1, "H"),
+            ]
+        )
+        G.add_edge((1, "C"), (0, "A"))
+        G.add_edge((1, "B"), (0, "A"))
+        G.add_edge((0, "G"), (1, "I"))
+        G.add_edge((0, "G"), (1, "H"))
+        G.add_edge((1, "F"), (0, "A"))
+        G.add_edge((1, "F"), (0, "C"))
+        G.add_edge((1, "F"), (0, "E"))
+        G.add_edge((1, "E"), (0, "A"))
+        G.add_edge((1, "E"), (0, "C"))
+        G.add_edge((0, "C"), (1, "D"))
+        G.add_edge((0, "C"), (1, "I"))
+        G.add_edge((0, "C"), (1, "G"))
+        G.add_edge((0, "C"), (1, "H"))
+        G.add_edge((1, "D"), (0, "A"))
+        G.add_edge((1, "I"), (0, "A"))
+        G.add_edge((1, "I"), (0, "E"))
+        G.add_edge((0, "A"), (1, "G"))
+        G.add_edge((0, "A"), (1, "H"))
+        G.add_edge((0, "E"), (1, "G"))
+        G.add_edge((0, "E"), (1, "H"))
+        self.disconnected_graph = G
+
+    def check_match(self, matching):
+        """Asserts that the matching is what we expect from the bipartite graph
+        constructed in the :meth:`setup` fixture.
+
+        """
+        # For the sake of brevity, rename `matching` to `M`.
+        M = matching
+        matched_vertices = frozenset(itertools.chain(*M.items()))
+        # Assert that the maximum number of vertices (10) is matched.
+        assert matched_vertices == frozenset(range(12)) - {1, 10}
+        # Assert that no vertex appears in two edges, or in other words, that
+        # the matching (u, v) and (v, u) both appear in the matching
+        # dictionary.
+        assert all(u == M[M[u]] for u in range(12) if u in M)
+
+    def check_vertex_cover(self, vertices):
+        """Asserts that the given set of vertices is the vertex cover we
+        expected from the bipartite graph constructed in the :meth:`setup`
+        fixture.
+
+        """
+        # By Konig's theorem, the number of edges in a maximum matching equals
+        # the number of vertices in a minimum vertex cover.
+        assert len(vertices) == 5
+        # Assert that the set is truly a vertex cover.
+        for u, v in self.graph.edges():
+            assert u in vertices or v in vertices
+        # TODO Assert that the vertices are the correct ones.
+
+    def test_eppstein_matching(self):
+        """Tests that David Eppstein's implementation of the Hopcroft--Karp
+        algorithm produces a maximum cardinality matching.
+
+        """
+        self.check_match(eppstein_matching(self.graph, self.top_nodes))
+
+    def test_hopcroft_karp_matching(self):
+        """Tests that the Hopcroft--Karp algorithm produces a maximum
+        cardinality matching in a bipartite graph.
+
+        """
+        self.check_match(hopcroft_karp_matching(self.graph, self.top_nodes))
+
+    def test_to_vertex_cover(self):
+        """Test for converting a maximum matching to a minimum vertex cover."""
+        matching = maximum_matching(self.graph, self.top_nodes)
+        vertex_cover = to_vertex_cover(self.graph, matching, self.top_nodes)
+        self.check_vertex_cover(vertex_cover)
+
+    def test_eppstein_matching_simple(self):
+        match = eppstein_matching(self.simple_graph)
+        assert match == self.simple_solution
+
+    def test_hopcroft_karp_matching_simple(self):
+        match = hopcroft_karp_matching(self.simple_graph)
+        assert match == self.simple_solution
+
+    def test_eppstein_matching_disconnected(self):
+        with pytest.raises(nx.AmbiguousSolution):
+            match = eppstein_matching(self.disconnected_graph)
+
+    def test_hopcroft_karp_matching_disconnected(self):
+        with pytest.raises(nx.AmbiguousSolution):
+            match = hopcroft_karp_matching(self.disconnected_graph)
+
+    def test_issue_2127(self):
+        """Test from issue 2127"""
+        # Build the example DAG
+        G = nx.DiGraph()
+        G.add_edge("A", "C")
+        G.add_edge("A", "B")
+        G.add_edge("C", "E")
+        G.add_edge("C", "D")
+        G.add_edge("E", "G")
+        G.add_edge("E", "F")
+        G.add_edge("G", "I")
+        G.add_edge("G", "H")
+
+        tc = nx.transitive_closure(G)
+        btc = nx.Graph()
+
+        # Create a bipartite graph based on the transitive closure of G
+        for v in tc.nodes():
+            btc.add_node((0, v))
+            btc.add_node((1, v))
+
+        for u, v in tc.edges():
+            btc.add_edge((0, u), (1, v))
+
+        top_nodes = {n for n in btc if n[0] == 0}
+        matching = hopcroft_karp_matching(btc, top_nodes)
+        vertex_cover = to_vertex_cover(btc, matching, top_nodes)
+        independent_set = set(G) - {v for _, v in vertex_cover}
+        assert {"B", "D", "F", "I", "H"} == independent_set
+
+    def test_vertex_cover_issue_2384(self):
+        G = nx.Graph([(0, 3), (1, 3), (1, 4), (2, 3)])
+        matching = maximum_matching(G)
+        vertex_cover = to_vertex_cover(G, matching)
+        for u, v in G.edges():
+            assert u in vertex_cover or v in vertex_cover
+
+    def test_vertex_cover_issue_3306(self):
+        G = nx.Graph()
+        edges = [(0, 2), (1, 0), (1, 1), (1, 2), (2, 2)]
+        G.add_edges_from([((i, "L"), (j, "R")) for i, j in edges])
+
+        matching = maximum_matching(G)
+        vertex_cover = to_vertex_cover(G, matching)
+        for u, v in G.edges():
+            assert u in vertex_cover or v in vertex_cover
+
+    def test_unorderable_nodes(self):
+        a = object()
+        b = object()
+        c = object()
+        d = object()
+        e = object()
+        G = nx.Graph([(a, d), (b, d), (b, e), (c, d)])
+        matching = maximum_matching(G)
+        vertex_cover = to_vertex_cover(G, matching)
+        for u, v in G.edges():
+            assert u in vertex_cover or v in vertex_cover
+
+
+def test_eppstein_matching():
+    """Test in accordance to issue #1927"""
+    G = nx.Graph()
+    G.add_nodes_from(["a", 2, 3, 4], bipartite=0)
+    G.add_nodes_from([1, "b", "c"], bipartite=1)
+    G.add_edges_from([("a", 1), ("a", "b"), (2, "b"), (2, "c"), (3, "c"), (4, 1)])
+    matching = eppstein_matching(G)
+    assert len(matching) == len(maximum_matching(G))
+    assert all(x in set(matching.keys()) for x in set(matching.values()))
+
+
+class TestMinimumWeightFullMatching:
+    @classmethod
+    def setup_class(cls):
+        pytest.importorskip("scipy")
+
+    def test_minimum_weight_full_matching_incomplete_graph(self):
+        B = nx.Graph()
+        B.add_nodes_from([1, 2], bipartite=0)
+        B.add_nodes_from([3, 4], bipartite=1)
+        B.add_edge(1, 4, weight=100)
+        B.add_edge(2, 3, weight=100)
+        B.add_edge(2, 4, weight=50)
+        matching = minimum_weight_full_matching(B)
+        assert matching == {1: 4, 2: 3, 4: 1, 3: 2}
+
+    def test_minimum_weight_full_matching_with_no_full_matching(self):
+        B = nx.Graph()
+        B.add_nodes_from([1, 2, 3], bipartite=0)
+        B.add_nodes_from([4, 5, 6], bipartite=1)
+        B.add_edge(1, 4, weight=100)
+        B.add_edge(2, 4, weight=100)
+        B.add_edge(3, 4, weight=50)
+        B.add_edge(3, 5, weight=50)
+        B.add_edge(3, 6, weight=50)
+        with pytest.raises(ValueError):
+            minimum_weight_full_matching(B)
+
+    def test_minimum_weight_full_matching_square(self):
+        G = nx.complete_bipartite_graph(3, 3)
+        G.add_edge(0, 3, weight=400)
+        G.add_edge(0, 4, weight=150)
+        G.add_edge(0, 5, weight=400)
+        G.add_edge(1, 3, weight=400)
+        G.add_edge(1, 4, weight=450)
+        G.add_edge(1, 5, weight=600)
+        G.add_edge(2, 3, weight=300)
+        G.add_edge(2, 4, weight=225)
+        G.add_edge(2, 5, weight=300)
+        matching = minimum_weight_full_matching(G)
+        assert matching == {0: 4, 1: 3, 2: 5, 4: 0, 3: 1, 5: 2}
+
+    def test_minimum_weight_full_matching_smaller_left(self):
+        G = nx.complete_bipartite_graph(3, 4)
+        G.add_edge(0, 3, weight=400)
+        G.add_edge(0, 4, weight=150)
+        G.add_edge(0, 5, weight=400)
+        G.add_edge(0, 6, weight=1)
+        G.add_edge(1, 3, weight=400)
+        G.add_edge(1, 4, weight=450)
+        G.add_edge(1, 5, weight=600)
+        G.add_edge(1, 6, weight=2)
+        G.add_edge(2, 3, weight=300)
+        G.add_edge(2, 4, weight=225)
+        G.add_edge(2, 5, weight=290)
+        G.add_edge(2, 6, weight=3)
+        matching = minimum_weight_full_matching(G)
+        assert matching == {0: 4, 1: 6, 2: 5, 4: 0, 5: 2, 6: 1}
+
+    def test_minimum_weight_full_matching_smaller_top_nodes_right(self):
+        G = nx.complete_bipartite_graph(3, 4)
+        G.add_edge(0, 3, weight=400)
+        G.add_edge(0, 4, weight=150)
+        G.add_edge(0, 5, weight=400)
+        G.add_edge(0, 6, weight=1)
+        G.add_edge(1, 3, weight=400)
+        G.add_edge(1, 4, weight=450)
+        G.add_edge(1, 5, weight=600)
+        G.add_edge(1, 6, weight=2)
+        G.add_edge(2, 3, weight=300)
+        G.add_edge(2, 4, weight=225)
+        G.add_edge(2, 5, weight=290)
+        G.add_edge(2, 6, weight=3)
+        matching = minimum_weight_full_matching(G, top_nodes=[3, 4, 5, 6])
+        assert matching == {0: 4, 1: 6, 2: 5, 4: 0, 5: 2, 6: 1}
+
+    def test_minimum_weight_full_matching_smaller_right(self):
+        G = nx.complete_bipartite_graph(4, 3)
+        G.add_edge(0, 4, weight=400)
+        G.add_edge(0, 5, weight=400)
+        G.add_edge(0, 6, weight=300)
+        G.add_edge(1, 4, weight=150)
+        G.add_edge(1, 5, weight=450)
+        G.add_edge(1, 6, weight=225)
+        G.add_edge(2, 4, weight=400)
+        G.add_edge(2, 5, weight=600)
+        G.add_edge(2, 6, weight=290)
+        G.add_edge(3, 4, weight=1)
+        G.add_edge(3, 5, weight=2)
+        G.add_edge(3, 6, weight=3)
+        matching = minimum_weight_full_matching(G)
+        assert matching == {1: 4, 2: 6, 3: 5, 4: 1, 5: 3, 6: 2}
+
+    def test_minimum_weight_full_matching_negative_weights(self):
+        G = nx.complete_bipartite_graph(2, 2)
+        G.add_edge(0, 2, weight=-2)
+        G.add_edge(0, 3, weight=0.2)
+        G.add_edge(1, 2, weight=-2)
+        G.add_edge(1, 3, weight=0.3)
+        matching = minimum_weight_full_matching(G)
+        assert matching == {0: 3, 1: 2, 2: 1, 3: 0}
+
+    def test_minimum_weight_full_matching_different_weight_key(self):
+        G = nx.complete_bipartite_graph(2, 2)
+        G.add_edge(0, 2, mass=2)
+        G.add_edge(0, 3, mass=0.2)
+        G.add_edge(1, 2, mass=1)
+        G.add_edge(1, 3, mass=2)
+        matching = minimum_weight_full_matching(G, weight="mass")
+        assert matching == {0: 3, 1: 2, 2: 1, 3: 0}
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matrix.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matrix.py
new file mode 100644
index 00000000..53d83115
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_matrix.py
@@ -0,0 +1,84 @@
+import pytest
+
+np = pytest.importorskip("numpy")
+sp = pytest.importorskip("scipy")
+sparse = pytest.importorskip("scipy.sparse")
+
+
+import networkx as nx
+from networkx.algorithms import bipartite
+from networkx.utils import edges_equal
+
+
+class TestBiadjacencyMatrix:
+    def test_biadjacency_matrix_weight(self):
+        G = nx.path_graph(5)
+        G.add_edge(0, 1, weight=2, other=4)
+        X = [1, 3]
+        Y = [0, 2, 4]
+        M = bipartite.biadjacency_matrix(G, X, weight="weight")
+        assert M[0, 0] == 2
+        M = bipartite.biadjacency_matrix(G, X, weight="other")
+        assert M[0, 0] == 4
+
+    def test_biadjacency_matrix(self):
+        tops = [2, 5, 10]
+        bots = [5, 10, 15]
+        for i in range(len(tops)):
+            G = bipartite.random_graph(tops[i], bots[i], 0.2)
+            top = [n for n, d in G.nodes(data=True) if d["bipartite"] == 0]
+            M = bipartite.biadjacency_matrix(G, top)
+            assert M.shape[0] == tops[i]
+            assert M.shape[1] == bots[i]
+
+    def test_biadjacency_matrix_order(self):
+        G = nx.path_graph(5)
+        G.add_edge(0, 1, weight=2)
+        X = [3, 1]
+        Y = [4, 2, 0]
+        M = bipartite.biadjacency_matrix(G, X, Y, weight="weight")
+        assert M[1, 2] == 2
+
+    def test_biadjacency_matrix_empty_graph(self):
+        G = nx.empty_graph(2)
+        M = nx.bipartite.biadjacency_matrix(G, [0])
+        assert np.array_equal(M.toarray(), np.array([[0]]))
+
+    def test_null_graph(self):
+        with pytest.raises(nx.NetworkXError):
+            bipartite.biadjacency_matrix(nx.Graph(), [])
+
+    def test_empty_graph(self):
+        with pytest.raises(nx.NetworkXError):
+            bipartite.biadjacency_matrix(nx.Graph([(1, 0)]), [])
+
+    def test_duplicate_row(self):
+        with pytest.raises(nx.NetworkXError):
+            bipartite.biadjacency_matrix(nx.Graph([(1, 0)]), [1, 1])
+
+    def test_duplicate_col(self):
+        with pytest.raises(nx.NetworkXError):
+            bipartite.biadjacency_matrix(nx.Graph([(1, 0)]), [0], [1, 1])
+
+    def test_format_keyword(self):
+        with pytest.raises(nx.NetworkXError):
+            bipartite.biadjacency_matrix(nx.Graph([(1, 0)]), [0], format="foo")
+
+    def test_from_biadjacency_roundtrip(self):
+        B1 = nx.path_graph(5)
+        M = bipartite.biadjacency_matrix(B1, [0, 2, 4])
+        B2 = bipartite.from_biadjacency_matrix(M)
+        assert nx.is_isomorphic(B1, B2)
+
+    def test_from_biadjacency_weight(self):
+        M = sparse.csc_matrix([[1, 2], [0, 3]])
+        B = bipartite.from_biadjacency_matrix(M)
+        assert edges_equal(B.edges(), [(0, 2), (0, 3), (1, 3)])
+        B = bipartite.from_biadjacency_matrix(M, edge_attribute="weight")
+        e = [(0, 2, {"weight": 1}), (0, 3, {"weight": 2}), (1, 3, {"weight": 3})]
+        assert edges_equal(B.edges(data=True), e)
+
+    def test_from_biadjacency_multigraph(self):
+        M = sparse.csc_matrix([[1, 2], [0, 3]])
+        B = bipartite.from_biadjacency_matrix(M, create_using=nx.MultiGraph())
+        assert edges_equal(B.edges(), [(0, 2), (0, 3), (0, 3), (1, 3), (1, 3), (1, 3)])
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_project.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_project.py
new file mode 100644
index 00000000..076bb42b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_project.py
@@ -0,0 +1,407 @@
+import pytest
+
+import networkx as nx
+from networkx.algorithms import bipartite
+from networkx.utils import edges_equal, nodes_equal
+
+
+class TestBipartiteProject:
+    def test_path_projected_graph(self):
+        G = nx.path_graph(4)
+        P = bipartite.projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        P = bipartite.projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        G = nx.MultiGraph([(0, 1)])
+        with pytest.raises(nx.NetworkXError, match="not defined for multigraphs"):
+            bipartite.projected_graph(G, [0])
+
+    def test_path_projected_properties_graph(self):
+        G = nx.path_graph(4)
+        G.add_node(1, name="one")
+        G.add_node(2, name="two")
+        P = bipartite.projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        assert P.nodes[1]["name"] == G.nodes[1]["name"]
+        P = bipartite.projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        assert P.nodes[2]["name"] == G.nodes[2]["name"]
+
+    def test_path_collaboration_projected_graph(self):
+        G = nx.path_graph(4)
+        P = bipartite.collaboration_weighted_projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        P[1][3]["weight"] = 1
+        P = bipartite.collaboration_weighted_projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        P[0][2]["weight"] = 1
+
+    def test_directed_path_collaboration_projected_graph(self):
+        G = nx.DiGraph()
+        nx.add_path(G, range(4))
+        P = bipartite.collaboration_weighted_projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        P[1][3]["weight"] = 1
+        P = bipartite.collaboration_weighted_projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        P[0][2]["weight"] = 1
+
+    def test_path_weighted_projected_graph(self):
+        G = nx.path_graph(4)
+
+        with pytest.raises(nx.NetworkXAlgorithmError):
+            bipartite.weighted_projected_graph(G, [1, 2, 3, 3])
+
+        P = bipartite.weighted_projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        P[1][3]["weight"] = 1
+        P = bipartite.weighted_projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        P[0][2]["weight"] = 1
+
+    def test_digraph_weighted_projection(self):
+        G = nx.DiGraph([(0, 1), (1, 2), (2, 3), (3, 4)])
+        P = bipartite.overlap_weighted_projected_graph(G, [1, 3])
+        assert nx.get_edge_attributes(P, "weight") == {(1, 3): 1.0}
+        assert len(P) == 2
+
+    def test_path_weighted_projected_directed_graph(self):
+        G = nx.DiGraph()
+        nx.add_path(G, range(4))
+        P = bipartite.weighted_projected_graph(G, [1, 3])
+        assert nodes_equal(list(P), [1, 3])
+        assert edges_equal(list(P.edges()), [(1, 3)])
+        P[1][3]["weight"] = 1
+        P = bipartite.weighted_projected_graph(G, [0, 2])
+        assert nodes_equal(list(P), [0, 2])
+        assert edges_equal(list(P.edges()), [(0, 2)])
+        P[0][2]["weight"] = 1
+
+    def test_star_projected_graph(self):
+        G = nx.star_graph(3)
+        P = bipartite.projected_graph(G, [1, 2, 3])
+        assert nodes_equal(list(P), [1, 2, 3])
+        assert edges_equal(list(P.edges()), [(1, 2), (1, 3), (2, 3)])
+        P = bipartite.weighted_projected_graph(G, [1, 2, 3])
+        assert nodes_equal(list(P), [1, 2, 3])
+        assert edges_equal(list(P.edges()), [(1, 2), (1, 3), (2, 3)])
+
+        P = bipartite.projected_graph(G, [0])
+        assert nodes_equal(list(P), [0])
+        assert edges_equal(list(P.edges()), [])
+
+    def test_project_multigraph(self):
+        G = nx.Graph()
+        G.add_edge("a", 1)
+        G.add_edge("b", 1)
+        G.add_edge("a", 2)
+        G.add_edge("b", 2)
+        P = bipartite.projected_graph(G, "ab")
+        assert edges_equal(list(P.edges()), [("a", "b")])
+        P = bipartite.weighted_projected_graph(G, "ab")
+        assert edges_equal(list(P.edges()), [("a", "b")])
+        P = bipartite.projected_graph(G, "ab", multigraph=True)
+        assert edges_equal(list(P.edges()), [("a", "b"), ("a", "b")])
+
+    def test_project_collaboration(self):
+        G = nx.Graph()
+        G.add_edge("a", 1)
+        G.add_edge("b", 1)
+        G.add_edge("b", 2)
+        G.add_edge("c", 2)
+        G.add_edge("c", 3)
+        G.add_edge("c", 4)
+        G.add_edge("b", 4)
+        P = bipartite.collaboration_weighted_projected_graph(G, "abc")
+        assert P["a"]["b"]["weight"] == 1
+        assert P["b"]["c"]["weight"] == 2
+
+    def test_directed_projection(self):
+        G = nx.DiGraph()
+        G.add_edge("A", 1)
+        G.add_edge(1, "B")
+        G.add_edge("A", 2)
+        G.add_edge("B", 2)
+        P = bipartite.projected_graph(G, "AB")
+        assert edges_equal(list(P.edges()), [("A", "B")])
+        P = bipartite.weighted_projected_graph(G, "AB")
+        assert edges_equal(list(P.edges()), [("A", "B")])
+        assert P["A"]["B"]["weight"] == 1
+
+        P = bipartite.projected_graph(G, "AB", multigraph=True)
+        assert edges_equal(list(P.edges()), [("A", "B")])
+
+        G = nx.DiGraph()
+        G.add_edge("A", 1)
+        G.add_edge(1, "B")
+        G.add_edge("A", 2)
+        G.add_edge(2, "B")
+        P = bipartite.projected_graph(G, "AB")
+        assert edges_equal(list(P.edges()), [("A", "B")])
+        P = bipartite.weighted_projected_graph(G, "AB")
+        assert edges_equal(list(P.edges()), [("A", "B")])
+        assert P["A"]["B"]["weight"] == 2
+
+        P = bipartite.projected_graph(G, "AB", multigraph=True)
+        assert edges_equal(list(P.edges()), [("A", "B"), ("A", "B")])
+
+
+class TestBipartiteWeightedProjection:
+    @classmethod
+    def setup_class(cls):
+        # Tore Opsahl's example
+        # http://toreopsahl.com/2009/05/01/projecting-two-mode-networks-onto-weighted-one-mode-networks/
+        cls.G = nx.Graph()
+        cls.G.add_edge("A", 1)
+        cls.G.add_edge("A", 2)
+        cls.G.add_edge("B", 1)
+        cls.G.add_edge("B", 2)
+        cls.G.add_edge("B", 3)
+        cls.G.add_edge("B", 4)
+        cls.G.add_edge("B", 5)
+        cls.G.add_edge("C", 1)
+        cls.G.add_edge("D", 3)
+        cls.G.add_edge("E", 4)
+        cls.G.add_edge("E", 5)
+        cls.G.add_edge("E", 6)
+        cls.G.add_edge("F", 6)
+        # Graph based on figure 6 from Newman (2001)
+        cls.N = nx.Graph()
+        cls.N.add_edge("A", 1)
+        cls.N.add_edge("A", 2)
+        cls.N.add_edge("A", 3)
+        cls.N.add_edge("B", 1)
+        cls.N.add_edge("B", 2)
+        cls.N.add_edge("B", 3)
+        cls.N.add_edge("C", 1)
+        cls.N.add_edge("D", 1)
+        cls.N.add_edge("E", 3)
+
+    def test_project_weighted_shared(self):
+        edges = [
+            ("A", "B", 2),
+            ("A", "C", 1),
+            ("B", "C", 1),
+            ("B", "D", 1),
+            ("B", "E", 2),
+            ("E", "F", 1),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.weighted_projected_graph(self.G, "ABCDEF")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+        edges = [
+            ("A", "B", 3),
+            ("A", "E", 1),
+            ("A", "C", 1),
+            ("A", "D", 1),
+            ("B", "E", 1),
+            ("B", "C", 1),
+            ("B", "D", 1),
+            ("C", "D", 1),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.weighted_projected_graph(self.N, "ABCDE")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+    def test_project_weighted_newman(self):
+        edges = [
+            ("A", "B", 1.5),
+            ("A", "C", 0.5),
+            ("B", "C", 0.5),
+            ("B", "D", 1),
+            ("B", "E", 2),
+            ("E", "F", 1),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.collaboration_weighted_projected_graph(self.G, "ABCDEF")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+        edges = [
+            ("A", "B", 11 / 6.0),
+            ("A", "E", 1 / 2.0),
+            ("A", "C", 1 / 3.0),
+            ("A", "D", 1 / 3.0),
+            ("B", "E", 1 / 2.0),
+            ("B", "C", 1 / 3.0),
+            ("B", "D", 1 / 3.0),
+            ("C", "D", 1 / 3.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.collaboration_weighted_projected_graph(self.N, "ABCDE")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+    def test_project_weighted_ratio(self):
+        edges = [
+            ("A", "B", 2 / 6.0),
+            ("A", "C", 1 / 6.0),
+            ("B", "C", 1 / 6.0),
+            ("B", "D", 1 / 6.0),
+            ("B", "E", 2 / 6.0),
+            ("E", "F", 1 / 6.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.weighted_projected_graph(self.G, "ABCDEF", ratio=True)
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+        edges = [
+            ("A", "B", 3 / 3.0),
+            ("A", "E", 1 / 3.0),
+            ("A", "C", 1 / 3.0),
+            ("A", "D", 1 / 3.0),
+            ("B", "E", 1 / 3.0),
+            ("B", "C", 1 / 3.0),
+            ("B", "D", 1 / 3.0),
+            ("C", "D", 1 / 3.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.weighted_projected_graph(self.N, "ABCDE", ratio=True)
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+    def test_project_weighted_overlap(self):
+        edges = [
+            ("A", "B", 2 / 2.0),
+            ("A", "C", 1 / 1.0),
+            ("B", "C", 1 / 1.0),
+            ("B", "D", 1 / 1.0),
+            ("B", "E", 2 / 3.0),
+            ("E", "F", 1 / 1.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.overlap_weighted_projected_graph(self.G, "ABCDEF", jaccard=False)
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+        edges = [
+            ("A", "B", 3 / 3.0),
+            ("A", "E", 1 / 1.0),
+            ("A", "C", 1 / 1.0),
+            ("A", "D", 1 / 1.0),
+            ("B", "E", 1 / 1.0),
+            ("B", "C", 1 / 1.0),
+            ("B", "D", 1 / 1.0),
+            ("C", "D", 1 / 1.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.overlap_weighted_projected_graph(self.N, "ABCDE", jaccard=False)
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+    def test_project_weighted_jaccard(self):
+        edges = [
+            ("A", "B", 2 / 5.0),
+            ("A", "C", 1 / 2.0),
+            ("B", "C", 1 / 5.0),
+            ("B", "D", 1 / 5.0),
+            ("B", "E", 2 / 6.0),
+            ("E", "F", 1 / 3.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.overlap_weighted_projected_graph(self.G, "ABCDEF")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in list(P.edges()):
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+        edges = [
+            ("A", "B", 3 / 3.0),
+            ("A", "E", 1 / 3.0),
+            ("A", "C", 1 / 3.0),
+            ("A", "D", 1 / 3.0),
+            ("B", "E", 1 / 3.0),
+            ("B", "C", 1 / 3.0),
+            ("B", "D", 1 / 3.0),
+            ("C", "D", 1 / 1.0),
+        ]
+        Panswer = nx.Graph()
+        Panswer.add_weighted_edges_from(edges)
+        P = bipartite.overlap_weighted_projected_graph(self.N, "ABCDE")
+        assert edges_equal(list(P.edges()), Panswer.edges())
+        for u, v in P.edges():
+            assert P[u][v]["weight"] == Panswer[u][v]["weight"]
+
+    def test_generic_weighted_projected_graph_simple(self):
+        def shared(G, u, v):
+            return len(set(G[u]) & set(G[v]))
+
+        B = nx.path_graph(5)
+        G = bipartite.generic_weighted_projected_graph(
+            B, [0, 2, 4], weight_function=shared
+        )
+        assert nodes_equal(list(G), [0, 2, 4])
+        assert edges_equal(
+            list(G.edges(data=True)),
+            [(0, 2, {"weight": 1}), (2, 4, {"weight": 1})],
+        )
+
+        G = bipartite.generic_weighted_projected_graph(B, [0, 2, 4])
+        assert nodes_equal(list(G), [0, 2, 4])
+        assert edges_equal(
+            list(G.edges(data=True)),
+            [(0, 2, {"weight": 1}), (2, 4, {"weight": 1})],
+        )
+        B = nx.DiGraph()
+        nx.add_path(B, range(5))
+        G = bipartite.generic_weighted_projected_graph(B, [0, 2, 4])
+        assert nodes_equal(list(G), [0, 2, 4])
+        assert edges_equal(
+            list(G.edges(data=True)), [(0, 2, {"weight": 1}), (2, 4, {"weight": 1})]
+        )
+
+    def test_generic_weighted_projected_graph_custom(self):
+        def jaccard(G, u, v):
+            unbrs = set(G[u])
+            vnbrs = set(G[v])
+            return len(unbrs & vnbrs) / len(unbrs | vnbrs)
+
+        def my_weight(G, u, v, weight="weight"):
+            w = 0
+            for nbr in set(G[u]) & set(G[v]):
+                w += G.edges[u, nbr].get(weight, 1) + G.edges[v, nbr].get(weight, 1)
+            return w
+
+        B = nx.bipartite.complete_bipartite_graph(2, 2)
+        for i, (u, v) in enumerate(B.edges()):
+            B.edges[u, v]["weight"] = i + 1
+        G = bipartite.generic_weighted_projected_graph(
+            B, [0, 1], weight_function=jaccard
+        )
+        assert edges_equal(list(G.edges(data=True)), [(0, 1, {"weight": 1.0})])
+        G = bipartite.generic_weighted_projected_graph(
+            B, [0, 1], weight_function=my_weight
+        )
+        assert edges_equal(list(G.edges(data=True)), [(0, 1, {"weight": 10})])
+        G = bipartite.generic_weighted_projected_graph(B, [0, 1])
+        assert edges_equal(list(G.edges(data=True)), [(0, 1, {"weight": 2})])
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_redundancy.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_redundancy.py
new file mode 100644
index 00000000..8d979db8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_redundancy.py
@@ -0,0 +1,35 @@
+"""Unit tests for the :mod:`networkx.algorithms.bipartite.redundancy` module."""
+
+import pytest
+
+from networkx import NetworkXError, cycle_graph
+from networkx.algorithms.bipartite import complete_bipartite_graph, node_redundancy
+
+
+def test_no_redundant_nodes():
+    G = complete_bipartite_graph(2, 2)
+
+    # when nodes is None
+    rc = node_redundancy(G)
+    assert all(redundancy == 1 for redundancy in rc.values())
+
+    # when set of nodes is specified
+    rc = node_redundancy(G, (2, 3))
+    assert rc == {2: 1.0, 3: 1.0}
+
+
+def test_redundant_nodes():
+    G = cycle_graph(6)
+    edge = {0, 3}
+    G.add_edge(*edge)
+    redundancy = node_redundancy(G)
+    for v in edge:
+        assert redundancy[v] == 2 / 3
+    for v in set(G) - edge:
+        assert redundancy[v] == 1
+
+
+def test_not_enough_neighbors():
+    with pytest.raises(NetworkXError):
+        G = complete_bipartite_graph(1, 2)
+        node_redundancy(G)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_spectral_bipartivity.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_spectral_bipartivity.py
new file mode 100644
index 00000000..b9406497
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/bipartite/tests/test_spectral_bipartivity.py
@@ -0,0 +1,80 @@
+import pytest
+
+pytest.importorskip("scipy")
+
+import networkx as nx
+from networkx.algorithms.bipartite import spectral_bipartivity as sb
+
+# Examples from Figure 1
+# E. Estrada and J. A. Rodríguez-Velázquez, "Spectral measures of
+# bipartivity in complex networks", PhysRev E 72, 046105 (2005)
+
+
+class TestSpectralBipartivity:
+    def test_star_like(self):
+        # star-like
+
+        G = nx.star_graph(2)
+        G.add_edge(1, 2)
+        assert sb(G) == pytest.approx(0.843, abs=1e-3)
+
+        G = nx.star_graph(3)
+        G.add_edge(1, 2)
+        assert sb(G) == pytest.approx(0.871, abs=1e-3)
+
+        G = nx.star_graph(4)
+        G.add_edge(1, 2)
+        assert sb(G) == pytest.approx(0.890, abs=1e-3)
+
+    def test_k23_like(self):
+        # K2,3-like
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(0, 1)
+        assert sb(G) == pytest.approx(0.769, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        assert sb(G) == pytest.approx(0.829, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        G.add_edge(3, 4)
+        assert sb(G) == pytest.approx(0.731, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(0, 1)
+        G.add_edge(2, 4)
+        assert sb(G) == pytest.approx(0.692, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        G.add_edge(3, 4)
+        G.add_edge(0, 1)
+        assert sb(G) == pytest.approx(0.645, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        G.add_edge(3, 4)
+        G.add_edge(2, 3)
+        assert sb(G) == pytest.approx(0.645, abs=1e-3)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        G.add_edge(3, 4)
+        G.add_edge(2, 3)
+        G.add_edge(0, 1)
+        assert sb(G) == pytest.approx(0.597, abs=1e-3)
+
+    def test_single_nodes(self):
+        # single nodes
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(2, 4)
+        sbn = sb(G, nodes=[1, 2])
+        assert sbn[1] == pytest.approx(0.85, abs=1e-2)
+        assert sbn[2] == pytest.approx(0.77, abs=1e-2)
+
+        G = nx.complete_bipartite_graph(2, 3)
+        G.add_edge(0, 1)
+        sbn = sb(G, nodes=[1, 2])
+        assert sbn[1] == pytest.approx(0.73, abs=1e-2)
+        assert sbn[2] == pytest.approx(0.82, abs=1e-2)