diff --git a/Project.toml b/Project.toml index e85f74f..9d29e62 100644 --- a/Project.toml +++ b/Project.toml @@ -8,8 +8,15 @@ CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d" +[weakdeps] +GraphsMatching = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" + +[extensions] +NetworkXGraphsGraphsMatchingExt = "GraphsMatching" + [compat] CondaPkg = "0.2" Graphs = "1" +GraphsMatching = "0.2" PythonCall = "0.9" julia = "1.10" diff --git a/README.md b/README.md index f0b460c..f199a10 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,5 @@ nv(gw2) == nv(g) # true ## Notes -- No graph algorithms are implemented in this package at this stage. +- Optional package extensions expose NetworkX-backed algorithms without adding hard dependencies to the base package. +- With `GraphsMatching.jl` installed, `minimum_weight_perfect_matching(g, weights, NXAlgorithm())` dispatches to NetworkX for exact integer-weight matching; floating-point weights follow GraphsMatching's existing integer-rescaling behavior before calling the backend. diff --git a/ext/NetworkXGraphsGraphsMatchingExt.jl b/ext/NetworkXGraphsGraphsMatchingExt.jl new file mode 100644 index 0000000..9e5f153 --- /dev/null +++ b/ext/NetworkXGraphsGraphsMatchingExt.jl @@ -0,0 +1,187 @@ +module NetworkXGraphsGraphsMatchingExt + +using NetworkXGraphs +using PythonCall: pybuiltins, pyconvert +using Graphs: Graphs +using GraphsMatching: GraphsMatching + +const _NX_WEIGHT_KEY = "weight" + +_label_for_vertex(g::NetworkXGraphs.AbstractNetworkXGraph, v::Integer) = g.nodes[Int(v)] +_label_for_vertex(::Graphs.AbstractGraph, v::Integer) = Int(v) + +function _index_for_label(g::NetworkXGraphs.AbstractNetworkXGraph, label) + Int(g.node_to_index[label]) +end +_index_for_label(::Graphs.AbstractGraph, label) = Int(label) + +function _lookup_weight( + w::Dict{E,U}, ::Type{E}, i::Integer, j::Integer +) where {E<:Graphs.AbstractEdge,U} + return get(w, E(i, j), get(w, E(j, i), zero(U))) +end + +function _matching_weighted_edges( + g::Graphs.AbstractGraph, w::Dict{E,U} +) where {E<:Graphs.AbstractEdge,U<:Real} + weighted = Tuple{Any,Any,U}[] + keep = Set{Tuple{Any,Any}}() + for (e, weight) in w + src = Graphs.src(e) + dst = Graphs.dst(e) + Graphs.has_edge(g, src, dst) || continue + ulab = _label_for_vertex(g, src) + vlab = _label_for_vertex(g, dst) + push!(weighted, (ulab, vlab, weight)) + push!(keep, (ulab, vlab)) + push!(keep, (vlab, ulab)) + end + return weighted, keep +end + +function _networkx_matching_graph( + g::NetworkXGraphs.AbstractNetworkXGraph, w::Dict{E,U} +) where {E<:Graphs.AbstractEdge,U<:Real} + pyg = g.pygraph.copy() + weighted, keep = _matching_weighted_edges(g, w) + existing = pyconvert(Vector{Tuple{Any,Any}}, pybuiltins.list(pyg.edges())) + remove = Tuple{Any,Any}[(u, v) for (u, v) in existing if (u, v) ∉ keep] + isempty(remove) || pyg.remove_edges_from(remove) + isempty(weighted) || pyg.add_weighted_edges_from(weighted) + return pyg +end + +function _networkx_matching_graph( + g::Graphs.AbstractGraph, w::Dict{E,U} +) where {E<:Graphs.AbstractEdge,U<:Real} + pyg = NetworkXGraphs.networkx_graph(g) + weighted, keep = _matching_weighted_edges(g, w) + existing = pyconvert(Vector{Tuple{Any,Any}}, pybuiltins.list(pyg.edges())) + remove = Tuple{Any,Any}[(u, v) for (u, v) in existing if (u, v) ∉ keep] + isempty(remove) || pyg.remove_edges_from(remove) + isempty(weighted) || pyg.add_weighted_edges_from(weighted) + return pyg +end + +function _matching_result( + g::Graphs.AbstractGraph, w::Dict{E,U}, pyg +) where {E<:Graphs.AbstractEdge,U<:Integer} + nx = NetworkXGraphs.PythonNetworkX.networkx + pymatching = nx.algorithms.matching.min_weight_matching(pyg; weight=_NX_WEIGHT_KEY) + pyconvert(Bool, nx.algorithms.matching.is_perfect_matching(pyg, pymatching)) || throw( + ErrorException( + "NetworkX's minimum-weight matching backend did not produce a perfect matching for this graph.", + ), + ) + + mate = fill(-1, Graphs.nv(g)) + weight = zero(U) + for (ulab, vlab) in pyconvert(Vector{Tuple{Any,Any}}, pybuiltins.list(pymatching)) + i = _index_for_label(g, ulab) + j = _index_for_label(g, vlab) + mate[i] = j + mate[j] = i + weight += _lookup_weight(w, E, i, j) + end + return GraphsMatching.MatchingResult(weight, mate) +end + +function _minimum_weight_perfect_matching( + g::Graphs.AbstractGraph, w::Dict{E,U} +) where {E<:Graphs.AbstractEdge,U<:Integer} + Graphs.is_directed(g) && throw( + ArgumentError( + "`NXAlgorithm()` only supports undirected graphs for minimum-weight perfect matching.", + ), + ) + return _matching_result(g, w, _networkx_matching_graph(g, w)) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.AbstractGraph, + w::Dict{E,U}, + cutoff::Real, + algorithm::NetworkXGraphs.NXAlgorithm=NetworkXGraphs.NXAlgorithm(); + kws..., +) where {E<:Graphs.AbstractEdge,U<:Real} + wnew = Dict{E,U}() + for (e, c) in w + c <= cutoff || continue + wnew[e] = c + end + return GraphsMatching.minimum_weight_perfect_matching(g, wnew, algorithm; kws...) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.AbstractGraph, + w::Dict{E,U}, + algorithm::NetworkXGraphs.NXAlgorithm=NetworkXGraphs.NXAlgorithm(); + tmaxscale=10.0, +) where {E<:Graphs.AbstractEdge,U<:AbstractFloat} + wnew = Dict{E,Int32}() + cmax = maximum(values(w)) + cmin = minimum(values(w)) + tmax = typemax(Int32) / tmaxscale + for (e, c) in w + wnew[e] = round(Int32, (c - cmin) / max(cmax - cmin, 1) * tmax) + end + match = GraphsMatching.minimum_weight_perfect_matching(g, wnew, algorithm) + weight = zero(U) + for i in 1:Graphs.nv(g) + j = match.mate[i] + if j > i + weight += _lookup_weight(w, E, i, j) + end + end + return GraphsMatching.MatchingResult(weight, match.mate) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.AbstractGraph, w::Dict{E,U}, ::NetworkXGraphs.NXAlgorithm +) where {E<:Graphs.AbstractEdge,U<:Integer} + return _minimum_weight_perfect_matching(g, w) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.SimpleGraph, w::Dict{E,U}, algorithm::NetworkXGraphs.NXAlgorithm +) where {E<:Graphs.AbstractEdge,U<:Integer} + return _minimum_weight_perfect_matching(g, w) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.SimpleGraph, + w::Dict{E,U}, + cutoff::Real, + algorithm::NetworkXGraphs.NXAlgorithm=NetworkXGraphs.NXAlgorithm(); + kws..., +) where {E<:Graphs.AbstractEdge,U<:Real} + return GraphsMatching.minimum_weight_perfect_matching( + g, Dict{E,U}(e => c for (e, c) in w if c <= cutoff), algorithm; kws... + ) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::Graphs.SimpleGraph, + w::Dict{E,U}, + algorithm::NetworkXGraphs.NXAlgorithm=NetworkXGraphs.NXAlgorithm(); + tmaxscale=10.0, +) where {E<:Graphs.AbstractEdge,U<:AbstractFloat} + wnew = Dict{E,Int32}() + cmax = maximum(values(w)) + cmin = minimum(values(w)) + tmax = typemax(Int32) / tmaxscale + for (e, c) in w + wnew[e] = round(Int32, (c - cmin) / max(cmax - cmin, 1) * tmax) + end + match = GraphsMatching.minimum_weight_perfect_matching(g, wnew, algorithm) + weight = zero(U) + for i in 1:Graphs.nv(g) + j = match.mate[i] + if j > i + weight += _lookup_weight(w, E, i, j) + end + end + return GraphsMatching.MatchingResult(weight, match.mate) +end + +end # module diff --git a/src/NetworkXGraphs.jl b/src/NetworkXGraphs.jl index d5aa4e1..aff365b 100644 --- a/src/NetworkXGraphs.jl +++ b/src/NetworkXGraphs.jl @@ -1,18 +1,15 @@ module NetworkXGraphs -import Graphs +using Graphs: Graphs using PythonCall: Py, pybuiltins, pyconvert export AbstractNetworkXGraph, - NetworkXGraph, - NetworkXDiGraph, - networkx_graph, - refresh_index! + NetworkXGraph, NetworkXDiGraph, NXAlgorithm, networkx_graph, refresh_index! include("python_networkx.jl") include("types.jl") +include("algorithms.jl") include("graph_api.jl") include("conversions.jl") end # module NetworkXGraphs - diff --git a/src/algorithms.jl b/src/algorithms.jl new file mode 100644 index 0000000..0547c21 --- /dev/null +++ b/src/algorithms.jl @@ -0,0 +1,7 @@ +""" + NXAlgorithm() + +Marker algorithm type for dispatching supported graph algorithms to the Python +`networkx` backend through package extensions. +""" +struct NXAlgorithm end diff --git a/test/Project.toml b/test/Project.toml index a5ee03a..6dba79b 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,6 +2,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" +GraphsMatching = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" GraphsInterfaceChecker = "3bef136c-15ff-4091-acbb-1a4aafe67608" Interfaces = "85a1e053-f937-4924-92a5-1367d23b7b87" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" diff --git a/test/runtests.jl b/test/runtests.jl index 8a905a5..9aeefc9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,6 +4,7 @@ using Base.Threads: nthreads using Aqua using ExplicitImports using Graphs +using GraphsMatching using GraphsInterfaceChecker using Interfaces if isempty(VERSION.prerelease) @@ -64,13 +65,14 @@ using PythonCall @test has_edge(gw2, 2, 3) end - @testset "Threaded isolation" begin if nthreads() > 1 results = fill(false, 2 * nthreads()) PythonCall.GIL.@unlock Threads.@threads for i in eachindex(results) pyg = PythonCall.GIL.@lock nx.Graph() - PythonCall.GIL.@lock pyg.add_edges_from([(1, 2), (2, 3), (3, 4), (4, 5), (5, 5 + i)]) + PythonCall.GIL.@lock pyg.add_edges_from([ + (1, 2), (2, 3), (3, 4), (4, 5), (5, 5 + i) + ]) gw = PythonCall.GIL.@lock NetworkXGraph(pyg) ok = gw isa NetworkXGraph ok &= PythonCall.GIL.@lock nv(gw) == 6 @@ -138,4 +140,54 @@ using PythonCall @test !add_edge!(gw, 1, 2) @test ne(gw) == 2 end + + @testset "GraphsMatching extension" begin + @test Base.get_extension(NetworkXGraphs, :NetworkXGraphsGraphsMatchingExt) !== + nothing + + g = complete_graph(4) + w = Dict( + Edge(1, 2) => 500, + Edge(1, 3) => 400, + Edge(1, 4) => 900, + Edge(2, 3) => 900, + Edge(2, 4) => 1000, + Edge(3, 4) => 1000, + ) + match = minimum_weight_perfect_matching(g, w, NXAlgorithm()) + @test match isa MatchingResult{Int} + @test match.mate == [3, 4, 1, 2] + @test match.weight == 1400 + @test match.mate isa Vector{Int} + + g_float = complete_graph(4) + w_float = Dict{Edge,Float64}() + w_float[Edge(1, 3)] = 10.0 + w_float[Edge(1, 4)] = 0.5 + w_float[Edge(2, 3)] = 11.0 + w_float[Edge(2, 4)] = 2.0 + w_float[Edge(1, 2)] = 100.0 + match_float = minimum_weight_perfect_matching(g_float, w_float, 50, NXAlgorithm()) + @test match_float isa MatchingResult{Float64} + @test match_float.mate == [4, 3, 2, 1] + @test match_float.weight ≈ 11.5 + + pyg = nx.Graph() + pyg.add_edges_from([(10, 20), (10, 30), (10, 40), (20, 30), (20, 40), (30, 40)]) + wrapped = NetworkXGraph(pyg) + w_wrapped = Dict( + Edge(1, 2) => 9, + Edge(1, 3) => 9, + Edge(1, 4) => 1, + Edge(2, 3) => 2, + Edge(2, 4) => 9, + Edge(3, 4) => 9, + ) + match_wrapped = minimum_weight_perfect_matching(wrapped, w_wrapped, NXAlgorithm()) + @test match_wrapped isa MatchingResult{Int} + @test match_wrapped.mate == [4, 3, 2, 1] + @test match_wrapped.weight == 3 + @test pyconvert(Int, pyg.number_of_edges()) == 6 + @test isempty(pyconvert(Dict{String,Any}, pyg.get_edge_data(10, 20))) + end end