From 1ed3d6a14614cb895eafee7a0e79687486b3858e Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Wed, 26 Aug 2026 13:38:22 +1200 Subject: [PATCH] Add SolverSpecific algorithm and Gurobi extension --- .github/workflows/ci.yml | 2 + Project.toml | 3 ++ README.md | 2 + ext/MathOptLazyGurobiExt.jl | 37 ++++++++++++++++ src/MathOptLazy.jl | 42 +++++++++++++++++- test/Project.toml | 1 + test/runtests.jl | 85 +++++++++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 ext/MathOptLazyGurobiExt.jl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4191df1..11ff9a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 + - shell: bash + run: echo "${{ secrets.GUROBI_LICENSE }}" > ~/gurobi.lic - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 diff --git a/Project.toml b/Project.toml index c7feaa5..b40b05d 100644 --- a/Project.toml +++ b/Project.toml @@ -7,12 +7,15 @@ authors = ["Oscar Dowson "] MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" [weakdeps] +Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" [extensions] +MathOptLazyGurobiExt = "Gurobi" MathOptLazyJuMPExt = "JuMP" [compat] +Gurobi = "1" JuMP = "1" MathOptInterface = "1" julia = "1.10" diff --git a/README.md b/README.md index d475412..31f74af 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ using JuMP import HiGHS import MathOptLazy model = Model(() -> MathOptLazy.Optimizer(HiGHS.Optimizer)) +set_attribute(model, MathOptLazy.Algorithm(), MathOptLazy.Iterative()) @variable(model, x[1:10] >= 0) @constraint(model, [i in 1:10], x[i] <= 1, MathOptLazy.Lazy()) ``` @@ -44,5 +45,6 @@ values are: * `MathOptLazy.Iterative()` [default] * `MathOptLazy.Callback()` + * `MathOptLazy.SolverSpecific()` See their docstrings for details. diff --git a/ext/MathOptLazyGurobiExt.jl b/ext/MathOptLazyGurobiExt.jl new file mode 100644 index 0000000..ab93e31 --- /dev/null +++ b/ext/MathOptLazyGurobiExt.jl @@ -0,0 +1,37 @@ +# Copyright (c) 2026 Oscar Dowson, and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +module MathOptLazyGurobiExt + +import Gurobi +import MathOptInterface as MOI +import MathOptLazy + +function _add_constraints!( + model::MathOptLazy.Optimizer{Gurobi.Optimizer}, + data::MathOptLazy._LazyData, +) + for (i, (f, s)) in enumerate(data.data) + if !data.active[i] + c = data.index[i] = MOI.add_constraint(model.inner, f, s) + MOI.set(model.inner, Gurobi.ConstraintAttribute("Lazy"), c, 1) + data.active[i] = true + end + end + return +end + +function MathOptLazy._optimize!( + model::MathOptLazy.Optimizer{Gurobi.Optimizer}, + ::MathOptLazy.SolverSpecific, +) + for data in values(model.lazy) + _add_constraints!(model, data) + end + MOI.optimize!(model.inner) + return +end + +end # module MathOptLazyJuMPExt diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index e06a2de..f9020af 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -94,6 +94,7 @@ Supported values are * `Iterative()` [default] * `Callback()` + * `SolverSpecific()` """ struct Algorithm <: MOI.AbstractOptimizerAttribute end @@ -114,14 +115,25 @@ struct Iterative <: AbstractAlgorithm end """ Callback() -This algorithm uses a `MOI.LazyConstraintCallback` to add violated laz - constraints to the main problem. +This algorithm uses a `MOI.LazyConstraintCallback` to add violated lazy +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 +""" + SolverSpecific() + +This algorithm uses a solver-specific extension to add lazy constraints. + +The following solvers are supported: + + * `Gurobi.jl` +""" +struct SolverSpecific <: AbstractAlgorithm end + ### Optimizer """ @@ -570,4 +582,30 @@ function _optimize!(model::Optimizer, ::Callback) return end +function _optimize!(model::Optimizer{T}, ::SolverSpecific) where {T} + return error( + """ + The `SolverSpecific` algorithm is not supported by the current solver. + + The current solver type is: `$T` + + The supported solvers are: + + * `Gurobi.Optimizer` + + ## Example + + ```julia + import Gurobi + import MathOptInterface as MOI + import MathOptLazy + optimizer = MOI.OptimizerWithAttributes( + () -> MathOptLazy.Optimizer(Gurobi.Optimizer), + MathOptLazy.Algorithm() => MathOptLazy.SolverSpecific(), + ) + ``` + """, + ) +end + end # module MathOptLazy diff --git a/test/Project.toml b/test/Project.toml index 916bc06..d4b8ae8 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,5 +1,6 @@ [deps] GLPK = "60bf3e95-4087-53dc-ae20-288a0d20c6a6" +Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" diff --git a/test/runtests.jl b/test/runtests.jl index a7afa81..153592b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,6 +9,7 @@ using JuMP using Test import GLPK +import Gurobi import HiGHS import MathOptInterface as MOI import MathOptLazy @@ -62,6 +63,7 @@ end function test_jump_broadcast() model = Model(() -> MathOptLazy.Optimizer(HiGHS.Optimizer)) + set_silent(model) @variable(model, x[1:3]) c = @constraint(model, x .<= 1:3, MathOptLazy.Lazy()) @test c isa Vector && length(c) == 3 @@ -74,6 +76,7 @@ end function test_jump_direct_basics() model = direct_model(MathOptLazy.Optimizer(HiGHS.Optimizer)) + set_silent(model) @variable(model, x) c = @constraint(model, x <= 1, MathOptLazy.Lazy()) o = constraint_object(c) @@ -88,6 +91,7 @@ function _basic_constraint_test_helper( activate::Bool, ) model = MathOptLazy.Optimizer(HiGHS.Optimizer) + MOI.set(model, MOI.Silent(), true) config = MOI.Test.Config() set = MathOptLazy.LazyScalarSet(inner_set) N = MOI.dimension(set) @@ -165,6 +169,7 @@ end function test_writing_mof_file() src = MathOptLazy.Optimizer(HiGHS.Optimizer) + MOI.set(src, MOI.Silent(), true) x = MOI.add_variable(src) c = MOI.add_constraint(src, x, MathOptLazy.LazyScalarSet(MOI.ZeroOne())) dest = MOI.FileFormats.MOF.Model() @@ -177,6 +182,7 @@ end function test_lazy_bounds() model = MathOptLazy.Optimizer(HiGHS.Optimizer) + MOI.set(model, MOI.Silent(), true) x = MOI.add_variable(model) set = MathOptLazy.LazyScalarSet(MOI.GreaterThan(0.0)) MOI.add_constraint(model, x, set) @@ -191,6 +197,7 @@ end function test_lazy_bounds_knapsack() model = MathOptLazy.Optimizer(HiGHS.Optimizer) + MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, 22) set = MathOptLazy.LazyScalarSet(MOI.GreaterThan(0.0)) MOI.add_constraint.(model, x, set) @@ -225,6 +232,84 @@ function test_jump_glpk_callback() return end +function test_gurobi_solver_specific() + N = 10 + model = MathOptLazy.Optimizer(Gurobi.Optimizer) + MOI.set(model, MathOptLazy.Algorithm(), MathOptLazy.SolverSpecific()) + MOI.set(model, MOI.Silent(), true) + x = MOI.add_variables(model, N) + MOI.add_constraint.(model, x, MOI.Integer()) + MOI.add_constraint.(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint.( + model, + 1.0 .* x, + MathOptLazy.LazyScalarSet(MOI.LessThan(1.0)), + ) + MOI.add_constraint( + model, + sum(abs(cos(i)) * x[i] for i in 1:N), + MOI.LessThan(0.1 * N), + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + f = sum(abs(sin(i)) * x[i] for i in 1:N) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT + @test all(<=(1 + 1e-6), MOI.get(model, MOI.VariablePrimal(), x)) + return +end + +function test_glpk_solver_specific() + N = 10 + model = MathOptLazy.Optimizer(GLPK.Optimizer) + MOI.set(model, MathOptLazy.Algorithm(), MathOptLazy.SolverSpecific()) + MOI.set(model, MOI.Silent(), true) + x = MOI.add_variables(model, N) + MOI.add_constraint.(model, x, MOI.Integer()) + MOI.add_constraint.(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint.( + model, + 1.0 .* x, + MathOptLazy.LazyScalarSet(MOI.LessThan(1.0)), + ) + MOI.add_constraint( + model, + sum(abs(cos(i)) * x[i] for i in 1:N), + MOI.LessThan(0.1 * N), + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + f = sum(abs(sin(i)) * x[i] for i in 1:N) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + @test_throws( + ErrorException( + """ + The `SolverSpecific` algorithm is not supported by the current solver. + + The current solver type is: `$(GLPK.Optimizer)` + + The supported solvers are: + + * `Gurobi.Optimizer` + + ## Example + + ```julia + import Gurobi + import MathOptInterface as MOI + import MathOptLazy + optimizer = MOI.OptimizerWithAttributes( + () -> MathOptLazy.Optimizer(Gurobi.Optimizer), + MathOptLazy.Algorithm() => MathOptLazy.SolverSpecific(), + ) + ``` + """, + ), + MOI.optimize!(model), + ) + return +end + end # TestMathOptLazy TestMathOptLazy.runtests()