nauty in Python
McKay's practical canonical labeling and automorphism package based on individualization-refinement search.
- File
nauty.py, 190 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nauty/. The authors are listed in prov/RGG08G.jsonl.
"""A small model of nauty: canonical labeling by individualization-refinement.
Graphs are simple and undirected, given as (n, edges) with vertices 0..n-1.
Optional vertex colors (integers) must be kept by isomorphisms. The search
is iterative (explicit stack), prunes with automorphisms that it discovers,
and returns a canonical form, a canonical labeling, generators of the
automorphism group, and the group order.
This is a teaching model of the method in McKay's nauty, not of the
program itself: nauty uses a better refinement, a better target cell rule,
node invariants and many other speedups.
"""
Edges = list[tuple[int, int]]
Partition = list[list[int]]
def refine(adj: list[set[int]], cells: Partition) -> Partition:
"""Refine an ordered partition to the coarsest equitable one below it.
Each pass uses every cell in order as a splitter and splits every cell
by the number of neighbors in the splitter, pieces in increasing order
of that number. The result does not depend on vertex names.
Cost: O(n) passes of O(n * (n + m)) work each, so O(n^2 (n + m)).
"""
cells = [list(c) for c in cells]
changed = True
while changed:
changed = False
for s in range(len(cells)):
splitter = set(cells[s])
out: Partition = []
for cell in cells:
if len(cell) == 1:
out.append(cell)
continue
groups: dict[int, list[int]] = {}
for v in cell:
groups.setdefault(len(adj[v] & splitter), []).append(v)
for key in sorted(groups):
out.append(groups[key])
if len(out) != len(cells):
cells = out
changed = True
break
return cells
def individualize(cells: Partition, w: int) -> Partition:
"""Split w off its cell, placing the singleton first. Cost O(n)."""
out: Partition = []
for cell in cells:
if w in cell:
out.append([w])
rest = [v for v in cell if v != w]
if rest:
out.append(rest)
else:
out.append(cell)
return out
def target_cell(cells: Partition) -> int:
"""Index of the first smallest cell with at least 2 vertices. Cost O(n)."""
best = -1
for i, cell in enumerate(cells):
if len(cell) > 1 and (best < 0 or len(cell) < len(cells[best])):
best = i
return best
def _orbit_ids(n: int, gens: list[list[int]]) -> list[int]:
"""Union-find representatives of the orbits of the group generated by gens."""
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for g in gens:
for v in range(n):
a, b = find(v), find(g[v])
if a != b:
parent[a] = b
return [find(v) for v in range(n)]
def canonical_form(n: int, edges: Edges, colors: list[int] | None = None
) -> tuple[tuple, list[int], list[list[int]], int]:
"""Return (form, labeling, generators, |Aut|).
form is the colors and adjacency rows of the graph relabeled by labeling,
where labeling[v] is the new name of v. Two colored graphs are isomorphic
exactly when their forms are equal. Cost: exponential in the worst case;
each search node costs O(n^2 (n + m)).
"""
adj = [set() for _ in range(n)]
for u, v in edges:
adj[u].add(v)
adj[v].add(u)
colors = colors if colors is not None else [0] * n
if n == 0:
return ((), ()), [], [], 1
def leaf_form(cells: Partition) -> tuple[tuple, list[int]]:
lab = [0] * n
for i, cell in enumerate(cells):
lab[cell[0]] = i
order = [cell[0] for cell in cells]
rows = tuple(sum(1 << lab[x] for x in adj[v]) for v in order)
return (tuple(colors[v] for v in order), rows), lab
# @line L1
start = refine(adj, [[v for v in range(n) if colors[v] == c] for c in sorted(set(colors))])
gens: list[list[int]] = []
first: tuple[tuple, list[int]] | None = None
best: tuple[tuple, list[int]] | None = None
first_path: list[tuple[list[int], list[int], int]] = []
# @line L2
# a frame: [cells, prefix, target cell, next index, tried vertices]
stack: list[list] = [[start, [], -1, 0, []]]
# @line L3
while stack:
frame = stack[-1]
cells, prefix = frame[0], frame[1]
# @line L4
if len(cells) == n:
stack.pop()
form, lab = leaf_form(cells)
# @line L5
if first is None:
first = best = (form, lab)
continue
for ref in (first, best):
# @line L6
if form == ref[0]:
inv = [0] * n
for v in range(n):
inv[lab[v]] = v
gens.append([inv[ref[1][v]] for v in range(n)])
break
else:
# @line L7
if form > best[0]:
best = (form, lab)
continue
# @line L8
if frame[2] < 0:
frame[2] = target_cell(cells)
cell = cells[frame[2]]
on_first_path = first is None
# @line L9
fixing = [g for g in gens if all(g[p] == p for p in prefix)]
orbit = _orbit_ids(n, fixing)
tried_orbits = {orbit[t] for t in frame[4]}
w = -1
while frame[3] < len(cell):
cand = cell[frame[3]]
frame[3] += 1
if orbit[cand] not in tried_orbits:
w = cand
break
if w < 0:
stack.pop()
continue
if on_first_path:
first_path.append((list(prefix), list(cell), w))
frame[4].append(w)
# @line L10
stack.append([refine(adj, individualize(cells, w)), prefix + [w], -1, 0, []])
# @line L11
order = 1
for prefix, cell, w in first_path:
fixing = [g for g in gens if all(g[p] == p for p in prefix)]
orbit = _orbit_ids(n, fixing)
order *= sum(1 for v in cell if orbit[v] == orbit[w])
assert best is not None
return best[0], best[1], gens, order
def isomorphic(g: tuple[int, Edges], h: tuple[int, Edges],
cg: list[int] | None = None, ch: list[int] | None = None) -> bool:
"""Test isomorphism of two colored graphs by comparing canonical forms."""
if g[0] != h[0] or len(g[1]) != len(h[1]):
return False
return canonical_form(g[0], g[1], cg)[0] == canonical_form(h[0], h[1], ch)[0]
test_nauty.py 149 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nauty/. The authors are listed in prov/RGG08G.jsonl.
import itertools
import random
from nauty import canonical_form, isomorphic, refine
Edges = list[tuple[int, int]]
def brute_auts(n: int, e: Edges, col: list[int] | None = None) -> list[tuple[int, ...]]:
s = {frozenset(x) for x in e}
col = col or [0] * n
return [p for p in itertools.permutations(range(n))
if all(col[p[v]] == col[v] for v in range(n))
and all(frozenset((p[u], p[v])) in s for u, v in e)]
def brute_iso(g: tuple[int, Edges], h: tuple[int, Edges],
cg: list[int] | None = None, ch: list[int] | None = None) -> bool:
n = g[0]
if n != h[0] or len(g[1]) != len(h[1]):
return False
cg = cg or [0] * n
ch = ch or [0] * n
s = {frozenset(x) for x in h[1]}
return any(all(ch[p[v]] == cg[v] for v in range(n))
and all(frozenset((p[u], p[v])) in s for u, v in g[1])
for p in itertools.permutations(range(n)))
def rand_graph(rng: random.Random, n: int, p: float) -> Edges:
return [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < p]
def relabel(rng: random.Random, n: int, e: Edges, col: list[int] | None
) -> tuple[Edges, list[int] | None]:
perm = list(range(n))
rng.shuffle(perm)
c2 = None
if col is not None:
c2 = [0] * n
for v in range(n):
c2[perm[v]] = col[v]
return [(perm[u], perm[v]) for u, v in e], c2
def rook4() -> tuple[int, Edges]:
c = [(i, j) for i in range(4) for j in range(4)]
return 16, [(a, b) for a in range(16) for b in range(a + 1, 16) if c[a][0] == c[b][0] or c[a][1] == c[b][1]]
def shrikhande() -> tuple[int, Edges]:
c = [(i, j) for i in range(4) for j in range(4)]
d = {(0, 1), (0, 3), (1, 0), (3, 0), (1, 1), (3, 3)}
return 16, [(a, b) for a in range(16) for b in range(a + 1, 16)
if ((c[b][0] - c[a][0]) % 4, (c[b][1] - c[a][1]) % 4) in d]
def petersen() -> tuple[int, Edges]:
v = list(itertools.combinations(range(5), 2))
return 10, [(i, j) for i in range(10) for j in range(i + 1, 10) if not set(v[i]) & set(v[j])]
def check_generators(n: int, e: Edges, gens: list[list[int]]) -> None:
s = {frozenset(x) for x in e}
for g in gens:
assert sorted(g) == list(range(n))
assert all(frozenset((g[u], g[v])) in s for u, v in e)
def test_page_example() -> None:
c6 = (6, [(i, (i + 1) % 6) for i in range(6)])
adj = [{(v - 1) % 6, (v + 1) % 6} for v in range(6)]
assert refine(adj, [[0], [1, 2, 3, 4, 5]]) == [[0], [2, 4], [3], [1, 5]]
form, lab, gens, order = canonical_form(*c6)
assert form == ((0,) * 6, (48, 40, 24, 6, 5, 3))
assert lab == [0, 5, 1, 3, 2, 4]
assert gens == [[0, 5, 4, 3, 2, 1], [1, 2, 3, 4, 5, 0], [1, 0, 5, 4, 3, 2]]
assert order == 12
def test_edge_cases() -> None:
assert canonical_form(0, [])[3] == 1
assert canonical_form(1, [])[3] == 1
assert canonical_form(3, [])[3] == 6
assert canonical_form(3, [], [0, 0, 1])[3] == 2
assert not isomorphic((3, []), (3, []), [0, 0, 1], [0, 1, 1])
assert isomorphic((3, [(0, 1)]), (3, [(1, 2)]))
assert not isomorphic((3, [(0, 1)]), (4, [(0, 1)]))
k5 = (5, [(a, b) for a in range(5) for b in range(a + 1, 5)])
assert canonical_form(*k5)[3] == 120
def test_regular_and_strongly_regular_pairs() -> None:
c6 = (6, [(i, (i + 1) % 6) for i in range(6)])
two_k3 = (6, [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)])
assert not isomorphic(c6, two_k3)
assert canonical_form(*two_k3)[3] == 72
r, s = rook4(), shrikhande()
assert not isomorphic(r, s)
for g, size in ((r, 1152), (s, 192), (petersen(), 120)):
_, _, gens, order = canonical_form(*g)
assert order == size
check_generators(g[0], g[1], gens)
rng = random.Random(7)
for g in (r, s, petersen()):
e2, _ = relabel(rng, g[0], g[1], None)
assert canonical_form(g[0], e2)[0] == canonical_form(*g)[0]
cube = (8, [(a, a ^ b) for a in range(8) for b in (1, 2, 4) if a < a ^ b])
two_k4 = (8, [(a, b) for a in range(8) for b in range(a + 1, 8) if a // 4 == b // 4])
c8 = (8, [(i, (i + 1) % 8) for i in range(8)])
two_c4 = (8, [(i, (i + 1) % 4) for i in range(4)] + [(4 + i, 4 + (i + 1) % 4) for i in range(4)])
assert not isomorphic(cube, two_k4) and not isomorphic(c8, two_c4)
assert canonical_form(*cube)[3] == 48
def test_random_against_brute_force() -> None:
rng = random.Random(20261105)
for trial in range(600):
n = rng.randint(1, 7)
p = rng.choice([0.2, 0.5, 0.8])
e = rand_graph(rng, n, p)
col = [rng.randint(0, 1) for _ in range(n)] if trial % 3 == 0 else None
form, lab, gens, order = canonical_form(n, e, col)
assert sorted(lab) == list(range(n))
assert order == len(brute_auts(n, e, col))
check_generators(n, e, gens)
e2, c2 = relabel(rng, n, e, col)
assert canonical_form(n, e2, c2)[0] == form
if trial % 2 == 0:
f = rand_graph(rng, n, p)
cf = [rng.randint(0, 1) for _ in range(n)] if col is not None else None
assert isomorphic((n, e), (n, f), col, cf) == brute_iso((n, e), (n, f), col, cf)
def test_random_pairs_with_equal_degrees() -> None:
rng = random.Random(99)
found = 0
for _ in range(300):
n = rng.choice([6, 8])
g = [rand_graph(rng, n, 0.5) for _ in range(2)]
degs = [sorted(sum(1 for x in e if v in x) for v in range(n)) for e in g]
if degs[0] != degs[1] or len(g[0]) != len(g[1]):
continue
found += 1
assert isomorphic((n, g[0]), (n, g[1])) == brute_iso((n, g[0]), (n, g[1]))
assert found > 0
What the next level needs
- the tests did not run in this build