From 73a9265b26291d35d534156158a66a52ee0cfd07 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Fri, 29 May 2026 01:28:50 +1200 Subject: [PATCH 1/2] Add Algorithm attribute and the ::Callback algorithm using lazy constraints --- README.md | 11 ++++++ src/MathOptLazy.jl | 94 ++++++++++++++++++++++++++++++++++++++++++++-- test/Project.toml | 2 + test/runtests.jl | 22 +++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 85b536b..d475412 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,14 @@ model = Model(() -> MathOptLazy.Optimizer(HiGHS.Optimizer)) @variable(model, x[1:10] >= 0) @constraint(model, [i in 1:10], x[i] <= 1, MathOptLazy.Lazy()) ``` + +## Algorithm + +Control the algorithm used to handle the lazy constraints by setting the +`MathOptLazy.Algorithm` attribute. See the docstring for details. The supoprted +values are: + + * `MathOptLazy.Iterative()` [default] + * `MathOptLazy.Callback()` + +See their docstrings for details. diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index 3661032..b8020e6 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -82,6 +82,46 @@ struct _LazyData{F<:MOI.AbstractScalarFunction,S<:MOI.AbstractScalarSet} end end +### Algorithm + +""" + Algorithm() <: MOI.AbstractOptimizerAttribute + +An `MOI.AbstractOptimizerAttribute` to control which algorithm we use to solve +the lazy constraints. + +Supported values are + + * `Iterative()` [default] + * `Callback()` +""" +struct Algorithm <: MOI.AbstractOptimizerAttribute end + +abstract type AbstractAlgorithm end + +""" + Iterative() + +This algorithm iteratively solves a sequence of problems that iteratively add +violated lazy constraints to the main problem. + +This algorithm works for all problem types, including continuous problems with +no discrete variables. The downside is that it may not re-use information +between solves. +""" +struct Iterative <: AbstractAlgorithm end + +""" + Callback() + +This algorithm uses a `MOI.LazyConstraintCallback` to add violated laz + constraints to the main problem. + +This algorithm works only for problems with discrete variables and only if the +solver supports `MOI.LazyConstraintCallback`. +""" +struct Callback <: AbstractAlgorithm end + ### Optimizer """ @@ -112,17 +152,34 @@ MathOptLazy.Optimizer{Float64, MOIB.LazyBridgeOptimizer{HiGHS.Optimizer}} └ NumberOfConstraints: 0 ``` """ -struct Optimizer{OT} <: MOI.AbstractOptimizer +mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer inner::OT - + algorithm::AbstractAlgorithm lazy::Dict{Tuple{Type,Type},_LazyData} function Optimizer(inner_fn; kwargs...) inner = MOI.instantiate(inner_fn; kwargs...) - return new{typeof(inner)}(inner, Dict{Tuple{Type,Type},_LazyData}()) + return new{typeof(inner)}( + inner, + Iterative(), + Dict{Tuple{Type,Type},_LazyData}(), + ) end end +### Algorithm + +MOI.supports(::Optimizer, ::Algorithm) = true + +MOI.get(model::Optimizer, ::Algorithm) = model.algorithm + +function MOI.set(model::Optimizer, ::Algorithm, value::AbstractAlgorithm) + model.algorithm = value + return +end + +MOI.Utilities.map_indices(::Function, algorithm::AbstractAlgorithm) = algorithm + ### Fallbacks function MOI.empty!(model::Optimizer) @@ -407,7 +464,9 @@ end ### MOI.optimize! -function MOI.optimize!(model::Optimizer) +MOI.optimize!(model::Optimizer) = _optimize!(model, model.algorithm) + +function _optimize!(model::Optimizer, ::Iterative) needs_solve = true x = MOI.get(model, MOI.ListOfVariableIndices()) # TODO(odow): if the solver supports VariablePrimalStart, we will update the @@ -481,4 +540,31 @@ function _add_if_feasible( return needs_solve end +function _optimize!(model::Optimizer, ::Callback) + function callback(cb_data) + x = MOI.get(model, MOI.ListOfVariableIndices()) + X = Dict( + xi => MOI.get(model.inner, MOI.CallbackVariablePrimal(cb_data), xi) + for xi in x + ) + # We don't check `.is_active` in this loop because callbacks are weird. + # In some solvers, callbacks may be called at a point that was + # previously cut off because the added cut was later removed. The only + # guarantee is that the solver won't terminate until this loop produces + # no new cuts. + for data in values(model.lazy) + for (i, (f, s)) in enumerate(data.data) + y = MOI.Utilities.eval_variables(Base.Fix1(getindex, X), model.inner, f) + if MOI.Utilities.distance_to_set(y, s) > 0 + MOI.submit(model.inner, MOI.LazyConstraint(cb_data), f, s) + end + end + end + return + end + MOI.set(model.inner, MOI.LazyConstraintCallback(), callback) + MOI.optimize!(model.inner) + return +end + end # module MathOptLazy diff --git a/test/Project.toml b/test/Project.toml index aa018e3..916bc06 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,4 +1,5 @@ [deps] +GLPK = "60bf3e95-4087-53dc-ae20-288a0d20c6a6" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" @@ -6,4 +7,5 @@ MathOptLazy = "5d5fe9b5-b0a4-4485-81f6-7b1b939155e1" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] +GLPK = "1" HiGHS = "1" diff --git a/test/runtests.jl b/test/runtests.jl index 4fd4969..a7afa81 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,7 @@ module TestMathOptLazy using JuMP using Test +import GLPK import HiGHS import MathOptInterface as MOI import MathOptLazy @@ -203,6 +204,27 @@ function test_lazy_bounds_knapsack() return end +function test_jump_glpk_callback() + N = 10 + model = Model(() -> MathOptLazy.Optimizer(GLPK.Optimizer)) + opt = unsafe_backend(model) + @test MOI.supports(opt, MathOptLazy.Algorithm()) + @test MOI.get(opt, MathOptLazy.Algorithm()) == MathOptLazy.Iterative() + set_attribute(model, MathOptLazy.Algorithm(), MathOptLazy.Callback()) + @test MOI.get(opt, MathOptLazy.Algorithm()) == MathOptLazy.Callback() + set_silent(model) + @variable(model, x[1:N] >= 0, Int) + @constraint(model, c[i in 1:N], x[i] <= 1, MathOptLazy.Lazy()) + @test endswith(sprint(show, c[1]), " [lazy]") + @constraint(model, sum(abs(cos(i)) * x[i] for i in 1:N) <= 0.1 * N) + @objective(model, Max, sum(abs(sin(i)) * x[i] for i in 1:N)) + optimize!(model) + @test termination_status(model) == OPTIMAL + @test primal_status(model) == FEASIBLE_POINT + @test all(<=(1 + 1e-6), value(x)) + return +end + end # TestMathOptLazy TestMathOptLazy.runtests() From dcde017e36a6b8fb92c3bef34ebc0812b5b70f78 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Fri, 29 May 2026 01:32:21 +1200 Subject: [PATCH 2/2] Fix format --- src/MathOptLazy.jl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index b8020e6..e06a2de 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -544,8 +544,7 @@ function _optimize!(model::Optimizer, ::Callback) function callback(cb_data) x = MOI.get(model, MOI.ListOfVariableIndices()) X = Dict( - xi => MOI.get(model.inner, MOI.CallbackVariablePrimal(cb_data), xi) - for xi in x + xi => MOI.get(model.inner, MOI.CallbackVariablePrimal(cb_data), xi) for xi in x ) # We don't check `.is_active` in this loop because callbacks are weird. # In some solvers, callbacks may be called at a point that was @@ -554,7 +553,11 @@ function _optimize!(model::Optimizer, ::Callback) # no new cuts. for data in values(model.lazy) for (i, (f, s)) in enumerate(data.data) - y = MOI.Utilities.eval_variables(Base.Fix1(getindex, X), model.inner, f) + y = MOI.Utilities.eval_variables( + Base.Fix1(getindex, X), + model.inner, + f, + ) if MOI.Utilities.distance_to_set(y, s) > 0 MOI.submit(model.inner, MOI.LazyConstraint(cb_data), f, s) end