From d5385ad6f2f55a0db0550bf7e1451548e58597bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Tue, 7 Jul 2026 17:34:04 +0200 Subject: [PATCH 1/8] Add slack variables in vertical bridge --- src/Bridges/VerticalBridge.jl | 100 +++++++++++++++------------- test/Bridges/lazy.jl | 44 ++++++++++++ test/Bridges/test_VerticalBridge.jl | 43 ++++++++++++ 3 files changed, 141 insertions(+), 46 deletions(-) diff --git a/src/Bridges/VerticalBridge.jl b/src/Bridges/VerticalBridge.jl index bbcb1c2..12c35e5 100644 --- a/src/Bridges/VerticalBridge.jl +++ b/src/Bridges/VerticalBridge.jl @@ -209,6 +209,15 @@ _get_variable(func::MOI.ScalarQuadraticFunction) = func.affine_terms[1].variable _get_variable(func::MOI.ScalarNonlinearFunction) = func.args[1] +# A single variable shifted by a constant, e.g. `x - 1`. Such a right-hand side +# is produced by `ComplementsVectorizeBridge` when it shifts the second variable +# by a nonzero bound. +function _is_shifted_variable(func::MOI.ScalarAffineFunction) + return length(func.terms) == 1 && isone(func.terms[1].coefficient) +end + +_is_shifted_variable(::MOI.AbstractScalarFunction) = false + # TODO: add support for ScalarNonlinearTerm function _parse_complementarity_constraint( fun::MOI.AbstractVectorFunction, @@ -216,32 +225,46 @@ 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 +# 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. +function _to_variable!(model, ::Type{T}, expr, slacks, equalities) where {T} + if _is_single_variable(expr) + return _get_variable(expr) + end + s = MOI.add_variable(model) + push!(slacks, s) + 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. Following MOI's specs, it should be a single variable, but it may be +# a single variable shifted by a constant (introduced by +# `ComplementsVectorizeBridge`), in which case a slack is added. Genuine +# multi-variable expressions are not supported (see Issue #2). +function _rhs_variable!(model, ::Type{T}, expr, slacks, equalities) where {T} + if !_is_single_variable(expr) && !_is_shifted_variable(expr) + error( + "Right-hand-side should be a single variable in complementarity constraints.", + ) + end + return _to_variable!(model, T, expr, slacks, equalities) +end + """ reformulate_to_vertical!(model::MOI.ModelLike, ::Type{T}, fun, set) @@ -265,7 +288,8 @@ function reformulate_to_vertical!( @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, slacks, equalities) if set isa MOI.Complements # Check if x2 is bounded. lb, ub = MOI.Utilities.get_bounds(model, T, x2) @@ -282,28 +306,12 @@ 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) + push!(ind_cc1, x1) + push!(ind_cc2, x2) end n_cc = length(ind_cc1) comp = MOI.VectorOfVariables([ind_cc1; ind_cc2]) 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..2e552e9 100644 --- a/test/Bridges/test_VerticalBridge.jl +++ b/test/Bridges/test_VerticalBridge.jl @@ -58,6 +58,49 @@ 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. + 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_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() From defbd2681c9dffab128336695ba09e04f90680d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 14:53:18 +0200 Subject: [PATCH 2/8] WIP --- src/Bridges/SplitIntervalBridge.jl | 18 ++-- src/Bridges/VerticalBridge.jl | 144 +++++++++++++++++++++++----- src/MOI_wrapper.jl | 1 + test/Bridges/test_VerticalBridge.jl | 27 +++++- test/test_optimizer.jl | 24 +++++ 5 files changed, 182 insertions(+), 32 deletions(-) diff --git a/src/Bridges/SplitIntervalBridge.jl b/src/Bridges/SplitIntervalBridge.jl index 32c8459..6b3b559 100644 --- a/src/Bridges/SplitIntervalBridge.jl +++ b/src/Bridges/SplitIntervalBridge.jl @@ -179,6 +179,17 @@ function MOI.set( 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( @@ -186,12 +197,7 @@ function MOI.Bridges.final_touch( 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 + ret = _activity_bounds(model, T, x) if ret === nothing if bridge.xp_upper !== nothing MOI.delete(model, bridge.xp_upper) diff --git a/src/Bridges/VerticalBridge.jl b/src/Bridges/VerticalBridge.jl index 12c35e5..295eed4 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,33 @@ 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 + function MOI.supports( ::MOI.ModelLike, ::ComplementarityReformulation, @@ -209,15 +247,6 @@ _get_variable(func::MOI.ScalarQuadraticFunction) = func.affine_terms[1].variable _get_variable(func::MOI.ScalarNonlinearFunction) = func.args[1] -# A single variable shifted by a constant, e.g. `x - 1`. Such a right-hand side -# is produced by `ComplementsVectorizeBridge` when it shifts the second variable -# by a nonzero bound. -function _is_shifted_variable(func::MOI.ScalarAffineFunction) - return length(func.terms) == 1 && isone(func.terms[1].coefficient) -end - -_is_shifted_variable(::MOI.AbstractScalarFunction) = false - # TODO: add support for ScalarNonlinearTerm function _parse_complementarity_constraint( fun::MOI.AbstractVectorFunction, @@ -230,15 +259,43 @@ function _parse_complementarity_constraint( 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::Union{MOI.VariableIndex,MOI.ScalarAffineFunction{T}}, +) where {T} + cache = Dict{MOI.VariableIndex,NTuple{2,T}}() + return MOI.Utilities.get_bounds(model, cache, expr) +end + +_expr_bounds(model, ::Type{T}, ::MOI.AbstractScalarFunction) where {T} = nothing + # 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. -function _to_variable!(model, ::Type{T}, expr, slacks, equalities) where {T} +# 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, @@ -252,17 +309,35 @@ function _to_variable!(model, ::Type{T}, expr, slacks, equalities) where {T} end # Resolve the right-hand side of a complementarity constraint to a single -# variable. Following MOI's specs, it should be a single variable, but it may be -# a single variable shifted by a constant (introduced by -# `ComplementsVectorizeBridge`), in which case a slack is added. Genuine -# multi-variable expressions are not supported (see Issue #2). -function _rhs_variable!(model, ::Type{T}, expr, slacks, equalities) where {T} - if !_is_single_variable(expr) && !_is_shifted_variable(expr) +# 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 - return _to_variable!(model, T, expr, slacks, equalities) + add_bounds = !(set isa MOI.Complements) + return _to_variable!( + model, + T, + expr, + slacks, + equalities, + slacks_to_bound, + add_bounds, + ) end """ @@ -283,13 +358,24 @@ 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, rhs) in zip(cc_lhs, cc_rhs) - x2 = _rhs_variable!(model, T, rhs, slacks, equalities) + 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) @@ -309,7 +395,15 @@ function reformulate_to_vertical!( # 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) + x1 = _to_variable!( + model, + T, + lhs, + slacks, + equalities, + slacks_to_bound, + add_bounds, + ) push!(ind_cc1, x1) push!(ind_cc2, x2) end @@ -321,5 +415,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/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index db5816c..c096691 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -24,6 +24,7 @@ end Optimizer(model::MOI.ModelLike) = Optimizer{Float64}(model) +MOI.Bridges.supports_constraint_bridges(::Optimizer) = true MOI.Bridges.Constraint.bridges(model::Optimizer) = model.constraint_map """ diff --git a/test/Bridges/test_VerticalBridge.jl b/test/Bridges/test_VerticalBridge.jl index 2e552e9..868627d 100644 --- a/test/Bridges/test_VerticalBridge.jl +++ b/test/Bridges/test_VerticalBridge.jl @@ -61,7 +61,8 @@ 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. + # 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, """ @@ -80,6 +81,30 @@ function test_Expression_RHS_with_constant_ComplementsWithSetType() 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 diff --git a/test/test_optimizer.jl b/test/test_optimizer.jl index 6c61380..8d278d9 100644 --- a/test/test_optimizer.jl +++ b/test/test_optimizer.jl @@ -404,6 +404,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) From 5d6ed3cb279be5c1c266590e96b2e4eba88812c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 16:51:42 +0200 Subject: [PATCH 3/8] WIP --- src/Bridges/SplitIntervalBridge.jl | 137 +++++++++++++++++++---------- src/Bridges/VerticalBridge.jl | 24 +++++ 2 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src/Bridges/SplitIntervalBridge.jl b/src/Bridges/SplitIntervalBridge.jl index 6b3b559..41b637e 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,8 +174,13 @@ 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 @@ -196,8 +201,10 @@ function MOI.Bridges.final_touch( bridge::SplitIntervalBridge{T}, model::MOI.ModelLike, ) where {T} - x = first(MOI.Utilities.eachscalar(bridge.func)) - ret = _activity_bounds(model, T, x) + # 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) @@ -207,20 +214,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 @@ -299,13 +337,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( @@ -315,17 +353,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( @@ -335,7 +375,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( @@ -369,9 +411,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 295eed4..92a1564 100644 --- a/src/Bridges/VerticalBridge.jl +++ b/src/Bridges/VerticalBridge.jl @@ -197,6 +197,30 @@ function MOI.delete(model::MOI.ModelLike, bridge::VerticalBridge) 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, From 0a7037189abfe9c0a7605e83210bab56bb2173be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 19:59:14 +0200 Subject: [PATCH 4/8] Fix format --- src/Bridges/SplitIntervalBridge.jl | 3 ++- src/Bridges/VerticalBridge.jl | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Bridges/SplitIntervalBridge.jl b/src/Bridges/SplitIntervalBridge.jl index 41b637e..50d0a1e 100644 --- a/src/Bridges/SplitIntervalBridge.jl +++ b/src/Bridges/SplitIntervalBridge.jl @@ -204,7 +204,8 @@ function MOI.Bridges.final_touch( # 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))) + 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) diff --git a/src/Bridges/VerticalBridge.jl b/src/Bridges/VerticalBridge.jl index 92a1564..f508acf 100644 --- a/src/Bridges/VerticalBridge.jl +++ b/src/Bridges/VerticalBridge.jl @@ -221,7 +221,6 @@ function MOI.Bridges.final_touch( return end - function MOI.supports( ::MOI.ModelLike, ::ComplementarityReformulation, @@ -382,8 +381,7 @@ function reformulate_to_vertical!( ) where {T} equalities = MOI.ConstraintIndex[] slacks = MOI.VariableIndex[] - slacks_to_bound = - Tuple{MOI.VariableIndex,MOI.AbstractScalarFunction}[] + 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) From 0eee78b95332ddb6bae58ab7f11a46db6bd7e30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 20:01:11 +0200 Subject: [PATCH 5/8] Improve cov --- src/Bridges/VerticalBridge.jl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Bridges/VerticalBridge.jl b/src/Bridges/VerticalBridge.jl index f508acf..173a2d8 100644 --- a/src/Bridges/VerticalBridge.jl +++ b/src/Bridges/VerticalBridge.jl @@ -287,14 +287,12 @@ end function _expr_bounds( model, ::Type{T}, - expr::Union{MOI.VariableIndex,MOI.ScalarAffineFunction{T}}, + expr::MOI.AbstractScalarFunction, ) where {T} cache = Dict{MOI.VariableIndex,NTuple{2,T}}() return MOI.Utilities.get_bounds(model, cache, expr) end -_expr_bounds(model, ::Type{T}, ::MOI.AbstractScalarFunction) where {T} = nothing - # 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` From 867ec899feafc6b69afd5f74e49762031358e8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 20:01:41 +0200 Subject: [PATCH 6/8] Remove separate thing --- src/MOI_wrapper.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index c096691..db5816c 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -24,7 +24,6 @@ end Optimizer(model::MOI.ModelLike) = Optimizer{Float64}(model) -MOI.Bridges.supports_constraint_bridges(::Optimizer) = true MOI.Bridges.Constraint.bridges(model::Optimizer) = model.constraint_map """ From cac53c8fb937f7e0a7bf7f893d4b8eb534a23bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 20:05:10 +0200 Subject: [PATCH 7/8] Improve coverage --- test/test_optimizer.jl | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/test_optimizer.jl b/test/test_optimizer.jl index 8d278d9..6c7c819 100644 --- a/test/test_optimizer.jl +++ b/test/test_optimizer.jl @@ -292,6 +292,44 @@ 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} From c5875328a3198efecb86ea76f7f2a0b3eb1b131b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 8 Jul 2026 22:03:59 +0200 Subject: [PATCH 8/8] Fix format --- test/test_optimizer.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_optimizer.jl b/test/test_optimizer.jl index 6c7c819..c62badb 100644 --- a/test/test_optimizer.jl +++ b/test/test_optimizer.jl @@ -309,7 +309,12 @@ function test_ComplementarityReformulation_through_SplitInterval_before_final_to MathOptComplements.ComplementsWithSetType{MOI.Interval{Float64}}(2), ) attr = MathOptComplements.ComplementarityReformulation() - MOI.set(model, attr, ci, MathOptComplements.FischerBurmeisterRelaxation(1e-8)) + 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).