edges = list(edges) above validation. The redundant ready-queue pre-sort is deleted (level is re-sorted one line later; 3000 random DAGs, 0 level-set diffs with it removed). The flattened-invariant assert is a raise, so -O cannot strip it. test_topo_levels resolves topo_levels from Path(__file__).parent, raises ImportError if the import resolves elsewhere, and its 17 cells are TestCase methods so unittest discover collects them (module-level functions were invisible to the gate). test_rendezvous_hash no longer sys.exit()s at import; its trials are a TestCase.
Measured at be363f5+worktree: bash wizard-work/beam/CHECK rc 0, Ran 136 tests, ALL PASS (242 checks) -- at be363f5 the same gate is rc 1 with zero cells run (ImportError: test_topo_sort imported from /root/.hermes/wizard-outputs/beam). Mutant grading and control cells: wizard-work/volt/q68-2026-10-25.md.
105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
"""Topological ordering with parallel execution levels.
|
|
|
|
Kahn's algorithm, but instead of emitting one node at a time we emit
|
|
*levels*: each level contains every node whose in-degree has just hit zero,
|
|
so all nodes in a level can run in parallel. Deterministic tie-break by node
|
|
key (string) so output is reproducible. Raises CycleError listing the
|
|
remaining (unresolved) nodes if the graph has a cycle.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict, deque
|
|
from typing import Any, Iterable, List, Tuple
|
|
|
|
|
|
class CycleError(ValueError):
|
|
def __init__(self, remaining: List[Any]):
|
|
super().__init__(f"graph contains a cycle; unresolved nodes: {remaining!r}")
|
|
self.remaining = remaining
|
|
|
|
|
|
def topo_levels(
|
|
nodes: Iterable[Any],
|
|
edges: Iterable[Tuple[Any, Any]],
|
|
) -> List[List[Any]]:
|
|
"""Return levels of nodes in dependency order.
|
|
|
|
`edges` are (prerequisite, dependent) pairs: prerequisite must come before
|
|
dependent. Nodes with no edges still appear (in their own level 0 group).
|
|
"""
|
|
node_set = list(dict.fromkeys(nodes)) # preserve insertion order, dedupe
|
|
index = {n: i for i, n in enumerate(node_set)}
|
|
edges = list(edges) # materialise once: `edges` may be a one-shot iterable
|
|
|
|
# validate edges reference known nodes
|
|
for a, b in edges:
|
|
if a not in index:
|
|
raise KeyError(f"edge references unknown node: {a!r}")
|
|
if b not in index:
|
|
raise KeyError(f"edge references unknown node: {b!r}")
|
|
|
|
indeg = {n: 0 for n in node_set}
|
|
adj = defaultdict(list)
|
|
for a, b in edges:
|
|
adj[a].append(b)
|
|
indeg[b] += 1
|
|
|
|
# deterministic: the only ordering point is `level` below; pre-sorting the
|
|
# ready queue here would re-sort at the next line and change nothing.
|
|
ready = [n for n in node_set if indeg[n] == 0]
|
|
levels: List[List[Any]] = []
|
|
processed = 0
|
|
# Kahn in waves
|
|
current = ready
|
|
while current:
|
|
level = sorted(current, key=_key)
|
|
levels.append(level)
|
|
next_ready: List[Any] = []
|
|
for n in level:
|
|
processed += 1
|
|
for m in adj[n]:
|
|
indeg[m] -= 1
|
|
if indeg[m] == 0:
|
|
next_ready.append(m)
|
|
# dedupe next_ready (a node can be pushed twice if two prereqs resolve same wave? no, indeg hits 0 once)
|
|
current = next_ready
|
|
|
|
if processed != len(node_set):
|
|
remaining = [n for n in node_set if indeg[n] > 0]
|
|
raise CycleError(sorted(remaining, key=_key))
|
|
|
|
# flatten check: every node appears exactly once (a raise, not an assert,
|
|
# so the invariant is not stripped under -O)
|
|
seen = []
|
|
for lvl in levels:
|
|
seen.extend(lvl)
|
|
if not (len(seen) == len(node_set) == len(set(seen))):
|
|
raise RuntimeError(
|
|
f"internal invariant violated: {len(seen)} emitted, "
|
|
f"{len(node_set)} unique nodes, {len(set(seen))} distinct"
|
|
)
|
|
return levels
|
|
|
|
|
|
def _key(n: Any):
|
|
return (type(n).__name__, n) if _comparable(n) else (type(n).__name__, repr(n))
|
|
|
|
|
|
def _comparable(n: Any) -> bool:
|
|
# Always comparable within the same concrete type for primitives we care about.
|
|
return isinstance(n, (int, float, str, bytes))
|
|
|
|
|
|
def flatten(levels: List[List[Any]]) -> List[Any]:
|
|
out = []
|
|
for lvl in levels:
|
|
out.extend(lvl)
|
|
return out
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# smoke
|
|
nodes = ["a", "b", "c", "d"]
|
|
edges = [("a", "c"), ("b", "c"), ("c", "d")]
|
|
print(topo_levels(nodes, edges))
|