diff --git a/src/Bridges/SplitIntervalBridge.jl b/src/Bridges/SplitIntervalBridge.jl index 32c8459..50d0a1e 100644 --- a/src/Bridges/SplitIntervalBridge.jl +++ b/src/Bridges/SplitIntervalBridge.jl @@ -64,13 +64,22 @@ mutable struct SplitIntervalBridge{ G<:MOI.AbstractScalarFunction, F<:MOI.AbstractVectorFunction, } <: MOI.Bridges.Constraint.AbstractBridge - lower::MOI.ConstraintIndex{ - MOI.VectorOfVariables, - ComplementsWithSetType{MOI.GreaterThan{T}}, + # `lower` and `upper` are created in `final_touch` (after `xp`/`xn` are + # bounded) so that the bridges they trigger (e.g. `VerticalBridge`) see the + # finite bounds. They are `nothing` until then. + lower::Union{ + Nothing, + MOI.ConstraintIndex{ + MOI.VectorOfVariables, + ComplementsWithSetType{MOI.GreaterThan{T}}, + }, } - upper::MOI.ConstraintIndex{ - MOI.VectorOfVariables, - ComplementsWithSetType{MOI.LessThan{T}}, + upper::Union{ + Nothing, + MOI.ConstraintIndex{ + MOI.VectorOfVariables, + ComplementsWithSetType{MOI.LessThan{T}}, + }, } equality::MOI.ConstraintIndex{G,MOI.EqualTo{T}} xp::MOI.VariableIndex @@ -85,6 +94,7 @@ mutable struct SplitIntervalBridge{ Nothing, MOI.ConstraintIndex{MOI.VariableIndex,MOI.GreaterThan{T}}, } + reformulation::Union{Nothing,AbstractComplementarityRelaxation} end function MOI.Bridges.Constraint.bridge_constraint( @@ -116,21 +126,10 @@ function MOI.Bridges.Constraint.bridge_constraint( eq_func, MOI.EqualTo(zero(T)), ) - # [xp, y] in ComplementsWithSetType{GreaterThan{T}} - lower = MOI.add_constraint( - model, - MOI.VectorOfVariables([xp, y_var]), - ComplementsWithSetType{MOI.GreaterThan{T}}(2), - ) - # [xn, y] in ComplementsWithSetType{LessThan{T}} - upper = MOI.add_constraint( - model, - MOI.VectorOfVariables([xn, y_var]), - ComplementsWithSetType{MOI.LessThan{T}}(2), - ) + # `lower` and `upper` are created in `final_touch`, once `xp`/`xn` are bounded. return SplitIntervalBridge{T,G,typeof(func)}( - lower, - upper, + nothing, + nothing, equality, xp, xn, @@ -138,6 +137,7 @@ function MOI.Bridges.Constraint.bridge_constraint( set, nothing, nothing, + nothing, ) end @@ -174,24 +174,38 @@ function MOI.set( bridge::SplitIntervalBridge, value::AbstractComplementarityRelaxation, ) - MOI.set(model, attr, bridge.lower, value) - MOI.set(model, attr, bridge.upper, value) + bridge.reformulation = value + if bridge.lower !== nothing + MOI.set(model, attr, bridge.lower, value) + end + if bridge.upper !== nothing + MOI.set(model, attr, bridge.upper, value) + end return end +# The bounds of the activity `x`, or `nothing` if they cannot be computed or the +# domain is not bounded. `get_bounds` is only defined for affine functions and +# variables. +function _activity_bounds(model, ::Type{T}, x) where {T} + if x isa MOI.VariableIndex || x isa MOI.ScalarAffineFunction{T} + cache = Dict{MOI.VariableIndex,NTuple{2,T}}() + return MOI.Utilities.get_bounds(model, cache, x) + end + return nothing +end + MOI.Bridges.needs_final_touch(::SplitIntervalBridge) = true function MOI.Bridges.final_touch( bridge::SplitIntervalBridge{T}, model::MOI.ModelLike, ) where {T} - x = first(MOI.Utilities.eachscalar(bridge.func)) - ret = if x isa MOI.VariableIndex || x isa MOI.ScalarAffineFunction{T} - cache = Dict{MOI.VariableIndex,NTuple{2,T}}() - MOI.Utilities.get_bounds(model, cache, x) - else - nothing - end + # Bound `xp` and `xn` from the activity bounds. This is done before creating + # `lower`/`upper` below so that the bridges they trigger (e.g. + # `VerticalBridge`) can compute the bounds of the slacks they introduce. + ret = + _activity_bounds(model, T, first(MOI.Utilities.eachscalar(bridge.func))) if ret === nothing if bridge.xp_upper !== nothing MOI.delete(model, bridge.xp_upper) @@ -201,20 +215,51 @@ function MOI.Bridges.final_touch( MOI.delete(model, bridge.xn_lower) bridge.xn_lower = nothing end - return - end - lb, ub = ret - upper = MOI.LessThan(max(ub, zero(T))) - if bridge.xp_upper === nothing - bridge.xp_upper = MOI.add_constraint(model, bridge.xp, upper) - elseif MOI.get(model, MOI.ConstraintSet(), bridge.xp_upper) != upper - MOI.set(model, MOI.ConstraintSet(), bridge.xp_upper, upper) + else + lb, ub = ret + upper = MOI.LessThan(max(ub, zero(T))) + if bridge.xp_upper === nothing + bridge.xp_upper = MOI.add_constraint(model, bridge.xp, upper) + elseif MOI.get(model, MOI.ConstraintSet(), bridge.xp_upper) != upper + MOI.set(model, MOI.ConstraintSet(), bridge.xp_upper, upper) + end + lower = MOI.GreaterThan(min(lb, zero(T))) + if bridge.xn_lower === nothing + bridge.xn_lower = MOI.add_constraint(model, bridge.xn, lower) + elseif MOI.get(model, MOI.ConstraintSet(), bridge.xn_lower) != lower + MOI.set(model, MOI.ConstraintSet(), bridge.xn_lower, lower) + end end - lower = MOI.GreaterThan(min(lb, zero(T))) - if bridge.xn_lower === nothing - bridge.xn_lower = MOI.add_constraint(model, bridge.xn, lower) - elseif MOI.get(model, MOI.ConstraintSet(), bridge.xn_lower) != lower - MOI.set(model, MOI.ConstraintSet(), bridge.xn_lower, lower) + # Create the two complementarity constraints (only once). + if bridge.lower === nothing + y_var = MOI.Utilities.scalarize(bridge.func)[2] + if !(y_var isa MOI.VariableIndex) + y_var = _get_variable(y_var) + end + bridge.lower = MOI.add_constraint( + model, + MOI.VectorOfVariables([bridge.xp, y_var]), + ComplementsWithSetType{MOI.GreaterThan{T}}(2), + ) + bridge.upper = MOI.add_constraint( + model, + MOI.VectorOfVariables([bridge.xn, y_var]), + ComplementsWithSetType{MOI.LessThan{T}}(2), + ) + if bridge.reformulation !== nothing + MOI.set( + model, + ComplementarityReformulation(), + bridge.lower, + bridge.reformulation, + ) + MOI.set( + model, + ComplementarityReformulation(), + bridge.upper, + bridge.reformulation, + ) + end end return end @@ -293,13 +338,13 @@ function MOI.get( end function MOI.get( - ::SplitIntervalBridge{T}, + bridge::SplitIntervalBridge{T}, ::MOI.NumberOfConstraints{ MOI.VectorOfVariables, ComplementsWithSetType{MOI.GreaterThan{T}}, }, )::Int64 where {T} - return 1 + return bridge.lower === nothing ? 0 : 1 end function MOI.get( @@ -309,17 +354,19 @@ function MOI.get( ComplementsWithSetType{MOI.GreaterThan{T}}, }, ) where {T} - return [bridge.lower] + F, S = MOI.VectorOfVariables, ComplementsWithSetType{MOI.GreaterThan{T}} + return bridge.lower === nothing ? MOI.ConstraintIndex{F,S}[] : + [bridge.lower] end function MOI.get( - ::SplitIntervalBridge{T}, + bridge::SplitIntervalBridge{T}, ::MOI.NumberOfConstraints{ MOI.VectorOfVariables, ComplementsWithSetType{MOI.LessThan{T}}, }, )::Int64 where {T} - return 1 + return bridge.upper === nothing ? 0 : 1 end function MOI.get( @@ -329,7 +376,9 @@ function MOI.get( ComplementsWithSetType{MOI.LessThan{T}}, }, ) where {T} - return [bridge.upper] + F, S = MOI.VectorOfVariables, ComplementsWithSetType{MOI.LessThan{T}} + return bridge.upper === nothing ? MOI.ConstraintIndex{F,S}[] : + [bridge.upper] end function MOI.get( @@ -363,9 +412,14 @@ function MOI.get( end function MOI.delete(model::MOI.ModelLike, bridge::SplitIntervalBridge) - MOI.delete(model, bridge.lower) - MOI.delete(model, bridge.upper) + if bridge.lower !== nothing + MOI.delete(model, bridge.lower) + end + if bridge.upper !== nothing + MOI.delete(model, bridge.upper) + end MOI.delete(model, bridge.equality) + # The bounds `xp_upper`/`xn_lower` are deleted together with `xp`/`xn`. MOI.delete(model, bridge.xp) MOI.delete(model, bridge.xn) return diff --git a/src/Bridges/VerticalBridge.jl b/src/Bridges/VerticalBridge.jl index bbcb1c2..173a2d8 100644 --- a/src/Bridges/VerticalBridge.jl +++ b/src/Bridges/VerticalBridge.jl @@ -36,6 +36,11 @@ struct VerticalBridge{T,S<:MOI.AbstractVectorSet} <: constraint::MOI.ConstraintIndex{MOI.VectorOfVariables,S} equalities::Vector{MOI.ConstraintIndex} slacks::Vector{MOI.VariableIndex} + # Slacks that should be bounded in `final_touch`, together with the + # expression they are equal to, so that their bounds can be computed once the + # bounds of the underlying variables are set. + slacks_to_bound::Vector{Tuple{MOI.VariableIndex,MOI.AbstractScalarFunction}} + bounds::Vector{MOI.ConstraintIndex{MOI.VariableIndex,MOI.Interval{T}}} end function MOI.Bridges.Constraint.bridge_constraint( @@ -44,8 +49,16 @@ function MOI.Bridges.Constraint.bridge_constraint( func::MOI.AbstractVectorFunction, set::MOI.Complements, ) where {T} - ci, equalities, slacks = reformulate_to_vertical!(model, T, func, set) - return VerticalBridge{T,MOI.Complements}(ci, equalities, slacks) + ci, equalities, slacks, slacks_to_bound = + reformulate_to_vertical!(model, T, func, set) + bounds = MOI.ConstraintIndex{MOI.VariableIndex,MOI.Interval{T}}[] + return VerticalBridge{T,MOI.Complements}( + ci, + equalities, + slacks, + slacks_to_bound, + bounds, + ) end function MOI.Bridges.Constraint.bridge_constraint( @@ -54,8 +67,16 @@ function MOI.Bridges.Constraint.bridge_constraint( func::MOI.AbstractVectorFunction, set::ComplementsWithSetType{S}, ) where {T,S} - ci, equalities, slacks = reformulate_to_vertical!(model, T, func, set) - return VerticalBridge{T,ComplementsWithSetType{S}}(ci, equalities, slacks) + ci, equalities, slacks, slacks_to_bound = + reformulate_to_vertical!(model, T, func, set) + bounds = MOI.ConstraintIndex{MOI.VariableIndex,MOI.Interval{T}}[] + return VerticalBridge{T,ComplementsWithSetType{S}}( + ci, + equalities, + slacks, + slacks_to_bound, + bounds, + ) end function MOI.supports_constraint( @@ -102,6 +123,7 @@ function MOI.Bridges.added_constraint_types( return Tuple{Type,Type}[ (MOI.VectorOfVariables, S), (MOI.ScalarAffineFunction{T}, MOI.EqualTo{T}), + (MOI.VariableIndex, MOI.Interval{T}), ] end @@ -149,17 +171,56 @@ function MOI.get( ] end +function MOI.get( + bridge::VerticalBridge{T}, + ::MOI.NumberOfConstraints{MOI.VariableIndex,MOI.Interval{T}}, +)::Int64 where {T} + return length(bridge.bounds) +end + +function MOI.get( + bridge::VerticalBridge{T}, + ::MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Interval{T}}, +) where {T} + return copy(bridge.bounds) +end + function MOI.delete(model::MOI.ModelLike, bridge::VerticalBridge) MOI.delete(model, bridge.constraint) for ci in bridge.equalities MOI.delete(model, ci) end + # The bounds are deleted together with the slack variables below. for vi in bridge.slacks MOI.delete(model, vi) end return end +MOI.Bridges.needs_final_touch(::VerticalBridge) = true + +function MOI.Bridges.final_touch( + bridge::VerticalBridge{T}, + model::MOI.ModelLike, +) where {T} + # Propagate the bounds of each slack's expression to the slack variable so + # that bridges requiring a finite domain (e.g. `SOS1ToMILPBridge`) can be + # applied. + for (s, expr) in bridge.slacks_to_bound + if any(ci -> ci.value == s.value, bridge.bounds) + continue # The slack is already bounded. + end + b = _expr_bounds(model, T, expr) + if b !== nothing + push!( + bridge.bounds, + MOI.add_constraint(model, s, MOI.Interval(b[1], b[2])), + ) + end + end + return +end + function MOI.supports( ::MOI.ModelLike, ::ComplementarityReformulation, @@ -216,32 +277,90 @@ function _parse_complementarity_constraint( ) exprs = MOI.Utilities.scalarize(fun) @assert length(exprs) == 2*n_comp - cc_lhs = MOI.AbstractScalarFunction[] - cc_rhs = MOI.VariableIndex[] - for i in 1:n_comp - # Parse LHS - t1 = exprs[i] - t2 = exprs[i+n_comp] - if _is_single_variable(t1) - push!(cc_lhs, _get_variable(t1)) - else - push!(cc_lhs, t1) - end - # Parse RHS - isvar2 = _is_single_variable(t2) - if !isvar2 - # The RHS should be a variable if we follow MOI's specs - # TODO: we should decide if we should add support complementarity - # between expressions (see Issue #2) - error( - "Right-hand-side should be a single variable in complementarity constraints.", - ) - end - push!(cc_rhs, _get_variable(t2)) - end + cc_lhs = MOI.AbstractScalarFunction[exprs[i] for i in 1:n_comp] + cc_rhs = MOI.AbstractScalarFunction[exprs[i+n_comp] for i in 1:n_comp] return cc_lhs, cc_rhs end +# The bounds of `expr`, or `nothing` if they cannot be computed or the domain is +# not bounded. `get_bounds` is only defined for affine functions and variables. +function _expr_bounds( + model, + ::Type{T}, + expr::MOI.AbstractScalarFunction, +) where {T} + cache = Dict{MOI.VariableIndex,NTuple{2,T}}() + return MOI.Utilities.get_bounds(model, cache, expr) +end + +# Return a single variable representing `expr`. If `expr` is already a single +# variable, it is returned directly. Otherwise, a slack variable `s` is created +# together with the equality `expr == s` and `s` is returned. When `add_bounds` +# is `true`, the bounds of `expr` are propagated to `s` so that bridges requiring +# a finite domain (e.g. `SOS1ToMILPBridge`) can be applied. This is only done for +# `ComplementsWithSetType` constraints; for `MOI.Complements`, the bound on the +# activity is added by `SpecifySetTypeBridge` instead. +function _to_variable!( + model, + ::Type{T}, + expr, + slacks, + equalities, + slacks_to_bound, + add_bounds, +) where {T} + if _is_single_variable(expr) + return _get_variable(expr) + end + s = MOI.add_variable(model) + push!(slacks, s) + if add_bounds + push!(slacks_to_bound, (s, expr)) + end + new_expr = MOI.Utilities.operate(-, T, expr, s) + push!( + equalities, + MOI.Utilities.normalize_and_add_constraint( + model, + new_expr, + MOI.EqualTo{T}(zero(T)), + ), + ) + return s +end + +# Resolve the right-hand side of a complementarity constraint to a single +# variable. For a `MOI.Complements` set, MOI's specs require it to be a single +# variable. For a `ComplementsWithSetType`, it may be any expression (e.g. a +# variable shifted by a nonzero bound by `ComplementsVectorizeBridge`), in which +# case a slack is added, with the bounds of the expression so that the +# `ComplementsWithSetType` set remains enforceable on the slack. +function _rhs_variable!( + model, + ::Type{T}, + expr, + set, + slacks, + equalities, + slacks_to_bound, +) where {T} + if set isa MOI.Complements && !_is_single_variable(expr) + error( + "Right-hand-side should be a single variable in complementarity constraints.", + ) + end + add_bounds = !(set isa MOI.Complements) + return _to_variable!( + model, + T, + expr, + slacks, + equalities, + slacks_to_bound, + add_bounds, + ) +end + """ reformulate_to_vertical!(model::MOI.ModelLike, ::Type{T}, fun, set) @@ -260,12 +379,23 @@ function reformulate_to_vertical!( ) where {T} equalities = MOI.ConstraintIndex[] slacks = MOI.VariableIndex[] + slacks_to_bound = Tuple{MOI.VariableIndex,MOI.AbstractScalarFunction}[] + add_bounds = !(set isa MOI.Complements) ind_cc1, ind_cc2 = MOI.VariableIndex[], MOI.VariableIndex[] n_comp = div(set.dimension, 2) @assert !(fun isa MOI.VectorOfVariables) # Read each complementarity constraint and get corresponding indices cc_lhs, cc_rhs = _parse_complementarity_constraint(fun, n_comp) - for (lhs, x2) in zip(cc_lhs, cc_rhs) + for (lhs, rhs) in zip(cc_lhs, cc_rhs) + x2 = _rhs_variable!( + model, + T, + rhs, + set, + slacks, + equalities, + slacks_to_bound, + ) if set isa MOI.Complements # Check if x2 is bounded. lb, ub = MOI.Utilities.get_bounds(model, T, x2) @@ -282,28 +412,20 @@ function reformulate_to_vertical!( continue end end - if isa(lhs, MOI.VariableIndex) - # If lhs is a variable, no need to reformulate the - # complementarity constraint using a slack. - # TODO: we should check if the variable lhs is bounded. - push!(ind_cc1, lhs) - push!(ind_cc2, x2) - else - # Else, reformulate LHS using vertical form - x1 = MOI.add_variable(model) - push!(slacks, x1) - new_lhs = MOI.Utilities.operate!(-, T, lhs, x1) - push!( - equalities, - MOI.Utilities.normalize_and_add_constraint( - model, - new_lhs, - MOI.EqualTo{T}(zero(T)), - ), - ) - push!(ind_cc1, x1) - push!(ind_cc2, x2) - end + # If lhs is a variable, no need to reformulate the complementarity + # constraint using a slack. + # TODO: we should check if the variable lhs is bounded. + x1 = _to_variable!( + model, + T, + lhs, + slacks, + equalities, + slacks_to_bound, + add_bounds, + ) + push!(ind_cc1, x1) + push!(ind_cc2, x2) end n_cc = length(ind_cc1) comp = MOI.VectorOfVariables([ind_cc1; ind_cc2]) @@ -313,5 +435,5 @@ function reformulate_to_vertical!( else ci = MOI.add_constraint(model, comp, S(2*n_cc)) end - return ci, equalities, slacks + return ci, equalities, slacks, slacks_to_bound end diff --git a/test/Bridges/lazy.jl b/test/Bridges/lazy.jl index d66cef8..44f0a5f 100644 --- a/test/Bridges/lazy.jl +++ b/test/Bridges/lazy.jl @@ -64,6 +64,28 @@ function MOI.supports_constraint( return true end +# A MIP solver that additionally supports `SOS1` natively, used to exercise the +# whole SOS1 reformulation chain end-to-end via `copy_to`. +MOI.Utilities.@model( + SOS1MIPModel, + (MOI.ZeroOne, MOI.Integer), + (MOI.EqualTo, MOI.GreaterThan, MOI.LessThan, MOI.Interval), + (MOI.Nonnegatives, MOI.Nonpositives, MOI.Zeros), + (MOI.SOS1,), + (), + (MOI.ScalarAffineFunction,), + (MOI.VectorOfVariables,), + (MOI.VectorAffineFunction,), +) + +function MOI.supports_constraint( + ::SOS1MIPModel, + ::Type{MOI.ScalarNonlinearFunction}, + ::Type{<:MOI.AbstractScalarSet}, +) + return false +end + function runtests() is_test(name) = startswith("$name", "test_") @testset "$name" for name in filter(is_test, names(@__MODULE__; all = true)) @@ -113,6 +135,28 @@ function test_mip_does_not_select_nonlinear_bridge() return end +function test_copy_interval_complements_uses_sos1_path() + # `z[2] in [0, 1]` makes `ComplementsVectorizeBridge` shift the second + # component by a nonzero bound, producing an affine right-hand side that + # `VerticalBridge` must handle by introducing a slack. Copying the model + # exercises the whole `SpecifySetType → SplitInterval → Vectorize → FlipSign + # → Vertical → ToSOS1` chain. + src = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + z = MOI.add_variables(src, 2) + for zi in z + MOI.add_constraint(src, zi, MOI.Interval(0.0, 1.0)) + end + MOI.add_constraint(src, MOI.VectorOfVariables(z), MOI.Complements(2)) + dest = _lazy(SOS1MIPModel{Float64}()) + MOI.copy_to(dest, src) + inner = dest.model + @test MOI.get( + inner, + MOI.NumberOfConstraints{MOI.VectorOfVariables,MOI.SOS1{Float64}}(), + ) >= 1 + return +end + function test_nlp_selects_nonlinear_bridge() lazy = _lazy(NLPModel{Float64}()) # When the solver supports nonlinear constraints, `NonlinearBridge` is diff --git a/test/Bridges/test_VerticalBridge.jl b/test/Bridges/test_VerticalBridge.jl index eadc815..868627d 100644 --- a/test/Bridges/test_VerticalBridge.jl +++ b/test/Bridges/test_VerticalBridge.jl @@ -58,6 +58,74 @@ function test_Expression_LHS_with_constant_ComplementsWithSetType() return end +function test_Expression_RHS_with_constant_ComplementsWithSetType() + # The right-hand side `1.0 * y + 2.0` is not a single variable (it appears + # after `ComplementsVectorizeBridge` shifts the second variable by a nonzero + # bound), so a slack `s == y + 2` is introduced. Since `y` is unbounded + # above, the bounds of `y + 2` cannot be computed and `s` is left free. + MOI.Bridges.runtests( + MathOptComplements.Bridges.VerticalBridge, + """ + variables: x, y + x >= 0.0 + [1.0 * x, 1.0 * y + 2.0] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """, + """ + variables: x, y, s + x >= 0.0 + 1.0 * y + -1.0 * s == -2.0 + [x, s] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_Expression_RHS_bounded_slack_ComplementsWithSetType() + # `y in [0, 5]` so the slack `s == y + 2` gets the bounds `[2, 7]`, allowing + # downstream bridges that require a finite domain (e.g. `SOS1ToMILPBridge`). + MOI.Bridges.runtests( + MathOptComplements.Bridges.VerticalBridge, + """ + variables: x, y + x >= 0.0 + y in Interval(0.0, 5.0) + [1.0 * x, 1.0 * y + 2.0] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """, + """ + variables: x, y, s + x >= 0.0 + y in Interval(0.0, 5.0) + s in Interval(2.0, 7.0) + 1.0 * y + -1.0 * s == -2.0 + [x, s] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_Expression_LHS_and_RHS_ComplementsWithSetType() + # Both components are expressions, so two slacks are introduced. The + # right-hand side is resolved first, so its slack `s_rhs` is added before the + # left-hand side slack `s_lhs`. + MOI.Bridges.runtests( + MathOptComplements.Bridges.VerticalBridge, + """ + variables: x, y + [2.0 * x + 3.0, 1.0 * y + 2.0] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """, + """ + variables: x, y, s_rhs, s_lhs + 1.0 * y + -1.0 * s_rhs == -2.0 + 2.0 * x + -1.0 * s_lhs == -3.0 + [s_lhs, s_rhs] in $M.ComplementsWithSetType{MOI.Nonnegatives}(2) + """; + cannot_unbridge = true, + ) + return +end + end # TestVerticalBridge TestVerticalBridge.runtests() diff --git a/test/test_optimizer.jl b/test/test_optimizer.jl index 6c61380..c62badb 100644 --- a/test/test_optimizer.jl +++ b/test/test_optimizer.jl @@ -292,6 +292,49 @@ function test_ComplementarityReformulation_through_SplitInterval() return end +function test_ComplementarityReformulation_through_SplitInterval_before_final_touch() + # Set the per-constraint reformulation *before* `final_touch`, so that + # `SplitIntervalBridge` stores it and applies it to `lower`/`upper` when it + # creates them in `final_touch`. + inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + B = MathOptComplements.Bridges.NonlinearBridge{Float64} + nl_model = MOI.Bridges.Constraint.SingleBridgeOptimizer{B}(inner) + B = MathOptComplements.Bridges.SplitIntervalBridge{Float64} + model = MOI.Bridges.Constraint.SingleBridgeOptimizer{B}(nl_model) + x = MOI.add_variable(model) + y, _ = MOI.add_constrained_variable(model, MOI.Interval(0.0, 1.0)) + ci = MOI.add_constraint( + model, + MOI.VectorOfVariables([x, y]), + MathOptComplements.ComplementsWithSetType{MOI.Interval{Float64}}(2), + ) + attr = MathOptComplements.ComplementarityReformulation() + MOI.set( + model, + attr, + ci, + MathOptComplements.FischerBurmeisterRelaxation(1e-8), + ) + MOI.Bridges.final_touch(model) + MOI.Bridges.final_touch(nl_model) + # Fischer-Burmeister → nonlinear constraints (not the default quadratic). + @test MOI.get( + inner, + MOI.NumberOfConstraints{ + MOI.ScalarNonlinearFunction, + MOI.LessThan{Float64}, + }(), + ) > 0 + @test MOI.get( + inner, + MOI.NumberOfConstraints{ + MOI.ScalarQuadraticFunction{Float64}, + MOI.LessThan{Float64}, + }(), + ) == 0 + return +end + function test_ComplementarityReformulation_through_FlipSign() inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) B = MathOptComplements.Bridges.FlipSignBridge{Float64} @@ -404,6 +447,30 @@ function test_bridge_chain() return end +function test_interval_complements_solve_HiGHS() + # End-to-end solve through the SOS1/MILP path (HiGHS has no native SOS1 nor + # nonlinear support). The interval bound `0 <= z <= 1e3` makes the second + # component be shifted, so `VerticalBridge` introduces a slack that must be + # given finite bounds for `SOS1ToMILPBridge` to apply. + model = Model( + () -> MathOptComplements.Optimizer( + MOI.instantiate(HiGHS.Optimizer; with_bridge_type = Float64), + ), + ) + set_silent(model) + @variable(model, 0 <= z[1:2] <= 1e3) + @variable(model, z3) + @objective(model, Min, sum(z) - z3) + @constraint(model, z3 .<= 4 .* z) + @constraint(model, z in MOI.Complements(2)) + optimize!(model) + @test is_solved_and_feasible(model) + @test isapprox(objective_value(model), 0.0; atol = 1e-6) + @test isapprox(value.(z), [0.0, 0.0]; atol = 1e-6) + @test isapprox(value(z3), 0.0; atol = 1e-6) + return +end + function test_add_all_bridges_JuMP_GenericModel() model = Model(Ipopt.Optimizer) MathOptComplements.Bridges.add_all_bridges(model)