Repository: graspologic-org/graspologic

Issue:
[BUG] Leiden on integer node ids and starting_communities
If I have a graph where the node ids are of type `int`, the resulting partition map should also be of type `int->int`. If I use this partition map when I call leiden with `starting_communities`, it fails because the node id in the partition map is not of type `str`.


## Example Code

```python
import unittest
class Example(unittest.TestCase):
    def test_hashable_nonstr_with_starting_communities(self):
        seed = 1234
        first_graph = nx.erdos_renyi_graph(20, 0.4, seed=seed)
        second_graph = nx.erdos_renyi_graph(21, 0.4, seed=seed)
        third_graph = nx.erdos_renyi_graph(19, 0.4, seed=seed)

        first_partitions = leiden(first_graph)
        second_partitions = leiden(second_graph, starting_communities=first_partitions)
        third_partitions = leiden(third_graph, starting_communities=second_partitions)
```

## Full Traceback
```pytb
self = <test_leiden.TestLeiden testMethod=test_hashable_nonstr_with_starting_communities>

    def test_hashable_nonstr_with_starting_communities(self):
        seed = 1234
        first_graph = nx.erdos_renyi_graph(20, 0.4, seed=seed)
        second_graph = nx.erdos_renyi_graph(21, 0.4, seed=seed)
        third_graph = nx.erdos_renyi_graph(19, 0.4, seed=seed)

        first_partitions = leiden(first_graph)
>       second_partitions = leiden(second_graph, starting_communities=first_partitions)

tests/partition/test_leiden.py:213:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

graph = <networkx.classes.graph.Graph object at 0x124667c40>, starting_communities = {0: 1, 1: 2, 2: 0, 3: 1, ...}, extra_forced_iterations = 0
resolution = 1.0, randomness = 0.001, use_modularity = True, random_seed = None, weight_attribute = 'weight', is_weighted = None
weight_default = 1.0, check_directed = True, trials = 1

    def leiden(
        graph: Union[
            List[Tuple[Any, Any, Union[int, float]]],
            nx.Graph,
            np.ndarray,
            scipy.sparse.csr.csr_matrix,
        ],
        starting_communities: Optional[Dict[str, int]] = None,
        extra_forced_iterations: int = 0,
        resolution: float = 1.0,
        randomness: float = 0.001,
        use_modularity: bool = True,
        random_seed: Optional[int] = None,
        weight_attribute: str = "weight",
        is_weighted: Optional[bool] = None,
        weight_default: float = 1.0,
        check_directed: bool = True,
        trials: int = 1,
    ) -> Dict[str, int]:
... PYDOC OMITTED FOR SOME SEMBLANCE OF BREVITY ...
        _validate_common_arguments(
            starting_communities,
            extra_forced_iterations,
            resolution,
            randomness,
            use_modularity,
            random_seed,
            is_weighted,
            weight_default,
            check_directed,
        )
        if not isinstance(trials, int):
            raise TypeError("trials must be a positive integer")
        if trials < 1:
            raise ValueError("trials must be a positive integer")
        node_id_mapping, edges = _validate_and_build_edge_list(
            graph, is_weighted, weight_attribute, check_directed, weight_default
        )

>       _modularity, partitions = gn.leiden(
            edges=edges,
            starting_communities=starting_communities,
            resolution=resolution,
            randomness=randomness,
            iterations=extra_forced_iterations + 1,
            use_modularity=use_modularity,
            seed=random_seed,
            trials=trials,
        )
E       TypeError: argument 'starting_communities': 'int' object cannot be converted to 'PyString'

graspologic/partition/leiden.py:340: TypeError
```

## Additional Details
We map the node ids for the graph into string versions prior to calling the native code, then re-reference the original node ids on the way back out for both the graph and the return partitions, but we don't ever do the same for starting_communities.

A proper fix is to refactor the mapping into a separate object that we can use for the provided graph, the return partitions, as well as the starting communities partition map.


Discussion:

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -75,7 +75,7 @@
     "networkx": ("https://networkx.org/documentation/stable", None),
     "numpy": ("https://numpy.org/doc/stable", None),
     "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None),
-    "python": ("https://docs.python.org/3", None),
+    "python": ("https://docs.python.org/3.9", None),
     "scipy": ("https://docs.scipy.org/doc/scipy/reference", None),
     "seaborn": ("https://seaborn.pydata.org", None),
     "sklearn": ("https://scikit-learn.org/dev", None),
diff --git a/graspologic/partition/__init__.py b/graspologic/partition/__init__.py
--- a/graspologic/partition/__init__.py
+++ b/graspologic/partition/__init__.py
@@ -1,11 +1,17 @@
 # Copyright (c) Microsoft Corporation and contributors.
 # Licensed under the MIT License.
 
-from .leiden import HierarchicalCluster, hierarchical_leiden, leiden
+from .leiden import (
+    HierarchicalCluster,
+    HierarchicalClusters,
+    hierarchical_leiden,
+    leiden,
+)
 from .modularity import modularity, modularity_components
 
 __all__ = [
     "HierarchicalCluster",
+    "HierarchicalClusters",
     "hierarchical_leiden",
     "leiden",
     "modularity",
diff --git a/graspologic/partition/leiden.py b/graspologic/partition/leiden.py
--- a/graspologic/partition/leiden.py
+++ b/graspologic/partition/leiden.py
@@ -8,213 +8,190 @@
 import networkx as nx
 import numpy as np
 import scipy
+from beartype import beartype
 
-from graspologic.types import Dict, List, Tuple
+from graspologic.types import AdjacencyMatrix, Dict, GraphRepresentation, List, Tuple
 
 from .. import utils
+from ..preconditions import check_argument
 
 
-def _put_node_in_node_str_map(node: Any, node_str_map: Dict[str, Any]) -> str:
-    """Add a node to the node_str_map, keyed by the node's string representation
-    which is returned by this function. Raise a ValueError in case of key collision."""
-    node_str = str(node)
+class _IdentityMapper:
+    def __init__(self) -> None:
+        self._inner_mapping: Dict[str, Any] = {}
 
-    if node_str in node_str_map and node_str_map[node_str] != node:
-        raise ValueError(
-            "str() representation collision in dataset. Please ensure that "
-            "str(node_id) cannot result in multiple node_ids being turned "
-            "into the same string. This exception is unlikely but would "
-            "result if a non primitive node ID of some sort had a "
-            "barebones __str__() definition for it."
-        )
+    def __call__(self, value: Any) -> str:
+        as_str = str(value)
+        mapped = self._inner_mapping.get(as_str, value)
+        if mapped != value:
+            # we could conceivably address this by also using the hashcode of the value and
+            # storing submaps but that is not super likely to occur
+            raise ValueError(
+                "str(value) results in a collision between distinct values"
+            )
+        self._inner_mapping[as_str] = mapped
+        return as_str
 
-    node_str_map[node_str] = node
+    def original(self, as_str: str) -> Any:
+        return self._inner_mapping[as_str]
 
-    return node_str
+    def __len__(self) -> int:
+        return len(self._inner_mapping)
 
 
-def _validate_and_build_edge_list(
-    graph: Union[
-        List[Tuple[Any, Any, Union[int, float]]],
-        nx.Graph,
-        np.ndarray,
-        scipy.sparse.csr.csr_matrix,
-    ],
+@beartype
+def _nx_to_edge_list(
+    graph: nx.Graph,
+    identifier: _IdentityMapper,
     is_weighted: Optional[bool],
     weight_attribute: str,
-    check_directed: bool,
     weight_default: float,
-) -> Tuple[Dict[str, Any], List[Tuple[str, str, float]]]:
-    if isinstance(graph, (nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph)):
-        raise TypeError("directed or multigraphs are not supported in these functions")
-    if (
-        isinstance(graph, (np.ndarray, scipy.sparse.csr.csr_matrix))
-        and check_directed is True
-        and not utils.is_almost_symmetric(graph)
-    ):
+) -> Tuple[int, List[Tuple[str, str, float]]]:
+    check_argument(
+        isinstance(graph, nx.Graph)
+        and not (graph.is_directed() or graph.is_multigraph()),
+        "Only undirected networkx graphs are supported",
+    )
+    native_safe: List[Tuple[str, str, float]] = []
+    edge_iter = (
+        graph.edges(data=weight_attribute)
+        if is_weighted is True
+        else graph.edges(data=weight_attribute, default=weight_default)
+    )
+    for source, target, weight in edge_iter:
+        source_str = identifier(source)
+        target_str = identifier(target)
+        native_safe.append((source_str, target_str, float(weight)))
+    return graph.number_of_nodes(), native_safe
+
+
+@beartype
+def _adjacency_matrix_to_edge_list(
+    matrix: AdjacencyMatrix,
+    identifier: _IdentityMapper,
+    check_directed: Optional[bool],
+    is_weighted: Optional[bool],
+    weight_default: float,
+) -> Tuple[int, List[Tuple[str, str, float]]]:
+    check_argument(
+        check_directed is True and utils.is_almost_symmetric(matrix),
+        "leiden only supports undirected graphs and the adjacency matrix provided "
+        "was found to be directed",
+    )
+    shape = matrix.shape
+    if len(shape) != 2 or shape[0] != shape[1]:
         raise ValueError(
-            "leiden only supports undirected graphs and the adjacency matrix provided "
-            "was found to be directed"
+            "graphs of type np.ndarray or csr.sparse.csr.csr_matrix should be "
+            "adjacency matrices with n x n shape"
         )
 
-    if weight_default is not None and not isinstance(weight_default, (float, int)):
-        raise TypeError("weight default must be a float or int")
-
-    if isinstance(graph, list):
-        if len(graph) == 0:
-            return {}, []
-        if not isinstance(graph[0], tuple) or len(graph[0]) != 3:
-            raise TypeError(
-                "If the provided graph is a list, it must be a list of tuples with 3 "
-                "values in the form of Tuple[Any, Any, Union[int, float]], you provided"
-                f"{type(graph[0])}, {repr(graph[0])}"
-            )
-
-        new_to_old: Dict[str, Any] = {}
-        stringified_new = []
-
-        for source, target, weight in graph:
-            source_str = _put_node_in_node_str_map(source, new_to_old)
-            target_str = _put_node_in_node_str_map(target, new_to_old)
-            weight_as_float = float(weight)
-            stringified_new.append((source_str, target_str, weight_as_float))
-
-        return new_to_old, stringified_new
-
-    if isinstance(graph, nx.Graph):
-        # will catch all networkx graph types
-        try:
-            new_to_old = {}
-            for node in graph.nodes:
-                _put_node_in_node_str_map(node, new_to_old)
-
-            stringified_new = []
-            for source, target, data in graph.edges(data=True):
-                source_str = str(source)
-                target_str = str(target)
-                weight = float(data.get(weight_attribute, weight_default))
-                stringified_new.append((source_str, target_str, weight))
-
-            return new_to_old, stringified_new
-        except TypeError:
-            # None is returned for the weight if it doesn't exist and a weight_default
-            # is not set, which results in a TypeError when you call float(None)
-            raise ValueError(
-                f"The networkx graph provided did not contain a {weight_attribute} that"
-                " could be cast to float in one of the edges"
-            )
-
-    if isinstance(graph, (np.ndarray, scipy.sparse.csr.csr_matrix)):
-        shape = graph.shape
-        if len(shape) != 2 or shape[0] != shape[1]:
-            raise ValueError(
-                "graphs of type np.ndarray or csr.sparse.csr.csr_matrix should be "
-                "adjacency matrices with n x n shape"
-            )
-
-        if is_weighted is None:
-            is_weighted = not utils.is_unweighted(graph)
-
-        if not is_weighted and weight_default is None:
-            raise ValueError(
-                "the adjacency matrix provided is not weighted and a default weight has"
-                " not been set"
-            )
-
-        edges = []
-        new_to_old = {}
-        if isinstance(graph, np.ndarray):
-            for i in range(0, shape[0]):
-                new_to_old[str(i)] = i
-                start = i
-                for j in range(start, shape[1]):
-                    weight = graph[i][j]
-                    if weight != 0:
-                        if not is_weighted and weight == 1:
-                            weight = weight_default
-                        edges.append((str(i), str(j), float(weight)))
-        else:
-            rows, columns = graph.nonzero()
-            for i in range(0, len(rows)):
-                row = rows[i]
-                column = columns[i]
-                if row <= column:
-                    edges.append((str(row), str(column), float(graph[row, column])))
-
-            # populate the node map using values of the same type as the CSR rows
-            for i in np.arange(shape[0], dtype=rows.dtype):
-                _put_node_in_node_str_map(i, new_to_old)
-
-        return new_to_old, edges
-
-    raise TypeError(
-        f"The type of graph provided {type(graph)} is not a list of 3-tuples, networkx "
-        f"graph, numpy.ndarray, or scipy.sparse.csr_matrix"
-    )
+    if is_weighted is None:
+        is_weighted = not utils.is_unweighted(matrix)
+
+    native_safe: List[Tuple[str, str, float]] = []
+    if isinstance(matrix, np.ndarray):
+        for i in range(0, shape[0]):
+            source = identifier(i)
+            for j in range(i, shape[1]):
+                target = identifier(j)
+                weight = matrix[i][j]
+                if weight != 0:
+                    if not is_weighted and weight == 1:
+                        weight = weight_default
+                    native_safe.append((source, target, float(weight)))
+    else:
+        rows, columns = matrix.nonzero()
+        for i in range(0, len(rows)):
+            source = rows[i]
+            source_str = identifier(source)
+            target = columns[i]
+            target_str = identifier(target)
+            weight = float(matrix[source, target])
+            if source <= target:
+                native_safe.append((source_str, target_str, weight))
+
+    return shape[0], native_safe
+
+
+@beartype
+def _edge_list_to_edge_list(
+    edges: List[Tuple[Any, Any, Union[int, float]]], identifier: _IdentityMapper
+) -> Tuple[int, List[Tuple[str, str, float]]]:
+    native_safe: List[Tuple[str, str, float]] = []
+    temp_node_set = set()
+
+    for source, target, weight in edges:
+        source_str = identifier(source)
+        target_str = identifier(target)
+        weight_as_float = float(weight)
+        if source_str != target_str:
+            native_safe.append((source_str, target_str, weight_as_float))
+            temp_node_set.add(source_str)
+            temp_node_set.add(target_str)
+    return len(temp_node_set), native_safe
+
+
+@beartype
+def _community_python_to_native(
+    starting_communities: Optional[Dict[Any, int]], identity: _IdentityMapper
+) -> Optional[Dict[str, int]]:
+    if starting_communities is None:
+        return None
+    native_safe: Dict[str, int] = {}
+    for (node_id, partition) in starting_communities.items():
+        node_id_as_str = identity(node_id)
+        native_safe[node_id_as_str] = partition
+    return native_safe
+
+
+@beartype
+def _community_native_to_python(
+    communities: Dict[str, int], identity: _IdentityMapper
+) -> Dict[Any, int]:
+    return {
+        identity.original(node_id_as_str): partition
+        for node_id_as_str, partition in communities.items()
+    }
 
 
+@beartype
 def _validate_common_arguments(
-    starting_communities: Optional[Dict[str, int]] = None,
     extra_forced_iterations: int = 0,
-    resolution: float = 1.0,
-    randomness: float = 0.001,
-    use_modularity: bool = True,
+    resolution: Union[float, int] = 1.0,
+    randomness: Union[float, int] = 0.001,
     random_seed: Optional[int] = None,
-    is_weighted: Optional[bool] = None,
-    weight_default: float = 1.0,
-    check_directed: bool = True,
 ) -> None:
-    if starting_communities is not None and not isinstance(starting_communities, dict):
-        raise TypeError("starting_communities must be a dictionary")
-    if not isinstance(extra_forced_iterations, int):
-        raise TypeError("iterations must be an int")
-    if not isinstance(resolution, (int, float)):
-        raise TypeError("resolution must be a float")
-    if not isinstance(randomness, (int, float)):
-        raise TypeError("randomness must be a float")
-    if not isinstance(use_modularity, bool):
-        raise TypeError("use_modularity must be a bool")
-    if random_seed is not None and not isinstance(random_seed, int):
-        raise TypeError("random_seed must either be an int or None")
-    if is_weighted is not None and not isinstance(is_weighted, bool):
-        raise TypeError("is_weighted must either be a bool or None")
-    if not isinstance(weight_default, (int, float)):
-        raise TypeError("weight_default must be a float")
-    if not isinstance(check_directed, bool):
-        raise TypeError("check_directed must be a bool")
-
-    if extra_forced_iterations < 0:
-        raise ValueError("iterations must be a non negative integer")
-    if resolution <= 0:
-        raise ValueError("resolution must be a positive float")
-    if randomness <= 0:
-        raise ValueError("randomness must be a positive float")
-    if random_seed is not None and random_seed <= 0:
-        raise ValueError(
-            "random_seed must be a positive integer (the native PRNG implementation is"
-            " an unsigned 64 bit integer)"
-        )
+    check_argument(
+        extra_forced_iterations >= 0,
+        "extra_forced_iterations must be a non negative integer",
+    )
+    check_argument(resolution > 0, "resolution must be a positive float")
+    check_argument(randomness > 0, "randomness must be a positive float")
+    check_argument(
+        random_seed is None or random_seed > 0,
+        "random_seed must be a positive integer (the native PRNG implementation is"
+        " an unsigned 64 bit integer)",
+    )
 
 
+@beartype
 def leiden(
     graph: Union[
         List[Tuple[Any, Any, Union[int, float]]],
-        nx.Graph,
-        np.ndarray,
-        scipy.sparse.csr.csr_matrix,
+        GraphRepresentation,
     ],
-    starting_communities: Optional[Dict[str, int]] = None,
+    starting_communities: Optional[Dict[Any, int]] = None,
     extra_forced_iterations: int = 0,
-    resolution: float = 1.0,
-    randomness: float = 0.001,
+    resolution: Union[int, float] = 1.0,
+    randomness: Union[int, float] = 0.001,
     use_modularity: bool = True,
     random_seed: Optional[int] = None,
     weight_attribute: str = "weight",
     is_weighted: Optional[bool] = None,
-    weight_default: float = 1.0,
+    weight_default: Union[int, float] = 1.0,
     check_directed: bool = True,
     trials: int = 1,
-) -> Dict[str, int]:
+) -> Dict[Any, int]:
     """
     Leiden is a global network partitioning algorithm. Given a graph, it will iterate
     through the network node by node, and test for an improvement in our quality
@@ -225,32 +202,28 @@ def leiden(
 
     Parameters
     ----------
-    graph : Union[List[Tuple[Any, Any, Union[int, float]]], nx.Graph, np.ndarray, scipy.sparse.csr.csr_matrix]
+    graph : Union[List[Tuple[Any, Any, Union[int, float]]], GraphRepresentation]
         A graph representation, whether a weighted edge list referencing an undirected
         graph, an undirected networkx graph, or an undirected adjacency matrix in either
-        numpy.ndarray or scipy.sparse.csr.csr_matrix form.
-    starting_communities : Optional[Dict[str, int]]
-        Default is ``None``. An optional community mapping dictionary that contains the
-        string representation of the node and the community it belongs to. Note this map
-        must contain precisely the same nodes as the graph and every node must have a
-        positive community id. This mapping forms the starting point of Leiden
-        clustering and can be very useful in saving the state of a previous partition
-        schema from a previous graph and then adjusting the graph based on new temporal
-        data (additions, removals, weight changes, connectivity changes, etc). New nodes
-        should get their own unique community positive integer, but the original
-        partition can be very useful to speed up future runs of leiden. If no community
-        map is provided, the default behavior is to create a node community identity
-        map, where every node is in their own community.
+        numpy.ndarray or scipy.sparse.csr.csr_matrix form. Please see the Notes section
+        regarding node ids used.
+    starting_communities : Optional[Dict[Any, int]]
+        Default is ``None``. An optional community mapping dictionary that contains a node
+        id mapping to the community it belongs to. Please see the Notes section regarding
+        node ids used.
+
+        If no community map is provided, the default behavior is to create a node
+        community identity map, where every node is in their own community.
     extra_forced_iterations : int
         Default is ``0``. Leiden will run until a maximum quality score has been found
         for the node clustering and no nodes are moved to a new cluster in another
         iteration. As there is an element of randomness to the Leiden algorithm, it is
         sometimes useful to set ``extra_forced_iterations`` to a number larger than 0
         where the process is forced to attempt further refinement.
-    resolution : float
+    resolution : Union[int, float]
         Default is ``1.0``. Higher resolution values lead to more communities and lower
         resolution values leads to fewer communities. Must be greater than 0.
-    randomness : float
+    randomness : Union[int, float]
         Default is ``0.001``. The larger the randomness value, the more exploration of
         the partition space is possible. This is a major difference from the Louvain
         algorithm, which is purely greedy in the partition exploration.
@@ -266,11 +239,11 @@ def leiden(
     is_weighted : Optional[bool]
         Default is ``None``. Only used when creating a weighted edge list of tuples
         when the source graph is an adjacency matrix. The
-        :func:`graspologic.utils.to_weighted_edge_list` function will scan these
+        :func:`graspologic.utils.is_unweighted` function will scan these
         matrices and attempt to determine whether it is weighted or not. This flag can
         short circuit this test and the values in the adjacency matrix will be treated
         as weights.
-    weight_default : float
+    weight_default : Union[int, float]
         Default is ``1.0``. If the graph is a networkx graph and the graph does not have
         a fully weighted sequence of edges, this default will be used. If the adjacency
         matrix is found or specified to be unweighted, this weight_default will be used
@@ -292,7 +265,7 @@ def leiden(
 
     Returns
     -------
-    Dict[str, int]
+    Dict[Any, int]
         The results of running leiden over the provided graph, a dictionary containing
         mappings of node -> community id. Isolate nodes in the input graph are not returned
         in the result.
@@ -301,10 +274,11 @@ def leiden(
     ------
     ValueError
     TypeError
+    BeartypeCallHintPepParamException
 
     See Also
     --------
-    graspologic.utils.to_weighted_edge_list
+    graspologic.utils.is_unweighted
 
     References
     ----------
@@ -314,32 +288,42 @@ def leiden(
 
     Notes
     -----
+    No two different nodes are allowed to encode to the **same** str representation,
+    e.g. node_a id of ``"1"`` and node_b id of ``1`` are different object types
+    but str(node_a) == str(node_b). This collision will result in a ``ValueError``
+
     This function is implemented in the `graspologic-native` Python module, a module
     written in Rust for Python.
-
     """
     _validate_common_arguments(
-        starting_communities,
         extra_forced_iterations,
         resolution,
         randomness,
-        use_modularity,
         random_seed,
-        is_weighted,
-        weight_default,
-        check_directed,
     )
-    if not isinstance(trials, int):
-        raise TypeError("trials must be a positive integer")
-    if trials < 1:
-        raise ValueError("trials must be a positive integer")
-    node_id_mapping, edges = _validate_and_build_edge_list(
-        graph, is_weighted, weight_attribute, check_directed, weight_default
+    check_argument(trials >= 1, "Trials must be a positive integer")
+
+    identifier = _IdentityMapper()
+    node_count: int
+    edges: List[Tuple[str, str, float]]
+    if isinstance(graph, nx.Graph):
+        node_count, edges = _nx_to_edge_list(
+            graph, identifier, is_weighted, weight_attribute, weight_default
+        )
+    elif isinstance(graph, list):
+        node_count, edges = _edge_list_to_edge_list(graph, identifier)
+    else:
+        node_count, edges = _adjacency_matrix_to_edge_list(
+            graph, identifier, check_directed, is_weighted, weight_default
+        )
+
+    native_friendly_communities = _community_python_to_native(
+        starting_communities, identifier
     )
 
-    _modularity, partitions = gn.leiden(
+    _quality, native_partitions = gn.leiden(
         edges=edges,
-        starting_communities=starting_communities,
+        starting_communities=native_friendly_communities,
         resolution=resolution,
         randomness=randomness,
         iterations=extra_forced_iterations + 1,
@@ -348,11 +332,9 @@ def leiden(
         trials=trials,
     )
 
-    proper_partitions = {
-        node_id_mapping[key]: value for key, value in partitions.items()
-    }
+    proper_partitions = _community_native_to_python(native_partitions, identifier)
 
-    if len(proper_partitions) < len(node_id_mapping):
+    if len(proper_partitions) < node_count:
         warnings.warn(
             "Leiden partitions do not contain all nodes from the input graph because input graph "
             "contained isolate nodes."
@@ -363,36 +345,66 @@ def leiden(
 
 class HierarchicalCluster(NamedTuple):
     node: Any
+    """Node id"""
     cluster: int
+    """Leiden cluster id"""
     parent_cluster: Optional[int]
+    """Only used when level != 0, but will indicate the previous cluster id that this node was in"""
     level: int
+    """
+    Each time a community has a higher population than we would like, we create a subnetwork 
+    of that community and process it again to break it into smaller chunks. Each time we 
+    detect this, the level increases by 1
+    """
     is_final_cluster: bool
+    """
+    Whether this is the terminal cluster in the hierarchical leiden process or not
+    """
 
-    @staticmethod
-    def final_hierarchical_clustering(
-        hierarchical_clusters: List[
-            Union["HierarchicalCluster", gn.HierarchicalCluster]
-        ],
-    ) -> Dict[str, int]:
-        if not isinstance(hierarchical_clusters, list):
-            raise TypeError(
-                "This static method requires a list of hierarchical clusters"
-            )
-        final_clusters = (
-            cluster for cluster in hierarchical_clusters if cluster.is_final_cluster
-        )
-        return {cluster.node: cluster.cluster for cluster in final_clusters}
+
+class HierarchicalClusters(List[HierarchicalCluster]):
+    """
+    HierarchicalClusters is a subclass of Python's :class:`list` class with two
+    helper methods for retrieving dictionary views of the first and final
+    level of hierarchical clustering in dictionary form.  The rest of the
+    HierarchicalCluster entries in this list can be seen as a transition
+    state log of our :func:`graspologic.partition.hierarchical_leiden` process
+    as it continuously tries to break down communities over a certain size,
+    with the two helper methods on this list providing you the starting point
+    community map and ending point community map.
+    """
+
+    def first_level_hierarchical_clustering(self) -> Dict[Any, int]:
+        """
+        Returns
+        -------
+        Dict[Any, int]
+            The initial leiden algorithm clustering results as a dictionary
+            of node id to community id.
+        """
+        return {entry.node: entry.cluster for entry in self if entry.level == 0}
+
+    def final_level_hierarchical_clustering(self) -> Dict[Any, int]:
+        """
+        Returns
+        -------
+        Dict[Any, int]
+            The last leiden algorithm clustering results as a dictionary
+            of node id to community id.
+        """
+        return {entry.node: entry.cluster for entry in self if entry.is_final_cluster}
 
 
 def _from_native(
     native_cluster: gn.HierarchicalCluster,
-    node_id_map: Dict[str, Any],
+    identifier: _IdentityMapper,
 ) -> HierarchicalCluster:
+
     if not isinstance(native_cluster, gn.HierarchicalCluster):
         raise TypeError(
             "This class method is only valid for graspologic_native.HierarchicalCluster"
         )
-    node_id = node_id_map[native_cluster.node]
+    node_id: Any = identifier.original(native_cluster.node)
     return HierarchicalCluster(
         node=node_id,
         cluster=native_cluster.cluster,
@@ -402,6 +414,7 @@ def _from_native(
     )
 
 
+@beartype
 def hierarchical_leiden(
     graph: Union[
         List[Tuple[Any, Any, Union[int, float]]],
@@ -412,15 +425,15 @@ def hierarchical_leiden(
     max_cluster_size: int = 1000,
     starting_communities: Optional[Dict[str, int]] = None,
     extra_forced_iterations: int = 0,
-    resolution: float = 1.0,
-    randomness: float = 0.001,
+    resolution: Union[int, float] = 1.0,
+    randomness: Union[int, float] = 0.001,
     use_modularity: bool = True,
     random_seed: Optional[int] = None,
     weight_attribute: str = "weight",
     is_weighted: Optional[bool] = None,
-    weight_default: float = 1.0,
+    weight_default: Union[int, float] = 1.0,
     check_directed: bool = True,
-) -> List[HierarchicalCluster]:
+) -> HierarchicalClusters:
     """
 
     Leiden is a global network partitioning algorithm. Given a graph, it will iterate
@@ -447,14 +460,15 @@ def hierarchical_leiden(
 
     Once a node's membership registration in a community cannot be changed any further,
     it is marked with the flag
-    ``graspologic.partition.HierarchicalCluster.is_final_cluster = 1``.
+    ``graspologic.partition.HierarchicalCluster.is_final_cluster = True``.
 
     Parameters
     ----------
-    graph : Union[List[Tuple[Any, Any, Union[int, float]]], nx.Graph, np.ndarray, scipy.sparse.csr.csr_matrix]
+    graph : Union[List[Tuple[Any, Any, Union[int, float]]], GraphRepresentation]
         A graph representation, whether a weighted edge list referencing an undirected
-        graph, an undirected networkx graph, or an undirected adjacency matrix in
-        either numpy.ndarray or scipy.sparse.csr.csr_matrix form.
+        graph, an undirected networkx graph, or an undirected adjacency matrix in either
+        numpy.ndarray or scipy.sparse.csr.csr_matrix form. Please see the Notes section
+        regarding node ids used.
     max_cluster_size : int
         Default is ``1000``. Any partition or cluster with
         membership >= ``max_cluster_size`` will be isolated into a subnetwork. This
@@ -465,28 +479,23 @@ def hierarchical_leiden(
         membership >= ``max_cluster_size`` and the process continues until every
         cluster's membership is < ``max_cluster_size`` or if they cannot be broken into
         more than one new community.
-    starting_communities : Optional[Dict[str, int]]
-        Default is ``None``. An optional community mapping dictionary that contains the
-        string representation of the node and the community it belongs to. Note this
-        map must contain precisely the same nodes as the graph and every node must
-        have a positive community id. This mapping forms the starting point of Leiden
-        clustering and can be very useful in saving the state of a previous partition
-        schema from a previous graph and then adjusting the graph based on new temporal
-        data (additions, removals, weight changes, connectivity changes, etc). New
-        nodes should get their own unique community positive integer, but the original
-        partition can be very useful to speed up future runs of leiden. If no community
-        map is provided, the default behavior is to create a node community identity
-        map, where every node is in their own community.
+    starting_communities : Optional[Dict[Any, int]]
+        Default is ``None``. An optional community mapping dictionary that contains a node
+        id mapping to the community it belongs to. Please see the Notes section regarding
+        node ids used.
+
+        If no community map is provided, the default behavior is to create a node
+        community identity map, where every node is in their own community.
     extra_forced_iterations : int
         Default is ``0``. Leiden will run until a maximum quality score has been found
         for the node clustering and no nodes are moved to a new cluster in another
         iteration. As there is an element of randomness to the Leiden algorithm, it is
         sometimes useful to set ``extra_forced_iterations`` to a number larger than 0
         where the entire process is forced to attempt further refinement.
-    resolution : float
+    resolution : Union[int, float]
         Default is ``1.0``. Higher resolution values lead to more communities and lower
         resolution values leads to fewer communities. Must be greater than 0.
-    randomness : float
+    randomness : Union[int, float]
         Default is ``0.001``. The larger the randomness value, the more exploration of
         the partition space is possible. This is a major difference from the Louvain
         algorithm, which is purely greedy in the partition exploration.
@@ -502,11 +511,11 @@ def hierarchical_leiden(
     is_weighted : Optional[bool]
         Default is ``None``. Only used when creating a weighted edge list of tuples
         when the source graph is an adjacency matrix. The
-        :func:`graspologic.utils.to_weighted_edge_list` function will scan these
+        :func:`graspologic.utils.is_unweighted` function will scan these
         matrices and attempt to determine whether it is weighted or not. This flag can
         short circuit this test and the values in the adjacency matrix will be treated
         as weights.
-    weight_default : float
+    weight_default : Union[int, float]
         Default is ``1.0``. If the graph is a networkx graph and the graph does not
         have a fully weighted sequence of edges, this default will be used. If the
         adjacency matrix is found or specified to be unweighted, this weight_default
@@ -521,22 +530,20 @@ def hierarchical_leiden(
 
     Returns
     -------
-    List[HierarchicalCluster]
+    HierarchicalClusters
         The results of running hierarchical leiden over the provided graph, a list of
         HierarchicalClusters identifying the state of every node and cluster at each
-        level. The function
-        :func:`graspologic.partition.HierarchicalCluster.final_hierarchical_clustering`
-        can be used to create a dictionary mapping of node -> cluster ID. Isolate nodes
-        in the input graph are not returned in the result.
+        level. Isolate nodes in the input graph are not returned in the result.
 
     Raises
     ------
     ValueError
     TypeError
+    BeartypeCallHintPepParamException
 
     See Also
     --------
-    graspologic.utils.to_weighted_edge_list
+    graspologic.utils.is_unweighted
 
     References
     ----------
@@ -546,32 +553,42 @@ def hierarchical_leiden(
 
     Notes
     -----
+    No two different nodes are allowed to encode to the **same** str representation,
+    e.g. node_a id of ``"1"`` and node_b id of ``1`` are different object types
+    but str(node_a) == str(node_b). This collision will result in a ``ValueError``
+
     This function is implemented in the `graspologic-native` Python module, a module
     written in Rust for Python.
-
     """
     _validate_common_arguments(
-        starting_communities,
         extra_forced_iterations,
         resolution,
         randomness,
-        use_modularity,
         random_seed,
-        is_weighted,
-        weight_default,
-        check_directed,
     )
-    if not isinstance(max_cluster_size, int):
-        raise TypeError("max_cluster_size must be an int")
-    if max_cluster_size <= 0:
-        raise ValueError("max_cluster_size must be a positive int")
+    check_argument(max_cluster_size > 0, "max_cluster_size must be a positive int")
 
-    node_id_mapping, graph = _validate_and_build_edge_list(
-        graph, is_weighted, weight_attribute, check_directed, weight_default
+    identifier = _IdentityMapper()
+    node_count: int
+    edges: List[Tuple[str, str, float]]
+    if isinstance(graph, nx.Graph):
+        node_count, edges = _nx_to_edge_list(
+            graph, identifier, is_weighted, weight_attribute, weight_default
+        )
+    elif isinstance(graph, list):
+        node_count, edges = _edge_list_to_edge_list(graph, identifier)
+    else:
+        node_count, edges = _adjacency_matrix_to_edge_list(
+            graph, identifier, check_directed, is_weighted, weight_default
+        )
+
+    native_friendly_communities = _community_python_to_native(
+        starting_communities, identifier
     )
+
     hierarchical_clusters_native = gn.hierarchical_leiden(
-        edges=graph,
-        starting_communities=starting_communities,
+        edges=edges,
+        starting_communities=native_friendly_communities,
         resolution=resolution,
         randomness=randomness,
         iterations=extra_forced_iterations + 1,
@@ -580,14 +597,14 @@ def hierarchical_leiden(
         seed=random_seed,
     )
 
-    result_partitions = []
+    result_partitions = HierarchicalClusters()
     all_nodes = set()
     for entry in hierarchical_clusters_native:
-        partition = _from_native(entry, node_id_mapping)
+        partition = _from_native(entry, identifier)
         result_partitions.append(partition)
         all_nodes.add(partition.node)
 
-    if len(result_partitions) < len(node_id_mapping):
+    if len(all_nodes) < node_count:
         warnings.warn(
             "Leiden partitions do not contain all nodes from the input graph because input graph "
             "contained isolate nodes."


Test patch:
diff --git a/tests/partition/test_leiden.py b/tests/partition/test_leiden.py
--- a/tests/partition/test_leiden.py
+++ b/tests/partition/test_leiden.py
@@ -8,9 +8,21 @@
 import numpy as np
 import pytest
 import scipy
-
-from graspologic.partition import HierarchicalCluster, hierarchical_leiden, leiden
-from graspologic.partition.leiden import _from_native, _validate_and_build_edge_list
+from beartype.roar import BeartypeCallHintPepParamException
+
+from graspologic.partition import (
+    HierarchicalCluster,
+    HierarchicalClusters,
+    hierarchical_leiden,
+    leiden,
+)
+from graspologic.partition.leiden import (
+    _adjacency_matrix_to_edge_list,
+    _edge_list_to_edge_list,
+    _from_native,
+    _IdentityMapper,
+    _nx_to_edge_list,
+)
 from tests.utils import data_file
 
 
@@ -23,19 +35,19 @@ def test_from_native(self):
         # test from_native indirectly through calling graspologic.partition.hierarchical_leiden()
 
     def test_final_hierarchical_clustering(self):
-        with self.assertRaises(TypeError):
-            HierarchicalCluster.final_hierarchical_clustering("I am not a list")
-
-        hierarchical_clusters = [
-            HierarchicalCluster("1", 0, None, 0, False),
-            HierarchicalCluster("2", 0, None, 0, False),
-            HierarchicalCluster("3", 0, None, 0, False),
-            HierarchicalCluster("4", 1, None, 0, True),
-            HierarchicalCluster("5", 1, None, 0, True),
-            HierarchicalCluster("1", 2, 0, 1, True),
-            HierarchicalCluster("2", 2, 0, 1, True),
-            HierarchicalCluster("3", 3, 0, 1, True),
-        ]
+
+        hierarchical_clusters = HierarchicalClusters(
+            [
+                HierarchicalCluster("1", 0, None, 0, False),
+                HierarchicalCluster("2", 0, None, 0, False),
+                HierarchicalCluster("3", 0, None, 0, False),
+                HierarchicalCluster("4", 1, None, 0, True),
+                HierarchicalCluster("5", 1, None, 0, True),
+                HierarchicalCluster("1", 2, 0, 1, True),
+                HierarchicalCluster("2", 2, 0, 1, True),
+                HierarchicalCluster("3", 3, 0, 1, True),
+            ]
+        )
 
         expected = {
             "1": 2,
@@ -46,7 +58,7 @@ def test_final_hierarchical_clustering(self):
         }
         self.assertEqual(
             expected,
-            HierarchicalCluster.final_hierarchical_clustering(hierarchical_clusters),
+            hierarchical_clusters.final_level_hierarchical_clustering(),
         )
 
 
@@ -79,7 +91,7 @@ def test_correct_types(self):
         graph.add_edge("2", "3", weight=4.0)
 
         leiden(graph=graph, **good_args)
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["starting_communities"] = 123
             leiden(graph=graph, **args)
@@ -88,7 +100,7 @@ def test_correct_types(self):
         args["starting_communities"] = None
         leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["extra_forced_iterations"] = 1234.003
             leiden(graph=graph, **args)
@@ -98,7 +110,7 @@ def test_correct_types(self):
             args["extra_forced_iterations"] = -4003
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["resolution"] = "leiden"
             leiden(graph=graph, **args)
@@ -108,7 +120,7 @@ def test_correct_types(self):
             args["resolution"] = 0
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["randomness"] = "leiden"
             leiden(graph=graph, **args)
@@ -118,12 +130,12 @@ def test_correct_types(self):
             args["randomness"] = 0
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["use_modularity"] = 1234
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["trials"] = "hotdog"
             leiden(graph=graph, **args)
@@ -139,7 +151,7 @@ def test_correct_types(self):
         args["random_seed"] = None
         leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["random_seed"] = "leiden"
             leiden(graph=graph, **args)
@@ -149,23 +161,23 @@ def test_correct_types(self):
             args["random_seed"] = -1
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["is_weighted"] = "leiden"
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["weight_default"] = "leiden"
             leiden(graph=graph, **args)
 
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["check_directed"] = "leiden"
             leiden(graph=graph, **args)
 
         # one extra parameter hierarchical needs
-        with self.assertRaises(TypeError):
+        with self.assertRaises(BeartypeCallHintPepParamException):
             args = good_args.copy()
             args["max_cluster_size"] = "leiden"
             hierarchical_leiden(graph=graph, **args)
@@ -175,8 +187,10 @@ def test_correct_types(self):
             args["max_cluster_size"] = 0
             hierarchical_leiden(graph=graph, **args)
 
+        cleared_partitions = good_args.copy()
+        del cleared_partitions["starting_communities"]
         as_csr = nx.to_scipy_sparse_matrix(graph)
-        partitions = leiden(graph=as_csr, **good_args)
+        partitions = leiden(graph=as_csr, **cleared_partitions)
         node_ids = partitions.keys()
         for node_id in node_ids:
             self.assertTrue(
@@ -193,7 +207,7 @@ def test_hierarchical(self):
 
         total_nodes = len([item for item in results if item.level == 0])
 
-        partitions = HierarchicalCluster.final_hierarchical_clustering(results)
+        partitions = results.final_level_hierarchical_clustering()
         self.assertEqual(total_nodes, len(partitions))
 
     # Github issue: 738
@@ -203,6 +217,17 @@ def test_matching_return_types(self):
         for node_id in partitions:
             self.assertTrue(isinstance(node_id, int))
 
+    # Github issue: 901
+    def test_hashable_nonstr_with_starting_communities(self):
+        seed = 1234
+        first_graph = nx.erdos_renyi_graph(20, 0.4, seed=seed)
+        second_graph = nx.erdos_renyi_graph(21, 0.4, seed=seed)
+        third_graph = nx.erdos_renyi_graph(19, 0.4, seed=seed)
+
+        first_partitions = leiden(first_graph)
+        second_partitions = leiden(second_graph, starting_communities=first_partitions)
+        third_partitions = leiden(third_graph, starting_communities=second_partitions)
+
 
 class TestLeidenIsolates(unittest.TestCase):
     """
@@ -316,154 +341,109 @@ def add_edges_to_graph(graph: nx.Graph) -> nx.Graph:
 class TestValidEdgeList(unittest.TestCase):
     def test_empty_edge_list(self):
         edges = []
-        results = _validate_and_build_edge_list(
-            graph=edges,
-            is_weighted=True,
-            weight_attribute="weight",
-            check_directed=False,
-            weight_default=1.0,
+        results = _edge_list_to_edge_list(
+            edges=edges,
+            identifier=_IdentityMapper(),
         )
         self.assertEqual([], results[1])
 
     def test_assert_list_does_not_contain_tuples(self):
         edges = ["invalid"]
-        with self.assertRaisesRegex(TypeError, "list of tuples"):
-            _validate_and_build_edge_list(
-                graph=edges,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
+        with self.assertRaises(BeartypeCallHintPepParamException):
+            _edge_list_to_edge_list(
+                edges=edges,
+                identifier=_IdentityMapper(),
             )
 
     def test_assert_list_contains_misshapen_tuple(self):
         edges = [(1, 2, 1.0, 1.0)]
-        with self.assertRaisesRegex(TypeError, "list of tuples"):
-            _validate_and_build_edge_list(
-                graph=edges,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
+        with self.assertRaises(BeartypeCallHintPepParamException):
+            _edge_list_to_edge_list(
+                edges=edges,
+                identifier=_IdentityMapper(),
             )
 
     def test_assert_wrong_types_in_tuples(self):
         edges = [(True, 4, "sandwich")]
-        with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=edges,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
+        with self.assertRaises(BeartypeCallHintPepParamException):
+            _edge_list_to_edge_list(
+                edges=edges,
+                identifier=_IdentityMapper(),
             )
 
         edges = [(True, False, 3.2)]
-        results = _validate_and_build_edge_list(
-            graph=edges,
-            is_weighted=True,
-            weight_attribute="weight",
-            check_directed=False,
-            weight_default=1.0,
+        _nodes, results = _edge_list_to_edge_list(
+            edges=edges,
+            identifier=_IdentityMapper(),
         )
-        self.assertEqual([("True", "False", 3.2)], results[1])
+        self.assertEqual([("True", "False", 3.2)], results)
 
     def test_empty_nx(self):
-        expected = {}, []
-        results = _validate_and_build_edge_list(
+        expected = 0, []
+        results = _nx_to_edge_list(
             graph=nx.Graph(),
-            is_weighted=True,
+            identifier=_IdentityMapper(),
+            is_weighted=None,
             weight_attribute="weight",
-            check_directed=False,
             weight_default=1.0,
         )
         self.assertEqual(expected, results)
-        with self.assertRaises(TypeError):
-            _validate_and_build_edge_list(
+        with self.assertRaises(ValueError):
+            _nx_to_edge_list(
                 graph=nx.DiGraph(),
-                is_weighted=True,
+                identifier=_IdentityMapper(),
+                is_weighted=None,
                 weight_attribute="weight",
-                check_directed=False,
                 weight_default=1.0,
             )
-        with self.assertRaises(TypeError):
-            _validate_and_build_edge_list(
+        with self.assertRaises(ValueError):
+            _nx_to_edge_list(
                 graph=nx.MultiGraph(),
-                is_weighted=True,
+                identifier=_IdentityMapper(),
+                is_weighted=None,
                 weight_attribute="weight",
-                check_directed=False,
                 weight_default=1.0,
             )
-        with self.assertRaises(TypeError):
-            _validate_and_build_edge_list(
+        with self.assertRaises(ValueError):
+            _nx_to_edge_list(
                 graph=nx.MultiDiGraph(),
-                is_weighted=True,
+                identifier=_IdentityMapper(),
+                is_weighted=None,
                 weight_attribute="weight",
-                check_directed=False,
                 weight_default=1.0,
             )
 
     def test_valid_nx(self):
         graph = add_edges_to_graph(nx.Graph())
         expected = [("nick", "dwayne", 2.2), ("dwayne", "ben", 0.001)]
-        _, edges = _validate_and_build_edge_list(
+        _, edges = _nx_to_edge_list(
             graph=graph,
-            is_weighted=True,
+            identifier=_IdentityMapper(),
+            is_weighted=None,
             weight_attribute="weight",
-            check_directed=False,
             weight_default=1.0,
         )
         self.assertEqual(expected, edges)
 
-        with self.assertRaises(TypeError):
-            graph = add_edges_to_graph(nx.DiGraph())
-            _validate_and_build_edge_list(
-                graph=graph,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
-            )
-
-        with self.assertRaises(TypeError):
-            graph = add_edges_to_graph(nx.MultiGraph())
-            _validate_and_build_edge_list(
-                graph=graph,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
-            )
-
-        with self.assertRaises(TypeError):
-            graph = add_edges_to_graph(nx.MultiDiGraph())
-            _validate_and_build_edge_list(
-                graph=graph,
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=False,
-                weight_default=1.0,
-            )
-
     def test_unweighted_nx(self):
         graph = nx.Graph()
         graph.add_edge("dwayne", "nick")
         graph.add_edge("nick", "ben")
 
-        with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
+        with self.assertRaises(TypeError):
+            _, edges = _nx_to_edge_list(
                 graph=graph,
+                identifier=_IdentityMapper(),
                 is_weighted=True,
                 weight_attribute="weight",
-                check_directed=False,
-                weight_default=None,
+                weight_default=1.0,
             )
 
-        _, edges = _validate_and_build_edge_list(
+        _, edges = _nx_to_edge_list(
             graph=graph,
-            is_weighted=True,
+            identifier=_IdentityMapper(),
+            is_weighted=False,
             weight_attribute="weight",
-            check_directed=False,
             weight_default=3.33333,
         )
         self.assertEqual(
@@ -472,11 +452,11 @@ def test_unweighted_nx(self):
         )
 
         graph.add_edge("salad", "sandwich", weight=100)
-        _, edges = _validate_and_build_edge_list(
+        _, edges = _nx_to_edge_list(
             graph=graph,
-            is_weighted=True,
+            identifier=_IdentityMapper(),
+            is_weighted=False,
             weight_attribute="weight",
-            check_directed=False,
             weight_default=3.33333,
         )
         self.assertEqual(
@@ -499,110 +479,93 @@ def test_matrices(self):
         sparse_directed = nx.to_scipy_sparse_matrix(di_graph)
 
         expected = [("0", "1", 2.2), ("1", "2", 0.001)]
-        _, edges = _validate_and_build_edge_list(
-            graph=dense_undirected,
-            is_weighted=True,
-            weight_attribute="weight",
+        _, edges = _adjacency_matrix_to_edge_list(
+            matrix=dense_undirected,
+            identifier=_IdentityMapper(),
             check_directed=True,
+            is_weighted=True,
             weight_default=1.0,
         )
         self.assertEqual(expected, edges)
-        _, edges = _validate_and_build_edge_list(
-            graph=sparse_undirected,
-            is_weighted=True,
-            weight_attribute="weight",
+        _, edges = _adjacency_matrix_to_edge_list(
+            matrix=sparse_undirected,
+            identifier=_IdentityMapper(),
             check_directed=True,
+            is_weighted=True,
             weight_default=1.0,
         )
         self.assertEqual(expected, edges)
 
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=dense_directed,
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=dense_directed,
+                identifier=_IdentityMapper(),
                 check_directed=True,
+                is_weighted=True,
                 weight_default=1.0,
             )
 
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=sparse_directed,
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=sparse_directed,
+                identifier=_IdentityMapper(),
                 check_directed=True,
+                is_weighted=True,
                 weight_default=1.0,
             )
 
     def test_empty_adj_matrices(self):
         dense = np.array([])
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=dense,
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=dense,
+                identifier=_IdentityMapper(),
                 check_directed=True,
+                is_weighted=True,
                 weight_default=1.0,
             )
 
         sparse = scipy.sparse.csr_matrix([])
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=sparse,
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=sparse,
+                identifier=_IdentityMapper(),
                 check_directed=True,
+                is_weighted=True,
                 weight_default=1.0,
             )
 
     def test_misshapen_matrices(self):
         data = [[3, 2, 0], [2, 0, 1]]  # this is utter gibberish
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=np.array(data),
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=np.array(data),
+                identifier=_IdentityMapper(),
                 check_directed=True,
+                is_weighted=True,
                 weight_default=1.0,
             )
         with self.assertRaises(ValueError):
-            _validate_and_build_edge_list(
-                graph=scipy.sparse.csr_matrix(data),
-                is_weighted=True,
-                weight_attribute="weight",
+            _adjacency_matrix_to_edge_list(
+                matrix=scipy.sparse.csr_matrix(data),
+                identifier=_IdentityMapper(),
                 check_directed=True,
-                weight_default=1.0,
-            )
-
-    def test_invalid_weight_default(self):
-        graph = nx.complete_graph(10)
-        with self.assertRaisesRegex(TypeError, "weight default"):
-            _validate_and_build_edge_list(
-                graph=graph,
                 is_weighted=True,
-                weight_attribute="weight",
-                check_directed=True,
-                weight_default="invalid",
+                weight_default=1.0,
             )
 
     def test_nx_graph_node_str_collision(self):
         graph = nx.Graph()
         graph.add_edge("1", 1, weight=1.0)
-        with self.assertRaisesRegex(ValueError, "representation collision"):
-            _validate_and_build_edge_list(
+        with self.assertRaisesRegex(ValueError, "collision"):
+            _nx_to_edge_list(
                 graph=graph,
+                identifier=_IdentityMapper(),
                 is_weighted=True,
                 weight_attribute="weight",
-                check_directed=True,
                 weight_default=1.0,
             )
 
     def test_edgelist_node_str_collision(self):
-        with self.assertRaisesRegex(ValueError, "representation collision"):
-            _validate_and_build_edge_list(
-                graph=[("1", 1, 1.0)],
-                is_weighted=True,
-                weight_attribute="weight",
-                check_directed=True,
-                weight_default=1.0,
-            )
+        with self.assertRaisesRegex(ValueError, "collision"):
+            _edge_list_to_edge_list(edges=[("1", 1, 1.0)], identifier=_IdentityMapper())