Algorithm Search⌘K

Bliss in Python

Junttila-Kaski canonical labeling and automorphism tool using individualization-refinement with pruning.

File
bliss.py, 241 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
bliss.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bliss/. The authors are listed in prov/XYMF0D.jsonl.
"""A small model of bliss: individualization-refinement with certificates.

Graphs are simple and undirected, given as (n, edges) with vertices 0..n-1,
with optional integer vertex colors. The partition is kept as one array of
vertices and a list of cell starts, as in bliss. Refinement uses a splitter
queue and records a trace (the certificate of the node). The search is
iterative, cuts nodes whose certificate is worse than the best path and
differs from the first path, and cuts children by orbits of the
automorphisms found so far.

This is a teaching model of the method of Junttila and Kaski, not of the
program, which adds many data-structure and pruning techniques.
"""

Edges = list[tuple[int, int]]


class Partition:
    """Ordered partition: elems[start:end] is the cell starting at start."""

    def __init__(self, elems: list[int], starts: list[int]) -> None:
        self.elems = elems
        self.starts = starts  # sorted list of cell starts
        self.cell_of = [0] * len(elems)
        self.end: dict[int, int] = {}
        bounds = starts + [len(elems)]
        for i, s in enumerate(starts):
            self.end[s] = bounds[i + 1]
            for p in range(s, bounds[i + 1]):
                self.cell_of[elems[p]] = s

    def copy(self) -> "Partition":
        return Partition(list(self.elems), list(self.starts))

    def discrete(self) -> bool:
        return len(self.starts) == len(self.elems)


def refine(adj: list[list[int]], part: Partition, queue: list[int]) -> tuple[int, ...]:
    """Refine part in place to an equitable partition; return the trace.

    Splitters are taken in FIFO order. A cell split while it waits in the
    queue puts all pieces in the queue; otherwise all pieces except the
    first largest one. The trace lists, for each split, the start of the
    split cell and the sizes and neighbor counts of its pieces.
    Cost: O((n + m) log n) splitter work plus sorting, O(n m) here in the worst case.
    """
    trace: list[int] = []
    in_queue = set(queue)
    head = 0
    while head < len(queue):
        s = queue[head]
        head += 1
        in_queue.discard(s)
        splitter = part.elems[s:part.end[s]]
        trace.append(-1 - s)
        count: dict[int, int] = {}
        for v in splitter:
            for x in adj[v]:
                count[x] = count.get(x, 0) + 1
        touched = sorted({part.cell_of[x] for x in count})
        for c in touched:
            e = part.end[c]
            if e - c == 1:
                continue
            cell = part.elems[c:e]
            groups: dict[int, list[int]] = {}
            for v in cell:
                groups.setdefault(count.get(v, 0), []).append(v)
            if len(groups) == 1:
                continue
            keys = sorted(groups)
            pos = c
            pieces: list[tuple[int, int]] = []
            for k in keys:
                g = groups[k]
                part.elems[pos:pos + len(g)] = g
                pieces.append((pos, pos + len(g)))
                trace.extend((c, k, len(g)))
                pos += len(g)
            new_starts = [a for a, _ in pieces[1:]]
            for a, b in pieces:
                part.end[a] = b
                for p in range(a, b):
                    part.cell_of[part.elems[p]] = a
            part.starts = sorted(part.starts + new_starts)
            if c in in_queue:
                add = new_starts
            else:
                largest = max(range(len(pieces)), key=lambda i: (pieces[i][1] - pieces[i][0], -i))
                add = [a for i, (a, _) in enumerate(pieces) if i != largest]
            for a in add:
                queue.append(a)
                in_queue.add(a)
    return tuple(trace)


def individualize(part: Partition, w: int) -> int:
    """Move w to the front of its cell and split it off; return its start."""
    c = part.cell_of[w]
    e = part.end[c]
    i = part.elems.index(w, c, e)
    part.elems[c], part.elems[i] = part.elems[i], part.elems[c]
    part.end[c] = c + 1
    part.end[c + 1] = e
    for p in range(c + 1, e):
        part.cell_of[part.elems[p]] = c + 1
    part.starts = sorted(part.starts + [c + 1])
    return c


def _orbit_ids(n: int, gens: list[list[int]]) -> list[int]:
    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 _worse(cert: list[tuple], ref: list[tuple]) -> bool:
    """True when every sequence starting with cert is smaller than ref."""
    for a, b in zip(cert, ref):
        if a != b:
            return a < b
    return False


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|) of a vertex-colored graph.

    labeling[v] is the canonical name of v; form is the color sequence and
    the adjacency rows after relabeling, so equal forms mean isomorphic
    graphs. Cost: exponential in the worst case, one refinement per node.
    """
    adj = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    colors = colors if colors is not None else [0] * n
    if n == 0:
        return (), [], [], 1
    # @line L1
    elems = sorted(range(n), key=lambda v: colors[v])
    starts = [i for i in range(n) if i == 0 or colors[elems[i]] != colors[elems[i - 1]]]
    root = Partition(elems, starts)
    root_cert = refine(adj, root, list(starts))
    gens: list[list[int]] = []
    first: tuple[list[tuple], list[int]] | None = None
    best: tuple[list[tuple], list[int]] | None = None
    first_path: list[tuple[list[int], list[int], int]] = []
    # @line L2
    # a frame: [partition, prefix, certificate list, target cell, next index, tried]
    stack: list[list] = [[root, [], [root_cert], -1, 0, []]]
    # @line L3
    while stack:
        frame = stack[-1]
        part, prefix, cert = frame[0], frame[1], frame[2]
        # @line L4
        if (best is not None and _worse(cert, best[0])
                and cert != first[0][:len(cert)]):
            stack.pop()
            continue
        # @line L5
        if part.discrete():
            stack.pop()
            lab = [0] * n
            for i, v in enumerate(part.elems):
                lab[v] = i
            rows = tuple(sum(1 << lab[x] for x in adj[v]) for v in part.elems)
            key = cert + [tuple(colors[v] for v in part.elems) + rows]
            # @line L6
            if first is None:
                first = best = (key, lab)
                continue
            for ref in (first, best):
                # @line L7
                if key == ref[0]:
                    inv = part.elems
                    gens.append([inv[ref[1][v]] for v in range(n)])
                    break
            else:
                # @line L8
                if key > best[0]:
                    best = (key, lab)
            continue
        # @line L9
        if frame[3] < 0:
            big = max(part.starts, key=lambda s: (part.end[s] - s, -s))
            frame[3] = big
        c = frame[3]
        cell = part.elems[c:part.end[c]]
        on_first_path = first is None
        # @line L10
        fixing = [g for g in gens if all(g[p] == p for p in prefix)]
        orbit = _orbit_ids(n, fixing)
        tried = {orbit[t] for t in frame[5]}
        w = -1
        while frame[4] < len(cell):
            cand = cell[frame[4]]
            frame[4] += 1
            if orbit[cand] not in tried:
                w = cand
                break
        if w < 0:
            stack.pop()
            continue
        if on_first_path:
            first_path.append((list(prefix), cell, w))
        frame[5].append(w)
        # @line L11
        child = part.copy()
        s = individualize(child, w)
        trace = refine(adj, child, [s])
        stack.append([child, prefix + [w], cert + [trace], -1, 0, []])
    # @line L12
    order = 1
    for prefix, cell, w in first_path:
        orbit = _orbit_ids(n, [g for g in gens if all(g[p] == p for p in prefix)])
        order *= sum(1 for v in cell if orbit[v] == orbit[w])
    assert best is not None
    return best[0][-1], 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_bliss.py 153 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bliss/. The authors are listed in prov/XYMF0D.jsonl.

import itertools
import random

from bliss import Partition, canonical_form, individualize, 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)]
    part = Partition(list(range(6)), [0])
    s = individualize(part, 0)
    trace = refine(adj, part, [s])
    assert [part.elems[x:part.end[x]] for x in part.starts] == [[0], [3], [2, 4], [1, 5]]
    assert trace == (-1, 1, 0, 3, 1, 1, 2, -5, 1, 0, 1, 1, 1, 2, -2)
    form, lab, gens, order = canonical_form(*c6)
    assert form == (0,) * 6 + (48, 12, 34, 18, 9, 5)
    assert lab == [0, 5, 2, 1, 3, 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 Bliss