Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
187 changes: 187 additions & 0 deletions ext/NetworkXGraphsGraphsMatchingExt.jl
Original file line number Diff line number Diff line change
@@ -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
9 changes: 3 additions & 6 deletions src/NetworkXGraphs.jl
Original file line number Diff line number Diff line change
@@ -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

7 changes: 7 additions & 0 deletions src/algorithms.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
NXAlgorithm()

Marker algorithm type for dispatching supported graph algorithms to the Python
`networkx` backend through package extensions.
"""
struct NXAlgorithm end
1 change: 1 addition & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 54 additions & 2 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ using Base.Threads: nthreads
using Aqua
using ExplicitImports
using Graphs
using GraphsMatching
using GraphsInterfaceChecker
using Interfaces
if isempty(VERSION.prerelease)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading