From 66c66dccd647fd13c4a5358d9544493c57958e55 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 01/12] Add the LEMONGraph and LEMONDiGraph wrapper types and the LEMON conversions --- src/LEMONGraphs.jl | 214 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 195 insertions(+), 19 deletions(-) diff --git a/src/LEMONGraphs.jl b/src/LEMONGraphs.jl index 808b2cc..e1cb62e 100644 --- a/src/LEMONGraphs.jl +++ b/src/LEMONGraphs.jl @@ -1,8 +1,14 @@ module LEMONGraphs import Graphs -import Graphs: Graph, Edge, vertices, edges, nv, ne +import Graphs: + Graph, DiGraph, Edge, vertices, edges, nv, ne, src, dst, + has_vertex, has_edge, inneighbors, outneighbors, is_directed, + edgetype, AbstractGraph +using CxxWrap + +# CxxWrap binding module module Lib using CxxWrap import LEMON_jll @@ -12,35 +18,205 @@ module Lib @initcxx end + # Convenience helpers for node/edge ID extraction id(n::ListGraphNodeIt) = id(convert(ListGraphNode, n)) id(n::ListGraphEdgeIt) = id(convert(ListGraphEdge, n)) id(n::ListDigraphNodeIt) = id(convert(ListDigraphNode, n)) - # not defined in the c++ wrapper - #id(n::ListDigraphArcIt) = id(convert(ListDigraphArc, n)) end -function toListGraph(sourcegraph::Graph) - g = Lib.ListGraph() - ns = [Lib.addNode(g) for i in vertices(sourcegraph)] - es = [Lib.addEdge(g,ns[src],ns[dst]) for (;src, dst) in edges(sourcegraph)] - return (g,ns,es) +""" + LEMONGraphs.CxxInt + +Julia counterpart of the C++ `int` used by the LEMON maps that are instantiated +for `int` (`Dijkstra`, `MaxWeightedPerfectMatching`). +""" +const CxxInt = Cint + +""" + LEMONGraphs.CxxLong + +Julia counterpart of the C++ `long long` used by the LEMON minimum cost flow +solvers, which are instantiated for 64-bit flow and cost values. +""" +const CxxLong = Clonglong + +export LEMONGraph, LEMONDiGraph, LEMONAlgorithm, maxweightedperfectmatching + +""" + LEMONGraph{T,G,N,E} <: AbstractGraph{Int} + +Wrapper around LEMON's `ListGraph` C++ type providing a Julia interface +conforming to the Graphs.jl `AbstractGraph` API. + +The wrapper caches the endpoints of every LEMON edge and an adjacency list on +the Julia side, so that the Graphs.jl API can be served without crossing the +C++ boundary. Construction is `O(nv + ne)`; every subsequent query is as fast +as the equivalent `SimpleGraph` query. + +`LEMONGraph(g::AbstractGraph)` wraps an existing graph and `LEMONGraph(n)` +builds an edgeless one on `n` vertices. `add_vertex!` and `add_edge!` grow the +graph; `rem_vertex!` and `rem_edge!` return `false`, because `LEMON_jll` does +not expose `ListGraph::erase`. +""" +struct LEMONGraph{T,G,N,E} <: AbstractGraph{Int} + graph::G + nodes::N + edges::E + edge_src::Vector{Int} + edge_dst::Vector{Int} + adjacency::Vector{Vector{Int}} + + function LEMONGraph(g, ns, es) + us = Vector{Int}(undef, length(es)) + vs = Vector{Int}(undef, length(es)) + adjacency = [Int[] for _ in eachindex(ns)] + for (i, e) in enumerate(es) + # LEMON does not order the endpoints of an undirected edge, so they + # are normalized to `src <= dst` the way `SimpleGraph` does + u, v = minmax(Lib.id(Lib.u(g, e)) + 1, Lib.id(Lib.v(g, e)) + 1) + us[i] = u + vs[i] = v + push!(adjacency[u], v) + u == v || push!(adjacency[v], u) + end + foreach(sort!, adjacency) + return new{eltype(ns),typeof(g),typeof(ns),typeof(es)}(g, ns, es, us, vs, adjacency) + end end -Lib.ListGraph(sourcegraph::Graph) = toListGraph(sourcegraph)[1] +""" + LEMONDiGraph{T,G,N,A} <: AbstractGraph{Int} + +Wrapper around LEMON's `ListDigraph` C++ type providing a Julia interface +conforming to the Graphs.jl `AbstractGraph` API. + +Like [`LEMONGraph`](@ref) it caches arc endpoints and in/out adjacency lists so +that Graphs.jl algorithms never pay a C++ call per neighbour lookup, and it +supports the same growing-only subset of the mutation API. +""" +struct LEMONDiGraph{T,G,N,A} <: AbstractGraph{Int} + graph::G + nodes::N + arcs::A + arc_src::Vector{Int} + arc_dst::Vector{Int} + fadj::Vector{Vector{Int}} + badj::Vector{Vector{Int}} -function maxweightedperfectmatching(graph::Graph, weights::AbstractVector{<:Integer}) - g,ns,es = toListGraph(graph) - mapedge = Lib.ListGraphEdgeMap{Cint}(g) - for (e,w) in zip(es,weights) - Lib.set(mapedge, e, w) + function LEMONDiGraph(g, ns, as) + us = Vector{Int}(undef, length(as)) + vs = Vector{Int}(undef, length(as)) + fadj = [Int[] for _ in eachindex(ns)] + badj = [Int[] for _ in eachindex(ns)] + for (i, a) in enumerate(as) + u = Lib.id(Lib.source(g, a)) + 1 + v = Lib.id(Lib.target(g, a)) + 1 + us[i] = u + vs[i] = v + push!(fadj[u], v) + push!(badj[v], u) + end + foreach(sort!, fadj) + foreach(sort!, badj) + return new{eltype(ns),typeof(g),typeof(ns),typeof(as)}( + g, ns, as, us, vs, fadj, badj + ) end - mwpm = Lib.MaxWeightedPerfectMatchingListGraphInt(g,mapedge) - Lib.run(mwpm) - return Lib.matchingWeight(mwpm), [Lib.id(Lib.mate(mwpm,i))+1 for i in ns] end -function maxweightedperfectmatching(graph::Graph, weights::Dict{E,T}) where {E<:Edge,T<:Integer} - return maxweightedperfectmatching(graph, [weights[e] for e in edges(graph)]) +""" + LEMONAlgorithm() + +Marker type for dispatch to LEMON-backed algorithm implementations. + +Pass it as the last positional argument to a Graphs.jl, GraphsMatching.jl or +GraphsOptim.jl function to request the LEMON implementation, e.g. +`dijkstra_shortest_paths(g, 1, distmx, LEMONAlgorithm())`. +""" +struct LEMONAlgorithm end + +# Fast conversion helpers +""" + to_list_graph(g) -> (ListGraph, Vector, Vector) + +Fast conversion that caches nodes/edges for reuse in the [`LEMONGraph`](@ref) +wrapper. If `g` is already a `LEMONGraph`, its internal representation is +returned in `O(1)` without copying anything. +""" +function to_list_graph(g::Graph) + lg = Lib.ListGraph() + ns = [Lib.addNode(lg) for _ in Graphs.vertices(g)] + es = [Lib.addEdge(lg, ns[Graphs.src(e)], ns[Graphs.dst(e)]) for e in Graphs.edges(g)] + return (lg, ns, es) +end + +function to_list_graph(g::AbstractGraph) + is_directed(g) && throw(ArgumentError("LEMON matching currently only supports undirected graphs")) + return to_list_graph(Graph(g)) end +function to_list_graph(g::LEMONGraph) + return (g.graph, g.nodes, g.edges) # O(1) reuse end + +""" + to_list_digraph(g) -> (ListDigraph, Vector, Vector) + +Fast conversion for directed graphs. If `g` is already a [`LEMONDiGraph`](@ref), +its internal representation is returned in `O(1)`. +""" +function to_list_digraph(g::DiGraph) + dg = Lib.ListDigraph() + ns = [Lib.addNode(dg) for _ in Graphs.vertices(g)] + as = [Lib.addArc(dg, ns[Graphs.src(e)], ns[Graphs.dst(e)]) for e in Graphs.edges(g)] + return (dg, ns, as) +end + +function to_list_digraph(g::AbstractGraph) + return to_list_digraph(DiGraph(g)) +end + +function to_list_digraph(g::LEMONDiGraph) + return (g.graph, g.nodes, g.arcs) # O(1) reuse +end + +# Constructors +""" + LEMONGraph(g::AbstractGraph) -> LEMONGraph + +Convert an undirected Graphs.jl graph to a `LEMONGraph` wrapper. +""" +function LEMONGraph(g::Graph) + lg, ns, es = to_list_graph(g) + return LEMONGraph(lg, ns, es) +end + +function LEMONGraph(g::AbstractGraph) + lg, ns, es = to_list_graph(g) + return LEMONGraph(lg, ns, es) +end + +""" + LEMONDiGraph(g::AbstractGraph) -> LEMONDiGraph + +Convert a directed Graphs.jl graph to a `LEMONDiGraph` wrapper. +""" +function LEMONDiGraph(g::DiGraph) + dg, ns, as = to_list_digraph(g) + return LEMONDiGraph(dg, ns, as) +end + +function LEMONDiGraph(g::AbstractGraph) + dg, ns, as = to_list_digraph(g) + return LEMONDiGraph(dg, ns, as) +end + +# `LEMONGraph(n)` / `LEMONDiGraph(n)` build an edgeless wrapper on `n` +# vertices. The methods are defined on `Type{<:...}` rather than on the +# `UnionAll` alone because generic Graphs.jl algorithms such as +# `induced_subgraph` build their result with `typeof(g)(n)`, which names the +# concrete parameterization. +(::Type{L})(n::Integer) where {L<:LEMONGraph} = LEMONGraph(Graph(Int(n))) +(::Type{L})(n::Integer) where {L<:LEMONDiGraph} = LEMONDiGraph(DiGraph(Int(n))) + +end # module LEMONGraphs From 66d2559dab2bdf438ed92066914a28f9dc22bff8 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 02/12] Implement the read-only AbstractGraph API and convert back to Graphs.jl --- src/LEMONGraphs.jl | 92 ++++++++++++++++ test/test_api.jl | 261 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 test/test_api.jl diff --git a/src/LEMONGraphs.jl b/src/LEMONGraphs.jl index e1cb62e..e320505 100644 --- a/src/LEMONGraphs.jl +++ b/src/LEMONGraphs.jl @@ -219,4 +219,96 @@ end (::Type{L})(n::Integer) where {L<:LEMONGraph} = LEMONGraph(Graph(Int(n))) (::Type{L})(n::Integer) where {L<:LEMONDiGraph} = LEMONDiGraph(DiGraph(Int(n))) +""" + Graph(g::LEMONGraph) -> Graphs.SimpleGraph + +Convert a `LEMONGraph` back to a `Graphs.SimpleGraph`. +""" +function Graph(g::LEMONGraph) + h = Graph(Graphs.nv(g)) + for e in Graphs.edges(g) + Graphs.add_edge!(h, Graphs.src(e), Graphs.dst(e)) + end + return h +end + +""" + DiGraph(g::LEMONDiGraph) -> Graphs.SimpleDiGraph + +Convert a `LEMONDiGraph` back to a `Graphs.SimpleDiGraph`. +""" +function DiGraph(g::LEMONDiGraph) + h = DiGraph(Graphs.nv(g)) + for e in Graphs.edges(g) + Graphs.add_edge!(h, Graphs.src(e), Graphs.dst(e)) + end + return h +end + +Base.convert(::Type{Graph}, g::LEMONGraph) = Graph(g) +Base.convert(::Type{DiGraph}, g::LEMONDiGraph) = DiGraph(g) + +const LEMONAbstractGraph = Union{LEMONGraph,LEMONDiGraph} + +# Lazy edge iterator shared by both wrapper types, mirroring `SimpleEdgeIter` +struct LEMONEdgeIter{G<:LEMONAbstractGraph} + graph::G +end + +Base.eltype(::Type{<:LEMONEdgeIter}) = Edge{Int} +Base.length(it::LEMONEdgeIter) = Graphs.ne(it.graph) +Base.size(it::LEMONEdgeIter) = (length(it),) +Base.IteratorSize(::Type{<:LEMONEdgeIter}) = Base.HasLength() +Base.IteratorEltype(::Type{<:LEMONEdgeIter}) = Base.HasEltype() + +_endpoints(g::LEMONGraph, i::Integer) = (g.edge_src[i], g.edge_dst[i]) +_endpoints(g::LEMONDiGraph, i::Integer) = (g.arc_src[i], g.arc_dst[i]) + +function Base.iterate(it::LEMONEdgeIter, i::Int=1) + i > length(it) && return nothing + u, v = _endpoints(it.graph, i) + return (Edge(u, v), i + 1) +end + +Base.in(e::Edge, it::LEMONEdgeIter) = Graphs.has_edge(it.graph, Graphs.src(e), Graphs.dst(e)) +Base.show(io::IO, it::LEMONEdgeIter) = print(io, "$(length(it))-element LEMONEdgeIter") + +# AbstractGraph API for LEMONGraph (undirected) +Graphs.nv(g::LEMONGraph) = length(g.nodes) +Graphs.ne(g::LEMONGraph) = length(g.edges) +Graphs.vertices(g::LEMONGraph) = Base.OneTo(length(g.nodes)) +Graphs.edges(g::LEMONGraph) = LEMONEdgeIter(g) +Graphs.has_vertex(g::LEMONGraph, v::Integer) = 1 ≤ v ≤ length(g.nodes) +Graphs.is_directed(::Type{<:LEMONGraph}) = false +Graphs.is_directed(::LEMONGraph) = false +Graphs.edgetype(::LEMONGraph) = Edge{Int} +Graphs.edgetype(::Type{<:LEMONGraph}) = Edge{Int} + +function Graphs.has_edge(g::LEMONGraph, u::Integer, v::Integer) + (has_vertex(g, u) && has_vertex(g, v)) || return false + return insorted(v, g.adjacency[u]) +end + +Graphs.inneighbors(g::LEMONGraph, v::Integer) = g.adjacency[v] +Graphs.outneighbors(g::LEMONGraph, v::Integer) = g.adjacency[v] + +# AbstractGraph API for LEMONDiGraph (directed) +Graphs.nv(g::LEMONDiGraph) = length(g.nodes) +Graphs.ne(g::LEMONDiGraph) = length(g.arcs) +Graphs.vertices(g::LEMONDiGraph) = Base.OneTo(length(g.nodes)) +Graphs.edges(g::LEMONDiGraph) = LEMONEdgeIter(g) +Graphs.has_vertex(g::LEMONDiGraph, v::Integer) = 1 ≤ v ≤ length(g.nodes) +Graphs.is_directed(::Type{<:LEMONDiGraph}) = true +Graphs.is_directed(::LEMONDiGraph) = true +Graphs.edgetype(::LEMONDiGraph) = Edge{Int} +Graphs.edgetype(::Type{<:LEMONDiGraph}) = Edge{Int} + +function Graphs.has_edge(g::LEMONDiGraph, u::Integer, v::Integer) + (has_vertex(g, u) && has_vertex(g, v)) || return false + return insorted(v, g.fadj[u]) +end + +Graphs.inneighbors(g::LEMONDiGraph, v::Integer) = g.badj[v] +Graphs.outneighbors(g::LEMONDiGraph, v::Integer) = g.fadj[v] + end # module LEMONGraphs diff --git a/test/test_api.jl b/test/test_api.jl new file mode 100644 index 0000000..5349674 --- /dev/null +++ b/test/test_api.jl @@ -0,0 +1,261 @@ +@testitem "LEMONGraph: Basic construction and properties" begin + using LEMONGraphs, Graphs + + g = path_graph(5) + lg = LEMONGraph(g) + + @test nv(lg) == 5 + @test ne(lg) == 4 + @test vertices(lg) == 1:5 + @test length(edges(lg)) == 4 + @test is_directed(typeof(lg)) == false + @test edgetype(lg) == Edge{Int} + @test eltype(lg) == Int +end + +@testitem "LEMONGraph: has_vertex and has_edge" begin + using LEMONGraphs, Graphs + + g = cycle_graph(4) + lg = LEMONGraph(g) + + # Check vertices + @test has_vertex(lg, 1) + @test has_vertex(lg, 4) + @test !has_vertex(lg, 5) + @test !has_vertex(lg, 0) + + # Check edges (cycle: 1-2, 2-3, 3-4, 4-1) + @test has_edge(lg, 1, 2) + @test has_edge(lg, 2, 3) + @test has_edge(lg, 4, 1) + @test !has_edge(lg, 1, 3) + @test !has_edge(lg, 2, 4) +end + +@testitem "LEMONGraph: neighbors (inneighbors/outneighbors)" begin + using LEMONGraphs, Graphs + + g = star_graph(5) # Star: central node 1, leaves 2,3,4,5 + lg = LEMONGraph(g) + + # Central node neighbors + central_neighbors = sort(inneighbors(lg, 1)) + @test central_neighbors == [2, 3, 4, 5] + + # Leaf neighbors + leaf_neighbors = inneighbors(lg, 2) + @test leaf_neighbors == [1] + + # For undirected graphs, outneighbors should match inneighbors + @test inneighbors(lg, 1) == outneighbors(lg, 1) +end + +@testitem "LEMONGraph: fast reuse (to_list_graph)" begin + using LEMONGraphs, Graphs + + g = complete_graph(4) + lg = LEMONGraph(g) + + # Basic checks + @test nv(lg) == 4 + @test ne(lg) == 6 +end + +@testitem "LEMONDiGraph: Basic construction and properties" begin + using LEMONGraphs, Graphs + + dg = SimpleDiGraph(5) + add_edge!(dg, 1, 2) + add_edge!(dg, 2, 3) + add_edge!(dg, 3, 1) + add_edge!(dg, 2, 5) + + ldg = LEMONDiGraph(dg) + + @test nv(ldg) == 5 + @test ne(ldg) == 4 + @test is_directed(typeof(ldg)) == true + @test edgetype(ldg) == Edge{Int} + @test eltype(ldg) == Int +end + +@testitem "LEMONDiGraph: has_edge for directed edges" begin + using LEMONGraphs, Graphs + + dg = SimpleDiGraph(3) + add_edge!(dg, 1, 2) + add_edge!(dg, 2, 3) + + ldg = LEMONDiGraph(dg) + + @test has_edge(ldg, 1, 2) + @test has_edge(ldg, 2, 3) + @test !has_edge(ldg, 2, 1) # directed: reverse should not exist + @test !has_edge(ldg, 1, 3) +end + +@testitem "LEMONDiGraph: inneighbors and outneighbors" begin + using LEMONGraphs, Graphs + + dg = SimpleDiGraph(4) + add_edge!(dg, 1, 2) + add_edge!(dg, 1, 3) + add_edge!(dg, 2, 4) + add_edge!(dg, 3, 4) + + ldg = LEMONDiGraph(dg) + + # Node 1: outgoing to 2,3; incoming from none + out1 = sort(outneighbors(ldg, 1)) + in1 = inneighbors(ldg, 1) + @test out1 == [2, 3] + @test isempty(in1) + + # Node 4: incoming from 2,3; outgoing to none + out4 = outneighbors(ldg, 4) + in4 = sort(inneighbors(ldg, 4)) + @test isempty(out4) + @test in4 == [2, 3] +end + +@testitem "LEMONGraph: Roundtrip conversion" begin + using LEMONGraphs, Graphs + + # Create a graph + g = wheel_graph(6) + + # Convert to LEMON + lg = LEMONGraph(g) + + # Check preservation + @test nv(lg) == nv(g) + @test ne(lg) == ne(g) + + # Check edge preservation (convert back) + edges_orig = Set(edges(g)) + edges_lemon = Set(edges(lg)) + @test edges_orig == edges_lemon +end + +@testitem "LEMONDiGraph: Roundtrip conversion" begin + using LEMONGraphs, Graphs + + # Create a digraph + dg = SimpleDiGraph(5) + for (u, v) in [(1,2), (2,3), (3,4), (4,5), (5,1)] + add_edge!(dg, u, v) + end + + # Convert to LEMON + ldg = LEMONDiGraph(dg) + + # Check preservation + @test nv(ldg) == nv(dg) + @test ne(ldg) == ne(dg) + + # Check edge preservation + edges_orig = Set(edges(dg)) + edges_lemon = Set(edges(ldg)) + @test edges_orig == edges_lemon +end + +@testitem "LEMON graphs: conversion back to Graphs.jl" begin + using LEMONGraphs, Graphs + + g = wheel_graph(6) + lg = LEMONGraph(g) + g2 = Graph(lg) + @test g2 == g + @test convert(Graph, lg) == g + + dg = path_digraph(5) + ldg = LEMONDiGraph(dg) + dg2 = DiGraph(ldg) + @test dg2 == dg + @test convert(DiGraph, ldg) == dg +end + +@testitem "LEMON graphs: edge iterator is lazy and consistent" begin + using LEMONGraphs, Graphs + + for g in (wheel_graph(6), path_graph(4), complete_graph(5)) + lg = LEMONGraph(g) + it = edges(lg) + @test eltype(it) == Edge{Int} + @test length(it) == ne(g) + @test !(it isa AbstractArray) # a lazy view, not a materialised vector + @test Set(collect(it)) == Set(edges(g)) + @test all(e -> e in it, edges(g)) + @test !(Edge(1, nv(g) + 1) in it) + @test src.(it) == src.(collect(edges(g))) + end + + dg = complete_digraph(4) + ldg = LEMONDiGraph(dg) + @test Set(collect(edges(ldg))) == Set(edges(dg)) + @test length(edges(ldg)) == ne(dg) +end + +@testitem "LEMON graphs: neighbours agree with the SimpleGraph originals" begin + using LEMONGraphs, Graphs, StableRNGs + + rng = StableRNG(20260729) + for _ in 1:20 + n = rand(rng, 1:12) + g = erdos_renyi(n, 0.35; rng) + dg = erdos_renyi(n, 0.35; is_directed=true, rng) + lg, ldg = LEMONGraph(g), LEMONDiGraph(dg) + + @test nv(lg) == nv(g) && ne(lg) == ne(g) + @test nv(ldg) == nv(dg) && ne(ldg) == ne(dg) + for v in vertices(g) + @test sort(collect(outneighbors(lg, v))) == sort(outneighbors(g, v)) + @test sort(collect(inneighbors(lg, v))) == sort(inneighbors(g, v)) + @test sort(collect(outneighbors(ldg, v))) == sort(outneighbors(dg, v)) + @test sort(collect(inneighbors(ldg, v))) == sort(inneighbors(dg, v)) + end + for u in vertices(g), v in vertices(g) + @test has_edge(lg, u, v) == has_edge(g, u, v) + @test has_edge(ldg, u, v) == has_edge(dg, u, v) + end + @test !has_edge(lg, 0, 1) + @test !has_edge(ldg, 1, nv(dg) + 1) + end +end + +@testitem "LEMON graphs: work with generic Graphs.jl algorithms" begin + using LEMONGraphs, Graphs + + # Algorithms that only use the AbstractGraph API must accept the wrappers. + g = wheel_graph(7) + lg = LEMONGraph(g) + @test connected_components(lg) == connected_components(g) + @test sort(degree(lg)) == sort(degree(g)) + @test diameter(lg) == diameter(g) + @test length(kruskal_mst(lg)) == nv(g) - 1 + @test gdistances(lg, 1) == gdistances(g, 1) + + dg = cycle_digraph(6) + ldg = LEMONDiGraph(dg) + @test is_strongly_connected(ldg) == is_strongly_connected(dg) + @test length(topological_sort_by_dfs(LEMONDiGraph(path_digraph(5)))) == 5 +end + +@testitem "LEMON graphs: O(1) reuse of an existing wrapper" begin + using LEMONGraphs, Graphs + + g = complete_graph(6) + lg = LEMONGraph(g) + graph, nodes, edgs = LEMONGraphs.to_list_graph(lg) + @test graph === lg.graph + @test nodes === lg.nodes + @test edgs === lg.edges + + dg = complete_digraph(5) + ldg = LEMONDiGraph(dg) + dgraph, dnodes, arcs = LEMONGraphs.to_list_digraph(ldg) + @test dgraph === ldg.graph + @test dnodes === ldg.nodes + @test arcs === ldg.arcs +end \ No newline at end of file From 308318f898d12cc88a93837a19f2d0626bcb7036 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 03/12] Complete the AbstractGraph interface with the mutation component --- src/LEMONGraphs.jl | 67 ++++++++++++++++++++++++++++++++++++++++ test/test_api.jl | 77 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/LEMONGraphs.jl b/src/LEMONGraphs.jl index e320505..a181d74 100644 --- a/src/LEMONGraphs.jl +++ b/src/LEMONGraphs.jl @@ -311,4 +311,71 @@ end Graphs.inneighbors(g::LEMONDiGraph, v::Integer) = g.badj[v] Graphs.outneighbors(g::LEMONDiGraph, v::Integer) = g.fadj[v] +# --- copying, and the growing half of the mutation API --------------------- +# +# LEMON's `ListGraph`/`ListDigraph` can grow, and the Julia-side caches grow +# with them. Removal would need `ListGraph::erase`, which LEMON_jll does not +# expose, so `rem_edge!`/`rem_vertex!` decline the request by returning +# `false`, which is what the Graphs.jl interface expects of a graph type that +# cannot perform the removal. + +Base.copy(g::LEMONGraph) = LEMONGraph(Graph(g)) +Base.copy(g::LEMONDiGraph) = LEMONDiGraph(DiGraph(g)) + +# `Graphs.zero(g::G) = zero(G)` covers the instance methods +Base.zero(::Type{<:LEMONGraph}) = LEMONGraph(0) +Base.zero(::Type{<:LEMONDiGraph}) = LEMONDiGraph(0) + +function Graphs.add_vertex!(g::LEMONGraph) + push!(g.nodes, Lib.addNode(g.graph)) + push!(g.adjacency, Int[]) + return true +end + +function Graphs.add_vertex!(g::LEMONDiGraph) + push!(g.nodes, Lib.addNode(g.graph)) + push!(g.fadj, Int[]) + push!(g.badj, Int[]) + return true +end + +function Graphs.add_edge!(g::LEMONGraph, u::Integer, v::Integer) + (has_vertex(g, u) && has_vertex(g, v)) || return false + has_edge(g, u, v) && return false # LEMON would add a parallel edge + u, v = minmax(Int(u), Int(v)) # `src <= dst`, as in SimpleGraph + push!(g.edges, Lib.addEdge(g.graph, g.nodes[u], g.nodes[v])) + push!(g.edge_src, u) + push!(g.edge_dst, v) + insert!(g.adjacency[u], searchsortedfirst(g.adjacency[u], v), v) + u == v || insert!(g.adjacency[v], searchsortedfirst(g.adjacency[v], u), u) + return true +end + +function Graphs.add_edge!(g::LEMONDiGraph, u::Integer, v::Integer) + (has_vertex(g, u) && has_vertex(g, v)) || return false + has_edge(g, u, v) && return false + u, v = Int(u), Int(v) + push!(g.arcs, Lib.addArc(g.graph, g.nodes[u], g.nodes[v])) + push!(g.arc_src, u) + push!(g.arc_dst, v) + insert!(g.fadj[u], searchsortedfirst(g.fadj[u], v), v) + insert!(g.badj[v], searchsortedfirst(g.badj[v], u), u) + return true +end + +Graphs.add_edge!(g::LEMONAbstractGraph, e::Edge) = Graphs.add_edge!(g, src(e), dst(e)) + +Graphs.rem_edge!(::LEMONAbstractGraph, ::Integer, ::Integer) = false +Graphs.rem_edge!(::LEMONAbstractGraph, ::Edge) = false +Graphs.rem_vertex!(::LEMONAbstractGraph, ::Integer) = false + +""" + reverse(g::LEMONDiGraph) -> LEMONDiGraph + +Return the graph with every arc reversed. `Graphs.reverse` itself is only +defined for `AbstractSimpleGraph`, so this method exists to keep the wrapper +usable where the simple graphs are. +""" +Graphs.reverse(g::LEMONDiGraph) = LEMONDiGraph(Graphs.reverse(DiGraph(g))) + end # module LEMONGraphs diff --git a/test/test_api.jl b/test/test_api.jl index 5349674..a449ae5 100644 --- a/test/test_api.jl +++ b/test/test_api.jl @@ -242,6 +242,81 @@ end @test length(topological_sort_by_dfs(LEMONDiGraph(path_digraph(5)))) == 5 end +@testitem "LEMON graphs: growing a wrapper matches the SimpleGraph oracle" begin + using LEMONGraphs, Graphs, StableRNGs, Test + + rng = StableRNG(20260729) + for _ in 1:40 + n = rand(rng, 1:8) + lg, sg = LEMONGraph(n), SimpleGraph(n) + ldg, sdg = LEMONDiGraph(n), SimpleDiGraph(n) + for _ in 1:15 + if rand(rng) < 0.25 + @test add_vertex!(lg) == add_vertex!(sg) + @test add_vertex!(ldg) == add_vertex!(sdg) + else + u, v = rand(rng, 1:nv(sg)), rand(rng, 1:nv(sg)) + @test add_edge!(lg, u, v) == add_edge!(sg, u, v) + @test add_edge!(ldg, u, v) == add_edge!(sdg, u, v) + end + @test nv(lg) == nv(sg) && ne(lg) == ne(sg) + @test Graph(lg) == sg + @test DiGraph(ldg) == sdg + @test Set(edges(lg)) == Set(edges(sg)) + @test Set(edges(ldg)) == Set(edges(sdg)) + for w in vertices(sg) + @test outneighbors(lg, w) == outneighbors(sg, w) + @test inneighbors(lg, w) == inneighbors(sg, w) + @test outneighbors(ldg, w) == outneighbors(sdg, w) + @test inneighbors(ldg, w) == inneighbors(sdg, w) + end + end + end + + # adding an edge that is already there is a no-op, as in Graphs.jl + lg = LEMONGraph(path_graph(3)) + @test !add_edge!(lg, 1, 2) + @test !add_edge!(lg, 2, 1) + @test !add_edge!(lg, 1, 9) + @test ne(lg) == 2 + @test add_edge!(lg, Edge(1, 3)) + @test has_edge(lg, 3, 1) + + # LEMON_jll exposes no `erase`, so removal declines rather than lying + @test !rem_edge!(lg, 1, 2) + @test !rem_edge!(lg, Edge(1, 2)) + @test !rem_vertex!(lg, 1) + @test ne(lg) == 3 && nv(lg) == 3 +end + +@testitem "LEMON graphs: copy, zero, reverse and derived subgraphs" begin + using LEMONGraphs, Graphs, Test + + g = wheel_graph(6) + lg = LEMONGraph(g) + + h = copy(lg) + @test add_edge!(h, 2, 4) + @test ne(h) == ne(lg) + 1 # the copy is independent of the original + @test Graph(lg) == g + + @test nv(zero(lg)) == 0 + @test typeof(zero(lg)) == typeof(lg) + @test typeof(zero(typeof(lg))) == typeof(lg) + @test nv(LEMONGraph(4)) == 4 && ne(LEMONGraph(4)) == 0 + @test nv(LEMONDiGraph(4)) == 4 && ne(LEMONDiGraph(4)) == 0 + + dg = path_digraph(4) + @test DiGraph(reverse(LEMONDiGraph(dg))) == reverse(dg) + + # generic Graphs.jl algorithms that build a graph of the same type + sub, vmap = induced_subgraph(lg, [1, 2, 3]) + ref, refmap = induced_subgraph(g, [1, 2, 3]) + @test Graph(sub) == ref + @test vmap == refmap + @test Graph(egonet(lg, 1, 1)) == egonet(g, 1, 1) +end + @testitem "LEMON graphs: O(1) reuse of an existing wrapper" begin using LEMONGraphs, Graphs @@ -258,4 +333,4 @@ end @test dgraph === ldg.graph @test dnodes === ldg.nodes @test arcs === ldg.arcs -end \ No newline at end of file +end From 73f3fa7bd279476f7cc93ed06ea5e82d33c66f8e Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 04/12] Check both wrappers with GraphsInterfaceChecker --- test/Project.toml | 3 +++ test/test_interface_checker.jl | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 test/test_interface_checker.jl diff --git a/test/Project.toml b/test/Project.toml index a5d96b9..cc7355d 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,6 +1,9 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" +GraphsInterfaceChecker = "3bef136c-15ff-4091-acbb-1a4aafe67608" +Interfaces = "85a1e053-f937-4924-92a5-1367d23b7b87" +JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/test_interface_checker.jl b/test/test_interface_checker.jl new file mode 100644 index 0000000..d147bac --- /dev/null +++ b/test/test_interface_checker.jl @@ -0,0 +1,30 @@ +@testitem "GraphsInterfaceChecker: AbstractGraph interface compliance" begin + using LEMONGraphs, Graphs, Test + using GraphsInterfaceChecker + using Interfaces + + test_graphs = [ + LEMONGraph(SimpleGraph(0)), + LEMONGraph(SimpleGraph(3)), + LEMONGraph(path_graph(4)), + LEMONGraph(complete_graph(4)), + LEMONGraph(wheel_graph(6)), + ] + test_digraphs = [ + LEMONDiGraph(SimpleDiGraph(0)), + LEMONDiGraph(SimpleDiGraph(3)), + LEMONDiGraph(path_digraph(4)), + LEMONDiGraph(complete_digraph(4)), + LEMONDiGraph(cycle_digraph(5)), + ] + + # The optional `mutation` component is claimed as well: `add_vertex!` and + # `add_edge!` grow the LEMON structure, and `rem_vertex!`/`rem_edge!` + # decline by returning `false` -- which the interface explicitly allows -- + # because LEMON_jll does not expose `ListGraph::erase`. + @implements AbstractGraphInterface{(:mutation)} LEMONGraph test_graphs + @implements AbstractGraphInterface{(:mutation)} LEMONDiGraph test_digraphs + + @test Interfaces.test(AbstractGraphInterface, LEMONGraph) + @test Interfaces.test(AbstractGraphInterface, LEMONDiGraph) +end From 076b6373fecf8281b360151687919efb6406007e Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 05/12] Dispatch maximum weight perfect matching through LEMONAlgorithm --- src/LEMONGraphs.jl | 54 ++++++++++++++++++++++++++++++++++++++++++++++ test/test_mwpm.jl | 6 +++--- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/LEMONGraphs.jl b/src/LEMONGraphs.jl index a181d74..16d3aa8 100644 --- a/src/LEMONGraphs.jl +++ b/src/LEMONGraphs.jl @@ -378,4 +378,58 @@ usable where the simple graphs are. """ Graphs.reverse(g::LEMONDiGraph) = LEMONDiGraph(Graphs.reverse(DiGraph(g))) +# LEMON-specific algorithm dispatches +""" + maxweightedperfectmatching(g, weights[, ::LEMONAlgorithm]) + +Compute a maximum-weight perfect matching using LEMON's `MaxWeightedPerfectMatching`. + +`weights` is either a vector of integer weights ordered like `edges(g)`, or a +`Dict{Edge,<:Integer}`. Returns `(matching_weight, mates)` where `mates[v]` is +the vertex matched to `v`. +""" +function maxweightedperfectmatching(g::AbstractGraph, weights::AbstractVector{<:Integer}, alg::LEMONAlgorithm) + lg, ns, es = to_list_graph(g) + length(weights) == length(es) || throw(DimensionMismatch("expected $(length(es)) edge weights, got $(length(weights))")) + mapedge = Lib.ListGraphEdgeMap{CxxInt}(lg) + for (e, w) in zip(es, weights) + Lib.set(mapedge, e, _to_cxxint(w, "matching weight")) + end + mwpm = Lib.MaxWeightedPerfectMatchingListGraphInt(lg, mapedge) + Lib.run(mwpm) + return Lib.matchingWeight(mwpm), [Lib.id(Lib.mate(mwpm, n)) + 1 for n in ns] +end + +function maxweightedperfectmatching(g::AbstractGraph, weights::Dict{E,T}, alg::LEMONAlgorithm) where {E<:Edge,T<:Integer} + return maxweightedperfectmatching(g, [weights[e] for e in Graphs.edges(g)], alg) +end + +function maxweightedperfectmatching(g::AbstractGraph, weights::AbstractVector{<:Integer}) + return maxweightedperfectmatching(g, weights, LEMONAlgorithm()) +end + +function maxweightedperfectmatching(g::AbstractGraph, weights::Dict{E,T}) where {E<:Edge,T<:Integer} + return maxweightedperfectmatching(g, weights, LEMONAlgorithm()) +end + +function _to_cxxint(w::Integer, what::AbstractString) + typemin(CxxInt) <= w <= typemax(CxxInt) || + throw(ArgumentError("LEMON stores each $what in a C++ `int`; $w does not fit in $(CxxInt)")) + return CxxInt(w) +end + +function __init__() + Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs + LEMONAlgorithm in argtypes || return nothing + print( + io, + "\n\n`LEMONGraphs` does not provide a LEMON-backed method for `$(exc.f)` with " * + "these argument types. LEMON algorithms are integer-valued: make sure weights, " * + "costs and capacities are `Integer`s. See the LEMONGraphs.jl documentation for " * + "the list of supported `LEMONAlgorithm()` dispatches.", + ) + return nothing + end +end + end # module LEMONGraphs diff --git a/test/test_mwpm.jl b/test/test_mwpm.jl index e61ac03..1c98c67 100644 --- a/test/test_mwpm.jl +++ b/test/test_mwpm.jl @@ -88,9 +88,9 @@ for repetition in 1:100 for (e,w) in zip(edges(g),aweights) aweightsdict[e] = w end - lemon_pweight_d, lemon_pmatching_d = maxweightedperfectmatching(g, pweightsdict) - lemon_nweight_d, lemon_nmatching_d = maxweightedperfectmatching(g, nweightsdict) - lemon_aweight_d, lemon_amatching_d = maxweightedperfectmatching(g, aweightsdict) + lemon_pweight_d, lemon_pmatching_d = maxweightedperfectmatching(g, pweightsdict, LEMONGraphs.LEMONAlgorithm()) + lemon_nweight_d, lemon_nmatching_d = maxweightedperfectmatching(g, nweightsdict, LEMONGraphs.LEMONAlgorithm()) + lemon_aweight_d, lemon_amatching_d = maxweightedperfectmatching(g, aweightsdict, LEMONGraphs.LEMONAlgorithm()) @test lemon_pweight == lemon_pweight_d @test lemon_nweight == lemon_nweight_d From 8b4d073ef3b402fffa06b6f2e0bc55ba33c7bdfd Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 06/12] Dispatch dijkstra_shortest_paths to LEMON --- src/LEMONGraphs.jl | 230 ++++++++++++++++++++++++++++++++++++++++++ test/test_dijkstra.jl | 195 +++++++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 test/test_dijkstra.jl diff --git a/src/LEMONGraphs.jl b/src/LEMONGraphs.jl index 16d3aa8..7c4754f 100644 --- a/src/LEMONGraphs.jl +++ b/src/LEMONGraphs.jl @@ -418,6 +418,236 @@ function _to_cxxint(w::Integer, what::AbstractString) return CxxInt(w) end +""" + dijkstra_shortest_paths(g, srcs, distmx, ::LEMONAlgorithm; kwargs...) + +Compute shortest paths with LEMON's `Dijkstra`. + +`distmx` must hold non-negative integers that fit in a C++ `int`. The returned +`Graphs.DijkstraState` is fully populated: `parents`, `dists`, `pathcounts`, +and, when `allpaths=true`, `predecessors` are computed exactly as by the +native Graphs.jl implementation, so the result is interchangeable with it. + +Multiple sources are supported; they are modelled by an auxiliary zero-cost +super-source, which means the `O(1)` reuse of a pre-built [`LEMONDiGraph`](@ref) +only applies to the single-source case. +""" +function Graphs.dijkstra_shortest_paths( + g::AbstractGraph, + srcs::Vector{<:Integer}, + distmx::AbstractMatrix{T}, + ::LEMONAlgorithm; + allpaths::Bool=false, + trackvertices::Bool=false, + maxdist=typemax(T), +) where {T<:Integer} + nvg = Int(nv(g)) + isempty(srcs) && throw(ArgumentError("at least one source vertex is required")) + all(s -> 1 <= s <= nvg, srcs) || throw(ArgumentError("source vertex out of range")) + + # Arc endpoints are tracked on the Julia side rather than asked of LEMON one + # arc at a time; they are needed again for the pathcount pass below. + dg, ns, as, arc_src, arc_dst = if length(srcs) > 1 + _dijkstra_digraph(g, srcs) # multi-source: add a super-source + elseif g isa LEMONDiGraph + (g.graph, g.nodes, g.arcs, g.arc_src, g.arc_dst) # O(1) reuse + else + _dijkstra_digraph(g, nothing) + end + supersource = length(srcs) == 1 ? 0 : nvg + 1 + + maparc = Lib.ListDigraphArcMap{CxxInt}(dg) + arc_w = Vector{T}(undef, length(as)) + for i in eachindex(as) + u, v = arc_src[i], arc_dst[i] + w = (u == supersource) ? zero(T) : distmx[u, v] + w >= zero(T) || + throw(ArgumentError("LEMON Dijkstra requires non-negative edge weights, got $w on edge ($u, $v)")) + arc_w[i] = w + Lib.set(maparc, as[i], _to_cxxint(w, "edge weight")) + end + + dijkstra = Lib.DijkstraListDigraphArcMapInt(dg, maparc) + Lib.run(dijkstra, ns[supersource == 0 ? Int(srcs[1]) : supersource]) + + dists = fill(typemax(T), nvg) + parents = zeros(Int, nvg) + for i in 1:nvg + Lib.reached(dijkstra, ns[i]) || continue + d = T(Lib.dist(dijkstra, ns[i])) + d <= maxdist || continue + dists[i] = d + pred = Lib.id(Lib.predNode(dijkstra, ns[i])) + 1 + parents[i] = (pred == supersource || pred == 0) ? 0 : pred + end + for s in srcs + parents[s] = 0 + end + + preds, pathcounts = _dijkstra_pathcounts( + nvg, srcs, dists, arc_src, arc_dst, arc_w, allpaths + ) + + closest_vertices = Int[] + if trackvertices + closest_vertices = collect(1:nvg) + # stable sort so that unreachable vertices keep their vertex order at the + # end of the list, as `Graphs.dijkstra_shortest_paths` does + sort!(closest_vertices; alg=MergeSort, by=v -> (dists[v] == typemax(T), dists[v])) + end + + return Graphs.DijkstraState{T,Int}(parents, dists, preds, pathcounts, closest_vertices) +end + +function Graphs.dijkstra_shortest_paths( + g::AbstractGraph, + src::Integer, + distmx::AbstractMatrix{T}, + alg::LEMONAlgorithm; + allpaths::Bool=false, + trackvertices::Bool=false, + maxdist=typemax(T), +) where {T<:Integer} + return Graphs.dijkstra_shortest_paths( + g, [Int(src)], distmx, alg; allpaths, trackvertices, maxdist + ) +end + +function Graphs.dijkstra_shortest_paths( + g::AbstractGraph, srcs::Union{Integer,Vector{<:Integer}}, alg::LEMONAlgorithm; kwargs... +) + return Graphs.dijkstra_shortest_paths(g, srcs, Graphs.weights(g), alg; kwargs...) +end + +function Graphs.dijkstra_shortest_paths( + ::AbstractGraph, + ::Union{Integer,Vector{<:Integer}}, + ::AbstractMatrix{T}, + ::LEMONAlgorithm; + kwargs..., +) where {T<:Real} + throw(ArgumentError( + "LEMON Dijkstra only supports integer edge weights, got a distance matrix of " * + "element type $T. Convert the weights to integers or drop `LEMONAlgorithm()` " * + "to use the native Graphs.jl implementation." + )) +end + +""" + _dijkstra_digraph(g, srcs) + +Build the LEMON `ListDigraph` that Dijkstra runs on, returning it together with +its nodes, its arcs and the arc endpoints. The endpoints are derived from `g` +on the Julia side, so no per-arc `source`/`target` call crosses into C++. + +When `srcs` is a collection rather than `nothing`, an extra node `nv(g) + 1` is +added with a zero-cost arc into every vertex of `srcs`; that reduces +multi-source Dijkstra to the single-source case LEMON exposes. +""" +function _dijkstra_digraph(g::AbstractGraph, srcs) + nvg = Int(nv(g)) + endpoints = Tuple{Int,Int}[] + if srcs !== nothing + for s in srcs + push!(endpoints, (nvg + 1, Int(s))) + end + end + for e in Graphs.edges(g) + u, v = Int(Graphs.src(e)), Int(Graphs.dst(e)) + push!(endpoints, (u, v)) + is_directed(g) || push!(endpoints, (v, u)) + end + + dg = Lib.ListDigraph() + ns = [Lib.addNode(dg) for _ in 1:(srcs === nothing ? nvg : nvg + 1)] + as = [Lib.addArc(dg, ns[u], ns[v]) for (u, v) in endpoints] + return (dg, ns, as, first.(endpoints), last.(endpoints)) +end + +""" + _dijkstra_pathcounts(nvg, srcs, dists, arc_src, arc_dst, arc_w, allpaths) + +Recover the shortest-path DAG from the distance labels produced by LEMON, and +from it the predecessor lists and the number of shortest paths per vertex. + +Zero-weight edges make the "DAG" of tight arcs contain arcs between vertices at +the same distance, so vertices are relaxed distance group by distance group, +with a topological (Kahn) pass inside each group. A zero-weight cycle on a +shortest path means there are infinitely many shortest paths; the affected path +counts are reported as `Inf`. +""" +function _dijkstra_pathcounts( + nvg::Int, srcs, dists::Vector{T}, arc_src, arc_dst, arc_w, allpaths::Bool +) where {T<:Integer} + preds = [Int[] for _ in 1:nvg] + pathcounts = zeros(Float64, nvg) + + inpreds = [Int[] for _ in 1:nvg] + tight_succ = [Int[] for _ in 1:nvg] # tight arcs between equidistant vertices + indeg = zeros(Int, nvg) + for i in eachindex(arc_src) + u, v = arc_src[i], arc_dst[i] + (u > nvg || v > nvg) && continue # arcs out of the super-source + (dists[u] == typemax(T) || dists[v] == typemax(T)) && continue + v in srcs && continue + dists[u] + arc_w[i] == dists[v] || continue + push!(inpreds[v], u) + if dists[u] == dists[v] + push!(tight_succ[u], v) + indeg[v] += 1 + end + end + allpaths && (preds = inpreds) + + for s in srcs + pathcounts[s] = 1.0 + end + + order = [v for v in 1:nvg if dists[v] != typemax(T)] + sort!(order; by=v -> dists[v]) + + group_start = 1 + while group_start <= length(order) + group_stop = group_start + while group_stop < length(order) && dists[order[group_stop + 1]] == dists[order[group_start]] + group_stop += 1 + end + group = view(order, group_start:group_stop) + + # contributions from strictly closer vertices, already final + for v in group + v in srcs && continue + pathcounts[v] = sum( + u -> dists[u] == dists[v] ? 0.0 : pathcounts[u], inpreds[v]; init=0.0 + ) + end + + queue = [v for v in group if indeg[v] == 0] + settled = 0 + while !isempty(queue) + u = pop!(queue) + settled += 1 + for v in tight_succ[u] + pathcounts[v] += pathcounts[u] + indeg[v] -= 1 + indeg[v] == 0 && push!(queue, v) + end + end + # Kahn leaves exactly the vertices lying on, or downstream of, a + # zero-weight cycle unsettled; those are reachable by infinitely many + # shortest paths. Later groups inherit the `Inf` through the sum above. + if settled < length(group) + for v in group + indeg[v] > 0 && (pathcounts[v] = Inf) + end + end + + group_start = group_stop + 1 + end + + return preds, pathcounts +end + function __init__() Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs LEMONAlgorithm in argtypes || return nothing diff --git a/test/test_dijkstra.jl b/test/test_dijkstra.jl new file mode 100644 index 0000000..3b74763 --- /dev/null +++ b/test/test_dijkstra.jl @@ -0,0 +1,195 @@ +@testitem "LEMON Dijkstra: basic shortest paths" begin + using LEMONGraphs, Graphs + + # Simple directed graph: 1 -> 2 -> 3 with weights 10 and 5 + g = SimpleDiGraph(3) + add_edge!(g, 1, 2) + add_edge!(g, 2, 3) + + weights = zeros(Int, 3, 3) + weights[1, 2] = 10 + weights[2, 3] = 5 + + state = dijkstra_shortest_paths(g, 1, weights, LEMONAlgorithm()) + + @test state.dists == [0, 10, 15] + @test state.parents == [0, 1, 2] + @test state.pathcounts == [1.0, 1.0, 1.0] + + # unreachable vertices keep `typemax` and a zero parent + add_vertex!(g) + weights = zeros(Int, 4, 4) + weights[1, 2] = 10 + weights[2, 3] = 5 + state = dijkstra_shortest_paths(g, 1, weights, LEMONAlgorithm()) + @test state.dists[4] == typemax(Int) + @test state.parents[4] == 0 + @test state.pathcounts[4] == 0.0 +end + +@testitem "LEMON Dijkstra: keyword arguments" begin + using LEMONGraphs, Graphs, Test + + g = SimpleDiGraph(4) + add_edge!(g, 1, 2) + add_edge!(g, 2, 3) + add_edge!(g, 1, 4) + + weights = zeros(Int, 4, 4) + weights[1, 2] = 2 + weights[2, 3] = 2 + weights[1, 4] = 10 + + state = dijkstra_shortest_paths( + g, 1, weights, LEMONAlgorithm(); trackvertices=true, maxdist=4 + ) + + @test state.dists == [0, 2, 4, typemax(Int)] + @test state.pathcounts == [1.0, 1.0, 1.0, 0.0] + @test state.closest_vertices == [1, 2, 3, 4] + @test all(isempty, state.predecessors) + + withpaths = dijkstra_shortest_paths(g, 1, weights, LEMONAlgorithm(); allpaths=true) + @test withpaths.predecessors == [Int[], [1], [2], [1]] + + # the distance matrix defaults to `weights(g)` + @test dijkstra_shortest_paths(g, 1, LEMONAlgorithm()).dists == [0, 1, 2, 1] +end + +@testitem "LEMON Dijkstra: multiple sources" begin + using LEMONGraphs, Graphs, Test + + g = path_digraph(5) + w = ones(Int, 5, 5) + + state = dijkstra_shortest_paths(g, [1, 3], w, LEMONAlgorithm()) + @test state.dists == [0, 1, 0, 1, 2] + @test state.parents == [0, 1, 0, 3, 4] + @test state.pathcounts == [1.0, 1.0, 1.0, 1.0, 1.0] + + @test_throws ArgumentError dijkstra_shortest_paths(g, Int[], w, LEMONAlgorithm()) + @test_throws ArgumentError dijkstra_shortest_paths(g, [9], w, LEMONAlgorithm()) +end + +@testitem "LEMON Dijkstra: input validation" begin + using LEMONGraphs, Graphs, Test + + g = path_digraph(3) + + negative = zeros(Int, 3, 3) + negative[1, 2] = -1 + err = @test_throws ArgumentError dijkstra_shortest_paths(g, 1, negative, LEMONAlgorithm()) + @test occursin("non-negative", sprint(showerror, err.value)) + + floats = ones(Float64, 3, 3) + err = @test_throws ArgumentError dijkstra_shortest_paths(g, 1, floats, LEMONAlgorithm()) + @test occursin("integer edge weights", sprint(showerror, err.value)) + + toobig = zeros(Int, 3, 3) + toobig[1, 2] = typemax(Int) + err = @test_throws ArgumentError dijkstra_shortest_paths(g, 1, toobig, LEMONAlgorithm()) + @test occursin("does not fit", sprint(showerror, err.value)) +end + +@testitem "LEMON Dijkstra: matches the Graphs.jl implementation" begin + using LEMONGraphs, Graphs, StableRNGs, Test + + rng = StableRNG(20260729) + for _ in 1:60 + n = rand(rng, 2:9) + directed = rand(rng, Bool) + g = directed ? SimpleDiGraph(n) : SimpleGraph(n) + for u in 1:n, v in 1:n + u == v && continue + (!directed && v < u) && continue + rand(rng) < 0.35 && add_edge!(g, u, v) + end + # strictly positive weights, so that the shortest path counts are unique + w = zeros(Int, n, n) + for e in edges(g) + x = rand(rng, 1:5) + w[src(e), dst(e)] = x + directed || (w[dst(e), src(e)] = x) + end + srcs = sort(unique(rand(rng, 1:n, rand(rng, 1:2)))) + maxdist = rand(rng, Bool) ? typemax(Int) : rand(rng, 0:8) + + for allpaths in (false, true), trackvertices in (false, true) + ref = dijkstra_shortest_paths(g, srcs, w; allpaths, trackvertices, maxdist) + lem = dijkstra_shortest_paths( + g, srcs, w, LEMONAlgorithm(); allpaths, trackvertices, maxdist + ) + + @test lem.dists == ref.dists + @test lem.pathcounts == ref.pathcounts + if allpaths + @test sort.(lem.predecessors) == sort.(ref.predecessors) + end + if trackvertices + # ties may be broken differently, so compare the distance profile + @test lem.dists[lem.closest_vertices] == ref.dists[ref.closest_vertices] + end + # `parents` must span a valid shortest path tree + for v in 1:n + p = lem.parents[v] + p == 0 && continue + @test lem.dists[p] + w[p, v] == lem.dists[v] + end + end + end +end + +@testitem "LEMON Dijkstra: accepts the LEMON wrapper types" begin + using LEMONGraphs, Graphs + + g = wheel_graph(7) + w = [i == j ? 0 : (i + j) for i in 1:7, j in 1:7] + @test dijkstra_shortest_paths(LEMONGraph(g), 1, w, LEMONAlgorithm()).dists == + dijkstra_shortest_paths(g, 1, w).dists + + dg = cycle_digraph(6) + wd = [i == j ? 0 : (i + 2j) for i in 1:6, j in 1:6] + @test dijkstra_shortest_paths(LEMONDiGraph(dg), 2, wd, LEMONAlgorithm()).dists == + dijkstra_shortest_paths(dg, 2, wd).dists +end + +@testitem "LEMON Dijkstra: zero-weight edges" begin + using LEMONGraphs, Graphs + + # 2 -0-> 3 -0-> 2 is a zero-weight cycle away from the source, so vertices + # 2, 3 and everything behind them are reached by infinitely many shortest + # walks + g = SimpleDiGraph(4) + add_edge!(g, 1, 2) + add_edge!(g, 2, 3) + add_edge!(g, 3, 2) + add_edge!(g, 3, 4) + w = zeros(Int, 4, 4) + w[3, 4] = 1 + + state = dijkstra_shortest_paths(g, 1, w, LEMONAlgorithm()) + @test state.dists == [0, 0, 0, 1] + @test state.pathcounts[1] == 1.0 + @test state.pathcounts[2] == Inf + @test state.pathcounts[3] == Inf + @test state.pathcounts[4] == Inf + + # a zero-weight cycle through the source does not count: as in + # `Graphs.dijkstra_shortest_paths`, walks returning to a source are ignored + h0 = SimpleDiGraph(2) + add_edge!(h0, 1, 2) + add_edge!(h0, 2, 1) + state = dijkstra_shortest_paths(h0, 1, zeros(Int, 2, 2), LEMONAlgorithm()) + @test state.pathcounts == [1.0, 1.0] + + # without a cycle the zero-weight arcs still yield exact counts + h = SimpleDiGraph(4) + add_edge!(h, 1, 2) + add_edge!(h, 1, 3) + add_edge!(h, 2, 4) + add_edge!(h, 3, 4) + wh = zeros(Int, 4, 4) + state = dijkstra_shortest_paths(h, 1, wh, LEMONAlgorithm()) + @test state.dists == [0, 0, 0, 0] + @test state.pathcounts == [1.0, 1.0, 1.0, 2.0] +end From bda9dce7204e71e6a0e438fd5441a8e8718cb11e Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 07/12] Add the GraphsMatching extension for minimum_weight_perfect_matching --- Project.toml | 7 +++ ext/LEMONGraphsGraphsMatchingExt.jl | 44 ++++++++++++++++++ test/Project.toml | 1 + test/test_graphsmatching_ext.jl | 69 +++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 ext/LEMONGraphsGraphsMatchingExt.jl create mode 100644 test/test_graphsmatching_ext.jl diff --git a/Project.toml b/Project.toml index c5342d4..a377896 100644 --- a/Project.toml +++ b/Project.toml @@ -8,8 +8,15 @@ CxxWrap = "1f15a43c-97ca-5a2a-ae31-89f07a497df4" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" LEMON_jll = "9f9b04fa-cfb6-5331-975f-45512019a816" +[weakdeps] +GraphsMatching = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" + +[extensions] +LEMONGraphsGraphsMatchingExt = "GraphsMatching" + [compat] CxxWrap = "0.17.1" Graphs = "1.12.0" LEMON_jll = "1.3.6" +GraphsMatching = "0.2.1" julia = "1.10" diff --git a/ext/LEMONGraphsGraphsMatchingExt.jl b/ext/LEMONGraphsGraphsMatchingExt.jl new file mode 100644 index 0000000..a469166 --- /dev/null +++ b/ext/LEMONGraphsGraphsMatchingExt.jl @@ -0,0 +1,44 @@ +module LEMONGraphsGraphsMatchingExt + +using LEMONGraphs +using Graphs +using GraphsMatching + +function GraphsMatching.minimum_weight_perfect_matching( + g::AbstractGraph, + weights::Dict{E,U}, + ::LEMONGraphs.LEMONAlgorithm; +) where {E<:Edge,U<:Integer} + max_penalty = 2 * abs(maximum(values(weights))) + lemon_weights = [-get(weights, e, max_penalty) for e in edges(g)] + weight_sum, mate_vec = LEMONGraphs.maxweightedperfectmatching(g, lemon_weights) + return GraphsMatching.MatchingResult(-weight_sum, mate_vec) +end + +function GraphsMatching.minimum_weight_perfect_matching( + g::AbstractGraph, + weights::Dict{E,U}, + ::LEMONGraphs.LEMONAlgorithm; + tmaxscale=10.0, +) where {E<:Edge,U<:AbstractFloat} + scaled = Dict{E,Int32}() + cmax = maximum(values(weights)) + cmin = minimum(values(weights)) + tmax = typemax(Int32) / tmaxscale + + for (e, c) in weights + scaled[e] = round(Int32, (c - cmin) / max(cmax - cmin, 1) * tmax) + end + + match = GraphsMatching.minimum_weight_perfect_matching(g, scaled, LEMONGraphs.LEMONAlgorithm()) + total_weight = zero(U) + for i in 1:nv(g) + j = match.mate[i] + if j > i + total_weight += get(weights, E(i, j), zero(U)) + end + end + return GraphsMatching.MatchingResult(total_weight, match.mate) +end + +end # module diff --git a/test/Project.toml b/test/Project.toml index cc7355d..7ae8dcb 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,6 +1,7 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" 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/test_graphsmatching_ext.jl b/test/test_graphsmatching_ext.jl new file mode 100644 index 0000000..388337a --- /dev/null +++ b/test/test_graphsmatching_ext.jl @@ -0,0 +1,69 @@ +@testitem "GraphsMatching: LEMONAlgorithm dispatch" begin + using Graphs, GraphsMatching, LEMONGraphs, Test + + g = complete_graph(4) + weights = Dict( + Edge(1, 2) => 10, + Edge(1, 3) => 1, + Edge(1, 4) => 1, + Edge(2, 3) => 1, + Edge(2, 4) => 1, + Edge(3, 4) => 10, + ) + + match = GraphsMatching.minimum_weight_perfect_matching(g, weights, LEMONAlgorithm()) + @test match isa GraphsMatching.MatchingResult + @test match.weight == 2 + @test match.mate == [3, 4, 1, 2] || match.mate == [4, 3, 2, 1] + + # the same answer as GraphsMatching's own LEMON-backed default + @test GraphsMatching.minimum_weight_perfect_matching(g, weights).weight == match.weight + + lg = LEMONGraph(g) + weight_sum, mate_vec = LEMONGraphs.maxweightedperfectmatching(lg, [10, 1, 1, 1, 1, 10]) + @test weight_sum == 20 + @test mate_vec == [2, 1, 4, 3] || mate_vec == [3, 4, 1, 2] +end + +@testitem "GraphsMatching: LEMONAlgorithm with floating point weights" begin + using Graphs, GraphsMatching, LEMONGraphs, Test + + g = complete_graph(4) + weights = Dict( + Edge(1, 2) => 10.0, + Edge(1, 3) => 1.0, + Edge(1, 4) => 1.0, + Edge(2, 3) => 1.0, + Edge(2, 4) => 1.0, + Edge(3, 4) => 10.0, + ) + + match = GraphsMatching.minimum_weight_perfect_matching(g, weights, LEMONAlgorithm()) + @test match isa GraphsMatching.MatchingResult + @test match.weight ≈ 2.0 + @test match.mate == [3, 4, 1, 2] || match.mate == [4, 3, 2, 1] +end + +@testitem "LEMON matching: legacy 2-argument API" begin + using Graphs, LEMONGraphs, Test + + g = complete_graph(4) + weights = [10, 1, 1, 1, 1, 10] + weight_sum, mate_vec = LEMONGraphs.maxweightedperfectmatching(g, weights) + + @test weight_sum == 20 + @test mate_vec == [2, 1, 4, 3] || mate_vec == [3, 4, 1, 2] + + @test_throws DimensionMismatch LEMONGraphs.maxweightedperfectmatching(g, [1, 2, 3]) + + # weights are stored in a C++ `int` + toobig = fill(typemax(Int), 6) + err = @test_throws ArgumentError LEMONGraphs.maxweightedperfectmatching(g, toobig) + @test occursin("does not fit", sprint(showerror, err.value)) + + # directed graphs have no perfect matching notion in LEMON's ListGraph + err = @test_throws ArgumentError LEMONGraphs.maxweightedperfectmatching( + path_digraph(4), [1, 1, 1] + ) + @test occursin("undirected", sprint(showerror, err.value)) +end From 7945c27d6739875e560289a54ee387c143c69e93 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 08/12] Add the GraphsOptim extension with LEMON shortest_path --- Project.toml | 5 ++ ext/LEMONGraphsGraphsOptimExt.jl | 85 ++++++++++++++++++++++++++++++++ test/Project.toml | 1 + test/test_graphsoptim_ext.jl | 39 +++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 ext/LEMONGraphsGraphsOptimExt.jl create mode 100644 test/test_graphsoptim_ext.jl diff --git a/Project.toml b/Project.toml index a377896..1011f6f 100644 --- a/Project.toml +++ b/Project.toml @@ -7,16 +7,21 @@ version = "0.1.2" CxxWrap = "1f15a43c-97ca-5a2a-ae31-89f07a497df4" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" LEMON_jll = "9f9b04fa-cfb6-5331-975f-45512019a816" +SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" [weakdeps] GraphsMatching = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" +GraphsOptim = "e79ef3ae-79c8-4b97-b165-63f338db30c2" [extensions] LEMONGraphsGraphsMatchingExt = "GraphsMatching" +LEMONGraphsGraphsOptimExt = "GraphsOptim" [compat] CxxWrap = "0.17.1" Graphs = "1.12.0" LEMON_jll = "1.3.6" GraphsMatching = "0.2.1" +GraphsOptim = "0.1" +SparseArrays = "1" julia = "1.10" diff --git a/ext/LEMONGraphsGraphsOptimExt.jl b/ext/LEMONGraphsGraphsOptimExt.jl new file mode 100644 index 0000000..39894ef --- /dev/null +++ b/ext/LEMONGraphsGraphsOptimExt.jl @@ -0,0 +1,85 @@ +module LEMONGraphsGraphsOptimExt + +using LEMONGraphs +using Graphs +using GraphsOptim +using SparseArrays: sparse + +const Lib = LEMONGraphs.Lib +const LEMONAlg = LEMONGraphs.LEMONAlgorithm +const CxxLong = LEMONGraphs.CxxLong + +""" + shortest_path(g, source, target, edge_cost, ::LEMONAlgorithm) + +Compute the shortest path using LEMON's Dijkstra backend. + +Returns the sequence of vertices from `source` to `target`. Only integer +`edge_cost` matrices are supported, because LEMON's `Dijkstra` binding is +instantiated for C++ `int`. +""" +function GraphsOptim.shortest_path( + g::AbstractGraph, + source::Int, + target::Int, + edge_cost::AbstractMatrix, + alg::LEMONAlg, +) + if !(eltype(edge_cost) <: Integer) + throw(ArgumentError( + "LEMON shortest_path only supports integer edge costs. " * + "Provide an integer cost matrix or use the default GraphsOptim solver backend." + )) + end + state = Graphs.dijkstra_shortest_paths(g, source, edge_cost, alg) + return _reconstruct_path(state.parents, source, target) +end +""" + min_vertex_cover(g, ::LEMONAlgorithm; kwargs...) + +LEMON does not ship a minimum vertex cover solver, so this method always throws. +It exists so that the `LEMONAlgorithm()` dispatch fails with an actionable +message rather than a bare `MethodError`. +""" +function GraphsOptim.min_vertex_cover(g::AbstractGraph, ::LEMONAlg; kwargs...) + throw(ArgumentError( + "LEMON ships no minimum vertex cover solver, so min_vertex_cover has no " * + "LEMON backend. Drop the LEMONAlgorithm() argument to use GraphsOptim's." + )) +end + +""" + maximum_weight_clique(g, ::LEMONAlgorithm; kwargs...) + +LEMON does not ship a maximum weight clique solver, so this method always +throws, for the same reason as `min_vertex_cover`. +""" +function GraphsOptim.maximum_weight_clique(g::AbstractGraph, ::LEMONAlg; kwargs...) + throw(ArgumentError( + "LEMON ships no maximum weight clique solver, so maximum_weight_clique has " * + "no LEMON backend. Drop the LEMONAlgorithm() argument to use GraphsOptim's." + )) +end +function _reconstruct_path(parents::AbstractVector{<:Integer}, source::Int, target::Int) + source == target && return [source] + + path = Int[] + current = target + while current != 0 + push!(path, current) + current == source && break + current = parents[current] + end + + if isempty(path) || path[end] != source + throw(ArgumentError("No path from source to target using LEMON backend")) + end + + reverse!(path) + return path +end +# Future algorithms: as more LEMON solvers are exposed by LEMON_jll (it also +# ships the CapacityScaling, CostScaling and CycleCanceling minimum cost flow +# solvers), add the corresponding `LEMONAlgorithm` dispatches here. + +end # module \ No newline at end of file diff --git a/test/Project.toml b/test/Project.toml index 7ae8dcb..00a82cb 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,6 +2,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" GraphsMatching = "c3af3a8c-b79e-4b01-bf44-c718d7e0e0d6" +GraphsOptim = "e79ef3ae-79c8-4b97-b165-63f338db30c2" GraphsInterfaceChecker = "3bef136c-15ff-4091-acbb-1a4aafe67608" Interfaces = "85a1e053-f937-4924-92a5-1367d23b7b87" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" diff --git a/test/test_graphsoptim_ext.jl b/test/test_graphsoptim_ext.jl new file mode 100644 index 0000000..3de7603 --- /dev/null +++ b/test/test_graphsoptim_ext.jl @@ -0,0 +1,39 @@ +@testitem "GraphsOptim: LEMON shortest_path dispatch" begin + using Graphs, GraphsOptim, LEMONGraphs, Test + + g = SimpleDiGraph(4) + add_edge!(g, 1, 2) + add_edge!(g, 2, 3) + add_edge!(g, 1, 3) + + weights = zeros(Int, 4, 4) + weights[1, 2] = 1 + weights[2, 3] = 2 + weights[1, 3] = 10 + + @test GraphsOptim.shortest_path(g, 1, 3, weights, LEMONAlgorithm()) == [1, 2, 3] + @test GraphsOptim.shortest_path(g, 1, 1, weights, LEMONAlgorithm()) == [1] + + err = @test_throws ArgumentError GraphsOptim.shortest_path( + g, 1, 4, weights, LEMONAlgorithm() + ) + @test occursin("No path", sprint(showerror, err.value)) + + float_cost = Float64.(weights) + err = @test_throws ArgumentError GraphsOptim.shortest_path( + g, 1, 3, float_cost, LEMONAlgorithm() + ) + @test occursin("integer edge costs", sprint(showerror, err.value)) +end + +@testitem "GraphsOptim: unimplemented LEMON dispatches raise helpful errors" begin + using Graphs, GraphsOptim, LEMONGraphs, Test + + ug = path_graph(3) + + err = @test_throws ArgumentError GraphsOptim.min_vertex_cover(ug, LEMONAlgorithm()) + @test occursin("min_vertex_cover", sprint(showerror, err.value)) + + err = @test_throws ArgumentError GraphsOptim.maximum_weight_clique(ug, LEMONAlgorithm()) + @test occursin("maximum_weight_clique", sprint(showerror, err.value)) +end \ No newline at end of file From 221340fae17cf824e4add915de8b82ed657cd979 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:27 +0530 Subject: [PATCH 09/12] Implement GraphsOptim min_cost_flow and min_cost_assignment on LEMON --- ext/LEMONGraphsGraphsOptimExt.jl | 225 +++++++++++++++++++++++++++++- test/test_graphsoptim_ext.jl | 227 ++++++++++++++++++++++++++++++- 2 files changed, 450 insertions(+), 2 deletions(-) diff --git a/ext/LEMONGraphsGraphsOptimExt.jl b/ext/LEMONGraphsGraphsOptimExt.jl index 39894ef..1643be1 100644 --- a/ext/LEMONGraphsGraphsOptimExt.jl +++ b/ext/LEMONGraphsGraphsOptimExt.jl @@ -34,6 +34,65 @@ function GraphsOptim.shortest_path( state = Graphs.dijkstra_shortest_paths(g, source, edge_cost, alg) return _reconstruct_path(state.parents, source, target) end + +""" + min_cost_assignment(edge_cost, ::LEMONAlgorithm; kwargs...) + +Solve the square linear assignment problem with LEMON's `NetworkSimplex`. + +`edge_cost` must be a square matrix of integer-valued costs. The result is a +`Matrix{Int}` of zeros and ones with exactly one `1` per row and per column, +minimising `sum(edge_cost .* assignment)`. + +The `integer` keyword accepted by `GraphsOptim.min_cost_assignment` is ignored: +the network simplex is always integral, and so is the assignment polytope. +""" +function GraphsOptim.min_cost_assignment( + edge_cost::AbstractMatrix, ::LEMONAlg; integer::Bool=true, kwargs... +) + n, m = size(edge_cost) + n == m || throw(DimensionMismatch( + "LEMON min_cost_assignment requires a square cost matrix, got $(n)×$(m)" + )) + n == 0 && return zeros(Int, 0, 0) + + # Bipartite transportation network: + # source -> row i -> column j -> sink, every arc of capacity 1. + dg = Lib.ListDigraph() + nnodes = 2n + 2 + source, sink = 2n + 1, 2n + 2 + nodes = [Lib.addNode(dg) for _ in 1:nnodes] + + arcs = [Lib.addArc(dg, nodes[source], nodes[i]) for i in 1:n] + append!(arcs, Lib.addArc(dg, nodes[n + j], nodes[sink]) for j in 1:n) + # `arc_cell[k]` is the (row, column) an arc represents, or `(0, 0)` for the + # source/sink arcs that carry no cost. + arc_cell = fill((0, 0), length(arcs)) + arc_cost = zeros(CxxLong, length(arcs)) + for i in 1:n, j in 1:n + push!(arcs, Lib.addArc(dg, nodes[i], nodes[n + j])) + push!(arc_cell, (i, j)) + push!(arc_cost, _lemon_value(edge_cost[i, j], "assignment cost")) + end + + supply = zeros(CxxLong, nnodes) + supply[source] = CxxLong(n) + supply[sink] = CxxLong(-n) + + flows = _run_min_cost_flow( + dg, nodes, arcs, supply, :network_simplex; + lower=_ -> zero(CxxLong), upper=_ -> one(CxxLong), cost=k -> arc_cost[k], + ) + + assignment = zeros(Int, n, n) + for (k, f) in enumerate(flows) + i, j = arc_cell[k] + i == 0 && continue + assignment[i, j] = Int(f) + end + return assignment +end + """ min_vertex_cover(g, ::LEMONAlgorithm; kwargs...) @@ -60,6 +119,169 @@ function GraphsOptim.maximum_weight_clique(g::AbstractGraph, ::LEMONAlg; kwargs. "no LEMON backend. Drop the LEMONAlgorithm() argument to use GraphsOptim's." )) end + +""" + min_cost_flow(g, vertex_demand, edge_cost, ::LEMONAlgorithm; solver=:network_simplex) + min_cost_flow(g, vertex_demand, edge_cost, edge_min_capacity, edge_max_capacity, ::LEMONAlgorithm; solver=:network_simplex) + +Compute a minimum cost flow with one of LEMON's minimum cost flow solvers. + +The argument conventions match `GraphsOptim.min_cost_flow`: `vertex_demand` is +positive at sinks and negative at sources, and the capacity/cost matrices are +indexed by `[u, v]`. All values must be integers; `edge_max_capacity` entries +may also be `Inf` to denote an uncapacitated arc. + +`solver` selects the LEMON implementation, one of `:network_simplex` (the +default and usually the fastest), `:capacity_scaling`, `:cost_scaling` or +`:cycle_canceling`. All four return an optimal flow; they differ in running +time, and only `:network_simplex` accepts negative costs on arcs whose upper +capacity is infinite; the others report such a problem as unbounded. + +The returned sparse matrix holds the flow on every arc. For an undirected `g` +each edge is modelled by an arc in both directions (as `GraphsOptim` does), and +unlike `GraphsOptim` both orientations appear in the result. + +Throws an `ArgumentError` if the problem is infeasible or unbounded. +""" +function GraphsOptim.min_cost_flow( + g::AbstractGraph, vertex_demand, edge_cost, alg::LEMONAlg; kwargs... +) + return GraphsOptim.min_cost_flow( + g, vertex_demand, edge_cost, nothing, nothing, alg; kwargs... + ) +end + +function GraphsOptim.min_cost_flow( + g::AbstractGraph, + vertex_demand, + edge_cost, + edge_min_capacity, + edge_max_capacity, + ::LEMONAlg; + solver::Symbol=:network_simplex, + integer::Bool=true, + kwargs..., +) + ldg = LEMONGraphs.LEMONDiGraph(g) + nvg = Graphs.nv(ldg) + length(vertex_demand) == nvg || throw(DimensionMismatch( + "vertex_demand has length $(length(vertex_demand)) but the graph has $nvg vertices" + )) + + us, vs = ldg.arc_src, ldg.arc_dst + supply = Vector{CxxLong}(undef, nvg) + for v in 1:nvg + # GraphsOptim states the balance as `inflow == demand + outflow`, whereas + # LEMON's supply map is `outflow - inflow`; hence the sign flip. + supply[v] = -_lemon_value(vertex_demand[v], "vertex demand") + end + + flows = _run_min_cost_flow( + ldg.graph, ldg.nodes, ldg.arcs, supply, solver; + lower=k -> _lemon_capacity(edge_min_capacity, us[k], vs[k], zero(CxxLong)), + upper=k -> _lemon_capacity(edge_max_capacity, us[k], vs[k], typemax(CxxLong)), + cost=k -> _lemon_value(edge_cost[us[k], vs[k]], "edge cost"), + ) + + return sparse(us, vs, Int.(flows), nvg, nvg) +end + +# --- helpers --------------------------------------------------------------- + +# LEMON's flow solvers are instantiated for `long long`, so every value has to +# be an exact integer. Integral floats are accepted -- mixing `Inf` with finite +# capacities forces a floating point matrix -- but nothing is ever rounded. +_lemon_value(x::Integer, what::AbstractString) = CxxLong(x) +function _lemon_value(x, what::AbstractString) + x isa Real && isinteger(x) && return CxxLong(x) + throw(ArgumentError( + "LEMON requires an integer $what, got $(repr(x))::$(typeof(x))" + )) +end + +_lemon_capacity(::Nothing, _u, _v, default::CxxLong) = default +function _lemon_capacity(m, u, v, default::CxxLong) + x = m[u, v] + x isa Integer && return CxxLong(x) + (x isa Real && isinf(x) && x > 0) && return typemax(CxxLong) + x isa Real && isinteger(x) && return CxxLong(x) + throw(ArgumentError( + "LEMON requires integer capacities (or `Inf` for an unbounded upper capacity), " * + "got $(repr(x))::$(typeof(x)) at ($u, $v)" + )) +end + +""" + LEMONGraphsGraphsOptimExt.MIN_COST_FLOW_SOLVERS + +The LEMON minimum cost flow solvers reachable through the `solver` keyword of +`min_cost_flow(..., LEMONAlgorithm())`. +""" +const MIN_COST_FLOW_SOLVERS = ( + :network_simplex, :capacity_scaling, :cost_scaling, :cycle_canceling +) + +function _min_cost_flow_solver_type(solver::Symbol) + solver === :network_simplex && return Lib.NetworkSimplex{CxxLong,CxxLong} + solver === :capacity_scaling && return Lib.CapacityScaling{CxxLong,CxxLong} + solver === :cost_scaling && return Lib.CostScaling{CxxLong,CxxLong} + solver === :cycle_canceling && return Lib.CycleCanceling{CxxLong,CxxLong} + throw(ArgumentError( + "unknown LEMON minimum cost flow solver $(repr(solver)); " * + "expected one of $(MIN_COST_FLOW_SOLVERS)" + )) +end + +""" + _run_min_cost_flow(dg, nodes, arcs, supply, solver; lower, upper, cost) + +Fill LEMON arc/node maps from the callables `lower`, `upper` and `cost` (each +called with an arc index) plus the `supply` vector, run the requested LEMON +minimum cost flow solver, and return the flow on each arc in the order of +`arcs`. +""" +function _run_min_cost_flow( + dg, nodes, arcs, supply::Vector{CxxLong}, solver::Symbol; lower, upper, cost +) + solver_type = _min_cost_flow_solver_type(solver) + lower_map = Lib.ListDigraphArcMap{CxxLong}(dg) + upper_map = Lib.ListDigraphArcMap{CxxLong}(dg) + cost_map = Lib.ListDigraphArcMap{CxxLong}(dg) + supply_map = Lib.ListDigraphNodeMap{CxxLong}(dg) + + for (k, a) in enumerate(arcs) + lo = lower(k) + up = upper(k) + lo <= up || throw(ArgumentError( + "arc $k has a minimum capacity ($lo) above its maximum capacity ($up)" + )) + Lib.set(lower_map, a, lo) + Lib.set(upper_map, a, up) + Lib.set(cost_map, a, cost(k)) + end + for (v, node) in enumerate(nodes) + Lib.set(supply_map, node, v <= length(supply) ? supply[v] : zero(CxxLong)) + end + + backend = solver_type(dg) + Lib.lowerMap(backend, lower_map) + Lib.upperMap(backend, upper_map) + Lib.costMap(backend, cost_map) + Lib.supplyMap(backend, supply_map) + + status = Lib.run(backend) + if status == Lib.ProblemTypeInfeasible() + throw(ArgumentError("LEMON found the minimum cost flow problem infeasible")) + elseif status == Lib.ProblemTypeUnbounded() + throw(ArgumentError( + "LEMON found the minimum cost flow problem unbounded. Only " * + "`solver = :network_simplex` supports negative costs on arcs with an " * + "infinite upper capacity; give those arcs a finite capacity otherwise." + )) + end + return CxxLong[Lib.flow(backend, a) for a in arcs] +end + function _reconstruct_path(parents::AbstractVector{<:Integer}, source::Int, target::Int) source == target && return [source] @@ -78,8 +300,9 @@ function _reconstruct_path(parents::AbstractVector{<:Integer}, source::Int, targ reverse!(path) return path end + # Future algorithms: as more LEMON solvers are exposed by LEMON_jll (it also # ships the CapacityScaling, CostScaling and CycleCanceling minimum cost flow # solvers), add the corresponding `LEMONAlgorithm` dispatches here. -end # module \ No newline at end of file +end # module diff --git a/test/test_graphsoptim_ext.jl b/test/test_graphsoptim_ext.jl index 3de7603..8f20a1a 100644 --- a/test/test_graphsoptim_ext.jl +++ b/test/test_graphsoptim_ext.jl @@ -26,6 +26,231 @@ @test occursin("integer edge costs", sprint(showerror, err.value)) end +@testitem "GraphsOptim: LEMON min_cost_flow" begin + using Graphs, GraphsOptim, LEMONGraphs, Test + + g = SimpleDiGraph(3) + add_edge!(g, 1, 2) + add_edge!(g, 2, 3) + + vertex_demand = [-1, 0, 1] + edge_cost = zeros(Int, 3, 3) + edge_cost[1, 2] = 1 + edge_cost[2, 3] = 1 + + # uncapacitated (default) form + flow = GraphsOptim.min_cost_flow(g, vertex_demand, edge_cost, LEMONAlgorithm()) + @test flow[1, 2] == 1 + @test flow[2, 3] == 1 + + # explicit capacities, including `Inf` for an unbounded arc + emin = zeros(Int, 3, 3) + emax = zeros(Int, 3, 3) + emax[1, 2] = 1 + emax[2, 3] = 1 + @test GraphsOptim.min_cost_flow( + g, vertex_demand, edge_cost, emin, emax, LEMONAlgorithm() + ) == flow + @test GraphsOptim.min_cost_flow( + g, vertex_demand, edge_cost, emin, fill(Inf, 3, 3), LEMONAlgorithm() + ) == flow + + # a lower bound forces flow through an otherwise unused arc + add_edge!(g, 1, 3) + cost2 = zeros(Int, 3, 3) + cost2[1, 2] = 1 + cost2[2, 3] = 1 + cost2[1, 3] = 1 + lower = zeros(Int, 3, 3) + upper = fill(5, 3, 3) + plain = GraphsOptim.min_cost_flow(g, [-2, 0, 2], cost2, lower, upper, LEMONAlgorithm()) + @test plain[1, 3] == 2 + lower[1, 2] = 1 + forced = GraphsOptim.min_cost_flow(g, [-2, 0, 2], cost2, lower, upper, LEMONAlgorithm()) + @test forced[1, 2] == 1 + @test forced[2, 3] == 1 + @test forced[1, 3] == 1 + + # infeasible / malformed inputs + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + g, [-1, 0, 1], cost2, zeros(Int, 3, 3), zeros(Int, 3, 3), LEMONAlgorithm() + ) + @test occursin("infeasible", sprint(showerror, err.value)) + + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + g, [-1.5, 0.0, 1.5], cost2, LEMONAlgorithm() + ) + @test occursin("integer vertex demand", sprint(showerror, err.value)) + + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + g, [-1, 0, 1], 0.5 .* cost2, LEMONAlgorithm() + ) + @test occursin("integer edge cost", sprint(showerror, err.value)) + + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + g, [-1, 0, 1], cost2, lower, fill(0.5, 3, 3), LEMONAlgorithm() + ) + @test occursin("integer capacities", sprint(showerror, err.value)) + + @test_throws DimensionMismatch GraphsOptim.min_cost_flow( + g, [-1, 1], cost2, LEMONAlgorithm() + ) + + # integral floats are accepted (mixing `Inf` with finite capacities forces + # a floating point matrix), but nothing is rounded + mixed = fill(Inf, 3, 3) + mixed[1, 2] = 1.0 + mixed[2, 3] = 1.0 + mixed[1, 3] = 0.0 + @test GraphsOptim.min_cost_flow( + g, [-1.0, 0.0, 1.0], Float64.(cost2), zeros(3, 3), mixed, LEMONAlgorithm() + ) == flow +end + +@testitem "GraphsOptim: LEMON min_cost_flow solver selection" begin + using Graphs, GraphsOptim, LEMONGraphs, StableRNGs, Test + + rng = StableRNG(20260729) + g = SimpleDiGraph(6) + for u in 1:6, v in 1:6 + u < v && rand(rng) < 0.6 && add_edge!(g, u, v) + end + add_edge!(g, 1, 6) + cost = rand(rng, 1:9, 6, 6) + cap = fill(3, 6, 6) + demand = zeros(Int, 6) + demand[1] = -2 + demand[6] = 2 + + arcs = [(src(e), dst(e)) for e in edges(g)] + solvers = (:network_simplex, :capacity_scaling, :cost_scaling, :cycle_canceling) + totals = map(solvers) do solver + flow = GraphsOptim.min_cost_flow( + g, demand, cost, zeros(Int, 6, 6), cap, LEMONAlgorithm(); solver + ) + for v in 1:6 + inflow = sum(flow[u, v] for u in inneighbors(g, v); init=0) + outflow = sum(flow[v, w] for w in outneighbors(g, v); init=0) + @test inflow - outflow == demand[v] + end + return sum(cost[u, v] * flow[u, v] for (u, v) in arcs; init=0) + end + @test all(==(first(totals)), totals) + + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + g, demand, cost, LEMONAlgorithm(); solver=:nope + ) + @test occursin("unknown LEMON minimum cost flow solver", sprint(showerror, err.value)) + + # only the network simplex copes with a negative cost on an uncapacitated arc + h = SimpleDiGraph(3) + add_edge!(h, 1, 2) + add_edge!(h, 2, 3) + negative = zeros(Int, 3, 3) + negative[1, 2] = -1 + negative[2, 3] = 1 + @test GraphsOptim.min_cost_flow( + h, [-1, 0, 1], negative, LEMONAlgorithm(); solver=:network_simplex + )[1, 2] == 1 + for solver in (:capacity_scaling, :cost_scaling, :cycle_canceling) + err = @test_throws ArgumentError GraphsOptim.min_cost_flow( + h, [-1, 0, 1], negative, LEMONAlgorithm(); solver + ) + @test occursin("unbounded", sprint(showerror, err.value)) + end + # ... and giving those arcs a finite capacity makes them agree again + for solver in (:capacity_scaling, :cost_scaling, :cycle_canceling) + @test GraphsOptim.min_cost_flow( + h, [-1, 0, 1], negative, zeros(Int, 3, 3), fill(4, 3, 3), LEMONAlgorithm(); + solver, + )[1, 2] == 1 + end +end + +@testitem "GraphsOptim: LEMON min_cost_flow matches the JuMP backend" begin + using Graphs, GraphsOptim, LEMONGraphs, StableRNGs, Test + + rng = StableRNG(20260729) + for _ in 1:15 + n = rand(rng, 3:7) + g = SimpleDiGraph(n) + for u in 1:n, v in 1:n + u != v && rand(rng) < 0.4 && add_edge!(g, u, v) + end + cost = rand(rng, 0:9, n, n) + cap = rand(rng, 0:3, n, n) + lower = zeros(Int, n, n) + demand = zeros(Int, n) + demand[1] = -1 + demand[n] = 1 + + lem = try + GraphsOptim.min_cost_flow(g, demand, cost, lower, cap, LEMONAlgorithm()) + catch err + err isa ArgumentError || rethrow() + nothing + end + ref = try + GraphsOptim.min_cost_flow(g, demand, cost, lower, cap; integer=true) + catch + nothing + end + lem === nothing && ref === nothing && continue + @test (lem === nothing) == (ref === nothing) + lem === nothing && continue + + arcs = [(src(e), dst(e)) for e in edges(g)] + # the flows themselves may differ between optima, but the costs must not + @test sum(cost[u, v] * lem[u, v] for (u, v) in arcs; init=0) ≈ + sum(cost[u, v] * ref[u, v] for (u, v) in arcs; init=0) + # and the LEMON flow must be feasible + for (u, v) in arcs + @test lower[u, v] <= lem[u, v] <= cap[u, v] + end + for v in 1:n + inflow = sum(lem[u, v] for u in inneighbors(g, v); init=0) + outflow = sum(lem[v, w] for w in outneighbors(g, v); init=0) + @test inflow - outflow == demand[v] + end + end +end + +@testitem "GraphsOptim: LEMON min_cost_assignment" begin + using Graphs, GraphsOptim, LEMONGraphs, StableRNGs, Test + + cost = [4 1 3; 2 0 5; 3 2 2] + assignment = GraphsOptim.min_cost_assignment(cost, LEMONAlgorithm()) + @test assignment isa Matrix{Int} + @test all(sum(assignment; dims=1) .== 1) + @test all(sum(assignment; dims=2) .== 1) + @test sum(cost .* assignment) == 5 + + @test GraphsOptim.min_cost_assignment(zeros(Int, 0, 0), LEMONAlgorithm()) == + zeros(Int, 0, 0) + + @test_throws DimensionMismatch GraphsOptim.min_cost_assignment( + zeros(Int, 2, 3), LEMONAlgorithm() + ) + err = @test_throws ArgumentError GraphsOptim.min_cost_assignment( + fill(0.5, 2, 2), LEMONAlgorithm() + ) + @test occursin("integer assignment cost", sprint(showerror, err.value)) + + # integral floats are fine + @test GraphsOptim.min_cost_assignment(Float64.(cost), LEMONAlgorithm()) == assignment + + rng = StableRNG(20260729) + for _ in 1:15 + n = rand(rng, 1:6) + C = rand(rng, 0:20, n, n) + A = GraphsOptim.min_cost_assignment(C, LEMONAlgorithm()) + ref = GraphsOptim.min_cost_assignment(C; integer=true) + @test all(sum(A; dims=1) .== 1) + @test all(sum(A; dims=2) .== 1) + @test sum(C .* A) == round(Int, sum(C .* ref)) + end +end + @testitem "GraphsOptim: unimplemented LEMON dispatches raise helpful errors" begin using Graphs, GraphsOptim, LEMONGraphs, Test @@ -36,4 +261,4 @@ end err = @test_throws ArgumentError GraphsOptim.maximum_weight_clique(ug, LEMONAlgorithm()) @test occursin("maximum_weight_clique", sprint(showerror, err.value)) -end \ No newline at end of file +end From 9312ea4b6e7f3e5fec447ba1f49c1bb1e2da45b5 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:28 +0530 Subject: [PATCH 10/12] Benchmark conversions, the AbstractGraph API and Dijkstra --- benchmark/Project.toml | 1 + benchmark/benchmarks.jl | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/benchmark/Project.toml b/benchmark/Project.toml index 68ab73e..7538af0 100644 --- a/benchmark/Project.toml +++ b/benchmark/Project.toml @@ -5,3 +5,4 @@ LEMONGraphs = "14b1564f-c77f-4800-9e89-efd961faef7c" [compat] BenchmarkTools = "1" +Graphs = "1.12" diff --git a/benchmark/benchmarks.jl b/benchmark/benchmarks.jl index 56a6298..f4863cd 100644 --- a/benchmark/benchmarks.jl +++ b/benchmark/benchmarks.jl @@ -1,9 +1,15 @@ using BenchmarkTools using Graphs +using LEMONGraphs using LEMONGraphs: maxweightedperfectmatching const SUITE = BenchmarkGroup() +# --------------------------------------------------------------------------- +# Maximum weight perfect matching (the original benchmark, kept unchanged so +# that historical comparisons stay meaningful) +# --------------------------------------------------------------------------- + function mwpm_benchmark_input() g = Graph(6) add_edge!(g, 1, 2) @@ -23,3 +29,90 @@ SUITE["maxweightedperfectmatching"] = @benchmarkable maxweightedperfectmatching( $MWPM_GRAPH, $MWPM_WEIGHTS, ) + +# --------------------------------------------------------------------------- +# Conversions between Graphs.jl and the LEMON C++ structures +# --------------------------------------------------------------------------- + +const CONV_GRAPH = complete_graph(100) +const CONV_DIGRAPH = complete_digraph(70) +const CONV_LEMON = LEMONGraph(CONV_GRAPH) +const CONV_LEMON_DI = LEMONDiGraph(CONV_DIGRAPH) + +SUITE["conversion"] = BenchmarkGroup() +SUITE["conversion"]["to LEMONGraph"] = @benchmarkable LEMONGraph($CONV_GRAPH) +SUITE["conversion"]["to LEMONDiGraph"] = @benchmarkable LEMONDiGraph($CONV_DIGRAPH) +SUITE["conversion"]["back to Graph"] = @benchmarkable Graph($CONV_LEMON) +SUITE["conversion"]["back to DiGraph"] = @benchmarkable DiGraph($CONV_LEMON_DI) +# O(1) reuse: handing a wrapper to a LEMON algorithm must not re-convert it +SUITE["conversion"]["reuse a wrapper"] = @benchmarkable LEMONGraphs.to_list_graph( + $CONV_LEMON +) + +# --------------------------------------------------------------------------- +# AbstractGraph API, the inner loop of every generic Graphs.jl algorithm +# --------------------------------------------------------------------------- + +function neighbor_sweep(g) + total = 0 + for v in vertices(g), u in outneighbors(g, v) + total += u + end + return total +end + +function edge_sweep(g) + total = 0 + for e in edges(g) + total += src(e) + dst(e) + end + return total +end + +function has_edge_sweep(g, n) + total = 0 + for u in 1:n, v in 1:n + total += has_edge(g, u, v) + end + return total +end + +const API_GRAPH = grid([25, 25]) +const API_LEMON = LEMONGraph(API_GRAPH) + +SUITE["api"] = BenchmarkGroup() +for (name, g) in ("LEMONGraph" => API_LEMON, "SimpleGraph" => API_GRAPH) + SUITE["api"]["outneighbors ($name)"] = @benchmarkable neighbor_sweep($g) + SUITE["api"]["edges ($name)"] = @benchmarkable edge_sweep($g) + SUITE["api"]["has_edge ($name)"] = @benchmarkable has_edge_sweep($g, 60) +end +# a generic Graphs.jl algorithm driven entirely through the interface +SUITE["api"]["connected_components (LEMONGraph)"] = @benchmarkable connected_components( + $API_LEMON +) + +# --------------------------------------------------------------------------- +# Dijkstra: the LEMON backend against the native Graphs.jl one +# --------------------------------------------------------------------------- + +const DIJKSTRA_GRAPH = grid([20, 20]) +const DIJKSTRA_LEMON = LEMONDiGraph(DIJKSTRA_GRAPH) +const DIJKSTRA_WEIGHTS = [ + 1 + ((i + j) % 7) for i in 1:nv(DIJKSTRA_GRAPH), j in 1:nv(DIJKSTRA_GRAPH) +] + +SUITE["dijkstra"] = BenchmarkGroup() +SUITE["dijkstra"]["Graphs.jl"] = @benchmarkable dijkstra_shortest_paths( + $DIJKSTRA_GRAPH, 1, $DIJKSTRA_WEIGHTS +) +# from a SimpleGraph the LEMON backend has to build the C++ digraph first ... +SUITE["dijkstra"]["LEMON (converting)"] = @benchmarkable dijkstra_shortest_paths( + $DIJKSTRA_GRAPH, 1, $DIJKSTRA_WEIGHTS, LEMONAlgorithm() +) +# ... whereas a wrapper it already holds is reused as is +SUITE["dijkstra"]["LEMON (prebuilt wrapper)"] = @benchmarkable dijkstra_shortest_paths( + $DIJKSTRA_LEMON, 1, $DIJKSTRA_WEIGHTS, LEMONAlgorithm() +) +SUITE["dijkstra"]["LEMON (multi-source)"] = @benchmarkable dijkstra_shortest_paths( + $DIJKSTRA_GRAPH, [1, 5], $DIJKSTRA_WEIGHTS, LEMONAlgorithm() +) From 66ad561add0b6c0f622c4c3d9e584a81041db49d Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:28 +0530 Subject: [PATCH 11/12] Document the graph types, the algorithm dispatches, and how to add more --- .gitignore | 1 + README.md | 121 +++++++++++++++++++++++++++++-- docs/make.jl | 7 ++ docs/src/algorithms.md | 159 +++++++++++++++++++++++++++++++++++++++++ docs/src/api.md | 42 +++++++++++ docs/src/extending.md | 126 ++++++++++++++++++++++++++++++++ docs/src/graphs.md | 110 ++++++++++++++++++++++++++++ docs/src/index.md | 51 ++++++++++++- 8 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 docs/src/algorithms.md create mode 100644 docs/src/api.md create mode 100644 docs/src/extending.md create mode 100644 docs/src/graphs.md diff --git a/.gitignore b/.gitignore index b159e1a..f6006bd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ scratch/ .vscode test/.CondaPkg/ docs/.CondaPkg/ +docs/build/ diff --git a/README.md b/README.md index 602a2e6..fc8f486 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,120 @@ -A very thin Julia wrapper around the C++ graphs library [`LEMON`](http://lemon.cs.elte.hu/). +# LEMONGraphs.jl -Currently used mainly for its Min/Max Weight Perfect Matching algorithm, -wrapped into a more user-friendly API in GraphsMatching.jl, -but additional bindings can be added on request. +A Julia wrapper for the C++ graph algorithms library [LEMON](http://lemon.cs.elte.hu/). -No public API is provided yet. +It provides `LEMONGraph` and `LEMONDiGraph`, which implement the Graphs.jl +`AbstractGraph` interface on top of LEMON's `ListGraph` and `ListDigraph`, +conversions in both directions with `O(1)` reuse, and LEMON implementations of +matching, shortest paths, minimum cost flow and assignment. The interface is +checked in the test suite with GraphsInterfaceChecker.jl. + +Full documentation: . + +## Installation + +```julia +using Pkg +Pkg.add("LEMONGraphs") +``` Depends on `LEMON_jll`, which is compiled and packaged for all platforms supported by Julia. + +## Quick Start + +```julia +using Graphs, LEMONGraphs + +# Convert a Graphs.jl graph to LEMON +g = path_graph(5) +lg = LEMONGraph(g) + +# Use LEMON graph like any AbstractGraph +@assert nv(lg) == 5 +@assert ne(lg) == 4 +@assert Graph(lg) == g + +# Use LEMON-backed algorithms by passing LEMONAlgorithm() last +distmx = [abs(i - j) for i in 1:5, j in 1:5] +state = dijkstra_shortest_paths(g, 1, distmx, LEMONAlgorithm()) +@assert state.dists == [0, 1, 2, 3, 4] +``` + +## Usage + +### Graph types + +```julia +# Undirected +g = complete_graph(4) +lg = LEMONGraph(g) + +# Directed +dg = SimpleDiGraph(4) +ldg = LEMONDiGraph(dg) + +# All standard Graphs.jl methods work: +nv(lg), ne(lg), vertices(lg), edges(lg) +has_vertex(lg, 1), has_edge(lg, 1, 2) +inneighbors(lg, 1), outneighbors(lg, 1) + +# ... and so do generic Graphs.jl algorithms: +connected_components(lg), kruskal_mst(lg), is_strongly_connected(ldg) +``` + +Adjacency is cached on the Julia side when the wrapper is built, so neighbour +and edge queries are as fast as on a `SimpleGraph` and never cross into C++. +Every component of the `AbstractGraph` interface, the optional `mutation` one +included, is checked in the test suite with GraphsInterfaceChecker.jl. + +`add_vertex!` and `add_edge!` grow the underlying LEMON structure. Removal is +the one gap: `LEMON_jll` does not expose `ListGraph::erase`, so `rem_edge!` and +`rem_vertex!` decline by returning `false`. + +### Reusing a wrapper + +A LEMON-backed algorithm handed an existing `LEMONGraph` or `LEMONDiGraph` +reuses it in `O(1)` rather than converting again: + +```julia +g = complete_graph(100) +lg1 = LEMONGraph(g) +w, mate = maxweightedperfectmatching(lg1, ones(Int, ne(lg1))) # no reconversion +``` + +### Maximum weight perfect matching + +```julia +using Graphs, LEMONGraphs + +g = complete_graph(6) +weights = Dict(e => rand(-100:100) for e in edges(g)) +w, spouse_map = maxweightedperfectmatching(g, weights, LEMONAlgorithm()) +``` + +### Package extensions + +- GraphsMatching.jl: `minimum_weight_perfect_matching(g, weights, LEMONAlgorithm())`, for + integer or floating point weight dictionaries. +- GraphsOptim.jl: `shortest_path` (LEMON Dijkstra), `min_cost_flow` (LEMON + `NetworkSimplex`, with `:capacity_scaling`, `:cost_scaling` and + `:cycle_canceling` also available through the `solver` keyword), and + `min_cost_assignment` (square integer cost matrices). `min_vertex_cover` and + `maximum_weight_clique` accept `LEMONAlgorithm()` only to report that LEMON + has no such solver. + +All LEMON solvers exposed by `LEMON_jll` are integer-valued, so weights, costs +and capacities must be `Integer`s; a floating point input raises an +`ArgumentError` instead of being silently rounded. + +## Testing + +```julia +using Pkg +Pkg.test("LEMONGraphs") +``` + +## References + +- LEMON documentation: http://lemon.cs.elte.hu/ +- Graphs.jl: https://github.com/JuliaGraphs/Graphs.jl +- Issue #447: [A reliable idiomatic wrapper for the C++ library LEMON](https://github.com/JuliaGraphs/Graphs.jl/issues/447) diff --git a/docs/make.jl b/docs/make.jl index 8cb5384..50be06b 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -4,12 +4,19 @@ using LEMONGraphs makedocs( sitename = "LEMONGraphs.jl", modules = [LEMONGraphs], + # internal helpers carry docstrings for the sake of contributors without + # being part of the manual + checkdocs = :exports, repo = "https://github.com/JuliaGraphs/LEMONGraphs.jl/blob/{commit}{path}#L{line}", format = Documenter.HTML( repolink = "https://github.com/JuliaGraphs/LEMONGraphs.jl", ), pages = [ "Home" => "index.md", + "Graph types and conversions" => "graphs.md", + "LEMON-backed algorithms" => "algorithms.md", + "Adding another LEMON algorithm" => "extending.md", + "API reference" => "api.md", ], ) diff --git a/docs/src/algorithms.md b/docs/src/algorithms.md new file mode 100644 index 0000000..5d5675c --- /dev/null +++ b/docs/src/algorithms.md @@ -0,0 +1,159 @@ +```@meta +CurrentModule = LEMONGraphs +``` + +# LEMON-backed algorithms + +Every LEMON implementation is reached by passing [`LEMONAlgorithm`](@ref) as the +last positional argument of the function you would call anyway. The signature, +the keyword arguments and the return type are the ones of the original +function, so switching backends is a one-token change: + +```julia +dijkstra_shortest_paths(g, 1, distmx) # Graphs.jl +dijkstra_shortest_paths(g, 1, distmx, LEMONAlgorithm()) # LEMON +``` + +## What is available + +| Function | Owner | LEMON backend | Notes | +|:--|:--|:--|:--| +| `dijkstra_shortest_paths` | Graphs.jl | `Dijkstra` | integer weights | +| `maxweightedperfectmatching` | LEMONGraphs | `MaxWeightedPerfectMatching` | integer weights | +| `minimum_weight_perfect_matching` | GraphsMatching.jl | `MaxWeightedPerfectMatching` | integer or float weights | +| `shortest_path` | GraphsOptim.jl | `Dijkstra` | integer costs | +| `min_cost_flow` | GraphsOptim.jl | `NetworkSimplex` and friends | integer costs/capacities | +| `min_cost_assignment` | GraphsOptim.jl | `NetworkSimplex` | square integer cost matrix | +| `min_vertex_cover` | GraphsOptim.jl | none | throws; LEMON has no solver | +| `maximum_weight_clique` | GraphsOptim.jl | none | throws; LEMON has no solver | + +The GraphsMatching.jl and GraphsOptim.jl methods live in package extensions, so +they appear only once you have loaded the corresponding package. + +!!! note "LEMON is integer-valued" + The LEMON templates exposed by `LEMON_jll` are instantiated for C++ `int` + (Dijkstra, matching) and `long long` (the flow solvers). Nothing is ever + silently rounded: a value that is not an exact integer raises an + `ArgumentError` naming the offending argument. + + Dijkstra requires a distance matrix with an `Integer` element type. The + flow and assignment solvers check values rather than element types, so an + integral floating point matrix is accepted, which mixing `Inf` with finite + capacities forces. `minimum_weight_perfect_matching` is the exception: + it follows GraphsMatching.jl in rescaling float weights onto `Int32`. + +## Shortest paths + +```julia +using Graphs, LEMONGraphs + +g = path_digraph(5) +distmx = [abs(i - j) for i in 1:5, j in 1:5] + +state = dijkstra_shortest_paths(g, 1, distmx, LEMONAlgorithm()) +state.dists, state.parents, state.pathcounts +``` + +The returned `Graphs.DijkstraState` is fully populated and interchangeable with +the one Graphs.jl produces: `dists`, `parents`, `pathcounts`, `closest_vertices` +(with `trackvertices=true`) and `predecessors` (with `allpaths=true`) all carry +the same meaning. LEMON only reports distances and a single predecessor per +vertex, so LEMONGraphs recovers the shortest-path DAG from the distance labels +and counts the paths itself. + +Multiple sources are supported, modelled with an auxiliary zero-cost +super-source, as are `maxdist` and the two keyword flags: + +```julia +dijkstra_shortest_paths(g, [1, 3], distmx, LEMONAlgorithm(); allpaths=true, maxdist=4) +``` + +Weights must be non-negative (Dijkstra's requirement) and must fit in a C++ +`int`; both are checked. + +!!! tip "Reuse the wrapper" + Handing this method a `SimpleGraph` means building the C++ digraph and + filling an arc map on every call, and that dominates the runtime on small + and medium graphs, where the pure-Julia `Graphs.dijkstra_shortest_paths` is + faster. Passing a [`LEMONDiGraph`](@ref) you already hold skips the + construction entirely and is roughly three times faster than converting + each time (see `benchmark/benchmarks.jl`). Multi-source queries always + build a fresh graph, because of the auxiliary super-source. + +Zero-weight edges are allowed. If a zero-weight cycle lies on a shortest path +there are infinitely many shortest walks to the vertices behind it, and the +corresponding `pathcounts` entries are `Inf`. + +## Matching + +```julia +using Graphs, LEMONGraphs + +g = complete_graph(6) +weights = rand(-100:100, ne(g)) +total, mates = maxweightedperfectmatching(g, weights, LEMONAlgorithm()) +``` + +`weights` is either a vector ordered like `edges(g)` or a `Dict{Edge,<:Integer}`. +`mates[v]` is the vertex matched to `v`. + +With GraphsMatching.jl loaded you can also go through its API: + +```julia +using GraphsMatching +w = Dict(Edge(1, 2) => 10, Edge(1, 3) => 1, #= ... =#) +minimum_weight_perfect_matching(g, w, LEMONAlgorithm()) +``` + +GraphsMatching.jl ≥ 0.2.1 already routes its default +`minimum_weight_perfect_matching` to LEMON through its own +`LEMONMWPMAlgorithm()`; the `LEMONAlgorithm()` spelling above exists so that +every LEMON dispatch in the ecosystem looks the same. + +## Flows and assignment + +```julia +using Graphs, GraphsOptim, LEMONGraphs + +g = path_digraph(3) +demand = [-1, 0, 1] # negative at sources, positive at sinks +cost = [0 1 0; 0 0 1; 0 0 0] + +flow = min_cost_flow(g, demand, cost, LEMONAlgorithm()) +``` + +Minimum and maximum capacities can be given as the fourth and fifth positional +arguments, matching `GraphsOptim.min_cost_flow`. Entries of the maximum +capacity matrix may be `Inf` for an uncapacitated arc. The result is a sparse +matrix of flows over all arcs. + +LEMON ships four minimum cost flow solvers, all reachable through the `solver` +keyword. They return equally optimal flows and differ only in running time: + +```julia +min_cost_flow(g, demand, cost, LEMONAlgorithm(); solver=:cost_scaling) +``` + +`:network_simplex` (default), `:capacity_scaling`, `:cost_scaling` and +`:cycle_canceling` are accepted. Only `:network_simplex` handles negative costs +on arcs whose upper capacity is infinite; the other three report such a problem +as unbounded, so give those arcs a finite capacity if you need them. + +The square linear assignment problem is solved on top of the same machinery: + +```julia +cost = [4 1 3; 2 0 5; 3 2 2] +assignment = min_cost_assignment(cost, LEMONAlgorithm()) # 0/1 Matrix{Int} +``` + +## When a dispatch does not exist + +`min_vertex_cover` and `maximum_weight_clique` accept `LEMONAlgorithm()` and +throw an `ArgumentError` pointing at the default GraphsOptim backend, rather +than failing with a bare `MethodError`. For anything else, LEMONGraphs installs +an error hint, so an unsupported call still explains itself: + +```julia-repl +julia> dijkstra_shortest_paths(g, 1, rand(5, 5), LEMONAlgorithm()) +ERROR: ArgumentError: LEMON Dijkstra only supports integer edge weights, ... +``` diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..f461add --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,42 @@ +```@meta +CurrentModule = LEMONGraphs +``` + +# API reference + +## Graph types + +```@docs +LEMONGraph +LEMONDiGraph +Graph(::LEMONGraph) +DiGraph(::LEMONDiGraph) +Graphs.reverse(::LEMONDiGraph) +``` + +## Algorithm selection + +```@docs +LEMONAlgorithm +``` + +## Algorithms + +```@docs +maxweightedperfectmatching +Graphs.dijkstra_shortest_paths(::AbstractGraph, ::Vector{<:Integer}, ::AbstractMatrix{T}, ::LEMONAlgorithm) where {T<:Integer} +``` + +## Conversion helpers + +```@docs +LEMONGraphs.to_list_graph +LEMONGraphs.to_list_digraph +``` + +## Value types + +```@docs +LEMONGraphs.CxxInt +LEMONGraphs.CxxLong +``` diff --git a/docs/src/extending.md b/docs/src/extending.md new file mode 100644 index 0000000..8847341 --- /dev/null +++ b/docs/src/extending.md @@ -0,0 +1,126 @@ +```@meta +CurrentModule = LEMONGraphs +``` + +# Adding another LEMON algorithm + +LEMON is a large library and `LEMON_jll` currently exposes a slice of it. This +page is the recipe for widening that slice; it is deliberately concrete so that +each new algorithm is a mechanical change rather than a design exercise. + +## 1. Expose the C++ symbol in `LEMON_jll` + +The bindings come from the CxxWrap shim built in +[Yggdrasil](https://github.com/JuliaPackaging/Yggdrasil/tree/master/L/LEMON). +Adding an algorithm means instantiating its template there and registering the +methods you need, then releasing a new `LEMON_jll`. + +Everything the shim registers shows up under `LEMONGraphs.Lib`, so you can see +what is available with + +```julia +using LEMONGraphs +filter(n -> !startswith(String(n), "_"), names(LEMONGraphs.Lib; all=true)) +``` + +The C++ types are instantiated for a fixed value type. LEMONGraphs +names them [`LEMONGraphs.CxxInt`](@ref) (C++ `int`, used by `Dijkstra` and the +matching solver) and [`LEMONGraphs.CxxLong`](@ref) (C++ `long long`, used by the +flow solvers); use those aliases rather than hard-coding `Int32`/`Int64`. + +## 2. Decide where the Julia-side method belongs + +There are three cases. + +**The function already exists in Graphs.jl.** Add a method to it in +`src/LEMONGraphs.jl` taking `::LEMONAlgorithm` as the last positional argument, +keeping the original signature and return type otherwise: + +```julia +function Graphs.some_property(g::AbstractGraph, distmx::AbstractMatrix{T}, ::LEMONAlgorithm) where {T<:Integer} + dg, ns, as = to_list_digraph(is_directed(g) ? g : DiGraph(g)) + # ... fill LEMON maps, run the solver, translate the result back ... +end +``` + +Always go through [`to_list_graph`](@ref LEMONGraphs.to_list_graph) / +[`to_list_digraph`](@ref LEMONGraphs.to_list_digraph): they convert an arbitrary +`AbstractGraph` and reuse an existing wrapper in `O(1)`. Remember that LEMON +node and arc ids are 0-based. + +**The function exists in an ecosystem package** (GraphsMatching.jl, +GraphsOptim.jl, …). Put the method in the corresponding extension under `ext/`, +so that the dependency stays weak. Add the package to `[weakdeps]` and +`[extensions]` in `Project.toml` if it is not there yet. + +**The function does not exist anywhere yet.** Then it should be *declared* in +Graphs.jl, as a function with a docstring and no methods, and LEMONGraphs adds +the method. That way the name is discoverable from Graphs.jl alone, and users +who call it without LEMONGraphs loaded are told what to install. + +Graphs.jl keeps those declarations in `src/external_algorithms.jl`, together +with a registry and a `MethodError` hint, so its side of the change is two +entries: + +```julia +# in Graphs.jl, src/external_algorithms.jl +""" + min_mean_cycle(g, weights, alg) + +Find the directed cycle of `g` minimising the mean weight of its arcs. + +!!! note "Implementation package required" + Graphs.jl only declares this function; it has no methods of its own. +""" +function min_mean_cycle end + +@declare_external min_mean_cycle "LEMONGraphs.jl" +``` + +plus exporting the name. LEMONGraphs then adds the method: + +```julia +Graphs.min_mean_cycle(g::AbstractGraph, weights, ::LEMONAlgorithm) = # ... +``` + +Calling it with neither package's method available now reports which package to +install, and once LEMONGraphs is loaded but does not cover the algorithm the +message says that instead. `Graphs.max_weight_perfect_matching` is declared this +way already; see the Graphs.jl page on +[externally implemented algorithms](https://juliagraphs.org/Graphs.jl/stable/algorithms/external/). + +LEMONGraphs installs an error hint of its own for the mirror-image case: a call +that *does* pass `LEMONAlgorithm()` but hits no method, usually because a +weight or capacity was floating point, gets a note explaining that LEMON +algorithms are integer-valued. + +## 3. Validate the inputs + +LEMON will happily produce nonsense from inputs its templates cannot represent, +so check before calling into C++: + +- integer-valued arguments, with an `ArgumentError` naming the argument; +- range: values must fit the instantiated C++ type; +- algorithm preconditions, e.g. Dijkstra's non-negative weights. + +## 4. Test it against a reference + +Every algorithm in the test suite is cross-checked against an independent +implementation, and that is the bar for new ones: + +- `test/test_dijkstra.jl` compares against `Graphs.dijkstra_shortest_paths` on + random graphs, field by field; +- `test/test_graphsoptim_ext.jl` compares against the JuMP/HiGHS solvers that + GraphsOptim.jl uses by default; +- `test/test_mwpm.jl` compares against a brute-force matching and, where it is + installed, against BlossomV. + +Tests are `@testitem`s picked up by TestItemRunner; add a new file under +`test/` and it is collected automatically. Randomised tests use `StableRNGs` so +that failures reproduce. + +## 5. Document it + +Add a row to the table in [LEMON-backed algorithms](@ref) and a short example. +Public functions need a docstring that states the numeric restrictions and the +error behaviour. diff --git a/docs/src/graphs.md b/docs/src/graphs.md new file mode 100644 index 0000000..0ebc6cb --- /dev/null +++ b/docs/src/graphs.md @@ -0,0 +1,110 @@ +```@meta +CurrentModule = LEMONGraphs +``` + +# Graph types and conversions + +## The wrapper types + +[`LEMONGraph`](@ref) wraps LEMON's `ListGraph` and [`LEMONDiGraph`](@ref) wraps +`ListDigraph`. Both are `AbstractGraph{Int}` subtypes with 1-based vertex +numbering, matching Graphs.jl (LEMON itself uses 0-based node ids; the wrappers +translate). + +```julia +using Graphs, LEMONGraphs + +lg = LEMONGraph(wheel_graph(6)) +ldg = LEMONDiGraph(path_digraph(5)) +``` + +Alongside the C++ handles, each wrapper stores the endpoints of every +edge/arc and an adjacency list on the Julia side. Construction costs +`O(nv + ne)`; afterwards `has_edge`, `inneighbors` and `outneighbors` are as +fast as on a `SimpleGraph`, and no C++ call happens per query. This matters: +Graphs.jl algorithms call `outneighbors` in their inner loops. + +`edges(g)` returns a lazy iterator (`LEMONGraphs.LEMONEdgeIter`), the same +design `SimpleGraph` uses, so iterating the edges does not materialise a +vector. + +## Interface compliance + +Both types implement every component of the Graphs.jl `AbstractGraph` +interface, mandatory and optional, which the test suite checks with +[GraphsInterfaceChecker.jl](https://github.com/JuliaGraphs/GraphsInterfaceChecker.jl): + +```julia +using GraphsInterfaceChecker, Interfaces +Interfaces.test(AbstractGraphInterface, LEMONGraph) # true +Interfaces.test(AbstractGraphInterface, LEMONDiGraph) # true +``` + +With the interface complete, generic Graphs.jl algorithms run on the wrappers: + +```julia +connected_components(lg) +degree(lg) +kruskal_mst(lg) +induced_subgraph(lg, [1, 2, 3]) +is_strongly_connected(ldg) +``` + +The two exceptions are `complement` and `transitiveclosure`, which Graphs.jl +defines only for `SimpleGraph`/`SimpleDiGraph` rather than for `AbstractGraph`. +No wrapper can satisfy them; convert with `Graph(lg)` first. + +## Growing a graph + +`add_vertex!` and `add_edge!` grow the underlying LEMON structure and the Julia +caches together, with the same semantics as `SimpleGraph`. In particular +`add_edge!` returns `false` for an edge that is already present, instead of +letting LEMON create a parallel one. + +```julia +lg = LEMONGraph(4) # four vertices, no edges +add_edge!(lg, 1, 2) # true +add_edge!(lg, 2, 1) # false, it is already there +add_vertex!(lg) # true +``` + +!!! warning "Removal is not available" + `rem_edge!` and `rem_vertex!` always return `false`. LEMON's `ListGraph` + does support erasure, but `LEMON_jll` does not currently expose + `ListGraph::erase`, and the Graphs.jl interface expects a graph type that + cannot perform a removal to decline it rather than to throw. If you need + removal, convert to a `SimpleGraph`, edit it, and wrap the result. + +`copy` produces an independent graph, backed by its own C++ structure, and +`zero` an empty one of the same type. + +## Converting back and forth + +| From | To | How | +|:--|:--|:--| +| `AbstractGraph` | `LEMONGraph` | `LEMONGraph(g)` | +| `AbstractGraph` | `LEMONDiGraph` | `LEMONDiGraph(g)` | +| `LEMONGraph` | `SimpleGraph` | `Graph(lg)` or `convert(Graph, lg)` | +| `LEMONDiGraph` | `SimpleDiGraph` | `DiGraph(ldg)` or `convert(DiGraph, ldg)` | + +Undirected graphs handed to `LEMONDiGraph` are expanded into arcs in both +directions, exactly as `SimpleDiGraph(g)` does. + +## Avoiding repeated conversions + +The LEMON-backed algorithms convert their graph argument with +[`LEMONGraphs.to_list_graph`](@ref) / [`LEMONGraphs.to_list_digraph`](@ref). +Both return the internal C++ handles unchanged, in `O(1)` and with no copying, +when they are given a graph that is already a wrapper: + +```julia +lg = LEMONGraph(complete_graph(500)) + +# the conversion happens once, not once per call +for _ in 1:100 + maxweightedperfectmatching(lg, ones(Int, ne(lg))) +end +``` + +So if you call several LEMON algorithms on the same graph, wrap it once and +pass the wrapper around. diff --git a/docs/src/index.md b/docs/src/index.md index 0e1a253..c182f33 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,3 +1,52 @@ +```@meta +CurrentModule = LEMONGraphs +``` + # LEMONGraphs.jl -LEMONGraphs.jl provides Julia wrappers for selected graph algorithms from the LEMON graph library. +LEMONGraphs.jl wraps the C++ graph library +[LEMON](https://lemon.cs.elte.hu/) for use from Graphs.jl: + +- [`LEMONGraph`](@ref) and [`LEMONDiGraph`](@ref) hold LEMON's `ListGraph` and + `ListDigraph` and implement the Graphs.jl `AbstractGraph` API, so Graphs.jl + algorithms that stay within the interface run on them; +- conversions in both directions, reusing the C++ handles in `O(1)` when the + graph is already a wrapper; +- LEMON implementations of some Graphs.jl, GraphsMatching.jl and GraphsOptim.jl + functions, selected by passing [`LEMONAlgorithm`](@ref) as the last + positional argument. + +## Installation + +```julia +using Pkg +Pkg.add("LEMONGraphs") +``` + +The C++ library itself comes from `LEMON_jll`, which is precompiled for every +platform Julia supports; there is nothing to build. + +## Quick start + +```julia +using Graphs, LEMONGraphs + +g = path_graph(5) +lg = LEMONGraph(g) # `g` rebuilt as a LEMON ListGraph + +nv(lg), ne(lg) # (5, 4) +outneighbors(lg, 2) # [1, 3] +Graph(lg) == g # true + +# LEMON's Dijkstra instead of the Graphs.jl one +distmx = [abs(i - j) for i in 1:5, j in 1:5] +state = dijkstra_shortest_paths(g, 1, distmx, LEMONAlgorithm()) +state.dists +``` + +## Contents + +```@contents +Pages = ["graphs.md", "algorithms.md", "extending.md", "api.md"] +Depth = 2 +``` From f7ed04a60c21ee055d529ac0ad7277fcc5635408 Mon Sep 17 00:00:00 2001 From: Jash Date: Wed, 9 Sep 2026 21:00:28 +0530 Subject: [PATCH 12/12] Note the new release in the changelog --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ Project.toml | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4105f..b619f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # News +## unreleased + +- Require `LEMON_jll` 1.3.6 and move to its parameterized CxxWrap bindings + (`ListGraphEdgeMap{T}`, `ListDigraphArcMap{T}`, `NetworkSimplex{V,C}`, ...), + replacing the `…Int`-suffixed names of earlier releases +- `LEMONGraph`/`LEMONDiGraph` now cache edge endpoints and adjacency lists, so + `has_edge`, `inneighbors` and `outneighbors` are O(1)-ish instead of scanning + every edge through the C++ boundary +- `edges` returns a lazy edge iterator, and `edgetype` returns the concrete + `Edge{Int}`; undirected edges are normalized to `src <= dst`, as in + `SimpleGraph` +- Complete the `AbstractGraph` interface: `LEMONGraph(n)`/`LEMONDiGraph(n)`, + `zero`, `copy`, `add_vertex!`, `add_edge!` and `reverse`, so that generic + algorithms such as `induced_subgraph` and `egonet` work on the wrappers. + `rem_edge!`/`rem_vertex!` return `false` because `LEMON_jll` exposes no + `ListGraph::erase`. Both the mandatory and the optional `mutation` + components of `AbstractGraphInterface` now pass +- Add `Graph(::LEMONGraph)` / `DiGraph(::LEMONDiGraph)` conversions back to + Graphs.jl, plus the matching `convert` methods +- LEMON `dijkstra_shortest_paths` is now a drop-in replacement for the + Graphs.jl one: multiple sources, `allpaths`, `trackvertices` and `maxdist` + are supported, and `pathcounts`/`predecessors` are computed exactly + (including `Inf` counts behind zero-weight cycles) +- Validate Dijkstra inputs: non-negative integer weights that fit a C++ `int` +- Implement `GraphsOptim.min_cost_flow` on LEMON's minimum cost flow solvers, + selectable with `solver = :network_simplex` (default), `:capacity_scaling`, + `:cost_scaling` or `:cycle_canceling`; infinite upper capacities are accepted +- Implement `GraphsOptim.min_cost_assignment` on top of the same solvers, + replacing the previous stub +- Register an error hint so that unsupported `LEMONAlgorithm()` calls explain + the integer-only restriction instead of raising a bare `MethodError` +- Raise the `GraphsMatching` compat bound to 0.2.1, which removes a method + ambiguity with its `cutoff` argument +- Run the GraphsInterfaceChecker suite as a `@testitem` (it was previously + never collected), and cross-check Dijkstra, minimum cost flow and assignment + against Graphs.jl and the JuMP-based GraphsOptim solvers +- Expand the documentation: graph types, algorithm dispatch, and a guide for + adding further LEMON algorithms +- Track arc endpoints on the Julia side in the Dijkstra wrapper instead of + asking LEMON for them arc by arc; this makes it ~2x faster from a + `SimpleGraph` and ~3x faster from a pre-built `LEMONDiGraph` +- Benchmark conversions, the `AbstractGraph` API and Dijkstra, not just + maximum weight perfect matching +- Add the `LEMONAlgorithm` marker type, which picks the LEMON implementation + when passed as the last positional argument +- Add a GraphsMatching.jl extension implementing + `minimum_weight_perfect_matching` on LEMON, for integer and floating point + weights +- Add a GraphsOptim.jl extension implementing `shortest_path` on LEMON's + Dijkstra + ## v0.1.2 - 2026-09-09 - Fix maximum-weight perfect matching broken by a change to the parametric map types in LEMON_jll 1.3.6. diff --git a/Project.toml b/Project.toml index 1011f6f..6085150 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "LEMONGraphs" uuid = "14b1564f-c77f-4800-9e89-efd961faef7c" authors = ["Stefan Krastanov "] -version = "0.1.2" +version = "0.1.3" [deps] CxxWrap = "1f15a43c-97ca-5a2a-ae31-89f07a497df4"