177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
"""Topological sort with deterministic ordering and cycle-path reporting.
|
|
|
|
Public API:
|
|
topo_sort(graph) -> TopoResult
|
|
where graph is a mapping node -> iterable of dependencies (edges node -> dep).
|
|
|
|
A node's dependencies must appear before it in the output. Ties broken by
|
|
node's natural ordering (so results are deterministic). If a cycle exists,
|
|
result.sorted is None and result.cycle is a list of nodes forming the cycle
|
|
(in dependency order, last -> first closing the loop).
|
|
|
|
The declared contract is `Iterable`, not "re-iterable": a dependency set may
|
|
arrive as a generator, a map object, a filter, or any other one-shot iterator.
|
|
Every dependency sequence is therefore materialised exactly once, up front, and
|
|
all later passes read that single materialised copy. Iterating `graph.values()`
|
|
twice would silently exhaust a generator, leaving the node with an empty
|
|
dependency set, in-degree 0 and a wrong-but-successful ordering.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TopoResult:
|
|
sorted: list[Any] | None
|
|
cycle: list[Any] | None
|
|
|
|
|
|
def topo_sort(graph: Mapping[Any, Iterable[Any]]) -> TopoResult:
|
|
# Materialise each dependency iterable once. `None` means "no dependencies".
|
|
# This is the only place any dependency sequence is consumed.
|
|
deps_src: dict[Any, tuple[Any, ...]] = {
|
|
n: () if ds is None else tuple(ds) for n, ds in graph.items()
|
|
}
|
|
|
|
# Normalise: every referenced node exists as a key, even if it has no entry.
|
|
nodes: set[Any] = set(graph.keys())
|
|
for ds in deps_src.values():
|
|
nodes.update(ds)
|
|
|
|
# adjacency: node -> set of dependencies (a self-dependency is a 1-cycle)
|
|
deps_of: dict[Any, set[Any]] = {n: set() for n in nodes}
|
|
for n, ds in deps_src.items():
|
|
deps_of[n].update(ds)
|
|
|
|
# Kahn's algorithm with a sorted ready queue for determinism.
|
|
# in-degree = number of unprocessed dependencies
|
|
in_deg: dict[Any, int] = {n: len(deps_of[n]) for n in nodes}
|
|
# reverse adjacency: dep -> dependents
|
|
dependents: dict[Any, list[Any]] = defaultdict(list)
|
|
for n in nodes:
|
|
for d in deps_of[n]:
|
|
dependents[d].append(n)
|
|
|
|
# ready set as a min-heap via sorted list (nodes sortable; if not, fall back)
|
|
try:
|
|
ready = sorted((n for n in nodes if in_deg[n] == 0), key=_key)
|
|
except TypeError:
|
|
ready = [n for n in nodes if in_deg[n] == 0]
|
|
out: list[Any] = []
|
|
# We'll use a list as a priority queue by re-sorting on pop; fine for tests.
|
|
while ready:
|
|
# pop smallest
|
|
ready.sort(key=_key)
|
|
cur = ready.pop(0)
|
|
out.append(cur)
|
|
for dep in sorted(dependents[cur], key=_key):
|
|
in_deg[dep] -= 1
|
|
if in_deg[dep] == 0:
|
|
ready.append(dep)
|
|
|
|
if len(out) == len(nodes):
|
|
return TopoResult(sorted=out, cycle=None)
|
|
|
|
# A cycle exists. Everything Kahn could not emit is in a cycle or reaches
|
|
# one, so a DFS restricted to those nodes is guaranteed to find a back edge.
|
|
emitted = set(out)
|
|
unemitted = [n for n in nodes if n not in emitted]
|
|
|
|
color: dict[Any, int] = {n: 0 for n in nodes} # 0 unvisited, 1 visiting, 2 done
|
|
parent: dict[Any, Any] = {}
|
|
|
|
# Iterative DFS (avoids RecursionError on deep chains). Returns the
|
|
# (node, ancestor) back edge closing a cycle, or None.
|
|
def find_back_edge(start: Any) -> tuple[Any, Any] | None:
|
|
stack: list[tuple[Any, Any]] = [(start, None)] # (node, parent)
|
|
# iterator state per node
|
|
iters: dict[Any, Any] = {}
|
|
color[start] = 1
|
|
parent[start] = None
|
|
# We manage traversal manually.
|
|
cur = start
|
|
# Use an explicit work stack of (node, iterator over deps)
|
|
work: list[tuple[Any, Any]] = [(start, iter(sorted(deps_of[start], key=_key)))]
|
|
color[start] = 1
|
|
while work:
|
|
u, it = work[-1]
|
|
advanced = False
|
|
for v in it:
|
|
state = color.get(v, 0)
|
|
if state == 0:
|
|
color[v] = 1
|
|
parent[v] = u
|
|
work.append((v, iter(sorted(deps_of[v], key=_key))))
|
|
advanced = True
|
|
break
|
|
elif state == 1:
|
|
# back edge u -> v
|
|
return (u, v)
|
|
if not advanced:
|
|
# exhausted
|
|
color[u] = 2
|
|
work.pop()
|
|
return None
|
|
|
|
back: tuple[Any, Any] | None = None
|
|
for s in sorted(unemitted, key=_key):
|
|
if color.get(s, 0) == 0:
|
|
parent[s] = None
|
|
back = find_back_edge(s)
|
|
if back is not None:
|
|
break
|
|
|
|
if back is None:
|
|
# Unreachable for a real cycle; never report a silent success instead.
|
|
return TopoResult(sorted=None, cycle=list(unemitted[:1]))
|
|
|
|
back_u, back_v = back
|
|
if back_u == back_v:
|
|
# self-loop: a cycle of length 1
|
|
return TopoResult(sorted=None, cycle=[back_v])
|
|
|
|
# back_u depends on back_v, and parent[x] = y means "y depends on x", so
|
|
# walking parents from back_u reaches back_v. Reversed, that walk is the
|
|
# cycle in dependency order: every node is followed by one of its own deps,
|
|
# and the last node depends on the first, closing the loop.
|
|
path: list[Any] = [back_u]
|
|
cur = parent.get(back_u)
|
|
while cur is not None and cur != back_v:
|
|
path.append(cur)
|
|
cur = parent.get(cur)
|
|
if cur != back_v:
|
|
# Parent chain broken (should not happen); degrade to a live node
|
|
# rather than inventing an edge that does not exist.
|
|
return TopoResult(sorted=None, cycle=list(unemitted[:1]))
|
|
path.append(back_v)
|
|
path.reverse()
|
|
return TopoResult(sorted=None, cycle=path)
|
|
|
|
|
|
class _Sortable:
|
|
__slots__ = ("x", "tn")
|
|
def __init__(self, x):
|
|
self.x = x
|
|
self.tn = type(x).__name__
|
|
def __lt__(self, other):
|
|
if not isinstance(other, _Sortable):
|
|
return NotImplemented
|
|
if self.tn != other.tn:
|
|
return self.tn < other.tn
|
|
try:
|
|
return self.x < other.x
|
|
except Exception:
|
|
rx, ry = repr(self.x), repr(other.x)
|
|
if rx != ry:
|
|
return rx < ry
|
|
return id(self.x) < id(other.x)
|
|
def __eq__(self, other):
|
|
return isinstance(other, _Sortable) and self.x == other.x
|
|
|
|
|
|
def _key(x):
|
|
return _Sortable(x)
|