Algorithm Search⌘K

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
nauty.pyRawGitHub
# 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

Read the full page about nauty