Luks's algorithm in Python
Polynomial-time isomorphism for bounded-degree graphs using group-theoretic composition series.
- File
luks_algorithm.py, 295 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/luks-algorithm/. The authors are listed in prov/QX3WY0.jsonl.
"""Luks's divide and conquer for string isomorphism under a permutation group.
A permutation of {0, ..., n-1} is a tuple p, with p[i] the image of i.
Products apply the left factor first: mul(p, q)[i] = q[p[i]].
Given generators of a group G, a G-invariant window W and two strings
x, y (lists of colors indexed by points), string_isomorphism returns the set
Iso = { g in G : x[d] == y[g[d]] for every d in W }
as a pair (generators of A, t) meaning Iso = { a t : a in A }, where
A = Aut(x) inside G restricted to W, or None when the set is empty.
The recursion follows Luks (1982): split an intransitive window into orbits
(the chain rule), and split a transitive one along a system of maximal blocks,
running over the cosets of the kernel of the action on the blocks.
As an application, colored_graph_isomorphism tests isomorphism of two
vertex-colored simple undirected graphs with the same color classes; with
classes of size at most b the group lies in Luks's class Gamma_b.
"""
Perm = tuple[int, ...]
Coset = tuple[list[Perm], Perm]
def identity(n: int) -> Perm:
return tuple(range(n))
def mul(p: Perm, q: Perm) -> Perm:
return tuple(q[i] for i in p)
def inverse(p: Perm) -> Perm:
out = [0] * len(p)
for i, j in enumerate(p):
out[j] = i
return tuple(out)
def _transversal(base_point: int, gens: list[Perm], n: int) -> dict[int, Perm]:
"""For each point of the orbit of base_point, a group element sending base_point there."""
trans = {base_point: identity(n)}
queue = [base_point]
for p in queue:
for g in gens:
q = g[p]
if q not in trans:
trans[q] = mul(trans[p], g)
queue.append(q)
return trans
class Group:
"""A permutation group with a base and strong generating set (Schreier-Sims)."""
def __init__(self, gens: list[Perm], n: int) -> None:
self.n = n
e = identity(n)
self.gens = list(dict.fromkeys(g for g in gens if g != e))
self.base: list[int] = []
self.levels: list[list[Perm]] = []
self.trans: list[dict[int, Perm]] = []
for g in self.gens:
self._add(g)
def _sift(self, g: Perm, start: int) -> tuple[Perm, int]:
for i in range(start, len(self.base)):
t = self.trans[i].get(g[self.base[i]])
if t is None:
return g, i
g = mul(g, inverse(t))
return g, len(self.base)
def _add(self, g: Perm) -> None:
"""Add g and restore the strong generating property (iterative Schreier-Sims)."""
e = identity(self.n)
g, level = self._sift(g, 0)
if g == e:
return
todo = [(g, level)]
while todo:
h, level = todo.pop()
h, level = self._sift(h, level)
if h == e:
continue
if level == len(self.base):
self.base.append(next(i for i in range(self.n) if h[i] != i))
self.levels.append([])
self.trans.append({})
for j in range(level + 1):
self.levels[j].append(h)
for j in range(level, -1, -1):
self.trans[j] = _transversal(self.base[j], self.levels[j], self.n)
# Schreier generators of every level touched must sift through.
for j in range(level, -1, -1):
fresh = False
for p, u in list(self.trans[j].items()):
for s in self.levels[j]:
w = mul(mul(u, s), inverse(self.trans[j][s[p]]))
r, lv = self._sift(w, j + 1)
if r != e:
todo.append((r, lv))
fresh = True
if fresh:
break
def order(self) -> int:
out = 1
for t in self.trans:
out *= len(t)
return out
def contains(self, g: Perm) -> bool:
return self._sift(g, 0)[0] == identity(self.n)
def strong_gens(self) -> list[Perm]:
return list(self.levels[0]) if self.levels else []
def orbits(gens: list[Perm], window: list[int]) -> list[list[int]]:
seen: set[int] = set()
out = []
for p in window:
if p in seen:
continue
seen.add(p)
orb = [p]
for q in orb:
for g in gens:
if g[q] not in seen:
seen.add(g[q])
orb.append(g[q])
out.append(sorted(orb))
return out
def _min_block(gens: list[Perm], window: list[int], seeds: list[int]) -> list[list[int]]:
"""The finest block system in which all seeds lie in one block (Atkinson)."""
parent = {p: p for p in window}
def find(p: int) -> int:
while parent[p] != p:
parent[p] = parent[parent[p]]
p = parent[p]
return p
pairs = [(seeds[0], s) for s in seeds[1:]]
while pairs:
a, b = pairs.pop()
ra, rb = find(a), find(b)
if ra == rb:
continue
parent[rb] = ra
for g in gens:
pairs.append((g[a], g[b]))
classes: dict[int, list[int]] = {}
for p in window:
classes.setdefault(find(p), []).append(p)
return sorted(classes.values())
def maximal_blocks(gens: list[Perm], window: list[int]) -> list[list[int]]:
"""A block system of a transitive group on which it acts primitively."""
alpha = window[0]
system = [[p] for p in window]
block = [alpha]
for beta in window:
if beta in block:
continue
cand = _min_block(gens, window, block + [beta])
if len(cand) > 1:
system = cand
block = next(b for b in cand if alpha in b)
return system
def _block_action(gens: list[Perm], system: list[list[int]]) -> dict[Perm, Perm]:
"""The image group on the blocks, each image with one preimage in G."""
where = {p: i for i, b in enumerate(system) for p in b}
images = [tuple(where[g[b[0]]] for b in system) for g in gens]
k = len(system)
n = len(gens[0]) if gens else len(where)
reps = {identity(k): identity(n)}
queue = [identity(k)]
for img in queue:
for g, gi in zip(gens, images):
nxt = mul(img, gi)
if nxt not in reps:
reps[nxt] = mul(reps[img], g)
queue.append(nxt)
return reps
def _shift(y: list[int], t: Perm) -> list[int]:
"""The string y composed with t: position d gets y[t[d]]."""
return [y[t[d]] for d in range(len(y))]
def string_isomorphism(gens: list[Perm], n: int, window: list[int], x: list[int], y: list[int]) -> Coset | None:
"""All g in <gens> with x[d] == y[g[d]] on the window, as a coset, or None.
The window must be invariant under the group. Recursion depth is at most
about 2 log2 of the window size, since blocks at least halve.
"""
e = identity(n)
gens = Group(gens, n).strong_gens()
# @line L1
if len(window) == 1:
d = window[0]
return (gens, e) if x[d] == y[d] else None
orbs = orbits(gens, window)
# @line L2
if len(orbs) > 1:
# @line L3
a_gens, tau = gens, e
# @line L4
for orb in orbs:
res = string_isomorphism(a_gens, n, orb, x, _shift(y, tau))
if res is None:
return None
a_gens, g = res
tau = mul(g, tau)
# @line L5
return a_gens, tau
# @line L6
system = maximal_blocks(gens, window)
reps = _block_action(gens, system)
where = {p: i for i, b in enumerate(system) for p in b}
images = [tuple(where[g[b[0]]] for b in system) for g in gens]
schreier = [mul(mul(t, g), inverse(reps[mul(img, gi)]))
for img, t in reps.items() for g, gi in zip(gens, images)]
kernel = Group(schreier, n).strong_gens()
# @line L7
found: list[Perm] = []
a_gens: list[Perm] = []
for t in reps.values():
res = string_isomorphism(kernel, n, window, x, _shift(y, t))
if res is not None:
a_gens = res[0]
found.append(mul(res[1], t))
# @line L8
if not found:
return None
# @line L9
t1_inv = inverse(found[0])
return Group(a_gens + [mul(t, t1_inv) for t in found[1:]], n).strong_gens(), found[0]
def colored_graph_isomorphism(n: int, e1: list[tuple[int, int]], e2: list[tuple[int, int]],
colors: list[int]) -> tuple[list[int], int] | None:
"""An isomorphism of two graphs on vertices 0..n-1 that keeps every vertex color,
and the number of color-preserving automorphisms of the first; or None.
The group is the product of the symmetric groups on the color classes,
acting on vertices and on vertex pairs; the window is the set of pairs.
"""
pairs = [(u, v) for u in range(n) for v in range(u + 1, n)]
pid = {p: n + i for i, p in enumerate(pairs)}
size = n + len(pairs)
def lift(vp: list[int]) -> Perm:
out = list(vp) + [0] * len(pairs)
for (u, v), i in pid.items():
a, b = vp[u], vp[v]
out[i] = pid[(a, b) if a < b else (b, a)]
return tuple(out)
gens = []
classes: dict[int, list[int]] = {}
for v in range(n):
classes.setdefault(colors[v], []).append(v)
for cls in classes.values():
for a, b in zip(cls, cls[1:]):
vp = list(range(n))
vp[a], vp[b] = b, a
gens.append(lift(vp))
s1 = {tuple(sorted(e)) for e in e1}
s2 = {tuple(sorted(e)) for e in e2}
x = [0] * size
y = [0] * size
for p, i in pid.items():
x[i] = 1 if p in s1 else 0
y[i] = 1 if p in s2 else 0
window = list(range(n, size))
if not window:
return list(range(n)), 1
res = string_isomorphism(gens, size, window, x, y)
if res is None:
return None
a_gens, tau = res
auts = Group(a_gens, size).order()
return list(tau[:n]), auts
test_luks_algorithm.py 162 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/luks-algorithm/. The authors are listed in prov/QX3WY0.jsonl.
import itertools
import random
from luks_algorithm import (Group, Perm, colored_graph_isomorphism, identity, maximal_blocks, mul,
orbits, string_isomorphism)
Edges = list[tuple[int, int]]
def closure(gens: list[Perm], n: int) -> set[Perm]:
"""Every element of the group, by breadth-first search."""
e = identity(n)
seen = {e}
queue = [e]
for p in queue:
for g in gens:
q = mul(p, g)
if q not in seen:
seen.add(q)
queue.append(q)
return seen
def brute_graph(n: int, e1: Edges, e2: Edges, col: list[int]) -> list[tuple[int, ...]]:
"""All color-preserving isomorphisms, over all vertex permutations."""
s1 = {tuple(sorted(e)) for e in e1}
s2 = {tuple(sorted(e)) for e in e2}
if len(s1) != len(s2):
return []
return [p for p in itertools.permutations(range(n))
if all(col[p[v]] == col[v] for v in range(n))
and all(tuple(sorted((p[u], p[v]))) in s2 for u, v in s1)]
def random_perm(rng: random.Random, n: int) -> Perm:
p = list(range(n))
if rng.random() < 0.5:
rng.shuffle(p)
else:
idx = rng.sample(range(n), rng.randint(1, n))
for a, b in zip(idx, idx[1:] + idx[:1]):
p[a] = b
return tuple(p)
G1 = [(0, 2), (2, 4), (0, 4), (1, 3), (3, 5), (1, 5)]
G2 = [(0, 2), (1, 3), (2, 4), (3, 5), (1, 4), (0, 5)]
G3 = [(0, 2), (2, 5), (0, 5), (1, 3), (3, 4), (1, 4)]
COL = [0, 0, 1, 1, 2, 2]
def test_page_example() -> None:
assert colored_graph_isomorphism(6, G1, G2, COL) is None
assert colored_graph_isomorphism(6, G1, G3, COL) == ([0, 1, 2, 3, 5, 4], 2)
assert colored_graph_isomorphism(6, G1, G1, COL) == ([0, 1, 2, 3, 4, 5], 2)
def test_edge_cases() -> None:
assert colored_graph_isomorphism(0, [], [], []) == ([], 1)
assert colored_graph_isomorphism(1, [], [], [0]) == ([0], 1)
assert colored_graph_isomorphism(2, [(0, 1)], [], [0, 0]) is None
assert colored_graph_isomorphism(2, [(0, 1)], [(1, 0)], [0, 1]) == ([0, 1], 1)
assert string_isomorphism([], 3, [1], [0, 5, 0], [0, 5, 1]) == ([], (0, 1, 2))
assert string_isomorphism([], 3, [2], [0, 5, 0], [0, 5, 1]) is None
def test_schreier_sims_orders() -> None:
rng = random.Random(11)
for _ in range(150):
n = rng.randint(1, 7)
gens = [random_perm(rng, n) for _ in range(rng.randint(0, 3))]
group = Group(gens, n)
elems = closure(gens, n)
assert group.order() == len(elems)
for _ in range(5):
q = random_perm(rng, n)
assert group.contains(q) == (q in elems)
for g in list(elems)[:10]:
assert group.contains(g)
def test_maximal_blocks_are_primitive() -> None:
rng = random.Random(4)
for _ in range(100):
n = rng.randint(2, 8)
gens = [random_perm(rng, n) for _ in range(rng.randint(1, 2))]
for orb in orbits(gens, list(range(n))):
if len(orb) < 2:
continue
system = maximal_blocks(gens, orb)
where = {p: i for i, b in enumerate(system) for p in b}
for g in gens:
for b in system:
assert len({where[g[p]] for p in b}) == 1
assert len(system) >= 2
def test_string_isomorphism_against_brute_force() -> None:
rng = random.Random(20261105)
for _ in range(350):
n = rng.randint(1, 6)
gens = [random_perm(rng, n) for _ in range(rng.randint(0, 3))]
elems = closure(gens, n)
orbs = orbits(gens, list(range(n)))
window = sorted(p for o in orbs if rng.random() < 0.7 for p in o) or orbs[0]
colors = rng.randint(1, 3)
x = [rng.randrange(colors) for _ in range(n)]
if rng.random() < 0.5:
g = rng.choice(sorted(elems))
y = [0] * n
for d in range(n):
y[g[d]] = x[d]
else:
y = [rng.randrange(colors) for _ in range(n)]
want = {g for g in elems if all(x[d] == y[g[d]] for d in window)}
res = string_isomorphism(gens, n, window, x, y)
if res is None:
assert not want
else:
a_gens, tau = res
assert {mul(a, tau) for a in closure(a_gens, n)} == want
def test_colored_graphs_against_brute_force() -> None:
rng = random.Random(2)
for trial in range(300):
n = rng.randint(1, 7)
col = [rng.randrange(rng.randint(1, 4)) for _ in range(n)]
e1 = [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < 0.5]
if trial % 2 == 0:
p = list(range(n))
for c in set(col):
idx = [v for v in range(n) if col[v] == c]
sh = idx[:]
rng.shuffle(sh)
for a, b in zip(idx, sh):
p[a] = b
e2 = [(p[u], p[v]) for u, v in e1]
else:
e2 = [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < 0.5]
res = colored_graph_isomorphism(n, e1, e2, col)
isos = brute_graph(n, e1, e2, col)
assert (res is None) == (not isos)
if res is not None:
assert tuple(res[0]) in isos
assert res[1] == len(brute_graph(n, e1, e1, col))
def test_regular_pairs() -> None:
c6 = [(i, (i + 1) % 6) for i in range(6)]
two_k3 = [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]
k33 = [(i, j) for i in range(3) for j in range(3, 6)]
prism = [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5), (0, 3), (1, 4), (2, 5)]
assert colored_graph_isomorphism(6, c6, two_k3, [0] * 6) is None
assert colored_graph_isomorphism(6, k33, prism, [0] * 6) is None
res = colored_graph_isomorphism(6, c6, [(1, 3), (3, 5), (5, 0), (0, 2), (2, 4), (4, 1)], [0] * 6)
assert res is not None and res[1] == 12
assert colored_graph_isomorphism(6, k33, k33, [0, 0, 0, 1, 1, 1])[1] == 36
assert colored_graph_isomorphism(6, k33, k33, [0] * 6)[1] == 72
What the next level needs
- the tests did not run in this build