diff --git a/.github/changelog-enforcer.yml b/.github/changelog-enforcer.yml new file mode 100644 index 0000000..b7a2038 --- /dev/null +++ b/.github/changelog-enforcer.yml @@ -0,0 +1,15 @@ +name: "Changelog Enforcer" +on: + pull_request: + # The specific activity types are listed here to include "labeled" and "unlabeled" + # (which are not included by default for the "pull_request" trigger). + # This is needed to allow skipping enforcement of the changelog in PRs with specific labels, + # as defined in the (optional) "skipLabels" property. + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +jobs: + # Enforces the update of a changelog file on every pull request + changelog: + runs-on: ubuntu-latest + steps: + - uses: dangoslen/changelog-enforcer@v3 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fabc11e..32987ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ jobs: - windows-latest arch: - x64 + include: + - arch: aarch64 + os: macos-latest + version: '1' + threads: '1' steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c62ce26 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# News + +## 0.2.1 - 2025-07-05 + +- Introduce LEMONGraphs as an option for running `minimum_weight_perfect_matching` and make it default. +- Move the BlossomV dispatch of `minimum_weight_perfect_matching` to a package extension as an optional dependency, thus making it possible to install GraphsMatching on systems where BlossomV does not install. + +## pre 0.2.0 + +Not tracked \ No newline at end of file diff --git a/Project.toml b/Project.toml index 5783ce8..5a35952 100644 --- a/Project.toml +++ b/Project.toml @@ -1,24 +1,31 @@ name = "GraphsMatching" uuid = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" authors = ["JuliaGraphs"] -version = "0.2.0" +version = "0.2.1" [deps] -BlossomV = "6c721016-9dae-5d90-abf6-67daaccb2332" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" Hungarian = "e91730f6-4275-51fb-a7a0-7064cfbd3b39" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +LEMONGraphs = "14b1564f-c77f-4800-9e89-efd961faef7c" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +[weakdeps] +BlossomV = "6c721016-9dae-5d90-abf6-67daaccb2332" + +[extensions] +GraphsMatchingBlossomVExt = "BlossomV" + [compat] BlossomV = "0.4" Graphs = "1.4, 1.7" Hungarian = "0.4, 0.6" JuMP = "1" +LEMONGraphs = "0.1.0" MathOptInterface = "1" -julia = "1" +julia = "1.10" [extras] Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" diff --git a/ext/GraphsMatchingBlossomVExt/GraphsMatchingBlossomVExt.jl b/ext/GraphsMatchingBlossomVExt/GraphsMatchingBlossomVExt.jl new file mode 100644 index 0000000..bd172cb --- /dev/null +++ b/ext/GraphsMatchingBlossomVExt/GraphsMatchingBlossomVExt.jl @@ -0,0 +1,26 @@ +module GraphsMatchingBlossomVExt + +using Graphs +using GraphsMatching +import BlossomV + +function GraphsMatching.minimum_weight_perfect_matching(g::Graph, w::Dict{E,U}, ::BlossomVAlgorithm) where {U<:Integer,E<:Edge} + m = BlossomV.Matching(nv(g)) + for (e, c) in w + BlossomV.add_edge(m, src(e) - 1, dst(e) - 1, c) + end + BlossomV.solve(m) + + mate = fill(-1, nv(g)) + totweight = zero(U) + for i in 1:nv(g) + j = BlossomV.get_match(m, i - 1) + 1 + mate[i] = j <= 0 ? -1 : j + if i < j + totweight += w[Edge(i, j)] + end + end + return MatchingResult(totweight, mate) +end + +end diff --git a/src/GraphsMatching.jl b/src/GraphsMatching.jl index 4001b9c..4182da5 100644 --- a/src/GraphsMatching.jl +++ b/src/GraphsMatching.jl @@ -7,10 +7,28 @@ using SparseArrays: spzeros using JuMP using MathOptInterface const MOI = MathOptInterface -import BlossomV # 'using BlossomV' leads to naming conflicts with JuMP +import LEMONGraphs using Hungarian -export MatchingResult, maximum_weight_matching, maximum_weight_matching_reduction, maximum_weight_maximal_matching, minimum_weight_perfect_matching, HungarianAlgorithm, LPAlgorithm +export MatchingResult, + maximum_weight_matching, + maximum_weight_matching_reduction, + maximum_weight_maximal_matching, + minimum_weight_perfect_matching, + HungarianAlgorithm, + LPAlgorithm, + BlossomVAlgorithm, + LEMONMWPMAlgorithm + +function __init__() + if isdefined(Base.Experimental, :register_error_hint) + Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs + if BlossomVAlgorithm in argtypes + print(io,"""\nPlease first import `BlossomV` to make BlossomV-based matching available.""") + end + end + end +end """ struct MatchingResult{U} @@ -32,7 +50,7 @@ end include("lp.jl") include("maximum_weight_matching.jl") -include("blossomv.jl") +include("minimum_weight_perfect_matching.jl") include("hungarian.jl") include("maximum_weight_maximal_matching.jl") diff --git a/src/blossomv.jl b/src/blossomv.jl deleted file mode 100644 index af56705..0000000 --- a/src/blossomv.jl +++ /dev/null @@ -1,75 +0,0 @@ -""" -minimum_weight_perfect_matching(g, w::Dict{Edge,Real}) -minimum_weight_perfect_matching(g, w::Dict{Edge,Real}, cutoff) - -Given a graph `g` and an edgemap `w` containing weights associated to edges, -returns a matching with the mimimum total weight among the ones containing -exactly `nv(g)/2` edges. - -Edges in `g` not present in `w` will not be considered for the matching. - -This function relies on the BlossomV.jl package, a julia wrapper -around Kolmogorov's BlossomV algorithm. - -Eventually a `cutoff` argument can be given, to the reduce computational time -excluding edges with weights higher than the cutoff. - -The returned object is of type `MatchingResult`. - -In case of error try to change the optional argument `tmaxscale` (default is `tmaxscale=10`). -""" -function minimum_weight_perfect_matching end - -function minimum_weight_perfect_matching( - g::Graph, w::Dict{E,U}, cutoff, kws... -) where {U<:Real,E<:Edge} - wnew = Dict{E,U}() - for (e, c) in w - if c <= cutoff - wnew[e] = c - end - end - return minimum_weight_perfect_matching(g, wnew; kws...) -end - -function minimum_weight_perfect_matching( - g::Graph, w::Dict{E,U}; tmaxscale=10.0 -) where {U<:AbstractFloat,E<:Edge} - wnew = Dict{E,Int32}() - cmax = maximum(values(w)) - cmin = minimum(values(w)) - - tmax = typemax(Int32) / tmaxscale # /10 is kinda arbitrary, - # hopefully high enough to not occur in overflow problems - for (e, c) in w - wnew[e] = round(Int32, (c - cmin) / max(cmax - cmin, 1) * tmax) - end - match = minimum_weight_perfect_matching(g, wnew) - weight = zero(U) - for i in 1:nv(g) - j = match.mate[i] - if j > i - weight += w[E(i, j)] - end - end - return MatchingResult(weight, match.mate) -end - -function minimum_weight_perfect_matching(g::Graph, w::Dict{E,U}) where {U<:Integer,E<:Edge} - m = BlossomV.Matching(nv(g)) - for (e, c) in w - BlossomV.add_edge(m, src(e) - 1, dst(e) - 1, c) - end - BlossomV.solve(m) - - mate = fill(-1, nv(g)) - totweight = zero(U) - for i in 1:nv(g) - j = BlossomV.get_match(m, i - 1) + 1 - mate[i] = j <= 0 ? -1 : j - if i < j - totweight += w[Edge(i, j)] - end - end - return MatchingResult(totweight, mate) -end diff --git a/src/minimum_weight_perfect_matching.jl b/src/minimum_weight_perfect_matching.jl new file mode 100644 index 0000000..2a5d411 --- /dev/null +++ b/src/minimum_weight_perfect_matching.jl @@ -0,0 +1,119 @@ +""" + minimum_weight_perfect_matching(g, w::Dict{Edge,Real}; tmaxscale) + minimum_weight_perfect_matching(g, w::Dict{Edge,Real}, cutoff; tmaxscale) + minimum_weight_perfect_matching(g, w::Dict{Edge,Real}, algorithm::AbstractMinimumWeightPerfectMatchingAlgorithm; tmaxscale) + minimum_weight_perfect_matching(g, w::Dict{Edge,Real}, cutoff, algorithm::AbstractMinimumWeightPerfectMatchingAlgorithm; tmaxscale) + +Given a graph `g` and an edgemap `w` containing weights associated to edges, +returns a matching with the mimimum total weight among the ones containing +exactly `nv(g)/2` edges. + +Edges in `g` not present in `w` will not be considered for the matching. + +You can use the `algorithm` argument to specify the algorithm to use. + +A `cutoff` argument can be given, to reduce the computational time by +excluding edges with weights higher than the cutoff (effective only for some algorithms, +not for the default `LEMONMWPMAlgorithm`). + +When the weights are non-integer types, the keyword argument `tmaxscale` can be used to +scale the weights to integer values. +In case of error try to change `tmaxscale` (default is `tmaxscale=10`). +The scaling is as follows: +``` +tmax = typemax(Int32) / tmaxscale +weight = round(Int32, (weight-minimum_weight) / max(maximum_weight-minimum_weight, 1) * tmax) +``` + +The returned object is of type [`MatchingResult`](@ref). + +See also: [`BlossomVAlgorithm`](@ref) +""" +function minimum_weight_perfect_matching end + +""" + AbstractMinimumWeightPerfectMatchingAlgorithm + +Abstract type that allows users to pass in their preferred algorithm + +See also: [`minimum_weight_perfect_matching`](@ref), [`BlossomVAlgorithm`](@ref) +""" +abstract type AbstractMinimumWeightPerfectMatchingAlgorithm end + +""" + BlossomVAlgorithm() + +Use the BlossomV algorithm to find the minimum weight perfect matching. +Depends on the BlossomV.jl package. You have to call `using BlossomV` +before using this algorithm. + +This algorithm dispatches to the BlossomV library, a C library for finding +minimum weight perfect matchings in general graphs. +The BlossomV library is not open source, and thus we can not distribute a +a precompiled binary with GraphsMatching.jl. We attempt to build it on +installation, but unlike typical Julia packages, the build process is prone +to failure if you do not have all the dependencies installed. +If BlossomV.jl does not work on your system, +consider using the LEMONGraphs.jl algorithm instead (the default algorithm), +which we distribute precompiled on all platforms. + +See also: [`minimum_weight_perfect_matching`](@ref), [`LEMONMWPMAlgorithm`](@ref) +""" +struct BlossomVAlgorithm <: AbstractMinimumWeightPerfectMatchingAlgorithm end + +""" + LEMONMWPMAlgorithm() + +Use the LEMON C++ implementation of minimum weight perfect matching. + +See also: [`minimum_weight_perfect_matching`](@ref), [`BlossomVAlgorithm`](@ref) +""" +struct LEMONMWPMAlgorithm <: AbstractMinimumWeightPerfectMatchingAlgorithm end + +function minimum_weight_perfect_matching( + g::Graph, w::Dict{E,U} +) where {U<:Integer,E<:Edge} + return minimum_weight_perfect_matching(g, w, LEMONMWPMAlgorithm()) +end + +function minimum_weight_perfect_matching( + g::Graph, w::Dict{E,U}, cutoff::Real, algorithm::AbstractMinimumWeightPerfectMatchingAlgorithm=LEMONMWPMAlgorithm(); kws... +) where {U<:Real,E<:Edge} + wnew = Dict{E,U}() + for (e, c) in w + if c <= cutoff + wnew[e] = c + end + end + return minimum_weight_perfect_matching(g, wnew, algorithm; kws...) +end + +function minimum_weight_perfect_matching( + g::Graph, w::Dict{E,U}, algorithm::AbstractMinimumWeightPerfectMatchingAlgorithm=LEMONMWPMAlgorithm(); tmaxscale=10.0 +) where {U<:AbstractFloat,E<:Edge} + wnew = Dict{E,Int32}() + cmax = maximum(values(w)) + cmin = minimum(values(w)) + + tmax = typemax(Int32) / tmaxscale # /10 is kinda arbitrary, + # hopefully high enough to not occur in overflow problems + for (e, c) in w + wnew[e] = round(Int32, (c - cmin) / max(cmax - cmin, 1) * tmax) + end + match = minimum_weight_perfect_matching(g, wnew, algorithm) + weight = zero(U) + for i in 1:nv(g) + j = match.mate[i] + if j > i + weight += get(w, E(i, j), zero(U)) + end + end + return MatchingResult(weight, match.mate) +end + +function minimum_weight_perfect_matching(g::Graph, w::Dict{E,U}, ::LEMONMWPMAlgorithm) where {U<:Integer,E<:Edge} + max = 2*abs(maximum(values(w))) + weights = [-get(w, e, max) for e in edges(g)] + totweight, mate = LEMONGraphs.maxweightedperfectmatching(g, weights) + return MatchingResult(-totweight, mate) +end diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 0000000..da4b9f3 --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,11 @@ +[deps] +Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" +Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" +Hungarian = "e91730f6-4275-51fb-a7a0-7064cfbd3b39" +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +LEMONGraphs = "14b1564f-c77f-4800-9e89-efd961faef7c" +MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/runtests.jl b/test/runtests.jl index e0767fa..cd0df89 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,6 +5,12 @@ using Cbc using JuMP using LinearAlgebra: I +if !Sys.iswindows() && Sys.ARCH == :x86_64 + using Pkg + Pkg.add("BlossomV") + import BlossomV # to test the extension +end + @testset "GraphsMatching" begin @testset "maximum_weight_matching_reduction" begin @@ -259,11 +265,16 @@ using LinearAlgebra: I @testset "minimum_weight_perfect_matching" begin + algos = Any[LEMONMWPMAlgorithm()] + if !Sys.iswindows() && Sys.ARCH == :x86_64 + push!(algos, BlossomVAlgorithm()) + end + for algorithm in algos w = Dict(Edge(1, 2) => 500) g = Graph(2) add_edge!(g, 1, 2) - match = minimum_weight_perfect_matching(g, w) + match = minimum_weight_perfect_matching(g, w, algorithm) @test match.mate[1] == 2 @@ -276,7 +287,7 @@ using LinearAlgebra: I ) g = complete_graph(4) - match = minimum_weight_perfect_matching(g, w) + match = minimum_weight_perfect_matching(g, w, algorithm) @test match.mate[1] == 2 @test match.mate[2] == 1 @test match.mate[3] == 4 @@ -291,7 +302,7 @@ using LinearAlgebra: I Edge(2, 4) => 1000, ) g = complete_graph(4) - match = minimum_weight_perfect_matching(g, w) + match = minimum_weight_perfect_matching(g, w, algorithm) @test match.mate[1] == 3 @test match.mate[2] == 4 @test match.mate[3] == 1 @@ -305,7 +316,7 @@ using LinearAlgebra: I w[Edge(2, 3)] = -11 w[Edge(2, 4)] = -1 - match = minimum_weight_perfect_matching(g, w) + match = minimum_weight_perfect_matching(g, w, algorithm) @test match.mate[1] == 4 @test match.mate[4] == 1 @test match.mate[2] == 3 @@ -321,7 +332,7 @@ using LinearAlgebra: I w[Edge(2, 4)] = 2 w[Edge(1, 2)] = 100 - match = minimum_weight_perfect_matching(g, w, 50) + match = minimum_weight_perfect_matching(g, w, 50, algorithm) @test match.mate[1] == 4 @test match.mate[4] == 1 @test match.mate[2] == 3 @@ -338,9 +349,11 @@ using LinearAlgebra: I g = Graph([Edge(1, 2)]) wFloat = Dict(Edge(1, 2) => 2.0) wInt = Dict(Edge(1, 2) => 2) - matchFloat = minimum_weight_perfect_matching(g, wFloat) - matchInt = minimum_weight_perfect_matching(g, wInt) + matchFloat = minimum_weight_perfect_matching(g, wFloat, algorithm) + matchInt = minimum_weight_perfect_matching(g, wInt, algorithm) @test matchFloat.mate == matchInt.mate @test matchFloat.weight == matchInt.weight + + end end end