From 36506dccdd3571ff225e0bd966f74fcf2b786bd4 Mon Sep 17 00:00:00 2001 From: Abdullah Rady Date: Fri, 14 Aug 2026 16:59:40 +0200 Subject: [PATCH] color refinement implementation --- CHANGELOG.md | 1 + docs/make.jl | 1 + docs/src/algorithms/color_refinement.md | 19 ++ src/Graphs.jl | 3 + src/color_refinement.jl | 359 ++++++++++++++++++++++++ test/color_refinement.jl | 264 +++++++++++++++++ test/runtests.jl | 1 + 7 files changed, 648 insertions(+) create mode 100644 docs/src/algorithms/color_refinement.md create mode 100644 src/color_refinement.jl create mode 100644 test/color_refinement.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7ea9a62..9f9a81ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ We follow SemVer as most of the Julia ecosystem. Below you might see the "breaking" label even for minor version bumps -- we use it a bit more loosely to denote things that are not breaking by SemVer's definition but might cause breakage to people using internal or experimental APIs or undocumented implementation details. ## unreleased +- Canonical color refinement with `canonical_color_refinement` and `color_refinement` - `is_articulation(g, v)` for checking whether a single vertex is an articulation point - The iFUB algorithm is used for faster diameter calculation and now supports weighted graph diameter calculation - ECG community detection algorithm diff --git a/docs/make.jl b/docs/make.jl index 30797f0f3..df4551008 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -60,6 +60,7 @@ pages_files = [ "Algorithms API" => [ "algorithms/biconnectivity.md", "algorithms/centrality.md", + "algorithms/color_refinement.md", "algorithms/community.md", "algorithms/connectivity.md", "algorithms/cut.md", diff --git a/docs/src/algorithms/color_refinement.md b/docs/src/algorithms/color_refinement.md new file mode 100644 index 000000000..1a038cb81 --- /dev/null +++ b/docs/src/algorithms/color_refinement.md @@ -0,0 +1,19 @@ +# Color refinement + +Color refinement repeatedly splits color classes according to the number of neighbors +each vertex has in every color class. The process stops when no class can be split +further. In the resulting stable coloring, vertices share a color when color refinement +cannot distinguish them. + +## Index + +```@index +Pages = ["color_refinement.md"] +``` + +## Full docs + +```@autodocs +Modules = [Graphs] +Pages = ["color_refinement.jl"] +``` diff --git a/src/Graphs.jl b/src/Graphs.jl index 9df01764e..b4af65fbe 100644 --- a/src/Graphs.jl +++ b/src/Graphs.jl @@ -209,6 +209,8 @@ export # coloring greedy_color, + canonical_color_refinement, + color_refinement, # connectivity connected_components, @@ -519,6 +521,7 @@ include("cycles/incremental.jl") include("traversals/bfs.jl") include("traversals/bipartition.jl") include("traversals/greedy_color.jl") +include("color_refinement.jl") include("traversals/dfs.jl") include("traversals/maxadjvisit.jl") include("traversals/randomwalks.jl") diff --git a/src/color_refinement.jl b/src/color_refinement.jl new file mode 100644 index 000000000..62739a837 --- /dev/null +++ b/src/color_refinement.jl @@ -0,0 +1,359 @@ +""" + canonical_color_refinement(g, alpha, S) + +Refine the initial coloring `alpha` of `g` using the refining color set `S`. If `S` +is sufficient, return a canonical coloring of the unique coarsest stable partition +that refines `alpha`. `S` contains labels from `alpha` whose color classes are used +as the initial refiners. + +`alpha` is an integer vector of length `nv(g)` assigning an initial color label to +every vertex. Labels may be arbitrary integers; they are ordered and mapped to a dense +internal numbering before refinement. The result is canonical in the sense of the +reference: a color-preserving isomorphism between two inputs preserves the output +color numbers. Thus, for an isomorphism `sigma` from `g1` to `g2`, the initial +colorings must satisfy `alpha2[sigma[v]] == alpha1[v]`; the resulting colorings then +satisfy `color2[sigma[v]] == color1[v]`. Two vertices receive the same color exactly +when the refinement does not distinguish them. + +`S` is sufficient if every pair that can be distinguished by an initial color class +can also be distinguished by a class listed in `S`. Passing every distinct label of +`alpha` is always sufficient and produces the unique coarsest stable partition that +refines `alpha`. A smaller set can also be sufficient, but an arbitrary strict subset +may terminate before the coloring is stable. The overloads that omit `S` use every +distinct initial label. + +An empty graph (equivalently an empty `alpha`) has no color classes to refine, so it +is returned unchanged as an empty vector regardless of `S`. + +This implementation follows the partition-refinement algorithm described in +Berkholz, Bonsma, and Grohe, "Tight Lower and Upper Bounds for the Complexity of +Canonical Colour Refinement". For digraphs, as in the paper's primary stability +definition, color degrees count out-neighbors. + +# References + +- C. Berkholz, P. Bonsma, M. Grohe, *Tight Lower and Upper Bounds for the + Complexity of Canonical Colour Refinement*, + [arXiv:1509.08251](https://arxiv.org/abs/1509.08251) + +# Examples +```jldoctest +julia> using Graphs + +julia> g = path_graph(5); + +julia> canonical_color_refinement(g, ones(Int, 5), [1]) +5-element Vector{Int64}: + 1 + 3 + 2 + 3 + 1 +``` +""" +function canonical_color_refinement( + g::AbstractGraph, alpha::AbstractVector{<:Integer}, S::AbstractVector{<:Integer} +)::Vector{Int} + n = nv(g) + + length(alpha) == n || + throw(ArgumentError("Initial coloring alpha must have length nv(g)")) + + isempty(alpha) && return Int[] + + colour_labels = Int.(alpha) + + # Map labels in sorted order so the dense internal IDs do not depend on vertex + # numbering. + color_to_id = Dict( + label => id for (id, label) in enumerate(sort(unique(colour_labels))) + ) + colour = Vector{Int}(undef, n) + for v in 1:n + colour[v] = color_to_id[colour_labels[v]] + end + + refining_color_ids = Int[] + for c in S + label = Int(c) + if !haskey(color_to_id, label) + throw( + ArgumentError("Refining color set S must contain labels present in alpha") + ) + end + push!(refining_color_ids, color_to_id[label]) + end + + k = length(color_to_id) + + # `C[c]` stores color class `c` as a set, allowing each moved vertex to be removed + # in O(1) time. During a refinement round, `A[c]` stores the vertices in class `c` + # with nonzero color degree. `maxcdeg` and `mincdeg` track the color-degree range + # of each affected class. + C = [Set{Int}() for _ in 1:n] + A = [Vector{Int}() for _ in 1:n] + maxcdeg = zeros(Int, n) + mincdeg = zeros(Int, n) + + cdeg = zeros(Int, n) + + for v in 1:n + push!(C[colour[v]], v) + end + + # Initialize the stack of refining color classes. Sorting gives a canonical + # processing order, while deduplication treats `S` as a mathematical set. + S_sorted = sort!(unique!(refining_color_ids)) + Srefine = Vector{Int}() + in_stack = falses(n) + for c in S_sorted + push!(Srefine, c) + in_stack[c] = true + end + + # Buffers reused across iterations to avoid allocations. + Colorsadj = Vector{Int}() + in_Colorsadj = falses(n) + Colorssplit = Vector{Int}() + + numcdeg = zeros(Int, n + 1) + f = zeros(Int, n + 1) + + while !isempty(Srefine) + r = pop!(Srefine) + in_stack[r] = false + + # 1. For refining class `C[r]`, compute + # `cdeg[w] = |outneighbors(g, w) ∩ C[r]|`. Iterating over the + # in-neighbors of each vertex in `C[r]` counts exactly these outgoing + # edges. For undirected graphs, in- and out-neighbors coincide. + for v in C[r] + for w in inneighbors(g, v) + cdeg[w] += 1 + if cdeg[w] == 1 + push!(A[colour[w]], w) + end + + if !in_Colorsadj[colour[w]] + push!(Colorsadj, colour[w]) + in_Colorsadj[colour[w]] = true + end + + if cdeg[w] > maxcdeg[colour[w]] + maxcdeg[colour[w]] = cdeg[w] + end + end + end + + # 2. Find the color-degree range of every affected class. A class splits + # exactly when its minimum and maximum color degrees differ. + empty!(Colorssplit) + for c in Colorsadj + if length(C[c]) != length(A[c]) + mincdeg[c] = 0 + else + mincdeg[c] = maxcdeg[c] + for v in A[c] + if cdeg[v] < mincdeg[c] + mincdeg[c] = cdeg[v] + end + end + end + + if mincdeg[c] < maxcdeg[c] + push!(Colorssplit, c) + end + end + + sort!(Colorssplit) + + # 3. Split each affected class by color degree (Algorithm 3, + # SplitUpColour). + for s in Colorssplit + k = _split_up_colour!( + s, k, C, A, colour, cdeg, maxcdeg, mincdeg, numcdeg, f, Srefine, in_stack + ) + end + + # 4. Clear the state accumulated during this refinement round. + for c in Colorsadj + for v in A[c] + cdeg[v] = 0 + end + maxcdeg[c] = 0 + empty!(A[c]) + in_Colorsadj[c] = false + end + empty!(Colorsadj) + end + + return colour +end + +""" + canonical_color_refinement(g) + +Return the stable coloring of `g` using the unit coloring and refining color class 1. +""" +function canonical_color_refinement(g::AbstractGraph) + canonical_color_refinement(g, ones(Int, nv(g)), [1]) +end + +""" + canonical_color_refinement(g, alpha) + +Return the coarsest stable coloring that refines `alpha`, using every distinct +initial label as the refining color set. +""" +function canonical_color_refinement(g::AbstractGraph, alpha::AbstractVector{<:Integer}) + canonical_color_refinement(g, alpha, sort(unique(alpha))) +end + +""" + canonical_color_refinement(g, alpha, S) + +Refine `alpha` using the single refining color `S`. +""" +function canonical_color_refinement( + g::AbstractGraph, alpha::AbstractVector{<:Integer}, S::Integer +) + canonical_color_refinement(g, alpha, [S]) +end + +""" + canonical_color_refinement(g, S) + +Refine the unit coloring using the provided refining color. +""" +function canonical_color_refinement(g::AbstractGraph, S::Integer) + canonical_color_refinement(g, ones(Int, nv(g)), [S]) +end + +""" + color_refinement(g, alpha, S) + +Convenience alias for [`canonical_color_refinement`](@ref) that returns the same +stable coloring with a shorter name. +""" +function color_refinement( + g::AbstractGraph, alpha::AbstractVector{<:Integer}, S::AbstractVector{<:Integer} +)::Vector{Int} + return canonical_color_refinement(g, alpha, S) +end + +""" + color_refinement(g) + +Convenience wrapper that uses the unit coloring and refines color class 1. +""" +color_refinement(g::AbstractGraph) = color_refinement(g, ones(Int, nv(g)), [1]) + +""" + color_refinement(g, alpha) + +Convenience wrapper that uses every distinct label of `alpha` as the refining color +set. +""" +function color_refinement(g::AbstractGraph, alpha::AbstractVector{<:Integer}) + color_refinement(g, alpha, sort(unique(alpha))) +end + +""" + color_refinement(g, alpha, S) + +Convenience wrapper that accepts one refining color and builds the corresponding +one-element refining color set. +""" +function color_refinement(g::AbstractGraph, alpha::AbstractVector{<:Integer}, S::Integer) + color_refinement(g, alpha, [S]) +end + +""" + color_refinement(g, S) + +Convenience wrapper that uses the unit coloring and the provided refining color. +""" +color_refinement(g::AbstractGraph, S::Integer) = color_refinement(g, ones(Int, nv(g)), [S]) + +""" + _split_up_colour!(s, k, C, A, colour, cdeg, maxcdeg, mincdeg, numcdeg, f, Srefine, in_stack) + +Split color class `s` into one class for each distinct color degree (Algorithm 3, +SplitUpColour), updating the partition in place. If `s` is not already in the stack +of refining classes, its largest fragment is omitted from `Srefine`. This is +Hopcroft's smaller-half optimization: every added fragment then contains at most half +as many vertices as its parent, which yields the logarithmic complexity factor. + +`k` is the current number of colors; new color IDs are assigned as `k + 1, k + 2, …`. +Returns the updated color counter `k`. `numcdeg` and `f` are scratch buffers (length +`≥ maxcdeg[s] + 1`) owned by the caller and reused across calls to avoid allocations. +""" +function _split_up_colour!( + s::Int, + k::Int, + C::Vector{Set{Int}}, + A::Vector{Vector{Int}}, + colour::Vector{Int}, + cdeg::Vector{Int}, + maxcdeg::Vector{Int}, + mincdeg::Vector{Int}, + numcdeg::Vector{Int}, + f::Vector{Int}, + Srefine::Vector{Int}, + in_stack::AbstractVector{Bool}, +) + maxcdeg_s = maxcdeg[s] + + # Count the vertices at each color degree; index `i + 1` represents degree `i`. + for i in 1:maxcdeg_s + numcdeg[i + 1] = 0 + end + numcdeg[1] = length(C[s]) - length(A[s]) # vertices of color degree zero + + for v in A[s] + numcdeg[cdeg[v] + 1] += 1 + end + + # `b` is the smallest color degree whose fragment has maximum size. This + # deterministic tie-break is required for canonical color assignment. + b = 0 + for i in 1:maxcdeg_s + if numcdeg[i + 1] > numcdeg[b + 1] + b = i + end + end + + instack = in_stack[s] ? 1 : 0 + + # Assign an internal color ID `f[i + 1]` to each occurring color degree `i`. + for i in 0:maxcdeg_s + if numcdeg[i + 1] >= 1 + if i == mincdeg[s] + f[i + 1] = s + if instack == 0 && b != i + push!(Srefine, f[i + 1]) + in_stack[f[i + 1]] = true + end + else + k += 1 + f[i + 1] = k + if instack == 1 || i != b + push!(Srefine, f[i + 1]) + in_stack[f[i + 1]] = true + end + end + end + end + + # Move vertices to their new color classes. + for v in A[s] + target_color = f[cdeg[v] + 1] + if target_color != s + delete!(C[s], v) + push!(C[target_color], v) + colour[v] = target_color + end + end + + return k +end diff --git a/test/color_refinement.jl b/test/color_refinement.jl new file mode 100644 index 000000000..a80c43cac --- /dev/null +++ b/test/color_refinement.jl @@ -0,0 +1,264 @@ +# Brute-force reference implementation for undirected graphs. It repeatedly groups +# vertices by their current color and the multiset of their neighbors' colors until +# the partition is stable. +function _naive_stable_coloring(g::AbstractGraph, alpha::AbstractVector{<:Integer}) + n = nv(g) + colour = collect(alpha) + while true + sigs = [(colour[v], Tuple(sort([colour[w] for w in neighbors(g, v)]))) for v in 1:n] + seen = Dict{Tuple,Int}() + newcolour = Vector{Int}(undef, n) + for v in 1:n + newcolour[v] = get!(seen, sigs[v], length(seen) + 1) + end + newcolour == colour && return colour + colour = newcolour + end +end + +function _same_partition(a::AbstractVector, b::AbstractVector) + length(a) == length(b) || return false + return all((a[u] == a[v]) == (b[u] == b[v]) for u in eachindex(a), v in eachindex(a)) +end + +@testset "Color refinement" begin + @testset "Path graph refines to a palindrome" begin + # Endpoints (degree 1) split from the interior (degree 2), and the interior + # further splits by distance to the ends, giving 3 stable classes. + g = path_graph(5) + c = canonical_color_refinement(g, ones(Int, nv(g)), [1]) + @test c[1] == c[5] + @test c[2] == c[4] + @test length(unique(c)) == 3 + end + + @testset "Vertex-transitive graphs stay monochromatic" begin + # No vertex can be told apart from any other, so refinement leaves one class. + for g in (cycle_graph(6), complete_graph(5), cycle_graph(4)) + c = canonical_color_refinement(g, ones(Int, nv(g)), [1]) + @test length(unique(c)) == 1 + end + end + + @testset "Star graph separates center from leaves" begin + g = star_graph(5) + c = canonical_color_refinement(g, ones(Int, nv(g)), [1]) + @test length(unique(c)) == 2 + @test count(==(c[1]), c) == 1 # the center is in a class of its own + end + + @testset "Color numbers are canonical under vertex relabeling" begin + # A color-preserving isomorphism must preserve the exact output color number, + # not merely the sizes of the resulting color classes. + g1 = path_graph(6) + σ = [4, 1, 6, 2, 5, 3] + g2 = SimpleGraph(nv(g1)) + for e in edges(g1) + add_edge!(g2, σ[src(e)], σ[dst(e)]) + end + c1 = canonical_color_refinement(g1, ones(Int, nv(g1)), [1]) + c2 = canonical_color_refinement(g2, ones(Int, nv(g2)), [1]) + @test all(c1[v] == c2[σ[v]] for v in vertices(g1)) + + # A supplied initial coloring is vertex-indexed and must be transported by + # the same isomorphism. Its color labels, and therefore the refining set, + # remain unchanged. + alpha1 = [10, 10, 20, 20, 10, 20] + alpha2 = similar(alpha1) + for v in vertices(g1) + alpha2[σ[v]] = alpha1[v] + end + c1 = canonical_color_refinement(g1, alpha1, [10, 20]) + c2 = canonical_color_refinement(g2, alpha2, [10, 20]) + @test all(c1[v] == c2[σ[v]] for v in vertices(g1)) + end + + @testset "Directed path is fully distinguished" begin + # Each vertex's out-structure differs (the sink has out-degree 0, its + # predecessor's only out-neighbor is the sink, and so on down the chain), so + # every vertex ends up in a class of its own. + dg = path_digraph(4) + c = canonical_color_refinement(dg, ones(Int, nv(dg)), [1]) + @test length(unique(c)) == nv(dg) + end + + @testset "Digraphs refine only out-edge structure" begin + # Digraph refinement uses only outgoing-edge structure. Iterating over + # `inneighbors` counts each vertex's outgoing edges into the refining class; + # incoming-edge structure does not cause a split. Thus, vertices with equal + # out-neighbors but different in-neighbors remain indistinguishable. This is + # the paper's primary stability definition. + dg2 = SimpleDiGraph(4) + add_edge!(dg2, 1, 3) # 1 and 2 both point only to 3 ... + add_edge!(dg2, 2, 3) + add_edge!(dg2, 4, 1) # ... but 1 (unlike 2) also has an incoming edge from 4 + c = canonical_color_refinement(dg2, ones(Int, nv(dg2)), [1]) + @test c[1] == c[2] + end + + @testset "All public entry points agree" begin + # The `color_refinement` alias and every convenience overload should delegate + # to `canonical_color_refinement` with the default coloring and refining set. + g_wrapper = path_graph(5) + c_wrapper = canonical_color_refinement(g_wrapper, ones(Int, nv(g_wrapper)), [1]) + @test color_refinement(g_wrapper, ones(Int, nv(g_wrapper)), [1]) == c_wrapper + @test canonical_color_refinement(g_wrapper) == c_wrapper + @test canonical_color_refinement(g_wrapper, ones(Int, nv(g_wrapper))) == c_wrapper + @test canonical_color_refinement(g_wrapper, 1) == c_wrapper + @test color_refinement(g_wrapper) == c_wrapper + @test color_refinement(g_wrapper, ones(Int, nv(g_wrapper))) == c_wrapper + @test color_refinement(g_wrapper, 1) == c_wrapper + end + + @testset "Scalar refining color equals a one-element set" begin + # Verify delegation for a non-unit initial coloring and a scalar other than + # the default refining color 1. + alpha = [100, 100, 200, 200, 100] + @test canonical_color_refinement(path_graph(5), alpha, 100) == + canonical_color_refinement(path_graph(5), alpha, [100]) + @test color_refinement(path_graph(5), alpha, 100) == + color_refinement(path_graph(5), alpha, [100]) + end + + @testset "Empty graphs" begin + @test canonical_color_refinement(SimpleGraph(0), Int[], Int[]) == Int[] + # An empty graph has no color classes, so `S` is ignored and the empty + # coloring is returned. + @test canonical_color_refinement(SimpleGraph(0), Int[], [1]) == Int[] + @test canonical_color_refinement(SimpleGraph(0)) == Int[] + end + + @testset "Arbitrary ordered label values are accepted" begin + # Initial labels need not form a dense range. Replacing them by another + # order-preserving set of labels leaves their canonical dense numbering—and + # therefore the result—unchanged. + c = canonical_color_refinement(path_graph(5), [100, 100, 200, 200, 100], [100]) + c_alt = canonical_color_refinement(path_graph(5), [7, 7, 9, 9, 7], [7]) + @test c == c_alt + end + + @testset "Default refining set includes every initial color" begin + g = path_graph(5) + alpha = [10, 10, 20, 20, 10] + @test canonical_color_refinement(g, alpha) == + canonical_color_refinement(g, alpha, unique(alpha)) + @test color_refinement(g, alpha) == color_refinement(g, alpha, unique(alpha)) + end + + @testset "Duplicate refining colors are ignored" begin + g = path_graph(5) + alpha = ones(Int, nv(g)) + @test canonical_color_refinement(g, alpha, [1, 1]) == + canonical_color_refinement(g, alpha, [1]) + end + + @testset "Zero and negative labels" begin + # Vertex 2 has a neighbor in the class labeled 0, while vertex 3 does not; + # refinement therefore distinguishes all three vertices. Replacing label 0 + # with a negative label preserves the same ordered initial coloring. + c = canonical_color_refinement(path_graph(3), [0, 1, 1], [0]) + c_negative = canonical_color_refinement(path_graph(3), [-7, 1, 1], [-7]) + @test c[1] != c[2] + @test c[2] != c[3] + @test c[1] != c[3] + @test c_negative == c + end + + @testset "Rejects wrong-length alpha" begin + @test_throws ArgumentError canonical_color_refinement( + path_graph(3), ones(Int, 2), [1] + ) + end + + @testset "Rejects refining colors absent from alpha" begin + @test_throws ArgumentError canonical_color_refinement( + path_graph(3), ones(Int, 3), [2] + ) + end + + @testset "Empty refining set leaves the initial partition unchanged" begin + # No refinement is performed; arbitrary labels are only mapped to their + # deterministic dense order. + @test canonical_color_refinement(path_graph(4), [3, 1, 1, 3], Int[]) == [2, 1, 1, 2] + end + + @testset "Self-loops" begin + # A self-loop makes vertex 1 structurally unique here. + g_loop = SimpleGraph(3) + add_edge!(g_loop, 1, 1) + add_edge!(g_loop, 1, 2) + add_edge!(g_loop, 2, 3) + c = canonical_color_refinement(g_loop, ones(Int, 3), [1]) + @test length(unique(c)) == 3 + end + + @testset "Disconnected graphs" begin + # Isomorphic components land on identical colors (color refinement has no + # notion of "component id"), and an isolated vertex forms its own class. + g_disc = SimpleGraph(7) + add_edge!(g_disc, 1, 2) + add_edge!(g_disc, 2, 3) + add_edge!(g_disc, 4, 5) + add_edge!(g_disc, 5, 6) + c = canonical_color_refinement(g_disc, ones(Int, 7), [1]) + @test c[1:3] == c[4:6] + @test c[1] != c[2] # endpoint vs. middle of each path component + @test c[7] ∉ c[1:6] # the isolated vertex is in a class of its own + end + + @testset "Refinement is idempotent" begin + # Re-refining an already-stable coloring with all classes changes nothing. + g_idem = path_graph(5) + c = canonical_color_refinement(g_idem, ones(Int, nv(g_idem)), [1]) + @test canonical_color_refinement(g_idem, c, unique(c)) == c + end + + @testset "One refining color can be sufficient" begin + # For this graph, using only the smaller initial class produces the same + # stable partition as using every initial class. This is specific to the + # instance: omitting a class can otherwise stop refinement early. Callers + # requiring the coarsest stable coloring should use every label in `alpha`. + g_refine = path_graph(6) + alpha_refine = [1, 1, 1, 1, 2, 2] + c_small_set = canonical_color_refinement(g_refine, alpha_refine, [2]) + c_all_colors = canonical_color_refinement(g_refine, alpha_refine, [1, 2]) + @test c_small_set == c_all_colors + end + + @testset "Multiple refining colors act together" begin + # Using every initial class is always sufficient to reach the true stable + # partition (unlike an arbitrary strict subset, which is not guaranteed to + # stabilize the coloring fully). + g_multi = path_graph(7) + alpha_multi = [1, 1, 2, 2, 2, 3, 3] + c = canonical_color_refinement(g_multi, alpha_multi, [1, 2, 3]) + @test length(unique(c)) == + length(unique(_naive_stable_coloring(g_multi, alpha_multi))) + end + + @testset "Complete bipartite graph" begin + # The two sides are distinguished by size, while vertices on the same side + # remain indistinguishable. + g_bip = complete_bipartite_graph(2, 3) + c = canonical_color_refinement(g_bip, ones(Int, nv(g_bip)), [1]) + @test c[1] == c[2] + @test all(==(c[3]), c[3:5]) + @test c[1] != c[3] + end + + @testset "Random graphs match brute-force fixed point" begin + # Compare against an independent brute-force fixed-point computation on + # random undirected graphs. Using every initial class guarantees full + # stabilization. + rng = MersenneTwister(20260705) + for _ in 1:30 + n = rand(rng, 3:10) + p = rand(rng, (0.1, 0.3, 0.5, 0.7)) + g_rand = erdos_renyi(n, p; rng=rng) + alpha_rand = rand(rng, 1:rand(rng, 1:min(4, n)), n) + expected = _naive_stable_coloring(g_rand, alpha_rand) + actual = canonical_color_refinement(g_rand, alpha_rand, unique(alpha_rand)) + @test _same_partition(actual, expected) + end + end +end diff --git a/test/runtests.jl b/test/runtests.jl index d5da00643..8592d4b9a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -108,6 +108,7 @@ tests = [ "traversals/bfs", "traversals/bipartition", "traversals/greedy_color", + "color_refinement", "traversals/dfs", "traversals/maxadjvisit", "traversals/randomwalks",