From c4e71b81c8e94fcc2cdbd6a7a7ca2d138c0260d7 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:49:35 +0200 Subject: [PATCH 01/44] Add variable structs for three types of formulations --- src/graph_edit_distance.jl | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/graph_edit_distance.jl diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl new file mode 100644 index 0000000..4509511 --- /dev/null +++ b/src/graph_edit_distance.jl @@ -0,0 +1,52 @@ +struct ReducedVariables + x::Matrix{VariableRef} + y::SparseAxisArray{VariableRef} +end + +struct FullVariables + x::Matrix{VariableRef} + y::SparseAxisArray{VariableRef} + nodeDelG::Vector{VariableRef} + nodeDelH::Vector{VariableRef} + edgeDelG::SparseAxisArray{VariableRef} + edgeDelH::SparseAxisArray{VariableRef} +end + +struct OrientedVariables + x::Matrix{VariableRef} + z::SparseAxisArray{VariableRef} +end + +# based on FORI implementation of the cost function +struct EditCosts + c_ik::Array{Number, 2} + c_iε::Vector{Number} + c_εk::Vector{Number} + # note that for sparse graphs we might want to use a sparse array here + c_ijkl::Array{Number, 4} + c_ijε::Array{Number, 2} + c_εkl::Array{Number, 2} +end + +# asserts that the given cost function could belong to G and H in terms of their dimensions +# This function simultaneously serves as a documentation on EditCosts +function validate_cost_function(c::EditCosts, G::Graph, H::Graph) + @assert size(c.c_ik) == (nv(G), nv(H)) + @assert size(c.c_iε) == (nv(G),) + @assert size(c.c_εk) == (nv(H),) + @assert size(c.c_ijkl) == (nv(G), nv(G), nv(H), nv(H)) + @assert size(c.c_ijε) == (nv(G), nv(G)) + @assert size(c.c_εkl) == (nv(H), nv(H)) +end + +# returns the intuitive "deleting things costs 1" cost function +function get_default_edit_costs(G::Graph, H::Graph) + return EditCosts( + zeros(Int, nv(G), nv(H)), + ones(Int, nv(G)), + ones(Int, nv(H)), + zeros(Int, nv(G), nv(G), nv(H), nv(H)), + ones(Int, nv(G), nv(G)), + ones(Int, nv(H), nv(H)) + ) +end From 375584dd33e38595a249ad25d85afc020aba5578 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:51:34 +0200 Subject: [PATCH 02/44] Add function to create model variables there are three kinds of variable structures. We break symmetry by requiring edge indices be ordered from i to j if i < j. --- src/graph_edit_distance.jl | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 4509511..53c5e0d 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -17,6 +17,40 @@ struct OrientedVariables z::SparseAxisArray{VariableRef} end +function create_model_vars_reduced!(model::GenericModel, G::Graph, H::Graph, bidirectional::Bool = false) + @variable(model, x[1:nv(G),1:nv(H)], Bin) + + # use a sparse indexed edge variable set. Edges are oriented to have only one edge per edge + @variable(model, + y[ + i in vertices(G), j in vertices(G), + k in vertices(H), l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && + i < j && (k < l || bidirectional) + ], + Bin + ) + + if bidirectional + return OrientedVariables(x, y) + else + return ReducedVariables(x, y) + end +end + +create_model_vars_bidirectional!(model::GenericModel, G::Graph, H::Graph) = create_model_vars_reduced!(model, G, H, true) + +function create_model_vars_full!(model::GenericModel, G::Graph, H::Graph) + vars = create_model_vars_reduced!(model, G, H) + @variable(model, nodeDelG[1:nv(G)], Bin) + @variable(model, nodeDelH[1:nv(H)], Bin) + + @variable(model, edgeDelG[i in vertices(G), j in vertices(G); has_edge(G, i, j)], Bin) + @variable(model, edgeDelH[k in vertices(H), l in vertices(H); has_edge(H, k, l)], Bin) + + return FullVariables(vars.x, vars.y, nodeDelG, nodeDelH, edgeDelG, edgeDelH) +end + # based on FORI implementation of the cost function struct EditCosts c_ik::Array{Number, 2} From bd86e20bbb5a6c337e4adea53defe4831b577143 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:56:05 +0200 Subject: [PATCH 03/44] Add node and edge map constraint functions --- src/graph_edit_distance.jl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 53c5e0d..9cba9eb 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -84,3 +84,20 @@ function get_default_edit_costs(G::Graph, H::Graph) ones(Int, nv(H), nv(H)) ) end + +function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) + @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) + vars.nodeDelG[i] == 1) + @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) + vars.nodeDelH[j] == 1) +end + +function add_node_map_constraints!(model::GenericModel, vars::Union{ReducedVariables, OrientedVariables}, G, H) + @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) <= 1) + @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) <= 1) +end + +function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) + @constraint(model, [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], + sum(vars.y[i, j, :, :]) + vars.edgeDelG[i, j] == 1) + @constraint(model, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], + sum(vars.y[:, :, k, l]) + vars.edgeDelH[k, l] == 1) +end From 856b61e0c98e4aad5efddac0ad2415dd2330a8c9 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:56:45 +0200 Subject: [PATCH 04/44] Add topology constraints These constraints encode graph structure --- src/graph_edit_distance.jl | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 9cba9eb..413b2ac 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -101,3 +101,54 @@ function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, @constraint(model, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], sum(vars.y[:, :, k, l]) + vars.edgeDelH[k, l] == 1) end + +function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) + @constraint(model, [ + i in vertices(G), j in vertices(G), + k in vertices(H), l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && + i < j && k < l], + vars.y[i, j, k, l] <= vars.x[i, k] + vars.x[j, k]) + @constraint(model, [ + i in vertices(G), j in vertices(G), + k in vertices(H), l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && + i < j && k < l], + vars.y[i, j, k, l] <= vars.x[i, l] + vars.x[j, l]) +end + +function add_improved_topology_constraints_G_to_H!(model::GenericModel, vars::Variables, G, H) + @constraint(model, [ + i in vertices(G), j in vertices(G), + k in vertices(H); + has_edge(G, i, j) && i < j], + sum(vars.y[i, j, k, :]) + sum(vars.y[i, j, :, k]) <= vars.x[i, k] + vars.x[j, k]) +end + +function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Variables, G, H) + @constraint(model, [ + k in vertices(H), l in vertices(H), + i in vertices(G); + has_edge(H, k, l) && k < l], + sum(vars.y[i, :, k, l]) + sum(vars.y[:, i, k, l]) <= vars.x[i, k] + vars.x[i, l]) +end + +function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedVariables, G, H) + # combines the constraints of add_improved_topology_constraints_G_to_H! and add_improved_topology_constraints_H_to_G! + # but can be more precise due to the directionality of H + @constraint(model, [ + k in vertices(H), + i in vertices(G), j in vertices(G); + has_edge(G, i, j) && i < j], + sum(vars.z[i, j, k, :]) <= vars.x[i, k]) + @constraint(model, [ + k in vertices(H), + i in vertices(G), j in vertices(G); + has_edge(G, i, j) && i < j], + sum(vars.z[i, j, :, k]) <= vars.x[j, k]) + @constraint(model, [ + k in vertices(H), l in vertices(H), + i in vertices(G); + has_edge(H, k, l)], + sum(vars.z[i, :, k, l]) + sum(vars.z[:, i, l, k]) <= vars.x[i, k]) +end From 81134b194c23cfc91e8ec7763519cefb996d2170 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:58:20 +0200 Subject: [PATCH 05/44] fixup! Add variable structs for three types of formulations --- src/graph_edit_distance.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 413b2ac..de20386 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -17,6 +17,8 @@ struct OrientedVariables z::SparseAxisArray{VariableRef} end +Variables = Union{ReducedVariables, FullVariables} + function create_model_vars_reduced!(model::GenericModel, G::Graph, H::Graph, bidirectional::Bool = false) @variable(model, x[1:nv(G),1:nv(H)], Bin) From bb7a42e558747e78e1701bc8dd3d1afd0e57b070 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 19:59:33 +0200 Subject: [PATCH 06/44] Add objective functions --- src/graph_edit_distance.jl | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index de20386..2357841 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -87,6 +87,45 @@ function get_default_edit_costs(G::Graph, H::Graph) ) end +function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Graph, H::Graph) + @objective(model, Min, + sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + + sum(vars.nodeDelG[i] * c.c_iε[i] for i in 1:nv(G)) + + sum(vars.nodeDelH[k] * c.c_εk[k] for k in 1:nv(H)) + + sum(vars.y[i, j, k, l] * c.c_ijkl[i, j, k, l] + for i in 1:nv(G) for j in 1:nv(G) + for k in 1:nv(H) for l in 1:nv(H) + if has_edge(G, i, j) && has_edge(H, k, l) && i < j && k < l) + + sum(vars.edgeDelG[i, j] * c.c_ijε[i, j] + for i in 1:nv(G) for j in 1:nv(G) + if has_edge(G, i, j) && i < j) + + sum(vars.edgeDelH[k, l] * c.c_εkl[k, l] + for k in 1:nv(H) for l in 1:nv(H) + if has_edge(H, k, l) && k < l) + ) +end + +function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Graph, H::Graph, bidirectional::Bool = false) + K = sum(c.c_iε[i] for i in 1:nv(G)) + + sum(c.c_εk[k] for k in 1:nv(H)) + + sum(c.c_ijε[i, j] + for i in 1:nv(G) for j in 1:nv(G) + if has_edge(G, i, j) && i < j) + + sum(c.c_εkl[k, l] + for k in 1:nv(H) for l in 1:nv(H) + if has_edge(H, k, l) && k < l) + @objective(model, Min, + sum(vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for k in 1:nv(H)) + + sum(vars.y[i, j, k, l] * (c.c_ijkl[i, j, k, l] - c.c_ijε[i, j] - c.c_εkl[k, l]) + for i in 1:nv(G) for j in 1:nv(G) + for k in 1:nv(H) for l in 1:nv(H) + if has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional)) + + K + ) +end + +add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::Graph, H::Graph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) + function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) + vars.nodeDelG[i] == 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) + vars.nodeDelH[j] == 1) From 1d8a45cbe954e81ccf3d716396488cb6e9b4b9f0 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 20:01:48 +0200 Subject: [PATCH 07/44] Add model constructing functions --- src/graph_edit_distance.jl | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 2357841..4000469 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -193,3 +193,93 @@ function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedV has_edge(H, k, l)], sum(vars.z[i, :, k, l]) + sum(vars.z[:, i, l, k]) <= vars.x[i, k]) end + +function construct_F1!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_full!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + add_edge_map_constraints!(model, vars, G, H) + + add_simple_topology_constraints!(model, vars, G, H) + + add_F1_objective!(model, c, vars, G, H) + return model +end + +function construct_F2minus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_reduced!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + + add_simple_topology_constraints!(model, vars, G, H) + + add_F2_objective!(model, c, vars, G, H) +end + +function construct_F2!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_reduced!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + # note that edge map constraints are implied by the better topology constraints, so we can skip + # them + + add_improved_topology_constraints_G_to_H!(model, vars, G, H) + + if ismissing(c) + c = get_default_edit_costs(G, H) + end + + add_F2_objective!(model, c, vars, G, H) + return model +end + +function construct_F2plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_reduced!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + # note that edge map constraints are implied by the better topology constraints, so we can skip + # them + + add_improved_topology_constraints_G_to_H!(model, vars, G, H) + add_improved_topology_constraints_H_to_G!(model, vars, G, H) + + add_F2_objective!(model, c, vars, G, H) + return model +end + +function construct_F1prime!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_full!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + # in the full variable set, we can't skip the edge map constraints + add_edge_map_constraints!(model, vars, G, H) + + add_improved_topology_constraints_G_to_H!(model, vars, G, H) + + add_F1_objective!(model, c, vars, G, H) + return model +end + +function construct_F1plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_full!(model, G, H) + + add_node_map_constraints!(model, vars, G, H) + # in the full variable set, we can't skip the edge map constraints + add_edge_map_constraints!(model, vars, G, H) + + add_improved_topology_constraints_G_to_H!(model, vars, G, H) + add_improved_topology_constraints_H_to_G!(model, vars, G, H) + + add_F1_objective!(model, c, vars, G, H) + return model +end + +function construct_FORI!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) + vars = create_model_vars_bidirectional!(model, G, H) + add_node_map_constraints!(model, vars, G, H) + + add_oriented_topology_constraints!(model, vars, G, H) + + add_FORI_objective!(model, c, vars, G, H) + return model +end From fd39fe00083322f64c10a95012b3ab5443c04425 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 24 Jun 2026 20:29:17 +0200 Subject: [PATCH 08/44] Add missing imports and the Graph typehints --- src/GraphsOptim.jl | 2 ++ src/graph_edit_distance.jl | 16 ++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 7156d42..dc600bc 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -15,6 +15,7 @@ using JuMP: objective_function, add_to_expression! using JuMP: set_silent, optimize!, termination_status, value using JuMP: set_optimizer, objective_value using JuMP: @variable, @constraint, @objective +using JuMP: VariableRef, Containers.SparseAxisArray, GenericModel using LinearAlgebra: norm, tr, dot using MathOptInterface: OPTIMAL using SparseArrays: sparse @@ -38,5 +39,6 @@ include("fractional_coloring.jl") include("shortest_path.jl") include("maximum_clique.jl") include("independent_set.jl") +include("graph_edit_distance.jl") end diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 4000469..e036597 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -19,7 +19,7 @@ end Variables = Union{ReducedVariables, FullVariables} -function create_model_vars_reduced!(model::GenericModel, G::Graph, H::Graph, bidirectional::Bool = false) +function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) @variable(model, x[1:nv(G),1:nv(H)], Bin) # use a sparse indexed edge variable set. Edges are oriented to have only one edge per edge @@ -40,9 +40,9 @@ function create_model_vars_reduced!(model::GenericModel, G::Graph, H::Graph, bid end end -create_model_vars_bidirectional!(model::GenericModel, G::Graph, H::Graph) = create_model_vars_reduced!(model, G, H, true) +create_model_vars_bidirectional!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) = create_model_vars_reduced!(model, G, H, true) -function create_model_vars_full!(model::GenericModel, G::Graph, H::Graph) +function create_model_vars_full!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) vars = create_model_vars_reduced!(model, G, H) @variable(model, nodeDelG[1:nv(G)], Bin) @variable(model, nodeDelH[1:nv(H)], Bin) @@ -66,7 +66,7 @@ end # asserts that the given cost function could belong to G and H in terms of their dimensions # This function simultaneously serves as a documentation on EditCosts -function validate_cost_function(c::EditCosts, G::Graph, H::Graph) +function validate_cost_function(c::EditCosts, G::AbstractGraph, H::AbstractGraph) @assert size(c.c_ik) == (nv(G), nv(H)) @assert size(c.c_iε) == (nv(G),) @assert size(c.c_εk) == (nv(H),) @@ -76,7 +76,7 @@ function validate_cost_function(c::EditCosts, G::Graph, H::Graph) end # returns the intuitive "deleting things costs 1" cost function -function get_default_edit_costs(G::Graph, H::Graph) +function get_default_edit_costs(G::AbstractGraph, H::AbstractGraph) return EditCosts( zeros(Int, nv(G), nv(H)), ones(Int, nv(G)), @@ -87,7 +87,7 @@ function get_default_edit_costs(G::Graph, H::Graph) ) end -function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Graph, H::Graph) +function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::AbstractGraph, H::AbstractGraph) @objective(model, Min, sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + sum(vars.nodeDelG[i] * c.c_iε[i] for i in 1:nv(G)) + @@ -105,7 +105,7 @@ function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Graph, H ) end -function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Graph, H::Graph, bidirectional::Bool = false) +function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) K = sum(c.c_iε[i] for i in 1:nv(G)) + sum(c.c_εk[k] for k in 1:nv(H)) + sum(c.c_ijε[i, j] @@ -124,7 +124,7 @@ function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Graph ) end -add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::Graph, H::Graph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) +add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::AbstractGraph, H::AbstractGraph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) + vars.nodeDelG[i] == 1) From 079366aa7fbeaca47296c350834ce6db584d4755 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Fri, 26 Jun 2026 17:46:38 +0200 Subject: [PATCH 09/44] Remove model returns from mutating construct functions --- src/graph_edit_distance.jl | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index e036597..9076ffb 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -203,7 +203,6 @@ function construct_F1!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) add_simple_topology_constraints!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) - return model end function construct_F2minus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) @@ -230,7 +229,6 @@ function construct_F2!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) end add_F2_objective!(model, c, vars, G, H) - return model end function construct_F2plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) @@ -244,7 +242,6 @@ function construct_F2plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, add_improved_topology_constraints_H_to_G!(model, vars, G, H) add_F2_objective!(model, c, vars, G, H) - return model end function construct_F1prime!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) @@ -257,7 +254,6 @@ function construct_F1prime!(model, G, H, c::EditCosts = get_default_edit_costs(G add_improved_topology_constraints_G_to_H!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) - return model end function construct_F1plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) @@ -271,7 +267,6 @@ function construct_F1plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, add_improved_topology_constraints_H_to_G!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) - return model end function construct_FORI!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) @@ -281,5 +276,4 @@ function construct_FORI!(model, G, H, c::EditCosts = get_default_edit_costs(G, H add_oriented_topology_constraints!(model, vars, G, H) add_FORI_objective!(model, c, vars, G, H) - return model end From 6dc705e7186a8857abc589efc63b1746f8557b46 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Fri, 26 Jun 2026 18:04:45 +0200 Subject: [PATCH 10/44] Rework construct functions to use type system instead of different names This is much cleaner and will prevent use of if statements later --- src/graph_edit_distance.jl | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 9076ffb..b527799 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -1,3 +1,12 @@ +abstract type Formulation end +struct F1 <: Formulation end +struct F1prime <: Formulation end +struct F1plus <: Formulation end +struct F2minus <: Formulation end +struct F2 <: Formulation end +struct F2plus <: Formulation end +struct FORI <: Formulation end + struct ReducedVariables x::Matrix{VariableRef} y::SparseAxisArray{VariableRef} @@ -194,7 +203,7 @@ function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedV sum(vars.z[i, :, k, l]) + sum(vars.z[:, i, l, k]) <= vars.x[i, k]) end -function construct_F1!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F1}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -205,7 +214,7 @@ function construct_F1!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) add_F1_objective!(model, c, vars, G, H) end -function construct_F2minus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F2minus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -215,7 +224,7 @@ function construct_F2minus!(model, G, H, c::EditCosts = get_default_edit_costs(G add_F2_objective!(model, c, vars, G, H) end -function construct_F2!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F2}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -231,7 +240,7 @@ function construct_F2!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) add_F2_objective!(model, c, vars, G, H) end -function construct_F2plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F2plus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -244,7 +253,7 @@ function construct_F2plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, add_F2_objective!(model, c, vars, G, H) end -function construct_F1prime!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F1prime}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -256,7 +265,7 @@ function construct_F1prime!(model, G, H, c::EditCosts = get_default_edit_costs(G add_F1_objective!(model, c, vars, G, H) end -function construct_F1plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{F1plus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -269,7 +278,7 @@ function construct_F1plus!(model, G, H, c::EditCosts = get_default_edit_costs(G, add_F1_objective!(model, c, vars, G, H) end -function construct_FORI!(model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!(::Type{FORI}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_bidirectional!(model, G, H) add_node_map_constraints!(model, vars, G, H) From 0cfab0b9539410f86c21f6aa73fc2bfea571e422 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Fri, 26 Jun 2026 18:22:42 +0200 Subject: [PATCH 11/44] Add common dispatch point The function checks if the graphs are undirected and defines a slightly nicer call interface --- src/GraphsOptim.jl | 1 + src/graph_edit_distance.jl | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index dc600bc..94072b2 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -8,6 +8,7 @@ module GraphsOptim using Graphs: AbstractGraph, is_directed using Graphs: vertices, edges, nv, ne, src, dst, inneighbors, outneighbors, has_edge using Graphs: complement, maximal_cliques +using Graphs: SimpleGraph using FillArrays: Zeros, Ones, Fill using HiGHS: HiGHS using JuMP: Model, AffExpr diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index b527799..1d3d62f 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -286,3 +286,10 @@ function construct_formulation!(::Type{FORI}, model, G, H, c::EditCosts = get_de add_FORI_objective!(model, c, vars, G, H) end + +function edit_distance!(model, G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI) + if is_directed(G) || is_directed(H) + error("This version of the graph edit distance only accepts undirected graphs.") + end + construct_formulation!(formulation, model, G, H, c) +end From fe5de9d6a0c242b8c39fba18030a82b994b8ee1c Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 28 Jun 2026 13:08:33 +0200 Subject: [PATCH 12/44] Remove typehints to account for empty edgesets I could not find a way to account for the more general type of the empty variable sets if a graph has no edges, so now we just don't typehint the edge variables. --- src/graph_edit_distance.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 1d3d62f..d720e73 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -9,21 +9,21 @@ struct FORI <: Formulation end struct ReducedVariables x::Matrix{VariableRef} - y::SparseAxisArray{VariableRef} + y end struct FullVariables x::Matrix{VariableRef} - y::SparseAxisArray{VariableRef} + y nodeDelG::Vector{VariableRef} nodeDelH::Vector{VariableRef} - edgeDelG::SparseAxisArray{VariableRef} - edgeDelH::SparseAxisArray{VariableRef} + edgeDelG + edgeDelH end struct OrientedVariables x::Matrix{VariableRef} - z::SparseAxisArray{VariableRef} + z end Variables = Union{ReducedVariables, FullVariables} From a34d1bf81e94431db0606608be853ee3328677b0 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 28 Jun 2026 13:16:39 +0200 Subject: [PATCH 13/44] Fix code for the case where one graph has no edges If the edgeset of one graph is empty, the edge map variables are nonexistent. In this case, the julia compiler doesn't know how it should handle sum() over an empty set of variables. This commit adds a default return value of 0, which fixes the crashes. --- src/graph_edit_distance.jl | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index d720e73..0a6bc4a 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -119,16 +119,16 @@ function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Abstr sum(c.c_εk[k] for k in 1:nv(H)) + sum(c.c_ijε[i, j] for i in 1:nv(G) for j in 1:nv(G) - if has_edge(G, i, j) && i < j) + + if has_edge(G, i, j) && i < j; init=0) + sum(c.c_εkl[k, l] for k in 1:nv(H) for l in 1:nv(H) - if has_edge(H, k, l) && k < l) + if has_edge(H, k, l) && k < l; init=0) @objective(model, Min, sum(vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for k in 1:nv(H)) + sum(vars.y[i, j, k, l] * (c.c_ijkl[i, j, k, l] - c.c_ijε[i, j] - c.c_εkl[k, l]) for i in 1:nv(G) for j in 1:nv(G) for k in 1:nv(H) for l in 1:nv(H) - if has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional)) + + if has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional); init=0) + K ) end @@ -147,9 +147,9 @@ end function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) @constraint(model, [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], - sum(vars.y[i, j, :, :]) + vars.edgeDelG[i, j] == 1) + sum(vars.y[i, j, :, :]; init=0) + vars.edgeDelG[i, j] == 1) @constraint(model, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], - sum(vars.y[:, :, k, l]) + vars.edgeDelH[k, l] == 1) + sum(vars.y[:, :, k, l]; init=0) + vars.edgeDelH[k, l] == 1) end function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) @@ -172,7 +172,7 @@ function add_improved_topology_constraints_G_to_H!(model::GenericModel, vars::Va i in vertices(G), j in vertices(G), k in vertices(H); has_edge(G, i, j) && i < j], - sum(vars.y[i, j, k, :]) + sum(vars.y[i, j, :, k]) <= vars.x[i, k] + vars.x[j, k]) + sum(vars.y[i, j, k, :]; init=0) + sum(vars.y[i, j, :, k]; init=0) <= vars.x[i, k] + vars.x[j, k]) end function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Variables, G, H) @@ -180,7 +180,7 @@ function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Va k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l) && k < l], - sum(vars.y[i, :, k, l]) + sum(vars.y[:, i, k, l]) <= vars.x[i, k] + vars.x[i, l]) + sum(vars.y[i, :, k, l]; init=0) + sum(vars.y[:, i, k, l]; init=0) <= vars.x[i, k] + vars.x[i, l]) end function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedVariables, G, H) @@ -190,17 +190,17 @@ function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedV k in vertices(H), i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], - sum(vars.z[i, j, k, :]) <= vars.x[i, k]) + sum(vars.z[i, j, k, :]; init=0) <= vars.x[i, k]) @constraint(model, [ k in vertices(H), i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], - sum(vars.z[i, j, :, k]) <= vars.x[j, k]) + sum(vars.z[i, j, :, k]; init=0) <= vars.x[j, k]) @constraint(model, [ k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l)], - sum(vars.z[i, :, k, l]) + sum(vars.z[:, i, l, k]) <= vars.x[i, k]) + sum(vars.z[i, :, k, l]; init=0) + sum(vars.z[:, i, l, k]; init=0) <= vars.x[i, k]) end function construct_formulation!(::Type{F1}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) From 66f4dcb26265c15a44becabc9ccff9dc820167df Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 1 Jul 2026 11:40:58 +0200 Subject: [PATCH 14/44] Fix objective for the case where at least one graph has no nodes --- src/graph_edit_distance.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 0a6bc4a..38c8913 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -115,8 +115,8 @@ function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Abstract end function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) - K = sum(c.c_iε[i] for i in 1:nv(G)) + - sum(c.c_εk[k] for k in 1:nv(H)) + + K = sum(c.c_iε[i] for i in 1:nv(G); init=0) + + sum(c.c_εk[k] for k in 1:nv(H); init=0) + sum(c.c_ijε[i, j] for i in 1:nv(G) for j in 1:nv(G) if has_edge(G, i, j) && i < j; init=0) + @@ -124,7 +124,7 @@ function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Abstr for k in 1:nv(H) for l in 1:nv(H) if has_edge(H, k, l) && k < l; init=0) @objective(model, Min, - sum(vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for k in 1:nv(H)) + + sum(vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for k in 1:nv(H); init=0) + sum(vars.y[i, j, k, l] * (c.c_ijkl[i, j, k, l] - c.c_ijε[i, j] - c.c_εkl[k, l]) for i in 1:nv(G) for j in 1:nv(G) for k in 1:nv(H) for l in 1:nv(H) From 42994c09cd0aea82c446e74af7745fdc70161522 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 1 Jul 2026 23:17:32 +0200 Subject: [PATCH 15/44] Add export for main function and Formulation symbols --- src/GraphsOptim.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 94072b2..487d44f 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -31,6 +31,8 @@ export fractional_chromatic_number, fractional_clique_number export shortest_path export maximum_weight_clique +export edit_distance!, F1, F1prime, F1plus, F2, F2minus, F2plus, FORI + include("utils.jl") include("flow.jl") include("assignment.jl") From 5a5cb164ac38248bd4c4b44e828864dea2b1a65c Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 1 Jul 2026 23:17:59 +0200 Subject: [PATCH 16/44] Add unit tests for edit distance code --- test/graph_edit_distance.jl | 149 ++++++++++++++++++++++++++++++++++++ test/runtests.jl | 4 + 2 files changed, 153 insertions(+) create mode 100644 test/graph_edit_distance.jl diff --git a/test/graph_edit_distance.jl b/test/graph_edit_distance.jl new file mode 100644 index 0000000..0962664 --- /dev/null +++ b/test/graph_edit_distance.jl @@ -0,0 +1,149 @@ +using GraphsOptim, Graphs, JuMP, HiGHS +using Test + +all_formulations = [F1, F1prime, F1plus, F2, F2minus, F2plus, FORI] + +@testset "Small Graph" begin + G = Graph(3) + add_edge!(G, 1, 2) + + H = Graph(3) + add_edge!(H, 2, 3) + add_edge!(H, 1, 3) + + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 1 + end +end + +@testset "Cost Functions Simple" begin + G = Graph(3) + add_edge!(G, 1, 2) + + H = Graph(3) + add_edge!(H, 2, 3) + add_edge!(H, 1, 3) + c_origin = GraphsOptim.get_default_edit_costs(G, H) + + # increating costs for node subsitutions + c = GraphsOptim.EditCosts( + ones(Int, nv(G), nv(H)), + 5 * c_origin.c_iε, + 5 * c_origin.c_εk, + c_origin.c_ijkl, + c_origin.c_ijε, + c_origin.c_εkl + ) + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; c=c, formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 4 + end + # increating costs for edge subsitutions + c = GraphsOptim.EditCosts( + c_origin.c_ik, + c_origin.c_iε, + c_origin.c_εk, + c_origin.c_ijkl, + 100 * c_origin.c_ijε, + 10 * c_origin.c_εkl + ) + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; c=c, formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 10 + end + + # force a specific suboptimal node map with edit costs + G = Graph(3) + add_edge!(G, 2, 3) + + H = Graph(4) + add_edge!(H, 1, 3) + add_edge!(H, 3, 4) + c_origin = GraphsOptim.get_default_edit_costs(G, H) + custom_substitution_cost = 100 .+ c_origin.c_ik + custom_substitution_cost[1,1] = 0 + custom_substitution_cost[2,2] = 0 + custom_substitution_cost[3,3] = 0 + c = GraphsOptim.EditCosts( + custom_substitution_cost, + c_origin.c_iε, + c_origin.c_εk, + c_origin.c_ijkl, + c_origin.c_ijε, + c_origin.c_εkl + ) + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + vars = GraphsOptim.edit_distance!(model, G, H; c=c, formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 4 + @test value(model[:x][1,1]) == 1 + @test value(model[:x][2,2]) == 1 + @test value(model[:x][3,3]) == 1 + end +end + +@testset "Edgecases" begin + G = Graph(3) + + H = Graph(3) + add_edge!(H, 1, 2) + + # one graph empty + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 1 + + # reverse order + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, H, G; formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 1 + end + + # both graphs empty + H = Graph(5) + for formulation in all_formulations + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 2 + end + + # no nodes in one graph + H = Graph(0) + for formulation in [F1, F1prime, F1plus] + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; formulation=formulation) + set_silent(model) + optimize!(model) + @test objective_value(model) == 3 + end + # for F2 type formulations, the formulation contains no variables + # we thus add a dummy variable to make the solver accept the model + for formulation in [F2, F2minus, F2plus, FORI] + model = Model(HiGHS.Optimizer) + GraphsOptim.edit_distance!(model, G, H; formulation=formulation) + @test num_variables(model) == 0 + @variable(model, dummy) + set_silent(model) + optimize!(model) + @test objective_value(model) == 3 + end +end diff --git a/test/runtests.jl b/test/runtests.jl index fb245da..2037947 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -59,4 +59,8 @@ using Test @testset verbose = true "Shortest path" begin include("shortest_path.jl") end + + @testset "Graph Edit Distance" begin + include("graph_edit_distance.jl") + end end; From 74353be1918c34b5d3e02937c5089e64ce90aacf Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 1 Jul 2026 23:21:48 +0200 Subject: [PATCH 17/44] Remove unnecessary imports They were removed to accomodate for the edge case of empty graphs. --- src/GraphsOptim.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 487d44f..75c2e79 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -16,7 +16,7 @@ using JuMP: objective_function, add_to_expression! using JuMP: set_silent, optimize!, termination_status, value using JuMP: set_optimizer, objective_value using JuMP: @variable, @constraint, @objective -using JuMP: VariableRef, Containers.SparseAxisArray, GenericModel +using JuMP: GenericModel using LinearAlgebra: norm, tr, dot using MathOptInterface: OPTIMAL using SparseArrays: sparse From 1ef64fb663a1db72ee04d9477f7cebc19ab33f99 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sat, 4 Jul 2026 12:18:56 +0200 Subject: [PATCH 18/44] Add VariableRef back in fixup commit --- src/GraphsOptim.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 75c2e79..7dfc94e 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -16,7 +16,7 @@ using JuMP: objective_function, add_to_expression! using JuMP: set_silent, optimize!, termination_status, value using JuMP: set_optimizer, objective_value using JuMP: @variable, @constraint, @objective -using JuMP: GenericModel +using JuMP: GenericModel, VariableRef using LinearAlgebra: norm, tr, dot using MathOptInterface: OPTIMAL using SparseArrays: sparse From 5c291887001f04a5582a8ff9cc22024a2c8ebe2c Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sat, 4 Jul 2026 12:50:28 +0200 Subject: [PATCH 19/44] Add function that constructs the model in-place It returns the node matching matrix --- src/graph_edit_distance.jl | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 38c8913..3d2e320 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -293,3 +293,15 @@ function edit_distance!(model, G::SimpleGraph, H::SimpleGraph; c::EditCosts = ge end construct_formulation!(formulation, model, G, H, c) end + +function edit_distance!(G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI, optimizer = HiGHS.Optimizer) + model = Model(optimizer) + set_silent(model) + edit_distance!(model, G, H; c=c, formulation=formulation) + optimize!(model) + if termination_status(model) != OPTIMAL + error("Graph edit distance was not solved optimally.") + end + node_matching = convert(Matrix{Int}, model[:x] |> value) + return node_matching +end From 7d959f872416394a49fa3748ae93d8c133f63d4a Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sat, 4 Jul 2026 12:50:41 +0200 Subject: [PATCH 20/44] Improve existing comments --- src/graph_edit_distance.jl | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 3d2e320..a95d0e5 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -31,7 +31,8 @@ Variables = Union{ReducedVariables, FullVariables} function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) @variable(model, x[1:nv(G),1:nv(H)], Bin) - # use a sparse indexed edge variable set. Edges are oriented to have only one edge per edge + # Use a sparse indexed edge variable set, so we can later sum over all neighbors with y[i, :, k, l]. + # Edge variables are always oriented to avoid ambiguity. @variable(model, y[ i in vertices(G), j in vertices(G), @@ -84,7 +85,7 @@ function validate_cost_function(c::EditCosts, G::AbstractGraph, H::AbstractGraph @assert size(c.c_εkl) == (nv(H), nv(H)) end -# returns the intuitive "deleting things costs 1" cost function +# returns the canonical "deleting things costs 1" cost function function get_default_edit_costs(G::AbstractGraph, H::AbstractGraph) return EditCosts( zeros(Int, nv(G), nv(H)), @@ -115,6 +116,7 @@ function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Abstract end function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) + # Note that init=0 is necessary to allow for empty graph edge cases. K = sum(c.c_iε[i] for i in 1:nv(G); init=0) + sum(c.c_εk[k] for k in 1:nv(H); init=0) + sum(c.c_ijε[i, j] @@ -146,6 +148,7 @@ function add_node_map_constraints!(model::GenericModel, vars::Union{ReducedVaria end function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) + # Note that init=0 is necessary to allow for empty graph edge cases. @constraint(model, [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], sum(vars.y[i, j, :, :]; init=0) + vars.edgeDelG[i, j] == 1) @constraint(model, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], @@ -218,6 +221,7 @@ function construct_formulation!(::Type{F2minus}, model, G, H, c::EditCosts = get vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) + # Edgemap constraints are implied, so we can skip them. add_simple_topology_constraints!(model, vars, G, H) @@ -228,8 +232,7 @@ function construct_formulation!(::Type{F2}, model, G, H, c::EditCosts = get_defa vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) - # note that edge map constraints are implied by the better topology constraints, so we can skip - # them + # Edgemap constraints are implied, so we can skip them. add_improved_topology_constraints_G_to_H!(model, vars, G, H) @@ -244,8 +247,7 @@ function construct_formulation!(::Type{F2plus}, model, G, H, c::EditCosts = get_ vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) - # note that edge map constraints are implied by the better topology constraints, so we can skip - # them + # Edgemap constraints are implied, so we can skip them. add_improved_topology_constraints_G_to_H!(model, vars, G, H) add_improved_topology_constraints_H_to_G!(model, vars, G, H) @@ -257,7 +259,6 @@ function construct_formulation!(::Type{F1prime}, model, G, H, c::EditCosts = get vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) - # in the full variable set, we can't skip the edge map constraints add_edge_map_constraints!(model, vars, G, H) add_improved_topology_constraints_G_to_H!(model, vars, G, H) @@ -269,7 +270,6 @@ function construct_formulation!(::Type{F1plus}, model, G, H, c::EditCosts = get_ vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) - # in the full variable set, we can't skip the edge map constraints add_edge_map_constraints!(model, vars, G, H) add_improved_topology_constraints_G_to_H!(model, vars, G, H) @@ -281,6 +281,7 @@ end function construct_formulation!(::Type{FORI}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_bidirectional!(model, G, H) add_node_map_constraints!(model, vars, G, H) + # Edgemap constraints are implied, so we can skip them. add_oriented_topology_constraints!(model, vars, G, H) From 39309b97f348d5b2292e73e14e1d1a8e992d90e2 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sat, 4 Jul 2026 13:11:05 +0200 Subject: [PATCH 21/44] Add docstring for edit_distance functions These are the most important ones, because they are the public facing entrypoints. --- src/graph_edit_distance.jl | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index a95d0e5..e0658c3 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -288,6 +288,15 @@ function construct_formulation!(::Type{FORI}, model, G, H, c::EditCosts = get_de add_FORI_objective!(model, c, vars, G, H) end +""" + edit_distance(model, G, H; + c, formulation + ) + +Modify a JuMP model to compute the graph edit distance between undirected graphs `G` and `H` +given cost function `c` using formulation `formulation`. See [`edit_distance`](@ref) for +more details. +""" function edit_distance!(model, G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI) if is_directed(G) || is_directed(H) error("This version of the graph edit distance only accepts undirected graphs.") @@ -295,7 +304,28 @@ function edit_distance!(model, G::SimpleGraph, H::SimpleGraph; c::EditCosts = ge construct_formulation!(formulation, model, G, H, c) end -function edit_distance!(G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI, optimizer = HiGHS.Optimizer) +""" + edit_distance(G, H; + c, formulation, optimizer + ) + +Compute the graph edit distance between undirected graphs `G` and `H` given edit costs `c`. + +# Arguments +- `G::Graphs.SimpleGraph`: graph +- `H::Graphs.SimpleGraph`: graph + +# Keywords +- `c::EditCosts`: pairwise edit cost struct for the graphs. See [`EditCosts`](@ref). + Defaults to the canonical edit costs [`get_default_edit_costs`](@ref) +- `formulation::Type{<:Formulation}`: the ILP formulation to use. Defaults to the most + powerful [`FORI`](@ref) +- `optimizer`: JuMP-compatible solver (default is `HiGHS.Optimizer`) + +# Returns +- `Matrix{Int}`: the node map matrix, which encodes the optimal edit path +""" +function edit_distance(G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI, optimizer = HiGHS.Optimizer) model = Model(optimizer) set_silent(model) edit_distance!(model, G, H; c=c, formulation=formulation) From f812412a8f8a7b11368c559d5321a5e0c14ddbee Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 11:59:59 +0200 Subject: [PATCH 22/44] Add docstring to construct_formulation! --- src/graph_edit_distance.jl | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index e0658c3..0da6bc1 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -206,6 +206,15 @@ function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedV sum(vars.z[i, :, k, l]; init=0) + sum(vars.z[:, i, l, k]; init=0) <= vars.x[i, k]) end +""" + construct_formulation!(::Type{<:Formulation}, model, G, H; + c) + +Modify `model` to use a specific formulation to solve the graph edit distance problem. Each +formulation has its own method implementing the required variables, constraints and objective. +""" +function construct_formulation! end + function construct_formulation!(::Type{F1}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) vars = create_model_vars_full!(model, G, H) From 1d32cd83e1eb94597a7bced63239daae3558cfaf Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:12:24 +0200 Subject: [PATCH 23/44] Add docstring to Formulation types --- src/graph_edit_distance.jl | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 0da6bc1..688fda5 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -1,10 +1,35 @@ +""" +Control flow types to communicate the ILP formulation to the edit distance code. The +subtypes are the different formulations, with `FORI` being the best performing one. +""" abstract type Formulation end +""" +Control flow type signifying the F1 ILP formulation. See also [`Formulation`](@ref). +""" struct F1 <: Formulation end +""" +Control flow type signifying the F1' ILP formulation. See also [`Formulation`](@ref). +""" struct F1prime <: Formulation end +""" +Control flow type signifying the F1+ ILP formulation. See also [`Formulation`](@ref). +""" struct F1plus <: Formulation end +""" +Control flow type signifying the F2- ILP formulation. See also [`Formulation`](@ref). +""" struct F2minus <: Formulation end +""" +Control flow type signifying the F2 ILP formulation. See also [`Formulation`](@ref). +""" struct F2 <: Formulation end +""" +Control flow type signifying the F2+ ILP formulation. See also [`Formulation`](@ref). +""" struct F2plus <: Formulation end +""" +Control flow type signifying the FORI ILP formulation. See also [`Formulation`](@ref). +""" struct FORI <: Formulation end struct ReducedVariables From e6b031e5ee4774394b2e89ac9258a0c8d42df577 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:17:12 +0200 Subject: [PATCH 24/44] Add docstring to the variable structs --- src/graph_edit_distance.jl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 688fda5..9bddc73 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -32,11 +32,20 @@ Control flow type signifying the FORI ILP formulation. See also [`Formulation`]( """ struct FORI <: Formulation end +""" +Struct maintaining the variable references for formulations [`F2minus`](@ref), [`F2`](@ref) +and [`F2plus`](@ref). Compared to [`FullVariables`](@ref), the deletion variables are +inferred. +""" struct ReducedVariables x::Matrix{VariableRef} y end +""" +Struct maintaining the variable references for formulations [`F1`](@ref), [`F1prime`](@ref) +and [`F1plus`](@ref). +""" struct FullVariables x::Matrix{VariableRef} y @@ -46,11 +55,19 @@ struct FullVariables edgeDelH end +""" +Struct maintaining the variable references for formulation [`FORI`](@ref). This formulation +implicitly adds two oriented edges for each edge the second graph to improve topology +constraints, which is reflected in the edge map variables. +""" struct OrientedVariables x::Matrix{VariableRef} z end +""" +Type used for functions applicable to both F1 and F2 derived formulations. +""" Variables = Union{ReducedVariables, FullVariables} function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) From adff0e0863f21353cf2a9c5825a9a7fab69f0220 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:25:04 +0200 Subject: [PATCH 25/44] Add docstring to variable creation functions --- src/graph_edit_distance.jl | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 9bddc73..6a04fef 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -70,6 +70,19 @@ Type used for functions applicable to both F1 and F2 derived formulations. """ Variables = Union{ReducedVariables, FullVariables} +""" + create_model_vars_reduced!(model, G, H, bidirectional = false) + +Adds necessary variables for edit distance computation between `G` and `H` to `model`. If +`bidirectional` is false, for formulations [`F2minus`](@ref), [`F2`](@ref), +[`F2plus`](@ref). If `bidirectional` is true, `H` is bidirected and the variables are used +for the [`FORI`](@ref) formulation. + +The variables model a full mapping between nodes and edges respectively. + +# Returns +- [`OrientedVariables`](@ref) or [`ReducedVariables`](@ref) containing the variables. +""" function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) @variable(model, x[1:nv(G),1:nv(H)], Bin) @@ -92,8 +105,21 @@ function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::Ab end end +""" +Convenience function bind. See [`create_model_vars_reduced`](@ref). +""" create_model_vars_bidirectional!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) = create_model_vars_reduced!(model, G, H, true) +""" + create_model_vars_full!(model, G, H, bidirectional = false) + +Adds necessary variables for edit distance computation between `G` and `H` to `model` for +formulations [`F1`](@ref), [`F1prime`](@ref) and [`F1plus`](@ref). + +In addition to the normal node and edge map variables (see +[`create_model_vars_reduced`](@ref)), these formulations explicitly have variables to model +nodes and edges being deleted or added. +""" function create_model_vars_full!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) vars = create_model_vars_reduced!(model, G, H) @variable(model, nodeDelG[1:nv(G)], Bin) From 67848c0efc88df1dc5aa0cdbfb877400271224ca Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:36:32 +0200 Subject: [PATCH 26/44] Add docstring to cost functions --- src/graph_edit_distance.jl | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 6a04fef..ba910b7 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -131,7 +131,13 @@ function create_model_vars_full!(model::GenericModel, G::AbstractGraph, H::Abstr return FullVariables(vars.x, vars.y, nodeDelG, nodeDelH, edgeDelG, edgeDelH) end -# based on FORI implementation of the cost function +""" +Models arbitrary edit costs between two graphs. The implementation is taken from +[FORI-GED](https://github.com/meffertj/FORI-GED). + +For details on valid varable dimensions see [`validate_cost_function`](@ref), for +constructing the canonical cost function see [`get_default_edit_costs`](@ref). +""" struct EditCosts c_ik::Array{Number, 2} c_iε::Vector{Number} @@ -142,8 +148,18 @@ struct EditCosts c_εkl::Array{Number, 2} end -# asserts that the given cost function could belong to G and H in terms of their dimensions -# This function simultaneously serves as a documentation on EditCosts +""" + validate_cost_function(c, G, H) + +Validates that cost function `c` has appropriate dimensions for graphs `G` and `H`. This means + +- `c.c_ik` has a cost for each mapping `i ∈ V(G)` to `k ∈ V(H)` +- `c.c_iε` has a cost for deleting each `i ∈ V(G)` +- `c.c_εk` has a cost for creating each `k ∈ V(H)` +- `c.c_ijkl` has a cost for each mapping from `ij ∈ E(G)` to `kl ∈ E(H)` +- `c.c_ijε` has a cost for deleting each `ij ∈ E(G)` +- `c.c_εkl` has a cost for creating each `kl ∈ E(H)` +""" function validate_cost_function(c::EditCosts, G::AbstractGraph, H::AbstractGraph) @assert size(c.c_ik) == (nv(G), nv(H)) @assert size(c.c_iε) == (nv(G),) @@ -153,7 +169,10 @@ function validate_cost_function(c::EditCosts, G::AbstractGraph, H::AbstractGraph @assert size(c.c_εkl) == (nv(H), nv(H)) end -# returns the canonical "deleting things costs 1" cost function +""" +Returns the cost function where mappings are free, deleting or creating things costs +uniformly 1. This corresponds to "number of edits needed to go from `G` to `H`". +""" function get_default_edit_costs(G::AbstractGraph, H::AbstractGraph) return EditCosts( zeros(Int, nv(G), nv(H)), From 817fcdce2d1aeaa2369092098402e8b2a5c68fae Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:39:33 +0200 Subject: [PATCH 27/44] Add docstring to objective generating functions --- src/graph_edit_distance.jl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index ba910b7..1cc054e 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -184,6 +184,9 @@ function get_default_edit_costs(G::AbstractGraph, H::AbstractGraph) ) end +""" +Adds objective function for F1 style formulations to `model`. +""" function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::AbstractGraph, H::AbstractGraph) @objective(model, Min, sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + @@ -202,6 +205,10 @@ function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::Abstract ) end +""" +Adds objective function for F2 style formulations to `model`. `bidirectional` is used to +support input for [`FORI`](@ref) variables. +""" function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) # Note that init=0 is necessary to allow for empty graph edge cases. K = sum(c.c_iε[i] for i in 1:nv(G); init=0) + @@ -222,6 +229,10 @@ function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::Abstr ) end +""" +Adds objective function for [`FORI`](@ref) formulation variables. The implementation is in +[`add_F2_objective`](@ref). +""" add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::AbstractGraph, H::AbstractGraph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) From 37556f2566261729828eb8de4de7a95bb3953ade Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:45:59 +0200 Subject: [PATCH 28/44] Add docstring to node and edge map functions Internal, so only rough description. --- src/graph_edit_distance.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 1cc054e..fb051b6 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -235,16 +235,29 @@ Adds objective function for [`FORI`](@ref) formulation variables. The implementa """ add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::AbstractGraph, H::AbstractGraph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) +""" +Add node map constraints to model for F1 style formulations, i.e. each node is only mapped +to exactly one other node or deleted/created. +""" function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) + vars.nodeDelG[i] == 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) + vars.nodeDelH[j] == 1) end +""" +Add node map constraints to model for F2 style and FORI formulations, i.e. each node is only +mapped to at most one other node (not being mapped implies being deleted or created). +""" function add_node_map_constraints!(model::GenericModel, vars::Union{ReducedVariables, OrientedVariables}, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) <= 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) <= 1) end +""" +Adds constraints on the edge map variables so each edge is mapped to exactly one other or be +deleted/created. Note that these constraints are only necessary in F1 style formulations, as +they are implied in F2 styled and FORI. +""" function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) # Note that init=0 is necessary to allow for empty graph edge cases. @constraint(model, [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], From de9f0bbbae70219093ae62c237da60ad5febdec6 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 12:53:29 +0200 Subject: [PATCH 29/44] Add docstring to topology constraint functions Since the functions are internal, only rough documentation on what implication is implemented are added. Technically inferrable from code, but I think it provides value. --- src/graph_edit_distance.jl | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index fb051b6..836d3d1 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -266,6 +266,10 @@ function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, sum(vars.y[:, :, k, l]; init=0) + vars.edgeDelH[k, l] == 1) end +""" +Add simplest form of topology constraints: If edge `ij` is mapped to `kl`, then `i` or `j` must map +to `k`, and `i` or `j` must map to `l`. +""" function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) @constraint(model, [ i in vertices(G), j in vertices(G), @@ -281,6 +285,10 @@ function add_simple_topology_constraints!(model::GenericModel, vars::Variables, vars.y[i, j, k, l] <= vars.x[i, l] + vars.x[j, l]) end +""" +Add improved topology constraints, replacing [`add_simple_topology_constraints`](@ref): +If edge `ij` is mapped to any edge incident to `k`, then `i` or `j` must be mapped to `k`. +""" function add_improved_topology_constraints_G_to_H!(model::GenericModel, vars::Variables, G, H) @constraint(model, [ i in vertices(G), j in vertices(G), @@ -289,6 +297,10 @@ function add_improved_topology_constraints_G_to_H!(model::GenericModel, vars::Va sum(vars.y[i, j, k, :]; init=0) + sum(vars.y[i, j, :, k]; init=0) <= vars.x[i, k] + vars.x[j, k]) end +""" +Add topology constraints mirroring [`add_improved_topology_constraints_G_to_H`](@ref), but backwards: +If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or `l`. +""" function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Variables, G, H) @constraint(model, [ k in vertices(H), l in vertices(H), @@ -297,9 +309,13 @@ function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Va sum(vars.y[i, :, k, l]; init=0) + sum(vars.y[:, i, k, l]; init=0) <= vars.x[i, k] + vars.x[i, l]) end +""" +Add topology constraints for FORI. Uses the same implictions as used in +[`add_improved_topology_constraints_G_to_H`](@ref) and +[`add_improved_topology_constraints_H_to_G`](@ref), but since edges in `G` are oriented and +each edge in `H` has two possible orientations, the implications are more precise. +""" function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedVariables, G, H) - # combines the constraints of add_improved_topology_constraints_G_to_H! and add_improved_topology_constraints_H_to_G! - # but can be more precise due to the directionality of H @constraint(model, [ k in vertices(H), i in vertices(G), j in vertices(G); From bd202e73f54b5477143e0e0896df4d7f30552b36 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Sun, 5 Jul 2026 13:21:51 +0200 Subject: [PATCH 30/44] Add mathematical description and paper reference in algorithms.md --- docs/src/algorithms.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/src/algorithms.md b/docs/src/algorithms.md index 7131a94..dda96f3 100644 --- a/docs/src/algorithms.md +++ b/docs/src/algorithms.md @@ -98,6 +98,46 @@ graph_matching GraphsOptim.graph_matching_step_size ``` +## Graph edit distance +```@docs +edit_distance +GraphsOptim.edit_distance! +``` + +The formulations implemented in the code are described in D'ascenzo, Andrea, et al. "Enhancing Graph Edit Distance Computation: Stronger and Orientation-based ILP Formulations." Proceedings of the VLDB Endowment 18.11 (2025): 4737-4749. The paper also containts a more detailed description of the graph edit distance problem. + +In the graph edit distance problem, we search for a minimum cost *edit path* between +two graphs $G$ and $H$, that is a sequence of edit operations transforming $G$ to $H$. Valid +edit operations are inserting or deleting a node or edge, or relabeling an existing node or +edge. + +This formulation of the problem is easy to visualize, but not very suitable for implementing +with integer programming. We thus use another, equivalent definition instead: a *node map* +is a relation $\pi \subset V_{G+ \epsilon} \times V_{H + \epsilon}$ on the vertex sets +augmented by $\epsilon$, in which + +* each node $v \in V_G$ is mapped to exactly one element of $V_{H + \epsilon}$ +* each node $w \in V_H$ has exactly one preimage in $V_{G + \epsilon}$ + +Being mapped to $\epsilon$ is equivalent to being deleted or created. We will not give a +proof here, but for metric cost functions this is equivalent to the edit path formulation +(and any cost function can be transformed into an equivalent metric one). + +This version of the problem is much better suited for integer programming, and is the +foundation for all formulations implemented here. The formulations all use the same basic +principle, defining binary variables to encode the mapping between nodes and edges, +constraints that ensure the variables encode valid node and edge maps, and finally *topology +constraints* which link node and edge variables based on the topology of the input graphs. +The formulations differ in the exact variable layout and more importantly the topology +constraints used. As seen in the paper mentioned above, the different topology constraints +not only provide massive speedups in practice but their relaxations provide different +bounds. + +The best formulation both in theory and practive orients the graphs to provide stricter +topology constraints. The graph $G$ is oriented in a canonical way, while in $H$ there are +forward and backwards edges for each undirected edge in the input. We can then additionally +demand that edges be mapped consistently with their orientation. + ## Coloring ```@docs From 9e52ce4c9405e154fd7d2e089296efb50b44e4c3 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 00:35:45 +0200 Subject: [PATCH 31/44] Improve formatting in unit tests --- test/graph_edit_distance.jl | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/graph_edit_distance.jl b/test/graph_edit_distance.jl index 0962664..dcd16a0 100644 --- a/test/graph_edit_distance.jl +++ b/test/graph_edit_distance.jl @@ -6,7 +6,7 @@ all_formulations = [F1, F1prime, F1plus, F2, F2minus, F2plus, FORI] @testset "Small Graph" begin G = Graph(3) add_edge!(G, 1, 2) - + H = Graph(3) add_edge!(H, 2, 3) add_edge!(H, 1, 3) @@ -23,12 +23,12 @@ end @testset "Cost Functions Simple" begin G = Graph(3) add_edge!(G, 1, 2) - + H = Graph(3) add_edge!(H, 2, 3) add_edge!(H, 1, 3) c_origin = GraphsOptim.get_default_edit_costs(G, H) - + # increating costs for node subsitutions c = GraphsOptim.EditCosts( ones(Int, nv(G), nv(H)), @@ -36,7 +36,7 @@ end 5 * c_origin.c_εk, c_origin.c_ijkl, c_origin.c_ijε, - c_origin.c_εkl + c_origin.c_εkl, ) for formulation in all_formulations model = Model(HiGHS.Optimizer) @@ -52,7 +52,7 @@ end c_origin.c_εk, c_origin.c_ijkl, 100 * c_origin.c_ijε, - 10 * c_origin.c_εkl + 10 * c_origin.c_εkl, ) for formulation in all_formulations model = Model(HiGHS.Optimizer) @@ -65,22 +65,22 @@ end # force a specific suboptimal node map with edit costs G = Graph(3) add_edge!(G, 2, 3) - + H = Graph(4) add_edge!(H, 1, 3) add_edge!(H, 3, 4) c_origin = GraphsOptim.get_default_edit_costs(G, H) custom_substitution_cost = 100 .+ c_origin.c_ik - custom_substitution_cost[1,1] = 0 - custom_substitution_cost[2,2] = 0 - custom_substitution_cost[3,3] = 0 + custom_substitution_cost[1, 1] = 0 + custom_substitution_cost[2, 2] = 0 + custom_substitution_cost[3, 3] = 0 c = GraphsOptim.EditCosts( custom_substitution_cost, c_origin.c_iε, c_origin.c_εk, c_origin.c_ijkl, c_origin.c_ijε, - c_origin.c_εkl + c_origin.c_εkl, ) for formulation in all_formulations model = Model(HiGHS.Optimizer) @@ -88,15 +88,15 @@ end set_silent(model) optimize!(model) @test objective_value(model) == 4 - @test value(model[:x][1,1]) == 1 - @test value(model[:x][2,2]) == 1 - @test value(model[:x][3,3]) == 1 + @test value(model[:x][1, 1]) == 1 + @test value(model[:x][2, 2]) == 1 + @test value(model[:x][3, 3]) == 1 end end @testset "Edgecases" begin G = Graph(3) - + H = Graph(3) add_edge!(H, 1, 2) @@ -125,7 +125,7 @@ end optimize!(model) @test objective_value(model) == 2 end - + # no nodes in one graph H = Graph(0) for formulation in [F1, F1prime, F1plus] From 7483f5f09ad297c1971227adc51991b7fe6d9eb2 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 00:41:44 +0200 Subject: [PATCH 32/44] Fix spaces in parentheses To pass the style check --- src/graph_edit_distance.jl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 836d3d1..e937c6d 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -68,7 +68,7 @@ end """ Type used for functions applicable to both F1 and F2 derived formulations. """ -Variables = Union{ReducedVariables, FullVariables} +Variables = Union{ReducedVariables,FullVariables} """ create_model_vars_reduced!(model, G, H, bidirectional = false) @@ -139,13 +139,13 @@ For details on valid varable dimensions see [`validate_cost_function`](@ref), fo constructing the canonical cost function see [`get_default_edit_costs`](@ref). """ struct EditCosts - c_ik::Array{Number, 2} + c_ik::Array{Number,2} c_iε::Vector{Number} c_εk::Vector{Number} # note that for sparse graphs we might want to use a sparse array here - c_ijkl::Array{Number, 4} - c_ijε::Array{Number, 2} - c_εkl::Array{Number, 2} + c_ijkl::Array{Number,4} + c_ijε::Array{Number,2} + c_εkl::Array{Number,2} end """ @@ -180,7 +180,7 @@ function get_default_edit_costs(G::AbstractGraph, H::AbstractGraph) ones(Int, nv(H)), zeros(Int, nv(G), nv(G), nv(H), nv(H)), ones(Int, nv(G), nv(G)), - ones(Int, nv(H), nv(H)) + ones(Int, nv(H), nv(H)), ) end @@ -240,8 +240,8 @@ Add node map constraints to model for F1 style formulations, i.e. each node is o to exactly one other node or deleted/created. """ function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) - @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) + vars.nodeDelG[i] == 1) - @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) + vars.nodeDelH[j] == 1) + @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) + vars.nodeDelG[i] == 1) + @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) + vars.nodeDelH[j] == 1) end """ From ce9855e1844a6affdd70921af31576a2bcf985d9 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 00:54:38 +0200 Subject: [PATCH 33/44] Fix method definition length --- src/graph_edit_distance.jl | 89 ++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index e937c6d..f07da34 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -83,7 +83,9 @@ The variables model a full mapping between nodes and edges respectively. # Returns - [`OrientedVariables`](@ref) or [`ReducedVariables`](@ref) containing the variables. """ -function create_model_vars_reduced!(model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) +function create_model_vars_reduced!( + model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool=false +) @variable(model, x[1:nv(G),1:nv(H)], Bin) # Use a sparse indexed edge variable set, so we can later sum over all neighbors with y[i, :, k, l]. @@ -108,7 +110,11 @@ end """ Convenience function bind. See [`create_model_vars_reduced`](@ref). """ -create_model_vars_bidirectional!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) = create_model_vars_reduced!(model, G, H, true) +function create_model_vars_bidirectional!( + model::GenericModel, G::AbstractGraph, H::AbstractGraph +) + return create_model_vars_reduced!(model, G, H, true) +end """ create_model_vars_full!(model, G, H, bidirectional = false) @@ -187,7 +193,9 @@ end """ Adds objective function for F1 style formulations to `model`. """ -function add_F1_objective!(model, c::EditCosts, vars::FullVariables, G::AbstractGraph, H::AbstractGraph) +function add_F1_objective!( + model, c::EditCosts, vars::FullVariables, G::AbstractGraph, H::AbstractGraph +) @objective(model, Min, sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + sum(vars.nodeDelG[i] * c.c_iε[i] for i in 1:nv(G)) + @@ -209,7 +217,14 @@ end Adds objective function for F2 style formulations to `model`. `bidirectional` is used to support input for [`FORI`](@ref) variables. """ -function add_F2_objective!(model, c::EditCosts, vars::ReducedVariables, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool = false) +function add_F2_objective!( + model, + c::EditCosts, + vars::ReducedVariables, + G::AbstractGraph, + H::AbstractGraph, + bidirectional::Bool=false, +) # Note that init=0 is necessary to allow for empty graph edge cases. K = sum(c.c_iε[i] for i in 1:nv(G); init=0) + sum(c.c_εk[k] for k in 1:nv(H); init=0) + @@ -233,7 +248,11 @@ end Adds objective function for [`FORI`](@ref) formulation variables. The implementation is in [`add_F2_objective`](@ref). """ -add_FORI_objective!(model, c::EditCosts, vars::OrientedVariables, G::AbstractGraph, H::AbstractGraph) = add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) +function add_FORI_objective!( + model, c::EditCosts, vars::OrientedVariables, G::AbstractGraph, H::AbstractGraph +) + return add_F2_objective!(model, c, ReducedVariables(vars.x, vars.z), G, H, true) +end """ Add node map constraints to model for F1 style formulations, i.e. each node is only mapped @@ -248,7 +267,9 @@ end Add node map constraints to model for F2 style and FORI formulations, i.e. each node is only mapped to at most one other node (not being mapped implies being deleted or created). """ -function add_node_map_constraints!(model::GenericModel, vars::Union{ReducedVariables, OrientedVariables}, G, H) +function add_node_map_constraints!( + model::GenericModel, vars::Union{ReducedVariables,OrientedVariables}, G, H +) @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) <= 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) <= 1) end @@ -289,7 +310,9 @@ end Add improved topology constraints, replacing [`add_simple_topology_constraints`](@ref): If edge `ij` is mapped to any edge incident to `k`, then `i` or `j` must be mapped to `k`. """ -function add_improved_topology_constraints_G_to_H!(model::GenericModel, vars::Variables, G, H) +function add_improved_topology_constraints_G_to_H!( + model::GenericModel, vars::Variables, G, H +) @constraint(model, [ i in vertices(G), j in vertices(G), k in vertices(H); @@ -301,7 +324,9 @@ end Add topology constraints mirroring [`add_improved_topology_constraints_G_to_H`](@ref), but backwards: If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or `l`. """ -function add_improved_topology_constraints_H_to_G!(model::GenericModel, vars::Variables, G, H) +function add_improved_topology_constraints_H_to_G!( + model::GenericModel, vars::Variables, G, H +) @constraint(model, [ k in vertices(H), l in vertices(H), i in vertices(G); @@ -315,7 +340,9 @@ Add topology constraints for FORI. Uses the same implictions as used in [`add_improved_topology_constraints_H_to_G`](@ref), but since edges in `G` are oriented and each edge in `H` has two possible orientations, the implications are more precise. """ -function add_oriented_topology_constraints!(model::GenericModel, vars::OrientedVariables, G, H) +function add_oriented_topology_constraints!( + model::GenericModel, vars::OrientedVariables, G, H +) @constraint(model, [ k in vertices(H), i in vertices(G), j in vertices(G); @@ -342,7 +369,9 @@ formulation has its own method implementing the required variables, constraints """ function construct_formulation! end -function construct_formulation!(::Type{F1}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F1}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -353,7 +382,9 @@ function construct_formulation!(::Type{F1}, model, G, H, c::EditCosts = get_defa add_F1_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{F2minus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F2minus}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -364,7 +395,9 @@ function construct_formulation!(::Type{F2minus}, model, G, H, c::EditCosts = get add_F2_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{F2}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F2}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -379,7 +412,9 @@ function construct_formulation!(::Type{F2}, model, G, H, c::EditCosts = get_defa add_F2_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{F2plus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F2plus}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_reduced!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -391,7 +426,9 @@ function construct_formulation!(::Type{F2plus}, model, G, H, c::EditCosts = get_ add_F2_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{F1prime}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F1prime}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -402,7 +439,9 @@ function construct_formulation!(::Type{F1prime}, model, G, H, c::EditCosts = get add_F1_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{F1plus}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{F1plus}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_full!(model, G, H) add_node_map_constraints!(model, vars, G, H) @@ -414,7 +453,9 @@ function construct_formulation!(::Type{F1plus}, model, G, H, c::EditCosts = get_ add_F1_objective!(model, c, vars, G, H) end -function construct_formulation!(::Type{FORI}, model, G, H, c::EditCosts = get_default_edit_costs(G, H)) +function construct_formulation!( + ::Type{FORI}, model, G, H, c::EditCosts=get_default_edit_costs(G, H) +) vars = create_model_vars_bidirectional!(model, G, H) add_node_map_constraints!(model, vars, G, H) # Edgemap constraints are implied, so we can skip them. @@ -433,7 +474,13 @@ Modify a JuMP model to compute the graph edit distance between undirected graphs given cost function `c` using formulation `formulation`. See [`edit_distance`](@ref) for more details. """ -function edit_distance!(model, G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI) +function edit_distance!( + model, + G::SimpleGraph, + H::SimpleGraph; + c::EditCosts=get_default_edit_costs(G, H), + formulation::Type{<:Formulation}=FORI, +) if is_directed(G) || is_directed(H) error("This version of the graph edit distance only accepts undirected graphs.") end @@ -461,7 +508,13 @@ Compute the graph edit distance between undirected graphs `G` and `H` given edit # Returns - `Matrix{Int}`: the node map matrix, which encodes the optimal edit path """ -function edit_distance(G::SimpleGraph, H::SimpleGraph; c::EditCosts = get_default_edit_costs(G, H), formulation::Type{<:Formulation} = FORI, optimizer = HiGHS.Optimizer) +function edit_distance( + G::SimpleGraph, + H::SimpleGraph; + c::EditCosts=get_default_edit_costs(G, H), + formulation::Type{<:Formulation}=FORI, + optimizer=HiGHS.Optimizer, +) model = Model(optimizer) set_silent(model) edit_distance!(model, G, H; c=c, formulation=formulation) From 6dc67d919832f5cb1f2966c35bd14a29e05945ce Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 01:06:52 +0200 Subject: [PATCH 34/44] Fix indentation in JuMP macros --- src/graph_edit_distance.jl | 204 ++++++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 84 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index f07da34..1569c02 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -86,19 +86,21 @@ The variables model a full mapping between nodes and edges respectively. function create_model_vars_reduced!( model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool=false ) - @variable(model, x[1:nv(G),1:nv(H)], Bin) + @variable(model, x[1:nv(G), 1:nv(H)], Bin) # Use a sparse indexed edge variable set, so we can later sum over all neighbors with y[i, :, k, l]. # Edge variables are always oriented to avoid ambiguity. - @variable(model, - y[ - i in vertices(G), j in vertices(G), - k in vertices(H), l in vertices(H); - has_edge(G, i, j) && has_edge(H, k, l) && - i < j && (k < l || bidirectional) - ], - Bin - ) + @variable( + model, + y[ + i in vertices(G), + j in vertices(G), + k in vertices(H), + l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional), + ], + Bin + ) if bidirectional return OrientedVariables(x, y) @@ -196,21 +198,26 @@ Adds objective function for F1 style formulations to `model`. function add_F1_objective!( model, c::EditCosts, vars::FullVariables, G::AbstractGraph, H::AbstractGraph ) - @objective(model, Min, - sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + - sum(vars.nodeDelG[i] * c.c_iε[i] for i in 1:nv(G)) + - sum(vars.nodeDelH[k] * c.c_εk[k] for k in 1:nv(H)) + - sum(vars.y[i, j, k, l] * c.c_ijkl[i, j, k, l] - for i in 1:nv(G) for j in 1:nv(G) - for k in 1:nv(H) for l in 1:nv(H) - if has_edge(G, i, j) && has_edge(H, k, l) && i < j && k < l) + - sum(vars.edgeDelG[i, j] * c.c_ijε[i, j] - for i in 1:nv(G) for j in 1:nv(G) - if has_edge(G, i, j) && i < j) + - sum(vars.edgeDelH[k, l] * c.c_εkl[k, l] - for k in 1:nv(H) for l in 1:nv(H) - if has_edge(H, k, l) && k < l) - ) + @objective( + model, + Min, + sum(vars.x[i, k] * c.c_ik[i, k] for i in 1:nv(G) for k in 1:nv(H)) + + sum(vars.nodeDelG[i] * c.c_iε[i] for i in 1:nv(G)) + + sum(vars.nodeDelH[k] * c.c_εk[k] for k in 1:nv(H)) + + sum( + vars.y[i, j, k, l] * c.c_ijkl[i, j, k, l] for i in 1:nv(G) for j in 1:nv(G) + for k in 1:nv(H) for + l in 1:nv(H) if has_edge(G, i, j) && has_edge(H, k, l) && i < j && k < l + ) + + sum( + vars.edgeDelG[i, j] * c.c_ijε[i, j] for i in 1:nv(G) for + j in 1:nv(G) if has_edge(G, i, j) && i < j + ) + + sum( + vars.edgeDelH[k, l] * c.c_εkl[k, l] for k in 1:nv(H) for + l in 1:nv(H) if has_edge(H, k, l) && k < l + ) + ) end """ @@ -226,22 +233,33 @@ function add_F2_objective!( bidirectional::Bool=false, ) # Note that init=0 is necessary to allow for empty graph edge cases. - K = sum(c.c_iε[i] for i in 1:nv(G); init=0) + - sum(c.c_εk[k] for k in 1:nv(H); init=0) + - sum(c.c_ijε[i, j] - for i in 1:nv(G) for j in 1:nv(G) - if has_edge(G, i, j) && i < j; init=0) + - sum(c.c_εkl[k, l] - for k in 1:nv(H) for l in 1:nv(H) - if has_edge(H, k, l) && k < l; init=0) - @objective(model, Min, - sum(vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for k in 1:nv(H); init=0) + - sum(vars.y[i, j, k, l] * (c.c_ijkl[i, j, k, l] - c.c_ijε[i, j] - c.c_εkl[k, l]) - for i in 1:nv(G) for j in 1:nv(G) - for k in 1:nv(H) for l in 1:nv(H) - if has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional); init=0) + - K - ) + K = + sum(c.c_iε[i] for i in 1:nv(G); init=0) + + sum(c.c_εk[k] for k in 1:nv(H); init=0) + + sum( + c.c_ijε[i, j] for i in 1:nv(G) for j in 1:nv(G) if has_edge(G, i, j) && i < j; + init=0, + ) + + sum( + c.c_εkl[k, l] for k in 1:nv(H) for l in 1:nv(H) if has_edge(H, k, l) && k < l; + init=0, + ) + @objective( + model, + Min, + sum( + vars.x[i, k] * (c.c_ik[i, k] - c.c_iε[i] - c.c_εk[k]) for i in 1:nv(G) for + k in 1:nv(H); + init=0, + ) + + sum( + vars.y[i, j, k, l] * (c.c_ijkl[i, j, k, l] - c.c_ijε[i, j] - c.c_εkl[k, l]) + for i in 1:nv(G) for j in 1:nv(G) for k in 1:nv(H) for l in 1:nv(H) if + has_edge(G, i, j) && has_edge(H, k, l) && i < j && (k < l || bidirectional); + init=0, + ) + + K + ) end """ @@ -270,8 +288,8 @@ mapped to at most one other node (not being mapped implies being deleted or crea function add_node_map_constraints!( model::GenericModel, vars::Union{ReducedVariables,OrientedVariables}, G, H ) - @constraint(model, [i in 1:nv(G)], sum(vars.x[i,:]) <= 1) - @constraint(model, [j in 1:nv(H)], sum(vars.x[:,j]) <= 1) + @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) <= 1) + @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) <= 1) end """ @@ -281,10 +299,16 @@ they are implied in F2 styled and FORI. """ function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) # Note that init=0 is necessary to allow for empty graph edge cases. - @constraint(model, [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], - sum(vars.y[i, j, :, :]; init=0) + vars.edgeDelG[i, j] == 1) - @constraint(model, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], - sum(vars.y[:, :, k, l]; init=0) + vars.edgeDelH[k, l] == 1) + @constraint( + model, + [i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], + sum(vars.y[i, j, :, :]; init=0) + vars.edgeDelG[i, j] == 1 + ) + @constraint( + model, + [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], + sum(vars.y[:, :, k, l]; init=0) + vars.edgeDelH[k, l] == 1 + ) end """ @@ -292,18 +316,28 @@ Add simplest form of topology constraints: If edge `ij` is mapped to `kl`, then to `k`, and `i` or `j` must map to `l`. """ function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) - @constraint(model, [ - i in vertices(G), j in vertices(G), - k in vertices(H), l in vertices(H); - has_edge(G, i, j) && has_edge(H, k, l) && - i < j && k < l], - vars.y[i, j, k, l] <= vars.x[i, k] + vars.x[j, k]) - @constraint(model, [ - i in vertices(G), j in vertices(G), - k in vertices(H), l in vertices(H); - has_edge(G, i, j) && has_edge(H, k, l) && - i < j && k < l], - vars.y[i, j, k, l] <= vars.x[i, l] + vars.x[j, l]) + @constraint( + model, + [ + i in vertices(G), + j in vertices(G), + k in vertices(H), + l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && i < j && k < l, + ], + vars.y[i, j, k, l] <= vars.x[i, k] + vars.x[j, k] + ) + @constraint( + model, + [ + i in vertices(G), + j in vertices(G), + k in vertices(H), + l in vertices(H); + has_edge(G, i, j) && has_edge(H, k, l) && i < j && k < l, + ], + vars.y[i, j, k, l] <= vars.x[i, l] + vars.x[j, l] + ) end """ @@ -313,11 +347,12 @@ If edge `ij` is mapped to any edge incident to `k`, then `i` or `j` must be mapp function add_improved_topology_constraints_G_to_H!( model::GenericModel, vars::Variables, G, H ) - @constraint(model, [ - i in vertices(G), j in vertices(G), - k in vertices(H); - has_edge(G, i, j) && i < j], - sum(vars.y[i, j, k, :]; init=0) + sum(vars.y[i, j, :, k]; init=0) <= vars.x[i, k] + vars.x[j, k]) + @constraint( + model, + [i in vertices(G), j in vertices(G), k in vertices(H); has_edge(G, i, j) && i < j], + sum(vars.y[i, j, k, :]; init=0) + sum(vars.y[i, j, :, k]; init=0) <= + vars.x[i, k] + vars.x[j, k] + ) end """ @@ -327,11 +362,12 @@ If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or function add_improved_topology_constraints_H_to_G!( model::GenericModel, vars::Variables, G, H ) - @constraint(model, [ - k in vertices(H), l in vertices(H), - i in vertices(G); - has_edge(H, k, l) && k < l], - sum(vars.y[i, :, k, l]; init=0) + sum(vars.y[:, i, k, l]; init=0) <= vars.x[i, k] + vars.x[i, l]) + @constraint( + model, + [k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l) && k < l], + sum(vars.y[i, :, k, l]; init=0) + sum(vars.y[:, i, k, l]; init=0) <= + vars.x[i, k] + vars.x[i, l] + ) end """ @@ -343,21 +379,21 @@ each edge in `H` has two possible orientations, the implications are more precis function add_oriented_topology_constraints!( model::GenericModel, vars::OrientedVariables, G, H ) - @constraint(model, [ - k in vertices(H), - i in vertices(G), j in vertices(G); - has_edge(G, i, j) && i < j], - sum(vars.z[i, j, k, :]; init=0) <= vars.x[i, k]) - @constraint(model, [ - k in vertices(H), - i in vertices(G), j in vertices(G); - has_edge(G, i, j) && i < j], - sum(vars.z[i, j, :, k]; init=0) <= vars.x[j, k]) - @constraint(model, [ - k in vertices(H), l in vertices(H), - i in vertices(G); - has_edge(H, k, l)], - sum(vars.z[i, :, k, l]; init=0) + sum(vars.z[:, i, l, k]; init=0) <= vars.x[i, k]) + @constraint( + model, + [k in vertices(H), i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], + sum(vars.z[i, j, k, :]; init=0) <= vars.x[i, k] + ) + @constraint( + model, + [k in vertices(H), i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], + sum(vars.z[i, j, :, k]; init=0) <= vars.x[j, k] + ) + @constraint( + model, + [k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l)], + sum(vars.z[i, :, k, l]; init=0) + sum(vars.z[:, i, l, k]; init=0) <= vars.x[i, k] + ) end """ From ff6b3b2f80a6af8e75d3789f801446f84b2f727d Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 01:08:10 +0200 Subject: [PATCH 35/44] Add minor formatting fixes --- src/graph_edit_distance.jl | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 1569c02..e5ba55d 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -88,7 +88,7 @@ function create_model_vars_reduced!( ) @variable(model, x[1:nv(G), 1:nv(H)], Bin) - # Use a sparse indexed edge variable set, so we can later sum over all neighbors with y[i, :, k, l]. + # Use a sparse indexed edge variable set, to later do partial sums y[i, :, k, l]. # Edge variables are always oriented to avoid ambiguity. @variable( model, @@ -159,7 +159,8 @@ end """ validate_cost_function(c, G, H) -Validates that cost function `c` has appropriate dimensions for graphs `G` and `H`. This means +Validates that cost function `c` has appropriate dimensions for graphs `G` and `H`. This +means - `c.c_ik` has a cost for each mapping `i ∈ V(G)` to `k ∈ V(H)` - `c.c_iε` has a cost for deleting each `i ∈ V(G)` @@ -312,8 +313,8 @@ function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, end """ -Add simplest form of topology constraints: If edge `ij` is mapped to `kl`, then `i` or `j` must map -to `k`, and `i` or `j` must map to `l`. +Add simplest form of topology constraints: If edge `ij` is mapped to `kl`, then `i` or `j` +must map to `k`, and `i` or `j` must map to `l`. """ function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) @constraint( @@ -356,8 +357,9 @@ function add_improved_topology_constraints_G_to_H!( end """ -Add topology constraints mirroring [`add_improved_topology_constraints_G_to_H`](@ref), but backwards: -If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or `l`. +Add topology constraints mirroring [`add_improved_topology_constraints_G_to_H`](@ref), but +backwards: If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or +`l`. """ function add_improved_topology_constraints_H_to_G!( model::GenericModel, vars::Variables, G, H @@ -401,7 +403,8 @@ end c) Modify `model` to use a specific formulation to solve the graph edit distance problem. Each -formulation has its own method implementing the required variables, constraints and objective. +formulation has its own method implementing the required variables, constraints and +objective. """ function construct_formulation! end @@ -558,6 +561,6 @@ function edit_distance( if termination_status(model) != OPTIMAL error("Graph edit distance was not solved optimally.") end - node_matching = convert(Matrix{Int}, model[:x] |> value) + node_matching = convert(Matrix{Int}, value(model[:x])) return node_matching end From 8322eb72b499fc3333c5984e69abf0b0cd04c314 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 01:13:46 +0200 Subject: [PATCH 36/44] Add nothing return value on all functions that only modify the input --- src/graph_edit_distance.jl | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index e5ba55d..0e9329a 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -176,6 +176,7 @@ function validate_cost_function(c::EditCosts, G::AbstractGraph, H::AbstractGraph @assert size(c.c_ijkl) == (nv(G), nv(G), nv(H), nv(H)) @assert size(c.c_ijε) == (nv(G), nv(G)) @assert size(c.c_εkl) == (nv(H), nv(H)) + return nothing end """ @@ -219,6 +220,7 @@ function add_F1_objective!( l in 1:nv(H) if has_edge(H, k, l) && k < l ) ) + return nothing end """ @@ -261,6 +263,7 @@ function add_F2_objective!( ) + K ) + return nothing end """ @@ -280,6 +283,7 @@ to exactly one other node or deleted/created. function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) + vars.nodeDelG[i] == 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) + vars.nodeDelH[j] == 1) + return nothing end """ @@ -291,6 +295,7 @@ function add_node_map_constraints!( ) @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) <= 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) <= 1) + return nothing end """ @@ -310,6 +315,7 @@ function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, [k in vertices(H), l in vertices(H); has_edge(H, k, l) && k < l], sum(vars.y[:, :, k, l]; init=0) + vars.edgeDelH[k, l] == 1 ) + return nothing end """ @@ -339,6 +345,7 @@ function add_simple_topology_constraints!(model::GenericModel, vars::Variables, ], vars.y[i, j, k, l] <= vars.x[i, l] + vars.x[j, l] ) + return nothing end """ @@ -354,6 +361,7 @@ function add_improved_topology_constraints_G_to_H!( sum(vars.y[i, j, k, :]; init=0) + sum(vars.y[i, j, :, k]; init=0) <= vars.x[i, k] + vars.x[j, k] ) + return nothing end """ @@ -370,6 +378,7 @@ function add_improved_topology_constraints_H_to_G!( sum(vars.y[i, :, k, l]; init=0) + sum(vars.y[:, i, k, l]; init=0) <= vars.x[i, k] + vars.x[i, l] ) + return nothing end """ @@ -396,6 +405,7 @@ function add_oriented_topology_constraints!( [k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l)], sum(vars.z[i, :, k, l]; init=0) + sum(vars.z[:, i, l, k]; init=0) <= vars.x[i, k] ) + return nothing end """ @@ -419,6 +429,7 @@ function construct_formulation!( add_simple_topology_constraints!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -432,6 +443,7 @@ function construct_formulation!( add_simple_topology_constraints!(model, vars, G, H) add_F2_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -449,6 +461,7 @@ function construct_formulation!( end add_F2_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -463,6 +476,7 @@ function construct_formulation!( add_improved_topology_constraints_H_to_G!(model, vars, G, H) add_F2_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -476,6 +490,7 @@ function construct_formulation!( add_improved_topology_constraints_G_to_H!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -490,6 +505,7 @@ function construct_formulation!( add_improved_topology_constraints_H_to_G!(model, vars, G, H) add_F1_objective!(model, c, vars, G, H) + return nothing end function construct_formulation!( @@ -502,6 +518,7 @@ function construct_formulation!( add_oriented_topology_constraints!(model, vars, G, H) add_FORI_objective!(model, c, vars, G, H) + return nothing end """ @@ -524,6 +541,7 @@ function edit_distance!( error("This version of the graph edit distance only accepts undirected graphs.") end construct_formulation!(formulation, model, G, H, c) + return nothing end """ From 7604fcad978727bc7e9033a9b5d1d1efb53bd95b Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 6 Jul 2026 13:04:44 +0200 Subject: [PATCH 37/44] Fix issues with documentation This commit doesn't build, but that is because of preexisting issues with the project that are out of the scope for this commit. --- docs/src/algorithms.md | 19 ++++++++++++++++++- src/GraphsOptim.jl | 2 +- src/graph_edit_distance.jl | 13 +++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/src/algorithms.md b/docs/src/algorithms.md index dda96f3..9c51633 100644 --- a/docs/src/algorithms.md +++ b/docs/src/algorithms.md @@ -104,7 +104,9 @@ edit_distance GraphsOptim.edit_distance! ``` -The formulations implemented in the code are described in D'ascenzo, Andrea, et al. "Enhancing Graph Edit Distance Computation: Stronger and Orientation-based ILP Formulations." Proceedings of the VLDB Endowment 18.11 (2025): 4737-4749. The paper also containts a more detailed description of the graph edit distance problem. +### Mathematical Details + +The formulations implemented in the code are described in `D'ascenzo, Andrea, et al. "Enhancing Graph Edit Distance Computation: Stronger and Orientation-based ILP Formulations." Proceedings of the VLDB Endowment 18.11 (2025): 4737-4749`. The paper also containts a more detailed description of the graph edit distance problem. In the graph edit distance problem, we search for a minimum cost *edit path* between two graphs $G$ and $H$, that is a sequence of edit operations transforming $G$ to $H$. Valid @@ -138,6 +140,21 @@ topology constraints. The graph $G$ is oriented in a canonical way, while in $H$ forward and backwards edges for each undirected edge in the input. We can then additionally demand that edges be mapped consistently with their orientation. +### Function documentation for advanced use +```@docs +GraphsOptim.EditCosts +GraphsOptim.get_default_edit_costs +GraphsOptim.validate_cost_function +GraphsOptim.Formulation +GraphsOptim.F1 +GraphsOptim.F1prime +GraphsOptim.F1plus +GraphsOptim.F2minus +GraphsOptim.F2 +GraphsOptim.F2plus +GraphsOptim.FORI +``` + ## Coloring ```@docs diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 7dfc94e..1d4eaea 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -31,7 +31,7 @@ export fractional_chromatic_number, fractional_clique_number export shortest_path export maximum_weight_clique -export edit_distance!, F1, F1prime, F1plus, F2, F2minus, F2plus, FORI +export edit_distance, F1, F1prime, F1plus, F2, F2minus, F2plus, FORI include("utils.jl") include("flow.jl") diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 0e9329a..1186189 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -143,7 +143,7 @@ end Models arbitrary edit costs between two graphs. The implementation is taken from [FORI-GED](https://github.com/meffertj/FORI-GED). -For details on valid varable dimensions see [`validate_cost_function`](@ref), for +For details on valid variable dimensions see [`validate_cost_function`](@ref), for constructing the canonical cost function see [`get_default_edit_costs`](@ref). """ struct EditCosts @@ -552,14 +552,15 @@ end Compute the graph edit distance between undirected graphs `G` and `H` given edit costs `c`. # Arguments -- `G::Graphs.SimpleGraph`: graph -- `H::Graphs.SimpleGraph`: graph +- `G::Graphs.SimpleGraph` +- `H::Graphs.SimpleGraph` # Keywords -- `c::EditCosts`: pairwise edit cost struct for the graphs. See [`EditCosts`](@ref). - Defaults to the canonical edit costs [`get_default_edit_costs`](@ref) +- `c::EditCosts`: pairwise edit cost struct for the graphs. See + [`GraphsOptim.EditCosts`](@ref). Defaults to the canonical edit costs + [`GraphsOptim.get_default_edit_costs`](@ref). - `formulation::Type{<:Formulation}`: the ILP formulation to use. Defaults to the most - powerful [`FORI`](@ref) + powerful `FORI`. - `optimizer`: JuMP-compatible solver (default is `HiGHS.Optimizer`) # Returns From fd827efbf1b8c2f35737f30171d0a14a2bcf0a33 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Tue, 7 Jul 2026 18:24:04 +0200 Subject: [PATCH 38/44] Add return of both solution value and mapping to edit_distance function This is in preparation for unit testing. Both values are returned because just one cannot (easily) reconstruct the other. --- src/graph_edit_distance.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 1186189..6281313 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -581,5 +581,5 @@ function edit_distance( error("Graph edit distance was not solved optimally.") end node_matching = convert(Matrix{Int}, value(model[:x])) - return node_matching + return (; objective_value=objective_value(model), node_matching) end From 2c96fb6443df09a71cedc14242ec7c73ea670e3c Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Tue, 7 Jul 2026 18:33:39 +0200 Subject: [PATCH 39/44] Remove dead leftover code The cleaner default argument syntax means this is never hit. --- src/graph_edit_distance.jl | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 6281313..37507fb 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -456,10 +456,6 @@ function construct_formulation!( add_improved_topology_constraints_G_to_H!(model, vars, G, H) - if ismissing(c) - c = get_default_edit_costs(G, H) - end - add_F2_objective!(model, c, vars, G, H) return nothing end From 1dc547da241eb5ac0a1a281c9b85217217e37482 Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Tue, 7 Jul 2026 18:49:01 +0200 Subject: [PATCH 40/44] Add unit tests for uncovered code Adds unit tests on the internal validation function of edit costs, and the edit_distance entrypoint. --- test/graph_edit_distance.jl | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/graph_edit_distance.jl b/test/graph_edit_distance.jl index dcd16a0..f235292 100644 --- a/test/graph_edit_distance.jl +++ b/test/graph_edit_distance.jl @@ -17,9 +17,31 @@ all_formulations = [F1, F1prime, F1plus, F2, F2minus, F2plus, FORI] set_silent(model) optimize!(model) @test objective_value(model) == 1 + # testing the normal entrypoint + @test GraphsOptim.edit_distance(G, H).objective_value == 1 end end +@testset "Cost function validations" begin + G = Graph(3) + add_edge!(G, 1, 2) + + H = Graph(3) + add_edge!(H, 2, 3) + add_edge!(H, 1, 3) + + c = GraphsOptim.get_default_edit_costs(G, H) + @test isnothing(GraphsOptim.validate_cost_function(c, G, H)) + + broken_c = GraphsOptim.EditCosts( + zeros(Int, 1, 2), c.c_iε, c.c_εk, c.c_ijkl, c.c_ijε, c.c_εkl + ) + @test_throws AssertionError GraphsOptim.validate_cost_function(broken_c, G, H) + + Hprime = Graph(4) + @test_throws AssertionError GraphsOptim.validate_cost_function(c, G, Hprime) +end + @testset "Cost Functions Simple" begin G = Graph(3) add_edge!(G, 1, 2) @@ -147,3 +169,9 @@ end @test objective_value(model) == 3 end end + +@testset "Invalid Input" begin + G = Graph(3) + H = DiGraph(4) + @test_throws MethodError GraphsOptim.edit_distance(G, H) +end From 2413b347789a628a73d990aed6021a1544f75c3d Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Tue, 7 Jul 2026 19:08:33 +0200 Subject: [PATCH 41/44] Replace GenericModel with Model in function argument typing --- src/GraphsOptim.jl | 2 +- src/graph_edit_distance.jl | 28 ++++++++++------------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/GraphsOptim.jl b/src/GraphsOptim.jl index 1d4eaea..584f97a 100644 --- a/src/GraphsOptim.jl +++ b/src/GraphsOptim.jl @@ -16,7 +16,7 @@ using JuMP: objective_function, add_to_expression! using JuMP: set_silent, optimize!, termination_status, value using JuMP: set_optimizer, objective_value using JuMP: @variable, @constraint, @objective -using JuMP: GenericModel, VariableRef +using JuMP: VariableRef using LinearAlgebra: norm, tr, dot using MathOptInterface: OPTIMAL using SparseArrays: sparse diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 37507fb..524bc46 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -84,7 +84,7 @@ The variables model a full mapping between nodes and edges respectively. - [`OrientedVariables`](@ref) or [`ReducedVariables`](@ref) containing the variables. """ function create_model_vars_reduced!( - model::GenericModel, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool=false + model::Model, G::AbstractGraph, H::AbstractGraph, bidirectional::Bool=false ) @variable(model, x[1:nv(G), 1:nv(H)], Bin) @@ -112,9 +112,7 @@ end """ Convenience function bind. See [`create_model_vars_reduced`](@ref). """ -function create_model_vars_bidirectional!( - model::GenericModel, G::AbstractGraph, H::AbstractGraph -) +function create_model_vars_bidirectional!(model::Model, G::AbstractGraph, H::AbstractGraph) return create_model_vars_reduced!(model, G, H, true) end @@ -128,7 +126,7 @@ In addition to the normal node and edge map variables (see [`create_model_vars_reduced`](@ref)), these formulations explicitly have variables to model nodes and edges being deleted or added. """ -function create_model_vars_full!(model::GenericModel, G::AbstractGraph, H::AbstractGraph) +function create_model_vars_full!(model::Model, G::AbstractGraph, H::AbstractGraph) vars = create_model_vars_reduced!(model, G, H) @variable(model, nodeDelG[1:nv(G)], Bin) @variable(model, nodeDelH[1:nv(H)], Bin) @@ -280,7 +278,7 @@ end Add node map constraints to model for F1 style formulations, i.e. each node is only mapped to exactly one other node or deleted/created. """ -function add_node_map_constraints!(model::GenericModel, vars::FullVariables, G, H) +function add_node_map_constraints!(model::Model, vars::FullVariables, G, H) @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) + vars.nodeDelG[i] == 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) + vars.nodeDelH[j] == 1) return nothing @@ -291,7 +289,7 @@ Add node map constraints to model for F2 style and FORI formulations, i.e. each mapped to at most one other node (not being mapped implies being deleted or created). """ function add_node_map_constraints!( - model::GenericModel, vars::Union{ReducedVariables,OrientedVariables}, G, H + model::Model, vars::Union{ReducedVariables,OrientedVariables}, G, H ) @constraint(model, [i in 1:nv(G)], sum(vars.x[i, :]) <= 1) @constraint(model, [j in 1:nv(H)], sum(vars.x[:, j]) <= 1) @@ -303,7 +301,7 @@ Adds constraints on the edge map variables so each edge is mapped to exactly one deleted/created. Note that these constraints are only necessary in F1 style formulations, as they are implied in F2 styled and FORI. """ -function add_edge_map_constraints!(model::GenericModel, vars::FullVariables, G, H) +function add_edge_map_constraints!(model::Model, vars::FullVariables, G, H) # Note that init=0 is necessary to allow for empty graph edge cases. @constraint( model, @@ -322,7 +320,7 @@ end Add simplest form of topology constraints: If edge `ij` is mapped to `kl`, then `i` or `j` must map to `k`, and `i` or `j` must map to `l`. """ -function add_simple_topology_constraints!(model::GenericModel, vars::Variables, G, H) +function add_simple_topology_constraints!(model::Model, vars::Variables, G, H) @constraint( model, [ @@ -352,9 +350,7 @@ end Add improved topology constraints, replacing [`add_simple_topology_constraints`](@ref): If edge `ij` is mapped to any edge incident to `k`, then `i` or `j` must be mapped to `k`. """ -function add_improved_topology_constraints_G_to_H!( - model::GenericModel, vars::Variables, G, H -) +function add_improved_topology_constraints_G_to_H!(model::Model, vars::Variables, G, H) @constraint( model, [i in vertices(G), j in vertices(G), k in vertices(H); has_edge(G, i, j) && i < j], @@ -369,9 +365,7 @@ Add topology constraints mirroring [`add_improved_topology_constraints_G_to_H`]( backwards: If any edge incident to `i` is mapped to `kl`, then `i` must be mapped to `k` or `l`. """ -function add_improved_topology_constraints_H_to_G!( - model::GenericModel, vars::Variables, G, H -) +function add_improved_topology_constraints_H_to_G!(model::Model, vars::Variables, G, H) @constraint( model, [k in vertices(H), l in vertices(H), i in vertices(G); has_edge(H, k, l) && k < l], @@ -387,9 +381,7 @@ Add topology constraints for FORI. Uses the same implictions as used in [`add_improved_topology_constraints_H_to_G`](@ref), but since edges in `G` are oriented and each edge in `H` has two possible orientations, the implications are more precise. """ -function add_oriented_topology_constraints!( - model::GenericModel, vars::OrientedVariables, G, H -) +function add_oriented_topology_constraints!(model::Model, vars::OrientedVariables, G, H) @constraint( model, [k in vertices(H), i in vertices(G), j in vertices(G); has_edge(G, i, j) && i < j], From e229c061eab034414f93c3c81e7d67efc7d4284c Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Tue, 7 Jul 2026 19:43:52 +0200 Subject: [PATCH 42/44] Remove redundant check for graph directedness The typing in the function argument already checks that the graphs are not directed. --- src/graph_edit_distance.jl | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 524bc46..27655ff 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -525,9 +525,6 @@ function edit_distance!( c::EditCosts=get_default_edit_costs(G, H), formulation::Type{<:Formulation}=FORI, ) - if is_directed(G) || is_directed(H) - error("This version of the graph edit distance only accepts undirected graphs.") - end construct_formulation!(formulation, model, G, H, c) return nothing end From c7adef01bb709ff86edf4a017bf0849785a9ff4a Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Wed, 8 Jul 2026 23:32:35 +0200 Subject: [PATCH 43/44] Upgrade JuMP dependency for SparseVariable index slicing --- Project.toml | 2 +- src/graph_edit_distance.jl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 0997a6a..68bd2db 100644 --- a/Project.toml +++ b/Project.toml @@ -19,7 +19,7 @@ Documenter = "0.27" FillArrays = "0.13, 1.15" Graphs = "1.7" HiGHS = "1" -JuMP = "1" +JuMP = "1.3" JuliaFormatter = "1" MathOptInterface = "1.18" OptimalTransport = "0.3" diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index 27655ff..c7cfed0 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -565,6 +565,6 @@ function edit_distance( if termination_status(model) != OPTIMAL error("Graph edit distance was not solved optimally.") end - node_matching = convert(Matrix{Int}, value(model[:x])) + node_matching = convert(Matrix{Int}, value.(model[:x])) return (; objective_value=objective_value(model), node_matching) end From 3de0ecb0ee11bb66f3a178fdf697443f34fbd3ae Mon Sep 17 00:00:00 2001 From: Jonas Handwerker Date: Mon, 20 Jul 2026 23:19:51 +0200 Subject: [PATCH 44/44] Fix rounding problem for larger graphs The ILP solver's result is close to 0 or 1, but the slight difference is enough for Julia to not make the conversion. --- src/graph_edit_distance.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graph_edit_distance.jl b/src/graph_edit_distance.jl index c7cfed0..5df8cdc 100644 --- a/src/graph_edit_distance.jl +++ b/src/graph_edit_distance.jl @@ -565,6 +565,6 @@ function edit_distance( if termination_status(model) != OPTIMAL error("Graph edit distance was not solved optimally.") end - node_matching = convert(Matrix{Int}, value.(model[:x])) + node_matching = convert(Matrix{Int}, round.(value.(model[:x]))) return (; objective_value=objective_value(model), node_matching) end