Canonical labelling of finite graphs (a compact "mini-nauty") #
This file is deliberately programming Lean rather than proving Lean: it implements an
individualisation–refinement (IR) canonical labelling algorithm in the style of McKay's nauty,
with no Prop-level content at all. Correctness proofs live elsewhere; the only thing this file
promises syntactically is termination.
The algorithm #
Fix a graph G on the vertex set {0, …, n-1}.
An ordered partition of the vertices is stored
nauty-style: an arraylablisting the vertices in partition order, its inversepos, and for every positionithe startcst[i]and endcen[i]of the cell containing positioni. Cells are contiguous blocks oflab.Refinement (
refine) computes the coarsest equitable ordered partition refining a given one, by the usual Hopcroft-style worklist: pop a cellS(the splitter), and split every other cellCaccording to|N(v) ∩ S|, ordering the fragments by increasing count. This is 1-dimensional Weisfeiler–Leman; on a random graph it already produces a discrete partition.A discrete ordered partition is a labelling, so it yields a certificate: the adjacency matrix read off in that order, stored one
Natbitmask per row. Certificates are totally ordered lexicographically.When refinement does not discretise, we individualise: pick the first non-singleton cell (an isomorphism-invariant choice), split off one of its vertices, re-refine, and recurse. This produces a search tree whose leaves are labellings.
The canonical labelling is the leaf that is largest for the order
(invariant path, certificate), lexicographically. The invariant path records, for each node on the root-to-leaf path, a hash of the trace of the refinement that produced it (which cells split, into what sizes, at which counts) together with the shape of the resulting partition. Everything hashed is a function of positions and multiplicities only — never of vertex names — so the invariant path is an isomorphism invariant.
Two prunings make this fast:
Invariant pruning. At a node of depth
d, compare its length-dinvariant path with that of the best leaf so far. Smaller ⇒ every leaf below is smaller ⇒ prune. Larger ⇒ every leaf below beats the incumbent ⇒ discard the incumbent.Automorphism pruning. Two leaves with equal certificates differ by an automorphism
γ, which we record. At a node with individualisation pathp, any recordedγfixingppointwise maps the subtree belowp ++ [v]isomorphically onto the one belowp ++ [γ v], so only one vertex per orbit needs to be explored.
Note that hash collisions can only weaken pruning: an invariant path is used solely as the first component of a total order on leaves, and any isomorphism-invariant function works there.
Graphs #
A finite graph on the vertex set {0, …, n-1}, stored both as a dense adjacency matrix (for
O(1) adjacency queries) and as neighbour lists (for the refinement inner loop).
The constructor is private so callers cannot supply arrays with inconsistent dimensions or
contents. Use Graph.ofOracle, which builds both representations from the same oracle.
- n : Nat
Number of vertices.
adj[v]![w]!istrueiffvandware adjacent.nbr[v]!lists the neighbours ofvin increasing order.
Instances For
Equations
Build a Graph from an adjacency oracle on {0, …, n-1}.
Written with Array.ofFn/Array.filter rather than as an imperative fill: the arrays are the
same, but every entry is then definitionally the oracle, which is what makes the lemmas in
IsoGraph/Canon/Equivariance.lean about this function short. vs is shared across the rows so the
nbr pass allocates only the neighbour lists themselves.
Equations
- One or more equations did not get rendered due to their size.
Instances For
Number of edges (counting each unordered pair once); handy for sanity checks.
Equations
- One or more equations did not get rendered due to their size.
Instances For
Hashing #
A 64-bit FNV-style mixer. Only used to compress isomorphism-invariant integer sequences into a comparable summary; collisions cost pruning power, never correctness.
The FNV-1a offset basis, used as the seed of every invariant hash.
Equations
- IsoGraph.Canon.hashSeed = 1469598103934665603
Instances For
Fold one number into a running hash.
Equations
- IsoGraph.Canon.mix h x = (h ^^^ x) * 1099511628211
Instances For
Fold one Nat into a running hash.
Equations
- IsoGraph.Canon.mixN h x = IsoGraph.Canon.mix h (UInt64.ofNat x)
Instances For
Ordered partitions #
An ordered partition of {0, …, n-1} into contiguous cells of lab.
lab[i]!— the vertex at positioni;pos[v]!— the position of vertexv(inverse oflab);cst[i]!— first position of the cell containing positioni;cen[i]!— one past the last position of that cell.
A cell is therefore identified by its start position, and cell starts are exactly the i with
cst[i]! = i.
Position
↦vertex.Vertex
↦position.Position
↦start of its cell.Position
↦end (exclusive) of its cell.
Instances For
Equations
The one-cell (unit) partition of {0, …, n-1}.
Equations
- IsoGraph.Canon.Part.unit n = { lab := Array.range n, pos := Array.range n, cst := Array.replicate n 0, cen := Array.replicate n n }
Instances For
Both readers of a partition below walk it cell by cell: from a cell start i, cen[i]! is
the start of the next cell, so the walk i ↦ cen[i]! visits every cell once and reaches n.
They are written as structural recursions on an explicit fuel rather than as for loops with a
break, because that is the form induction works on — see IsoGraph/Canon/Equivariance.lean, where
everything about them is proved. n is always enough fuel: there are at most n cells.
Fold the cell sizes from cell start i into the hash h.
Equations
- IsoGraph.Canon.cenHashFrom cen n 0 x✝¹ x✝ = x✝
- IsoGraph.Canon.cenHashFrom cen n fuel.succ x✝¹ x✝ = if x✝¹ ≥ n then x✝ else IsoGraph.Canon.cenHashFrom cen n fuel cen[x✝¹]! (IsoGraph.Canon.mixN x✝ (cen[x✝¹]! - x✝¹))
Instances For
Start of the first non-singleton cell at or after cell start i, if any.
Equations
Instances For
Hash of the sequence of cell sizes. Isomorphism-invariant.
Equations
- p.shapeHash n = IsoGraph.Canon.cenHashFrom p.cen n n 0 IsoGraph.Canon.hashSeed
Instances For
Start position of the first non-singleton cell, if any. This is the target cell for individualisation; picking the first one is an isomorphism-invariant rule.
Equations
- p.targetCell n = IsoGraph.Canon.cenTargetFrom p.cen n n 0
Instances For
Refinement #
Scratch space reused across refinement steps.
Allocating these three arrays afresh in every step would make a step cost Ω(n) even when the
splitter is tiny, which on sparse graphs dominates everything else. Instead they are threaded
through the worklist loop and each step restores them, so the invariant
holds on entry to and on exit from every step, and clearing costs only what was dirtied.
Vertex
↦number of neighbours in the current splitter cell.Cell start
↦has this cell already been collected?Neighbour count
↦bucket size, then bucket offset, during the counting sort.
Instances For
Equations
Cleared scratch space for a graph on n vertices. Counts never exceed n, so bc needs
n + 1 entries.
Equations
- IsoGraph.Canon.Scratch.empty n = { cnt := Array.replicate n 0, hit := Array.replicate n false, bc := Array.replicate (n + 1) 0 }
Instances For
Bump cnt[v] for every v in nbrs[j:], pushing each newly-touched vertex onto touched.
Like cenHashFrom above this is a structural recursion on an explicit fuel (only ever
nbrs.size - j) rather than a for loop, so that the equivariance proof can read off the
resulting count at each index; the j < nbrs.size that a for loop hides is exactly what the
proof needs.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.bumpFrom nbrs 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Accumulate into cnt the number of neighbours each vertex has among lab[k:e], recording in
touched the vertices whose count became nonzero. This is phase (1) of refineStep, and is the
hot loop of the whole algorithm: it costs the splitter cell's degree sum.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.countFrom G lab e 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Sort an array of naturals increasingly.
Array.qsort would do the same job, but it has no verified specification in this toolchain, and
the sorted order of the cells and of the counts is part of what makes the trace canonical. So
this goes through List.mergeSort, which does. The round trip through List costs nothing
measurable on the benchmarks: both call sites sort at most one entry per cell of the partition,
against a refinement step that already costs the splitter's degree sum.
Instances For
Collect the distinct cell starts of the vertices in touched[j:], using hit to deduplicate.
Phase (2) of refineStep, as a structural recursion on fuel; fuel is only ever
touched.size - j.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.collectFrom pos cst touched 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Bucket the cell lab[k:ec] by neighbour count: bc[t] counts the members whose count is t,
and ks lists the counts that occur, in first-occurrence order. Phase (3a) of refineStep, and
another fuel recursion in place of a for loop; fuel is only ever ec - k.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.bucketFrom lab cnt ec 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Turn the bucket sizes into the fragment sizes sizes[j] and the bucket offsets bc[ks[j]]
(relative to the start of the cell). acc is the running offset. Phase (3b).
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.offsetFrom ks 0 x✝³ x✝² x✝¹ x✝ = (x✝², x✝¹)
Instances For
Scatter the cell's vertices into block in count order, each bucket keeping the order it had
in the cell. Phase (3c).
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.scatterFrom lab cnt ec 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Zero the buckets the cell used, leaving bc clear for the next cell. Phase (3d).
Equations
Instances For
Copy the sorted block back into lab[c:], keeping pos its inverse. Phase (3e).
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.writeFrom block c 0 x✝² x✝¹ x✝ = (x✝¹, x✝)
Instances For
Write the boundaries of the fragment [st, en) into cst/cen. Phase (4a).
Equations
Instances For
Install the boundaries of every fragment of a split cell, collecting the fragment starts and hashing each fragment's size and count into the trace. Phase (4).
Equations
Instances For
Queue every fragment of a split cell. Phase (5), the case where the parent was queued.
Equations
Instances For
Index of a largest fragment, scanning left to right.
Equations
Instances For
Queue every fragment but starts[bi]. Phase (5), Hopcroft's case: skipping one largest
fragment is what keeps refinement near-linear.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.markExceptFrom starts bi 0 x✝¹ x✝ = x✝
Instances For
Zero the counts of the touched vertices. Phase (6).
Equations
Instances For
Unmark the cells that were collected. Phase (6).
Equations
Instances For
The state refineStep's cell loop carries: the partition being rewritten, the worklist, the
trace, and the bucket scratch. (cnt is read-only during the loop, so it stays outside.)
Position to vertex.
Vertex to position.
Position to the start of its cell.
Position to the end of its cell.
Which cells are queued as splitters.
- tr : UInt64
The trace hash so far.
The bucket scratch, all-zero between cells.
Instances For
Split the cell starting at position c by neighbour count, phases (3) to (5). Written as a
chain of matches rather than a do block for the same reason as the loops above.
Equations
- One or more equations did not get rendered due to their size.
Instances For
Split every cell in cells[j:], left to right.
Equations
- IsoGraph.Canon.splitCellsFrom cnt cells 0 x✝¹ x✝ = x✝
- IsoGraph.Canon.splitCellsFrom cnt cells fuel.succ x✝¹ x✝ = if x✝¹ ≥ cells.size then x✝ else IsoGraph.Canon.splitCellsFrom cnt cells fuel (x✝¹ + 1) (IsoGraph.Canon.splitCell cnt cells[x✝¹]! x✝)
Instances For
Perform one refinement step: use the cell starting at position s as a splitter, splitting
every cell that meets its neighbourhood.
Returns the new partition, the updated worklist (inW, indexed by cell start position), the
updated trace hash, and the scratch space, restored to its cleared state. Cells created by a
split are pushed onto the worklist following Hopcroft's rule: all fragments if the parent was
queued, otherwise all but a largest fragment.
Equations
- One or more equations did not get rendered due to their size.
Instances For
The refinement worklist loop. fuel bounds the number of splitter pops.
The guard s < G.n && p.cst[s]! == s is never false in a real run — only cell starts are ever
queued, and a cell start stays one when its cell is split — but checking it costs one array read
per pop and saves Equivariance.refineLoop_equiv from having to carry the worklist invariant.
Popping a position that is not a cell start simply drops it.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.refineLoop G 0 x✝³ x✝² x✝¹ x✝ = (x✝³, x✝¹)
Instances For
Refine p to the coarsest equitable partition refining it, using the cells whose start
positions are flagged in inW as initial splitters. Returns the refined partition together with
the trace hash of the refinement.
The fuel n² + n + 1 is a genuine bound: a cell start enters the worklist once initially and once
per fragment of each split, there are at most n - 1 splits, and each split creates at most n
fragments.
Equations
- IsoGraph.Canon.refine G p inW tr = IsoGraph.Canon.refineLoop G (G.n * G.n + G.n + 1) p inW tr (IsoGraph.Canon.Scratch.empty G.n)
Instances For
Refine from the unit partition: equivalently, the coarsest equitable partition of G.
Equations
- IsoGraph.Canon.initialRefine G = IsoGraph.Canon.refine G (IsoGraph.Canon.Part.unit G.n) (if (G.n == 0) = true then #[] else (Array.replicate G.n false).set! 0 true) IsoGraph.Canon.hashSeed
Instances For
Write c + 1 into cst[j] for every j ∈ [j₀, ec), where j₀ is the second argument. A
structural recursion rather than a for loop so that Equivariance.setCstFrom_getElemD can read
off each entry; fuel is only ever ec - j₀, so the work is the same.
Equations
- IsoGraph.Canon.setCstFrom c ec 0 x✝¹ x✝ = x✝
- IsoGraph.Canon.setCstFrom c ec fuel.succ x✝¹ x✝ = if x✝¹ ≥ ec then x✝ else IsoGraph.Canon.setCstFrom c ec fuel (x✝¹ + 1) (x✝.set! x✝¹ (c + 1))
Instances For
Split the vertex v off from its cell, placing it first. Returns the new partition and the
position of the new singleton cell {v} (which is the only splitter needed to re-refine, since
the input partition is assumed equitable).
Equations
- One or more equations did not get rendered due to their size.
Instances For
Certificates #
Number of 64-bit words used for one row of a certificate.
Equations
- IsoGraph.Canon.rowWords n = (n + 63) / 64
Instances For
An n × n bit matrix packed into 64-bit words: row i occupies words
[i * rowWords n, (i+1) * rowWords n), and column j of a row is bit 63 - j % 64 of word
j / 64. Unused trailing bits are zero.
The matrix is given as a curried function so that a caller can do its per-row work — for
certOf below, one array index — in the outer lambda, where this loop applies it once per row
rather than once per bit.
Like the partition walks above, the two loops are structural recursions on fuel rather than
for loops, so that induction applies to them: fuel counts the entries still to do and j
(resp. i) the position reached, and j + fuel = n is the invariant that gives j < n inside
the body — which is exactly what a proof about the loop needs and what a for loop hides.
Equations
Instances For
Pack rows i, i+1, … of the matrix, fuel of them, into out.
Equations
- IsoGraph.Canon.certRowsFrom n bit w 0 x✝¹ x✝ = x✝
- IsoGraph.Canon.certRowsFrom n bit w fuel.succ x✝¹ x✝ = IsoGraph.Canon.certRowsFrom n bit w fuel (x✝¹ + 1) (IsoGraph.Canon.certRow n (bit x✝¹) n 0 0 (x✝¹ * w) x✝)
Instances For
An n × n bit matrix packed into 64-bit words: row i occupies words
[i * rowWords n, (i+1) * rowWords n), and column j of a row is bit 63 - j % 64 of word
j / 64. Unused trailing bits are zero.
The matrix is given as a curried function so that a caller can do its per-row work — for
certOf below, one array index — in the outer lambda, where this loop applies it once per row
rather than once per bit.
Like the partition walks above, the two loops are structural recursions on fuel rather than
for loops, so that induction applies to them: fuel counts the entries still to do and j
(resp. i) the position reached, and j + fuel = n is the invariant that gives j < n inside
the body — which is exactly what a proof about the loop needs and what a for loop hides.
Equations
- IsoGraph.Canon.certBits n bit = IsoGraph.Canon.certRowsFrom n bit (IsoGraph.Canon.rowWords n) n 0 (Array.replicate (n * IsoGraph.Canon.rowWords n) 0)
Instances For
The adjacency matrix of G read off in the order lab, packed by certBits.
Packing bits most-significant-first means that comparing the word arrays lexicographically, as unsigned integers, compares the bit strings lexicographically. Two labellings give the same certificate exactly when they differ by an automorphism.
Equations
Instances For
Lexicographic comparison of a and b from index i on, with fuel bounding the number of
positions still to look at. Written as a structural recursion rather than a for loop so that
the order lemmas in IsoGraph.Canon.Search can be proved by induction on fuel.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.lexCmpFrom a b 0 x✝ = compare a.size b.size
Instances For
Lexicographic comparison of UInt64 arrays (shorter is smaller on a common prefix).
Equations
- IsoGraph.Canon.lexCmpU64 a b = IsoGraph.Canon.lexCmpFrom a b (min a.size b.size) 0
Instances For
Automorphisms #
Given two labellings σ τ : position → vertex with equal certificates, the permutation
γ = τ ∘ σ⁻¹, which is an automorphism of the graph.
Written as a foldl over List.range n rather than as a for loop so that
IsoGraph.Canon.Autos.autoOf_get can read off each entry; the work is the same.
Equations
- IsoGraph.Canon.autoOf n σ τ = List.foldl (fun (g : Array Nat) (i : Nat) => g.set! σ[i]! τ[i]!) (Array.replicate n 0) (List.range n)
Instances For
Whether a permutation moves some point.
Equations
- One or more equations did not get rendered due to their size.
Instances For
One step of orbit closure: mark the images of v under all generators. A foldl rather
than a for loop so that IsoGraph.Canon.Orbits can induct on the generator list.
Equations
- One or more equations did not get rendered due to their size.
Instances For
Close mark under the generators, using stack as the frontier. fuel bounds the number of
pops, which is at most the number of marked points.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.closureLoop gens 0 x✝¹ x✝ = x✝¹
Instances For
The union of the gens-orbits of the vertices in seed, as a membership array of size n.
Equations
- IsoGraph.Canon.orbitClosure n gens seed = IsoGraph.Canon.closureLoop gens (n + 1) (Array.foldl (fun (mark : Array Bool) (v : Nat) => mark.set! v true) (Array.replicate n false) seed) seed
Instances For
The search #
Scan for the first disagreement at or after i, stopping at m. A structural recursion on
fuel rather than a for loop with a break, for the same reason as the partition walks above:
IsoGraph.Canon.Jump needs to induct on it.
Equations
Instances For
Length of the longest common prefix of two paths.
Equations
- IsoGraph.Canon.commonPrefix a b = IsoGraph.Canon.commonPrefixFrom a b (min a.size b.size) (min a.size b.size) 0
Instances For
A leaf of the search tree: a discrete ordered partition together with the data used to compare it against other leaves.
The vertices individualised to reach this leaf.
Node invariants along the root-to-leaf path.
The certificate of
lab.The labelling itself: position
↦vertex.
Instances For
Equations
Mutable state threaded through the depth-first search.
The best leaf seen so far, for the order
(invPath, cert).The very first leaf reached, kept only to detect automorphisms.
Automorphisms discovered so far, as image arrays
γ[v]!.- nodes : Nat
Number of search-tree nodes visited.
When
some k: abandon the search below depthk. SeeleafUpdate.
Instances For
Equations
Cap on the number of stored automorphism generators. Dropping generators only weakens orbit pruning, so this is a pure performance guard.
Equations
Instances For
Invariant pruning at a node. Returns none if the whole subtree is dominated by the current
best leaf, and otherwise the state to continue with (with the incumbent discarded if the subtree
is guaranteed to beat it).
Equations
- One or more equations did not get rendered due to their size.
Instances For
Process a leaf: update the incumbent, harvest any automorphism, and decide how far to backjump.
If the new leaf ν has the same certificate as a previously completed leaf ζ, then
γ = ζ ∘ ν⁻¹ is an automorphism. Writing k for the depth of the greatest common ancestor of
the two leaves, γ fixes the first k individualised vertices and maps ν's branch at depth k
onto ζ's. Since depth-first search had already finished ζ's branch before entering ν's,
every leaf still unexplored below ν's branch is a γ-image of one already seen, and carries the
same certificate. So the whole remainder of that branch can be abandoned: we request a backjump
to depth k.
Equations
- One or more equations did not get rendered due to their size.
Instances For
Cached orbit information for the children of one search-tree node: the orbit of the already processed children, under those automorphisms that fix the node's individualisation path.
- nGens : Nat
Size of
St.autoswhen this was computed; used to detect staleness. The automorphisms fixing the node's path pointwise.
Membership array for the orbit of the processed children.
Instances For
Equations
Visit one node of the search tree. p is the (already refined) ordered partition, path
the vertices individualised to reach it, and invPath the node invariants along that path.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.dfsNode G 0 path invPath p st = st
Instances For
Visit the remaining children verts of a node, skipping those in the orbit of an already
visited child, and honouring any backjump request coming back from below.
Equations
- One or more equations did not get rendered due to their size.
- IsoGraph.Canon.dfsChildren G fuel path invPath p [] processed orb st = st
Instances For
Entry points #
The result of canonicalisation.
The canonical labelling:
lab[i]!is the vertex placed at canonical positioni.Generators of (a subgroup of) the automorphism group found along the way.
- nodes : Nat
Number of search-tree nodes visited, for diagnostics.
Instances For
Equations
Compute a canonical labelling of G.
Result.lab is a permutation of {0, …, n-1} such that Result.cert depends only on the
isomorphism class of G.
Equations
- One or more equations did not get rendered due to their size.
Instances For
The canonical form of G: an isomorphism invariant that is complete (equal iff isomorphic,
for graphs on the same number of vertices).
Equations
Instances For
Positional inverse of a: if a is a permutation of {0, …, n-1} then this is the array
with invLab n a at position a[i]! equal to i. Used only to check that, so nothing is
claimed about it when a is not a permutation.
Equations
- IsoGraph.Canon.invLab n a = List.foldl (fun (b : Array Nat) (i : Nat) => if a[i]! < n then b.set! a[i]! i else b) (Array.replicate n 0) (List.range n)
Instances For
Is a a permutation of {0, …, n-1}? O(n): build the positional inverse and check that
it really inverts, which gives injectivity for free (if a[v]! = a[w]! then
v = b[a[v]!]! = b[a[w]!]! = w).
Equations
Instances For
Canonical labelling from an adjacency oracle.
The search's output is checked to be a permutation of {0, …, n-1} before being returned, and
the identity is substituted if it is not. The check costs O(n) against an Ω(n²) search, and
makes the returned array a permutation whatever the search does (Spec.labellingIsPerm).
Equations
- One or more equations did not get rendered due to their size.