Repository: networkx/networkx

Issue:
Move `_tree_center` to a separate module
> What I would propose is that the definition gets moved to the `networkx/algorithms/tree` package, perhaps in a new, private module `tree/_distance_measures.py`[^1] with the name exported *only* to the `tree` namespace. Therefore users could in principle call the function like `nx.tree.center`. I'd definitely be in favor of keeping the condition in `distance_measures.center` as well as you've done here (L756 would just change from `return _tree_center(G)` to `return nx.tree.center(G)`)

[^1]: I suspect that `center` is not the only distance measure for which a more efficient algorithm exists specifically for trees!

---

This came up again in #8089 but was _originally posted by @rossbar in https://github.com/networkx/networkx/pull/7946#pullrequestreview-2728050181_
            


Discussion:

Patch:
diff --git a/benchmarks/benchmarks/distance_measures.py b/benchmarks/benchmarks/distance_measures.py
new file mode 100644
index 00000000000..7c30e9de8cf
--- /dev/null
+++ b/benchmarks/benchmarks/distance_measures.py
@@ -0,0 +1,23 @@
+"""Benchmarks for distance measure functions."""
+
+import networkx as nx
+
+
+class RandomUnlabeledTree:
+    seed = 42
+    number_of_trees = 1000
+    params = [10, 20, 50, 100, 200]
+    param_names = ["n"]
+
+    def setup(self, n):
+        self.trees = nx.random_unlabeled_tree(
+            n, number_of_trees=self.number_of_trees, seed=self.seed
+        )
+
+    def time_center(self, n):
+        for T in self.trees:
+            _ = nx.center(T)
+
+    def time_tree_center(self, n):
+        for T in self.trees:
+            _ = nx.tree.center(T)
diff --git a/doc/reference/algorithms/tree.rst b/doc/reference/algorithms/tree.rst
index e74b40a363d..49f50a41b5c 100644
--- a/doc/reference/algorithms/tree.rst
+++ b/doc/reference/algorithms/tree.rst
@@ -31,6 +31,14 @@ Branchings and Spanning Arborescences
    minimum_spanning_arborescence
    ArborescenceIterator
 
+Distance Measures
+-----------------
+.. automodule:: networkx.algorithms.tree.distance_measures
+.. autosummary::
+   :toctree: generated/
+
+   center
+
 Encoding and decoding
 ---------------------
 .. automodule:: networkx.algorithms.tree.coding
diff --git a/networkx/algorithms/distance_measures.py b/networkx/algorithms/distance_measures.py
index 37597c2b3ae..e501221b470 100644
--- a/networkx/algorithms/distance_measures.py
+++ b/networkx/algorithms/distance_measures.py
@@ -19,77 +19,6 @@
 ]
 
 
-@not_implemented_for("directed")
-def _tree_center(G):
-    """Returns the center of an undirected tree graph.
-
-    The center of a tree consists of nodes that minimize the maximum eccentricity.
-    That is, these nodes minimize the maximum distance to all other nodes.
-    This implementation currently only works for unweighted edges.
-
-    If the input graph is not a tree, results are not guaranteed to be correct and while
-    some non-trees will raise a ``NetworkXError`` not all non-trees will be discovered.
-    Thus, this function should not be used if caller is unsure whether the input graph
-    is a tree. Use ``networkx.is_tree(G)`` to check.
-
-    Parameters
-    ----------
-    G : NetworkX graph
-        A tree graph (undirected, acyclic graph).
-
-    Returns
-    -------
-    center : list
-        A list of nodes in the center of the tree. This could be one or two nodes.
-
-    Raises
-    ------
-    NetworkXError
-        If algorithm detects input graph is not a tree. There is no guarantee
-        this error will always raise if a non-tree is passed.
-
-    Notes
-    -----
-    This algorithm iteratively removes leaves (nodes with degree 1) from the tree until
-    there are only 1 or 2 nodes left. The remaining nodes form the center of the tree.
-
-    This algorithm's time complexity is O(N) where N is the number of nodes in the tree.
-
-    Examples
-    --------
-    >>> G = nx.Graph([(1, 2), (1, 3), (2, 4), (2, 5)])
-    >>> _tree_center(G)
-    [1, 2]
-
-    >>> G = nx.Graph([(1, 2), (2, 3), (3, 4), (4, 5)])
-    >>> _tree_center(G)
-    [3]
-    """
-    center_candidates_degree = dict(G.degree)
-    leaves = {node for node, degree in center_candidates_degree.items() if degree == 1}
-
-    # It's better to fail than an infinite loop, so check leaves to ensure progress
-    while len(center_candidates_degree) > 2 and leaves:
-        new_leaves = set()
-        for leaf in leaves:
-            del center_candidates_degree[leaf]
-            for neighbor in G.neighbors(leaf):
-                if neighbor not in center_candidates_degree:
-                    continue
-                center_candidates_degree[neighbor] -= 1
-                if center_candidates_degree[neighbor] == 1:
-                    new_leaves.add(neighbor)
-        leaves = new_leaves
-
-    if not leaves and len(center_candidates_degree) >= 2:
-        # We detected graph is not a tree. This check does not cover all cases.
-        # For example, it does not cover the case where we have two islands (A-B) and (B-C)
-        # where we might eliminate (B-C) leaves and return [A, B] as centers.
-        raise nx.NetworkXError("Input graph is not a tree")
-
-    return list(center_candidates_degree)
-
-
 def _extrema_bounding(G, compute="diameter", weight=None):
     """Compute requested extreme distance metric of undirected graph G
 
@@ -753,7 +682,7 @@ def center(G, e=None, usebounds=False, weight=None):
     if usebounds is True and e is None and not G.is_directed():
         return _extrema_bounding(G, compute="center", weight=weight)
     if e is None and weight is None and not G.is_directed() and nx.is_tree(G):
-        return _tree_center(G)
+        return nx.tree.center(G)
     if e is None:
         e = eccentricity(G, weight=weight)
     radius = min(e.values())
diff --git a/networkx/algorithms/tree/__init__.py b/networkx/algorithms/tree/__init__.py
index 7120d4bc7ef..c6b6da30e2d 100644
--- a/networkx/algorithms/tree/__init__.py
+++ b/networkx/algorithms/tree/__init__.py
@@ -1,5 +1,6 @@
 from .branchings import *
 from .coding import *
+from .distance_measures import *
 from .mst import *
 from .recognition import *
 from .operations import *
diff --git a/networkx/algorithms/tree/distance_measures.py b/networkx/algorithms/tree/distance_measures.py
new file mode 100644
index 00000000000..659a842b28c
--- /dev/null
+++ b/networkx/algorithms/tree/distance_measures.py
@@ -0,0 +1,87 @@
+"""
+Algorithms for computing distance measures on trees.
+"""
+
+import networkx as nx
+from networkx.utils import not_implemented_for
+
+__all__ = [
+    "center",
+]
+
+
+@not_implemented_for("directed")
+def center(G):
+    """Returns the center of an undirected tree graph.
+
+    The center of a tree consists of nodes that minimize the maximum eccentricity.
+    That is, these nodes minimize the maximum distance to all other nodes.
+    This implementation currently only works for unweighted edges.
+
+    If the input graph is not a tree, results are not guaranteed to be correct and while
+    some non-trees will raise an ``nx.NotATree`` exception, not all non-trees will be discovered.
+    Thus, this function should not be used if caller is unsure whether the input graph
+    is a tree. Use ``nx.is_tree(G)`` to check.
+
+    Parameters
+    ----------
+    G : NetworkX graph
+        A tree graph (undirected, acyclic graph).
+
+    Returns
+    -------
+    center : list
+        A list of nodes forming the center of the tree. This can be one or two nodes.
+
+    Raises
+    ------
+    NetworkXNotImplemented
+        If the input graph is directed.
+
+    NotATree
+        If the algorithm detects the input graph is not a tree. There is no guarantee
+        this error will always raise if a non-tree is passed.
+
+    Notes
+    -----
+    This algorithm iteratively removes leaves (nodes with degree 1) from the tree until
+    there are only 1 or 2 nodes left. The remaining nodes form the center of the tree.
+
+    This algorithm's time complexity is ``O(N)`` where ``N`` is the number of nodes in the tree.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(1, 2), (1, 3), (2, 4), (2, 5)])
+    >>> nx.tree.center(G)
+    [1, 2]
+
+    >>> G = nx.path_graph(5)
+    >>> nx.tree.center(G)
+    [2]
+    """
+    center_candidates_degree = dict(G.degree)
+    leaves = {node for node, degree in center_candidates_degree.items() if degree == 1}
+
+    # It's better to fail than an infinite loop, so check leaves to ensure progress.
+    while len(center_candidates_degree) > 2 and leaves:
+        new_leaves = set()
+        for leaf in leaves:
+            del center_candidates_degree[leaf]
+            for neighbor in G.neighbors(leaf):
+                if neighbor not in center_candidates_degree:
+                    continue
+                center_candidates_degree[neighbor] -= 1
+                if (cddn := center_candidates_degree[neighbor]) == 1:
+                    new_leaves.add(neighbor)
+                elif cddn == 0 and len(center_candidates_degree) != 1:
+                    raise nx.NotATree("input graph is not a tree")
+        leaves = new_leaves
+
+    n = len(center_candidates_degree)
+    # check disconnected or cyclic
+    if (n == 2 and not leaves) or n not in {1, 2}:
+        # We have detected graph is not a tree. This check does not cover all cases.
+        # For example, `nx.Graph([(0, 0)])` will not raise an error.
+        raise nx.NotATree("input graph is not a tree")
+
+    return list(center_candidates_degree)


Test patch:
diff --git a/networkx/algorithms/tree/tests/test_distance_measures.py b/networkx/algorithms/tree/tests/test_distance_measures.py
new file mode 100644
index 00000000000..d123ab4f267
--- /dev/null
+++ b/networkx/algorithms/tree/tests/test_distance_measures.py
@@ -0,0 +1,58 @@
+import math
+
+import pytest
+
+import networkx as nx
+
+
+class TestCenter:
+    @pytest.mark.parametrize("graph_type", (nx.Graph, nx.MultiGraph))
+    def test_center_simple_tree(self, graph_type):
+        G = nx.Graph([(1, 2), (1, 3), (2, 4), (2, 5)], create_using=graph_type)
+        assert set(nx.tree.center(G)) == {1, 2}
+
+    @pytest.mark.parametrize("r", range(2, 5))
+    @pytest.mark.parametrize("h", range(1, 5))
+    def test_center_balanced_tree(self, r, h):
+        G = nx.balanced_tree(r, h)
+        assert nx.tree.center(G) == [0]
+
+    @pytest.mark.parametrize("n", [1, 2, 99, 100])
+    def test_center_path_graph(self, n):
+        G = nx.path_graph(n)
+        expected = {(n - 1) // 2, math.ceil((n - 1) / 2)}
+        assert set(nx.tree.center(G)) == expected
+
+    @pytest.mark.parametrize("n", [0, 2, 3, 5, 99, 100])
+    def test_center_star_graph(self, n):
+        G = nx.star_graph(n)
+        assert nx.tree.center(G) == [0]
+
+    @pytest.mark.parametrize(
+        "G",
+        (
+            nx.cycle_graph(5),
+            nx.complete_graph(5),
+            nx.Graph([(0, 1), (2, 3)]),
+            nx.empty_graph(2),
+            nx.Graph(),
+            nx.MultiGraph([(0, 1), (0, 1)]),
+            nx.Graph([(0, 1), (1, 2), (3, 4)]),
+            nx.Graph([(0, 1), (1, 2), (3, 4), (4, 5)]),
+            pytest.param(
+                nx.Graph([(0, 0)]),
+                marks=pytest.mark.xfail(reason="no check for self-loops"),
+            ),
+        ),
+    )
+    def test_center_non_tree(self, G):
+        with pytest.raises(nx.NotATree, match=r"not a tree"):
+            nx.tree.center(G)
+
+    @pytest.mark.parametrize("graph_type", (nx.DiGraph, nx.MultiDiGraph))
+    def test_center_directed(self, graph_type):
+        G = nx.path_graph(4, create_using=graph_type)
+        with pytest.raises(
+            nx.NetworkXNotImplemented, match=r"not implemented for directed"
+        ):
+            nx.tree.center(G)