From ff8129dca6d26630b17e16377dfe18792b97dcac Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 8 Oct 2026 15:42:38 -0400 Subject: [PATCH 1/5] stale lazy constraint removal --- .gitignore | 1 + ext/MathOptLazyJuMPExt.jl | 13 +++ src/MathOptLazy.jl | 202 ++++++++++++++++++++++++++++++++++++++ test/runtests.jl | 188 +++++++++++++++++++++++++++++++++++ 4 files changed, 404 insertions(+) diff --git a/.gitignore b/.gitignore index ba39cc5..80516d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ Manifest.toml +.DS_Store diff --git a/ext/MathOptLazyJuMPExt.jl b/ext/MathOptLazyJuMPExt.jl index 49477ee..0e7e54a 100644 --- a/ext/MathOptLazyJuMPExt.jl +++ b/ext/MathOptLazyJuMPExt.jl @@ -79,4 +79,17 @@ function MathOptLazy.Lazy( return MathOptLazy.Lazy(; lazy) end +""" + MathOptLazy.prune_stale_constraints!(model::JuMP.GenericModel)::Int + +Call [`MathOptLazy.prune_stale_constraints!`](@ref) on the +`MathOptLazy.Optimizer` of `model`. + +!!! compat + This method requires JuMP to be loaded to activate the package extension. +""" +function MathOptLazy.prune_stale_constraints!(model::JuMP.GenericModel) + return MathOptLazy.prune_stale_constraints!(JuMP.unsafe_backend(model)) +end + end # module MathOptLazyJuMPExt diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index 8964b4d..5f4fca2 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -90,6 +90,7 @@ struct _LazyData{F<:MOI.AbstractScalarFunction,S<:MOI.AbstractScalarSet} data::Vector{Tuple{F,S}} status::Vector{_LAZY_CONSTRAINT_STATUS} index::Vector{MOI.ConstraintIndex{F,S}} + stale::Vector{Int} function _LazyData{ F, @@ -99,6 +100,7 @@ struct _LazyData{F<:MOI.AbstractScalarFunction,S<:MOI.AbstractScalarSet} Tuple{F,S}[], _LAZY_CONSTRAINT_STATUS[], MOI.ConstraintIndex{F,S}[], + Int[], ) end end @@ -121,6 +123,48 @@ struct Algorithm <: MOI.AbstractOptimizerAttribute end abstract type AbstractAlgorithm end +""" + StaleSolveLimit() <: MOI.AbstractOptimizerAttribute + +An `MOI.AbstractOptimizerAttribute` to control the number of consecutive solves +for which an active lazy constraint must be non-binding before it is considered +stale. + +A solve is one call to `MOI.optimize!`. Whether a constraint is binding is +controlled by [`BindingTolerance`](@ref). + +The default is `typemax(Int)`, which means that no constraint is ever stale. + +Stale constraints are tracked only by the [`Iterative`](@ref) algorithm. +""" +struct StaleSolveLimit <: MOI.AbstractOptimizerAttribute end + +""" + PruneBatchSize() <: MOI.AbstractOptimizerAttribute + +An `MOI.AbstractOptimizerAttribute` to control the number of stale constraints +that are required before `MOI.optimize!` calls +[`prune_stale_constraints!`](@ref). + +The default is `1`. + +See also [`StaleSolveLimit`](@ref). +""" +struct PruneBatchSize <: MOI.AbstractOptimizerAttribute end + +""" + BindingTolerance() <: MOI.AbstractOptimizerAttribute + +An `MOI.AbstractOptimizerAttribute` to control the absolute tolerance used to +decide whether an active lazy constraint is binding. A constraint is binding if +its primal value is within the tolerance of a bound of its set. + +The default is `1e-6`. + +See also [`StaleSolveLimit`](@ref). +""" +struct BindingTolerance <: MOI.AbstractOptimizerAttribute end + """ Iterative() @@ -205,6 +249,9 @@ mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer algorithm::AbstractAlgorithm lazy::Dict{Tuple{Type,Type},_LazyData} silent::Bool + stale_solve_limit::Int + prune_batch_size::Int + binding_tolerance::Float64 # Result attributes barrier_iterations::Int64 node_count::Int64 @@ -218,6 +265,9 @@ mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer Iterative(), Dict{Tuple{Type,Type},_LazyData}(), false, + typemax(Int), + 1, + 1e-6, 0, 0, 0, @@ -239,6 +289,48 @@ end MOI.Utilities.map_indices(::Function, algorithm::AbstractAlgorithm) = algorithm +### StaleSolveLimit + +MOI.supports(::Optimizer, ::StaleSolveLimit) = true + +MOI.get(model::Optimizer, ::StaleSolveLimit) = model.stale_solve_limit + +function MOI.set(model::Optimizer, ::StaleSolveLimit, value::Int) + if value < 1 + throw(ArgumentError("StaleSolveLimit must be at least 1.")) + end + model.stale_solve_limit = value + return +end + +### PruneBatchSize + +MOI.supports(::Optimizer, ::PruneBatchSize) = true + +MOI.get(model::Optimizer, ::PruneBatchSize) = model.prune_batch_size + +function MOI.set(model::Optimizer, ::PruneBatchSize, value::Int) + if value < 1 + throw(ArgumentError("PruneBatchSize must be at least 1.")) + end + model.prune_batch_size = value + return +end + +### BindingTolerance + +MOI.supports(::Optimizer, ::BindingTolerance) = true + +MOI.get(model::Optimizer, ::BindingTolerance) = model.binding_tolerance + +function MOI.set(model::Optimizer, ::BindingTolerance, value::Float64) + if value < 0 + throw(ArgumentError("BindingTolerance must be non-negative.")) + end + model.binding_tolerance = value + return +end + ### MOI.Silent MOI.supports(::Optimizer, ::MOI.Silent) = true @@ -489,6 +581,7 @@ function MOI.add_constraint( push!(data.data, (f, s.set)) push!(data.status, _kLAZY_CONSTRAINT_INACTIVE) push!(data.index, MOI.ConstraintIndex{F,S}(0)) + push!(data.stale, 0) return MOI.ConstraintIndex{F,LazyScalarSet{S}}(length(data.data)) end @@ -655,6 +748,113 @@ function MOI.get( return count(==(_kLAZY_CONSTRAINT_ACTIVE), _data(model, F, S).status) end +### MathOptLazy.prune_stale_constraints! + +function _is_stale(model::Optimizer, data::_LazyData, i::Int) + return data.status[i] == _kLAZY_CONSTRAINT_ACTIVE && + data.stale[i] >= model.stale_solve_limit +end + +function _number_stale(model::Optimizer, data::_LazyData) + return count(i -> _is_stale(model, data, i), eachindex(data.status)) +end + +function _prune_stale!(model::Optimizer, data::_LazyData{F,S}) where {F,S} + constraints_pruned = 0 + for i in eachindex(data.status) + if _is_stale(model, data, i) + MOI.delete(model.inner, data.index[i]) + data.index[i] = MOI.ConstraintIndex{F,S}(0) + data.status[i] = _kLAZY_CONSTRAINT_INACTIVE + data.stale[i] = 0 + constraints_pruned += 1 + end + end + return constraints_pruned +end + +""" + prune_stale_constraints!(model::Optimizer)::Int + +Remove every stale lazy constraint from the subproblem and return the number of +constraints that were removed. + +A lazy constraint is stale if it is active in the subproblem and it has been +non-binding for at least [`StaleSolveLimit`](@ref) consecutive solves. A pruned +constraint is still part of the model: it will be added to the subproblem again +if a future solution violates it. + +`MOI.optimize!` calls this function before solving if there are at least +[`PruneBatchSize`](@ref) stale constraints. Call it manually to prune +regardless of [`PruneBatchSize`](@ref). + +!!! warning + This function modifies the inner optimizer, which may invalidate the + current solution. Query the solution before calling it. + +## Example + +```julia +julia> import HiGHS, MathOptLazy + +julia> import MathOptInterface as MOI + +julia> model = MathOptLazy.Optimizer(HiGHS.Optimizer); + +julia> MOI.set(model, MathOptLazy.StaleSolveLimit(), 5) + +julia> MathOptLazy.prune_stale_constraints!(model) +0 +``` +""" +function prune_stale_constraints!(model::Optimizer) + return sum(d -> _prune_stale!(model, d), values(model.lazy); init = 0) +end + +function _maybe_prune_stale!(model::Optimizer) + n = sum(d -> _number_stale(model, d), values(model.lazy); init = 0) + if n < model.prune_batch_size + return + end + prune_stale_constraints!(model) + if !model.silent + println("[MathOptLazy] pruned $(n) stale constraints") + end + return +end + +_is_binding(y, s::MOI.LessThan, tol) = y >= s.upper - tol +_is_binding(y, s::MOI.GreaterThan, tol) = y <= s.lower + tol +_is_binding(y, s::MOI.Interval, tol) = y <= s.lower + tol || y >= s.upper - tol + +# For sets whose slackness we can't measure, assume they bind and don't prune. +_is_binding(y, ::MOI.AbstractScalarSet, tol) = true + +function _update_stale!(model::Optimizer, data::_LazyData) + for (i, (_, s)) in enumerate(data.data) + if data.status[i] != _kLAZY_CONSTRAINT_ACTIVE + continue + end + y = MOI.get(model.inner, MOI.ConstraintPrimal(), data.index[i]) + if _is_binding(y, s, model.binding_tolerance) + data.stale[i] = 0 + else + data.stale[i] += 1 + end + end + return +end + +function _update_stale!(model::Optimizer) + if MOI.get(model, MOI.PrimalStatus()) != MOI.FEASIBLE_POINT + return + end + for data in values(model.lazy) + _update_stale!(model, data) + end + return +end + ### MOI.optimize! function _try_get(model::Optimizer, attr::MOI.AbstractModelAttribute, default) @@ -691,6 +891,7 @@ end ### MathOptLazy.Iterative function _optimize!(model::Optimizer, ::Iterative) + _maybe_prune_stale!(model) if (undo = _relax_integrality(model.inner)) !== nothing if !model.silent println("[MathOptLazy] relaxing binary and integer variables") @@ -702,6 +903,7 @@ function _optimize!(model::Optimizer, ::Iterative) undo() end _iterate(model; start = true) + _update_stale!(model) return end diff --git a/test/runtests.jl b/test/runtests.jl index 5dc46ff..306227f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -855,6 +855,194 @@ function test_result_attributes_BarrierIterations() return end +function _stale_model() + model = MathOptLazy.Optimizer(HiGHS.Optimizer) + MOI.set(model, MOI.Silent(), true) + x = MOI.add_variables(model, 2) + MOI.add_constraint.(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, 1.0 * x[1] + 1.0 * x[2], MOI.LessThan(10.0)) + for xi in x + set = MathOptLazy.LazyScalarSet(MOI.LessThan(1.0)) + MOI.add_constraint(model, 1.0 * xi, set) + end + F, S = MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64} + return model, x, MathOptLazy._data(model, F, S) +end + +function _stale_solve(model, sense, f) + MOI.set(model, MOI.ObjectiveSense(), sense) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + return +end + +function test_stale_attributes() + model = MathOptLazy.Optimizer(HiGHS.Optimizer) + for (attr, default, value, invalid) in [ + (MathOptLazy.StaleSolveLimit(), typemax(Int), 3, 0), + (MathOptLazy.PruneBatchSize(), 1, 4, 0), + (MathOptLazy.BindingTolerance(), 1e-6, 1e-3, -1.0), + ] + @test MOI.supports(model, attr) + @test MOI.get(model, attr) == default + MOI.set(model, attr, value) + @test MOI.get(model, attr) == value + @test_throws ArgumentError MOI.set(model, attr, invalid) + MOI.empty!(model) + @test MOI.get(model, attr) == value + end + @test_throws( + ArgumentError, + MOI.set(model, MathOptLazy.BindingTolerance(), NaN), + ) + return +end + +function test_is_binding() + tol = 1e-3 + s = MOI.LessThan(1.0) + @test MathOptLazy._is_binding(1.0, s, tol) + @test MathOptLazy._is_binding(0.9995, s, tol) + @test !MathOptLazy._is_binding(0.99, s, tol) + s = MOI.GreaterThan(1.0) + @test MathOptLazy._is_binding(1.0, s, tol) + @test MathOptLazy._is_binding(1.0005, s, tol) + @test !MathOptLazy._is_binding(1.01, s, tol) + s = MOI.Interval(1.0, 2.0) + @test MathOptLazy._is_binding(1.0005, s, tol) + @test MathOptLazy._is_binding(1.9995, s, tol) + @test !MathOptLazy._is_binding(1.5, s, tol) + @test MathOptLazy._is_binding(1.5, MOI.EqualTo(1.0), tol) + @test MathOptLazy._is_binding(0.5, MOI.ZeroOne(), tol) + return +end + +function test_stale_counter() + model, x, data = _stale_model() + @test data.stale == [0, 0] + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [0, 0] + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] - 1.0 * x[2]) + @test data.stale == [0, 1] + MOI.optimize!(model) + @test data.stale == [0, 2] + _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [1, 3] + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [0, 0] + return +end + +function test_stale_counter_binding_tolerance() + model, x, data = _stale_model() + MOI.set(model, MathOptLazy.BindingTolerance(), 2.0) + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [0, 0] + return +end + +function test_stale_counter_no_feasible_point() + model, x, data = _stale_model() + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [1, 1] + MOI.add_constraint(model, 1.0 * x[1], MOI.GreaterThan(2.0)) + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.INFEASIBLE + @test data.stale == [1, 1] + return +end + +function test_prune_automatic() + model, x, data = _stale_model() + MOI.set(model, MathOptLazy.StaleSolveLimit(), 2) + MOI.set(model, MathOptLazy.PruneBatchSize(), 2) + F, S = MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64} + attr = + MathOptLazy.NumberOfConstraintsActive{F,MathOptLazy.LazyScalarSet{S}}() + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] - 1.0 * x[2]) + MOI.optimize!(model) + @test data.stale == [0, 2] + MOI.optimize!(model) + @test data.stale == [0, 3] + @test MOI.get(model, attr) == 2 + _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) + MOI.optimize!(model) + @test data.stale == [2, 5] + @test MOI.get(model, attr) == 2 + @test MOI.get(model.inner, MOI.NumberOfConstraints{F,S}()) == 3 + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, attr) == 0 + @test MOI.get(model.inner, MOI.NumberOfConstraints{F,S}()) == 1 + @test all(==(MathOptLazy._kLAZY_CONSTRAINT_INACTIVE), data.status) + @test all(==(MOI.ConstraintIndex{F,S}(0)), data.index) + @test data.stale == [0, 0] + return +end + +function test_prune_readded() + model, x, data = _stale_model() + MOI.set(model, MathOptLazy.StaleSolveLimit(), 1) + F, S = MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64} + attr = + MathOptLazy.NumberOfConstraintsActive{F,MathOptLazy.LazyScalarSet{S}}() + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test data.stale == [1, 1] + @test MOI.get(model, attr) == 0 + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test all(==(MathOptLazy._kLAZY_CONSTRAINT_ACTIVE), data.status) + @test data.stale == [0, 0] + @test MOI.get(model, attr) == 2 + return +end + +function test_prune_manual() + model, x, data = _stale_model() + MOI.set(model, MathOptLazy.PruneBatchSize(), 100) + @test MathOptLazy.prune_stale_constraints!(model) == 0 + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] - 1.0 * x[2]) + @test data.stale == [0, 1] + @test MathOptLazy.prune_stale_constraints!(model) == 0 + MOI.set(model, MathOptLazy.StaleSolveLimit(), 1) + @test MathOptLazy.prune_stale_constraints!(model) == 1 + @test data.status == [ + MathOptLazy._kLAZY_CONSTRAINT_ACTIVE, + MathOptLazy._kLAZY_CONSTRAINT_INACTIVE, + ] + @test data.stale == [0, 0] + @test MathOptLazy.prune_stale_constraints!(model) == 0 + _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) + @test MOI.get(model, MOI.VariablePrimal(), x) ≈ [1.0, 1.0] + return +end + +function test_prune_manual_jump() + model = Model(() -> MathOptLazy.Optimizer(HiGHS.Optimizer)) + set_silent(model) + set_attribute(model, MathOptLazy.StaleSolveLimit(), 1) + set_attribute(model, MathOptLazy.PruneBatchSize(), 100) + @variable(model, x[1:2] >= 0) + @constraint(model, sum(x) <= 10) + @constraint(model, [i in 1:2], 1.0 * x[i] <= 1, MathOptLazy.Lazy()) + @objective(model, Max, sum(x)) + optimize!(model) + @test value.(x) ≈ [1.0, 1.0] + @objective(model, Min, sum(x)) + optimize!(model) + @test MathOptLazy.prune_stale_constraints!(model) == 2 + @test MathOptLazy.prune_stale_constraints!(model) == 0 + @objective(model, Max, sum(x)) + optimize!(model) + @test value.(x) ≈ [1.0, 1.0] + return +end + end # TestMathOptLazy TestMathOptLazy.runtests() From fef867bc74666a89476bb833f1aed54e3697b410 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 8 Oct 2026 15:48:34 -0400 Subject: [PATCH 2/5] prune in reverse order --- src/MathOptLazy.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index 5f4fca2..e4ba76a 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -761,7 +761,7 @@ end function _prune_stale!(model::Optimizer, data::_LazyData{F,S}) where {F,S} constraints_pruned = 0 - for i in eachindex(data.status) + for i in reverse(eachindex(data.status)) if _is_stale(model, data, i) MOI.delete(model.inner, data.index[i]) data.index[i] = MOI.ConstraintIndex{F,S}(0) From 2a8e647dc56d2f0c27aac8cdbd953f7eaec74e51 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 9 Oct 2026 08:21:25 -0400 Subject: [PATCH 3/5] oscar review: attributes -> kwargs; fixed tolerance; no manual prune --- ext/MathOptLazyJuMPExt.jl | 13 --- src/MathOptLazy.jl | 185 +++++++++++++++----------------------- test/runtests.jl | 116 +++++++----------------- 3 files changed, 101 insertions(+), 213 deletions(-) diff --git a/ext/MathOptLazyJuMPExt.jl b/ext/MathOptLazyJuMPExt.jl index 0e7e54a..49477ee 100644 --- a/ext/MathOptLazyJuMPExt.jl +++ b/ext/MathOptLazyJuMPExt.jl @@ -79,17 +79,4 @@ function MathOptLazy.Lazy( return MathOptLazy.Lazy(; lazy) end -""" - MathOptLazy.prune_stale_constraints!(model::JuMP.GenericModel)::Int - -Call [`MathOptLazy.prune_stale_constraints!`](@ref) on the -`MathOptLazy.Optimizer` of `model`. - -!!! compat - This method requires JuMP to be loaded to activate the package extension. -""" -function MathOptLazy.prune_stale_constraints!(model::JuMP.GenericModel) - return MathOptLazy.prune_stale_constraints!(JuMP.unsafe_backend(model)) -end - end # module MathOptLazyJuMPExt diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index e4ba76a..1f32270 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -124,49 +124,7 @@ struct Algorithm <: MOI.AbstractOptimizerAttribute end abstract type AbstractAlgorithm end """ - StaleSolveLimit() <: MOI.AbstractOptimizerAttribute - -An `MOI.AbstractOptimizerAttribute` to control the number of consecutive solves -for which an active lazy constraint must be non-binding before it is considered -stale. - -A solve is one call to `MOI.optimize!`. Whether a constraint is binding is -controlled by [`BindingTolerance`](@ref). - -The default is `typemax(Int)`, which means that no constraint is ever stale. - -Stale constraints are tracked only by the [`Iterative`](@ref) algorithm. -""" -struct StaleSolveLimit <: MOI.AbstractOptimizerAttribute end - -""" - PruneBatchSize() <: MOI.AbstractOptimizerAttribute - -An `MOI.AbstractOptimizerAttribute` to control the number of stale constraints -that are required before `MOI.optimize!` calls -[`prune_stale_constraints!`](@ref). - -The default is `1`. - -See also [`StaleSolveLimit`](@ref). -""" -struct PruneBatchSize <: MOI.AbstractOptimizerAttribute end - -""" - BindingTolerance() <: MOI.AbstractOptimizerAttribute - -An `MOI.AbstractOptimizerAttribute` to control the absolute tolerance used to -decide whether an active lazy constraint is binding. A constraint is binding if -its primal value is within the tolerance of a bound of its set. - -The default is `1e-6`. - -See also [`StaleSolveLimit`](@ref). -""" -struct BindingTolerance <: MOI.AbstractOptimizerAttribute end - -""" - Iterative() + Iterative(; stale_solve_limit::Int = typemax(Int), prune_batch_size::Int = 1) This algorithm iteratively solves a sequence of problems that iteratively add violated lazy constraints to the main problem. @@ -189,8 +147,34 @@ warm-start from the previous basis after new constraints are added, whereas every iteration of the second phase solves a mixed-integer program from scratch. The second phase is still needed because a lazy constraint can be violated by an integer solution even if the relaxation satisfies it. + +## Stale constraints + +An active lazy constraint is stale if it has been non-binding for at least +`stale_solve_limit` consecutive solves, where a solve is one call to +`MOI.optimize!` and a constraint is binding if its primal value is equal to a +bound of its set. The default of `typemax(Int)` means that no constraint is ever +stale. + +`MOI.optimize!` calls [`prune_stale_constraints!`](@ref) before solving if there +are at least `prune_batch_size` stale constraints. """ -struct Iterative <: AbstractAlgorithm end +struct Iterative <: AbstractAlgorithm + stale_solve_limit::Int + prune_batch_size::Int + + function Iterative(; + stale_solve_limit::Int = typemax(Int), + prune_batch_size::Int = 1, + ) + if stale_solve_limit < 1 + throw(ArgumentError("stale_solve_limit must be at least 1.")) + elseif prune_batch_size < 1 + throw(ArgumentError("prune_batch_size must be at least 1.")) + end + return new(stale_solve_limit, prune_batch_size) + end +end """ Callback() @@ -249,9 +233,6 @@ mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer algorithm::AbstractAlgorithm lazy::Dict{Tuple{Type,Type},_LazyData} silent::Bool - stale_solve_limit::Int - prune_batch_size::Int - binding_tolerance::Float64 # Result attributes barrier_iterations::Int64 node_count::Int64 @@ -265,9 +246,6 @@ mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer Iterative(), Dict{Tuple{Type,Type},_LazyData}(), false, - typemax(Int), - 1, - 1e-6, 0, 0, 0, @@ -289,48 +267,6 @@ end MOI.Utilities.map_indices(::Function, algorithm::AbstractAlgorithm) = algorithm -### StaleSolveLimit - -MOI.supports(::Optimizer, ::StaleSolveLimit) = true - -MOI.get(model::Optimizer, ::StaleSolveLimit) = model.stale_solve_limit - -function MOI.set(model::Optimizer, ::StaleSolveLimit, value::Int) - if value < 1 - throw(ArgumentError("StaleSolveLimit must be at least 1.")) - end - model.stale_solve_limit = value - return -end - -### PruneBatchSize - -MOI.supports(::Optimizer, ::PruneBatchSize) = true - -MOI.get(model::Optimizer, ::PruneBatchSize) = model.prune_batch_size - -function MOI.set(model::Optimizer, ::PruneBatchSize, value::Int) - if value < 1 - throw(ArgumentError("PruneBatchSize must be at least 1.")) - end - model.prune_batch_size = value - return -end - -### BindingTolerance - -MOI.supports(::Optimizer, ::BindingTolerance) = true - -MOI.get(model::Optimizer, ::BindingTolerance) = model.binding_tolerance - -function MOI.set(model::Optimizer, ::BindingTolerance, value::Float64) - if value < 0 - throw(ArgumentError("BindingTolerance must be non-negative.")) - end - model.binding_tolerance = value - return -end - ### MOI.Silent MOI.supports(::Optimizer, ::MOI.Silent) = true @@ -750,19 +686,23 @@ end ### MathOptLazy.prune_stale_constraints! -function _is_stale(model::Optimizer, data::_LazyData, i::Int) +function _is_stale(algorithm::Iterative, data::_LazyData, i::Int) return data.status[i] == _kLAZY_CONSTRAINT_ACTIVE && - data.stale[i] >= model.stale_solve_limit + data.stale[i] >= algorithm.stale_solve_limit end -function _number_stale(model::Optimizer, data::_LazyData) - return count(i -> _is_stale(model, data, i), eachindex(data.status)) +function _number_stale(algorithm::Iterative, data::_LazyData) + return count(i -> _is_stale(algorithm, data, i), eachindex(data.status)) end -function _prune_stale!(model::Optimizer, data::_LazyData{F,S}) where {F,S} +function _prune_stale!( + model::Optimizer, + algorithm::Iterative, + data::_LazyData{F,S}, +) where {F,S} constraints_pruned = 0 for i in reverse(eachindex(data.status)) - if _is_stale(model, data, i) + if _is_stale(algorithm, data, i) MOI.delete(model.inner, data.index[i]) data.index[i] = MOI.ConstraintIndex{F,S}(0) data.status[i] = _kLAZY_CONSTRAINT_INACTIVE @@ -773,6 +713,16 @@ function _prune_stale!(model::Optimizer, data::_LazyData{F,S}) where {F,S} return constraints_pruned end +function _prune_stale!(model::Optimizer, algorithm::Iterative) + return sum( + d -> _prune_stale!(model, algorithm, d), + values(model.lazy); + init = 0, + ) +end + +_prune_stale!(::Optimizer, ::AbstractAlgorithm) = 0 + """ prune_stale_constraints!(model::Optimizer)::Int @@ -780,13 +730,16 @@ Remove every stale lazy constraint from the subproblem and return the number of constraints that were removed. A lazy constraint is stale if it is active in the subproblem and it has been -non-binding for at least [`StaleSolveLimit`](@ref) consecutive solves. A pruned +non-binding for at least `stale_solve_limit` consecutive solves. A pruned constraint is still part of the model: it will be added to the subproblem again if a future solution violates it. `MOI.optimize!` calls this function before solving if there are at least -[`PruneBatchSize`](@ref) stale constraints. Call it manually to prune -regardless of [`PruneBatchSize`](@ref). +`prune_batch_size` stale constraints. Call it manually to prune regardless of +`prune_batch_size`. + +Stale constraints are tracked only by the [`Iterative`](@ref) algorithm, which +is where `stale_solve_limit` and `prune_batch_size` are set. !!! warning This function modifies the inner optimizer, which may invalidate the @@ -801,34 +754,36 @@ julia> import MathOptInterface as MOI julia> model = MathOptLazy.Optimizer(HiGHS.Optimizer); -julia> MOI.set(model, MathOptLazy.StaleSolveLimit(), 5) +julia> algorithm = MathOptLazy.Iterative(; stale_solve_limit = 5); + +julia> MOI.set(model, MathOptLazy.Algorithm(), algorithm) julia> MathOptLazy.prune_stale_constraints!(model) 0 ``` """ function prune_stale_constraints!(model::Optimizer) - return sum(d -> _prune_stale!(model, d), values(model.lazy); init = 0) + return _prune_stale!(model, model.algorithm) end -function _maybe_prune_stale!(model::Optimizer) - n = sum(d -> _number_stale(model, d), values(model.lazy); init = 0) - if n < model.prune_batch_size +function _maybe_prune_stale!(model::Optimizer, algorithm::Iterative) + n = sum(d -> _number_stale(algorithm, d), values(model.lazy); init = 0) + if n < algorithm.prune_batch_size return end - prune_stale_constraints!(model) + _prune_stale!(model, algorithm) if !model.silent println("[MathOptLazy] pruned $(n) stale constraints") end return end -_is_binding(y, s::MOI.LessThan, tol) = y >= s.upper - tol -_is_binding(y, s::MOI.GreaterThan, tol) = y <= s.lower + tol -_is_binding(y, s::MOI.Interval, tol) = y <= s.lower + tol || y >= s.upper - tol +_is_binding(y, s::MOI.LessThan) = y >= s.upper - 1e-2 +_is_binding(y, s::MOI.GreaterThan) = y <= s.lower + 1e-2 +_is_binding(y, s::MOI.Interval) = y <= s.lower + 1e-2 || y >= s.upper - 1e-2 # For sets whose slackness we can't measure, assume they bind and don't prune. -_is_binding(y, ::MOI.AbstractScalarSet, tol) = true +_is_binding(y, ::MOI.AbstractScalarSet) = true function _update_stale!(model::Optimizer, data::_LazyData) for (i, (_, s)) in enumerate(data.data) @@ -836,7 +791,7 @@ function _update_stale!(model::Optimizer, data::_LazyData) continue end y = MOI.get(model.inner, MOI.ConstraintPrimal(), data.index[i]) - if _is_binding(y, s, model.binding_tolerance) + if _is_binding(y, s) data.stale[i] = 0 else data.stale[i] += 1 @@ -890,8 +845,8 @@ end ### MathOptLazy.Iterative -function _optimize!(model::Optimizer, ::Iterative) - _maybe_prune_stale!(model) +function _optimize!(model::Optimizer, algorithm::Iterative) + _maybe_prune_stale!(model, algorithm) if (undo = _relax_integrality(model.inner)) !== nothing if !model.silent println("[MathOptLazy] relaxing binary and integer variables") @@ -903,7 +858,6 @@ function _optimize!(model::Optimizer, ::Iterative) undo() end _iterate(model; start = true) - _update_stale!(model) return end @@ -944,6 +898,7 @@ function _iterate(model::Optimizer; start::Bool) if !model.silent println("\n[MathOptLazy] added $(constraints_added) constraints") end + _update_stale!(model) end return end diff --git a/test/runtests.jl b/test/runtests.jl index 306227f..a7cd4b0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -877,44 +877,42 @@ function _stale_solve(model, sense, f) return end -function test_stale_attributes() +function _set_iterative(model; kwargs...) + algorithm = MathOptLazy.Iterative(; kwargs...) + MOI.set(model, MathOptLazy.Algorithm(), algorithm) + return +end + +function test_stale_iterative_kwargs() + algorithm = MathOptLazy.Iterative() + @test algorithm.stale_solve_limit == typemax(Int) + @test algorithm.prune_batch_size == 1 + algorithm = + MathOptLazy.Iterative(; stale_solve_limit = 3, prune_batch_size = 4) + @test algorithm.stale_solve_limit == 3 + @test algorithm.prune_batch_size == 4 + @test_throws ArgumentError MathOptLazy.Iterative(; stale_solve_limit = 0) + @test_throws ArgumentError MathOptLazy.Iterative(; prune_batch_size = 0) model = MathOptLazy.Optimizer(HiGHS.Optimizer) - for (attr, default, value, invalid) in [ - (MathOptLazy.StaleSolveLimit(), typemax(Int), 3, 0), - (MathOptLazy.PruneBatchSize(), 1, 4, 0), - (MathOptLazy.BindingTolerance(), 1e-6, 1e-3, -1.0), - ] - @test MOI.supports(model, attr) - @test MOI.get(model, attr) == default - MOI.set(model, attr, value) - @test MOI.get(model, attr) == value - @test_throws ArgumentError MOI.set(model, attr, invalid) - MOI.empty!(model) - @test MOI.get(model, attr) == value - end - @test_throws( - ArgumentError, - MOI.set(model, MathOptLazy.BindingTolerance(), NaN), - ) + MOI.set(model, MathOptLazy.Algorithm(), algorithm) + MOI.empty!(model) + @test MOI.get(model, MathOptLazy.Algorithm()) == algorithm return end function test_is_binding() - tol = 1e-3 s = MOI.LessThan(1.0) - @test MathOptLazy._is_binding(1.0, s, tol) - @test MathOptLazy._is_binding(0.9995, s, tol) - @test !MathOptLazy._is_binding(0.99, s, tol) + @test MathOptLazy._is_binding(1.0, s) + @test !MathOptLazy._is_binding(0.99, s) s = MOI.GreaterThan(1.0) - @test MathOptLazy._is_binding(1.0, s, tol) - @test MathOptLazy._is_binding(1.0005, s, tol) - @test !MathOptLazy._is_binding(1.01, s, tol) + @test MathOptLazy._is_binding(1.0, s) + @test !MathOptLazy._is_binding(1.01, s) s = MOI.Interval(1.0, 2.0) - @test MathOptLazy._is_binding(1.0005, s, tol) - @test MathOptLazy._is_binding(1.9995, s, tol) - @test !MathOptLazy._is_binding(1.5, s, tol) - @test MathOptLazy._is_binding(1.5, MOI.EqualTo(1.0), tol) - @test MathOptLazy._is_binding(0.5, MOI.ZeroOne(), tol) + @test MathOptLazy._is_binding(1.0, s) + @test MathOptLazy._is_binding(2.0, s) + @test !MathOptLazy._is_binding(1.5, s) + @test MathOptLazy._is_binding(1.5, MOI.EqualTo(1.0)) + @test MathOptLazy._is_binding(0.5, MOI.ZeroOne()) return end @@ -934,15 +932,6 @@ function test_stale_counter() return end -function test_stale_counter_binding_tolerance() - model, x, data = _stale_model() - MOI.set(model, MathOptLazy.BindingTolerance(), 2.0) - _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) - _stale_solve(model, MOI.MIN_SENSE, 1.0 * x[1] + 1.0 * x[2]) - @test data.stale == [0, 0] - return -end - function test_stale_counter_no_feasible_point() model, x, data = _stale_model() _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) @@ -955,10 +944,9 @@ function test_stale_counter_no_feasible_point() return end -function test_prune_automatic() +function test_prune() model, x, data = _stale_model() - MOI.set(model, MathOptLazy.StaleSolveLimit(), 2) - MOI.set(model, MathOptLazy.PruneBatchSize(), 2) + _set_iterative(model; stale_solve_limit = 2, prune_batch_size = 2) F, S = MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64} attr = MathOptLazy.NumberOfConstraintsActive{F,MathOptLazy.LazyScalarSet{S}}() @@ -986,7 +974,7 @@ end function test_prune_readded() model, x, data = _stale_model() - MOI.set(model, MathOptLazy.StaleSolveLimit(), 1) + _set_iterative(model; stale_solve_limit = 1) F, S = MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64} attr = MathOptLazy.NumberOfConstraintsActive{F,MathOptLazy.LazyScalarSet{S}}() @@ -1001,48 +989,6 @@ function test_prune_readded() return end -function test_prune_manual() - model, x, data = _stale_model() - MOI.set(model, MathOptLazy.PruneBatchSize(), 100) - @test MathOptLazy.prune_stale_constraints!(model) == 0 - _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) - _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] - 1.0 * x[2]) - @test data.stale == [0, 1] - @test MathOptLazy.prune_stale_constraints!(model) == 0 - MOI.set(model, MathOptLazy.StaleSolveLimit(), 1) - @test MathOptLazy.prune_stale_constraints!(model) == 1 - @test data.status == [ - MathOptLazy._kLAZY_CONSTRAINT_ACTIVE, - MathOptLazy._kLAZY_CONSTRAINT_INACTIVE, - ] - @test data.stale == [0, 0] - @test MathOptLazy.prune_stale_constraints!(model) == 0 - _stale_solve(model, MOI.MAX_SENSE, 1.0 * x[1] + 1.0 * x[2]) - @test MOI.get(model, MOI.VariablePrimal(), x) ≈ [1.0, 1.0] - return -end - -function test_prune_manual_jump() - model = Model(() -> MathOptLazy.Optimizer(HiGHS.Optimizer)) - set_silent(model) - set_attribute(model, MathOptLazy.StaleSolveLimit(), 1) - set_attribute(model, MathOptLazy.PruneBatchSize(), 100) - @variable(model, x[1:2] >= 0) - @constraint(model, sum(x) <= 10) - @constraint(model, [i in 1:2], 1.0 * x[i] <= 1, MathOptLazy.Lazy()) - @objective(model, Max, sum(x)) - optimize!(model) - @test value.(x) ≈ [1.0, 1.0] - @objective(model, Min, sum(x)) - optimize!(model) - @test MathOptLazy.prune_stale_constraints!(model) == 2 - @test MathOptLazy.prune_stale_constraints!(model) == 0 - @objective(model, Max, sum(x)) - optimize!(model) - @test value.(x) ≈ [1.0, 1.0] - return -end - end # TestMathOptLazy TestMathOptLazy.runtests() From 36b0e41ad1ccb93952028772c7dd6e3118b39a63 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 9 Oct 2026 08:49:17 -0400 Subject: [PATCH 4/5] update needs to happen before new rows added; update docstring --- src/MathOptLazy.jl | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index 1f32270..f5d61b1 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -151,13 +151,9 @@ violated by an integer solution even if the relaxation satisfies it. ## Stale constraints An active lazy constraint is stale if it has been non-binding for at least -`stale_solve_limit` consecutive solves, where a solve is one call to -`MOI.optimize!` and a constraint is binding if its primal value is equal to a -bound of its set. The default of `typemax(Int)` means that no constraint is ever -stale. - -`MOI.optimize!` calls [`prune_stale_constraints!`](@ref) before solving if there -are at least `prune_batch_size` stale constraints. +`stale_solve_limit` consecutive solves (including internal iterative solves). +Stale constraints are pruned before solving if there are at least +`prune_batch_size` stale constraints. """ struct Iterative <: AbstractAlgorithm stale_solve_limit::Int @@ -165,7 +161,7 @@ struct Iterative <: AbstractAlgorithm function Iterative(; stale_solve_limit::Int = typemax(Int), - prune_batch_size::Int = 1, + prune_batch_size::Int = typemax(Int), ) if stale_solve_limit < 1 throw(ArgumentError("stale_solve_limit must be at least 1.")) @@ -846,7 +842,6 @@ end ### MathOptLazy.Iterative function _optimize!(model::Optimizer, algorithm::Iterative) - _maybe_prune_stale!(model, algorithm) if (undo = _relax_integrality(model.inner)) !== nothing if !model.silent println("[MathOptLazy] relaxing binary and integer variables") @@ -857,6 +852,7 @@ function _optimize!(model::Optimizer, algorithm::Iterative) end undo() end + _maybe_prune_stale!(model, algorithm) _iterate(model; start = true) return end @@ -878,6 +874,7 @@ function _iterate(model::Optimizer; start::Bool) println("[MathOptLazy] solving current subproblem\n") end _optimize_inner!(model) + _update_stale!(model) if MOI.get(model, MOI.TerminationStatus()) == MOI.DUAL_INFEASIBLE # The problem is unbounded, but it might not be if we add more # constraints. @@ -898,7 +895,6 @@ function _iterate(model::Optimizer; start::Bool) if !model.silent println("\n[MathOptLazy] added $(constraints_added) constraints") end - _update_stale!(model) end return end From 50bb068074fad21dd4691d8bec7cb1e177dd28ce Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 9 Oct 2026 08:54:22 -0400 Subject: [PATCH 5/5] remove old prune entrypoint --- src/MathOptLazy.jl | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/src/MathOptLazy.jl b/src/MathOptLazy.jl index f5d61b1..19468aa 100644 --- a/src/MathOptLazy.jl +++ b/src/MathOptLazy.jl @@ -717,51 +717,6 @@ function _prune_stale!(model::Optimizer, algorithm::Iterative) ) end -_prune_stale!(::Optimizer, ::AbstractAlgorithm) = 0 - -""" - prune_stale_constraints!(model::Optimizer)::Int - -Remove every stale lazy constraint from the subproblem and return the number of -constraints that were removed. - -A lazy constraint is stale if it is active in the subproblem and it has been -non-binding for at least `stale_solve_limit` consecutive solves. A pruned -constraint is still part of the model: it will be added to the subproblem again -if a future solution violates it. - -`MOI.optimize!` calls this function before solving if there are at least -`prune_batch_size` stale constraints. Call it manually to prune regardless of -`prune_batch_size`. - -Stale constraints are tracked only by the [`Iterative`](@ref) algorithm, which -is where `stale_solve_limit` and `prune_batch_size` are set. - -!!! warning - This function modifies the inner optimizer, which may invalidate the - current solution. Query the solution before calling it. - -## Example - -```julia -julia> import HiGHS, MathOptLazy - -julia> import MathOptInterface as MOI - -julia> model = MathOptLazy.Optimizer(HiGHS.Optimizer); - -julia> algorithm = MathOptLazy.Iterative(; stale_solve_limit = 5); - -julia> MOI.set(model, MathOptLazy.Algorithm(), algorithm) - -julia> MathOptLazy.prune_stale_constraints!(model) -0 -``` -""" -function prune_stale_constraints!(model::Optimizer) - return _prune_stale!(model, model.algorithm) -end - function _maybe_prune_stale!(model::Optimizer, algorithm::Iterative) n = sum(d -> _number_stale(algorithm, d), values(model.lazy); init = 0) if n < algorithm.prune_batch_size