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
# 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