From 73f0c9c6991689900f35e0f81aecb609cd325dcf Mon Sep 17 00:00:00 2001 From: Roman Lebedev Date: Fri, 19 Jan 2024 04:21:33 +0300 Subject: [PATCH] [WIP] Handle `!=` constraint via `MOI.AllDifferent(2)` ``` (@v1.10) pkg> activate /repositories/JuMP.jl Activating project at `/repositories/JuMP.jl` julia> using JuMP Precompiling JuMP 1 dependency successfully precompiled in 10 seconds. 37 already precompiled. julia> model = Model(); julia> @variable(model, x[1:3]) 3-element Vector{VariableRef}: x[1] x[2] x[3] julia> @variable(model, y[1:3]) 3-element Vector{VariableRef}: y[1] y[2] y[3] julia> @constraint(model, x[1] != y[1]) x[1] != y[1] julia> @constraint(model, x .!= y) 3-element Vector{ConstraintRef{Model, MathOptInterface.ConstraintIndex{MathOptInterface.VectorOfVariables, MathOptInterface.AllDifferent}, VectorShape}}: x[1] != y[1] x[2] != y[2] x[3] != y[3] julia> @constraint(model, x != y) ERROR: At REPL[8]:1: `@constraint(model, x != y)`: Ineqality operator with vector operands must be explicitly vectorized, use `.!=` instead of `!=`. Stacktrace: [1] error(::String, ::String) @ Base ./error.jl:44 [2] (::JuMP.Containers.var"#error_fn#98"{String})(str::String) @ JuMP.Containers ~/.julia/compiled/v1.10/JuMP/DmXqY_F8XkK.so:-1 [3] macro expansion @ /repositories/JuMP.jl/src/macros/@constraint.jl:132 [inlined] [4] macro expansion @ /repositories/JuMP.jl/src/macros.jl:393 [inlined] [5] top-level scope @ REPL[8]:1 ``` I'm not yet sure how to support the not-explicitly vectorized case. We'd need to somehow deduce (in `parse_constraint_call()`) that our arguments are vectors, and extend `parse_constraint_call()` to return `vectorized` itself. I'm not convinced this is even possible. Otherwise, we get ``` julia> @constraint(model, x != y) vectorized = false ERROR: MethodError: no method matching _build_inequality_constraint(::Bool, ::JuMP.Containers.var"#error_fn#98"{String}, ::Vector{VariableRef}, ::Vector{VariableRef}) Closest candidates are: _build_inequality_constraint(::Function, ::Bool, ::Vector{VariableRef}, ::Vector{VariableRef}) @ JuMP /repositories/JuMP.jl/src/inequality.jl:14 Stacktrace: [1] macro expansion @ /repositories/JuMP.jl/src/macros/@constraint.jl:132 [inlined] [2] macro expansion @ /repositories/JuMP.jl/src/macros.jl:393 [inlined] [3] top-level scope @ REPL[8]:1 ``` (because we should have called `@constraint.jl:123`) Missing tests, docs. As discussed in https://github.com/jump-dev/MathOptInterface.jl/issues/2405 --- src/JuMP.jl | 1 + src/inequality.jl | 78 ++++++++++++++ src/macros/@constraint.jl | 7 +- src/print.jl | 4 + test/test_inequality.jl | 212 ++++++++++++++++++++++++++++++++++++++ test/test_macros.jl | 2 +- 6 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 src/inequality.jl create mode 100644 test/test_inequality.jl diff --git a/src/JuMP.jl b/src/JuMP.jl index cb13acbafbb..2f320f18035 100644 --- a/src/JuMP.jl +++ b/src/JuMP.jl @@ -1103,6 +1103,7 @@ include("operators.jl") include("sd.jl") include("sets.jl") include("solution_summary.jl") +include("inequality.jl") # print.jl must come last, because it uses types defined in earlier files. include("print.jl") diff --git a/src/inequality.jl b/src/inequality.jl new file mode 100644 index 00000000000..cff0e4e90fc --- /dev/null +++ b/src/inequality.jl @@ -0,0 +1,78 @@ +# Copyright 2017, Iain Dunning, Joey Huchette, Miles Lubin, and contributors +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +function _build_inequality_constraint( + error_fn::Function, + vectorized::Bool, + lhs::VariableRef, + rhs::VariableRef, +) + @assert !vectorized + set = MOI.AllDifferent(2) + return VectorConstraint([lhs; rhs], set) +end + +function _build_inequality_constraint( + error_fn::Function, + vectorized::Bool, + lhs::Vector{VariableRef}, + rhs::Vector{VariableRef}, +) + if !vectorized + error_fn( + "Ineqality operator with vector operands must be explicitly " * + "vectorized, use `.!=` instead of `!=`.", + ) + end + if length(lhs) != length(rhs) + error_fn("Operand length mismatch, $(length(lhs)) vs $(length(rhs)).") + end + lhs = _desparsify(lhs) + rhs = _desparsify(rhs) + return _build_inequality_constraint.(error_fn, false, lhs, rhs) +end + +function _build_inequality_constraint(error_fn::Function, ::Bool, lhs, rhs) + return error_fn( + "Unsupported form of inequality constraint. The left- and right-hand " * + "sides must both be decision variables.", + ) +end + +function parse_constraint_call( + error_fn::Function, + vectorized::Bool, + ::Val{:(!=)}, + lhs, + rhs, +) + build_call = Expr( + :call, + :_build_inequality_constraint, + error_fn, + vectorized, + esc(lhs), + esc(rhs), + ) + return nothing, build_call +end + +function constraint_string( + print_mode, + constraint::VectorConstraint{F,<:MOI.AllDifferent}, +) where {F} + set = constraint.set + if set.dimension == 2 + ineq_sym = JuMP._math_symbol(print_mode, :(!=)) + lhs = function_string(print_mode, constraint.func[1]) + rhs = function_string(print_mode, constraint.func[2]) + return string(lhs, " $ineq_sym ", rhs) + end + + # FIXME: can we just fallback to the generic handling here? + ops = [function_string(print_mode, op) for op in constraint.func[1:end]] + in_sym = JuMP._math_symbol(print_mode, :in) + return string("[", join(ops, ", "), "] $in_sym $set") +end diff --git a/src/macros/@constraint.jl b/src/macros/@constraint.jl index bc0feeb03a8..40df474e9f4 100644 --- a/src/macros/@constraint.jl +++ b/src/macros/@constraint.jl @@ -20,7 +20,7 @@ The expression `expr` may be one of following forms: which is either a [`MOI.AbstractSet`](@ref) or one of the JuMP shortcuts like [`SecondOrderCone`](@ref) or [`PSDCone`](@ref) - * `a b`, where `` is one of `==`, `≥`, `>=`, `≤`, `<=` + * `a b`, where `` is one of `==`, `!=`, `≥`, `>=`, `≤`, `<=` * `l <= f <= u` or `u >= f >= l`, constraining the expression `f` to lie between `l` and `u` @@ -233,6 +233,7 @@ The entry-point for all constraint-related parsing. JuMP currently supports the following `expr` objects: * `lhs <= rhs` * `lhs == rhs` + * `lhs != rhs` * `lhs >= rhs` * `l <= body <= u` * `u >= body >= l` @@ -259,7 +260,7 @@ end function parse_constraint(error_fn::Function, arg) return error_fn( "Incomplete constraint specification $arg. Are you missing a " * - "comparison (<=, >=, or ==)?", + "comparison (<=, >=, == or !=)?", ) end @@ -591,7 +592,7 @@ julia> @constraint(model, A * x == b) """ struct Zeros end -operator_to_set(::Function, ::Val{:(==)}) = Zeros() +operator_to_set(::Function, ::Union{Val{:(==)},Val{:(!=)}}) = Zeros() """ parse_constraint_call( diff --git a/src/print.jl b/src/print.jl index 5847ab3922b..a5556f8fcba 100644 --- a/src/print.jl +++ b/src/print.jl @@ -144,6 +144,8 @@ function _math_symbol(::MIME"text/plain", name::Symbol) return Sys.iswindows() ? ">=" : "≥" elseif name == :eq return Sys.iswindows() ? "==" : "=" + elseif name == :(!=) + return Sys.iswindows() ? "!=" : "≠" elseif name == :sq return "²" else @@ -160,6 +162,8 @@ function _math_symbol(::MIME"text/latex", name::Symbol) return "\\geq" elseif name == :eq return "=" + elseif name == :(!=) + return "\\neq" else @assert name == :sq return "^2" diff --git a/test/test_inequality.jl b/test/test_inequality.jl new file mode 100644 index 00000000000..680fe2efe0e --- /dev/null +++ b/test/test_inequality.jl @@ -0,0 +1,212 @@ +# Copyright 2017, Iain Dunning, Joey Huchette, Miles Lubin, and contributors +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +module TestInequality + +using JuMP +using Test + +include(joinpath(@__DIR__, "utilities.jl")) + +function test_inequality_two_int_scalars() + model = Model() + @variable(model, -4 <= x <= 4, Int) + @variable(model, -4 <= y <= 4, Int) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +function test_inequality_latex() + model = Model() + @variable(model, -4 <= x <= 4, Int) + @variable(model, -4 <= y <= 4, Int) + c = @constraint(model, x != y) + set = MOI.AllDifferent(2) + @test sprint(io -> show(io, MIME("text/latex"), c)) == "\$\$ x \\neq y \$\$" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +function test_inequality_two_bin_scalars() + model = Model() + @variable(model, x, Bin) + @variable(model, y, Bin) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +# FIXME: should this fail? +function test_inequality_two_scalars_only_one_being_int() + model = Model() + @variable(model, -4 <= x <= 4) + @variable(model, -4 <= y <= 4, Int) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +# FIXME: should this fail? +function test_inequality_two_scalars_only_one_being_bin() + model = Model() + @variable(model, -4 <= x <= 4) + @variable(model, y, Bin) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +# FIXME: should this fail? +function test_inequality_two_scalars_int_vs_bin() + model = Model() + @variable(model, -4 <= x <= 4, Int) + @variable(model, y, Bin) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +# FIXME: should this fail? +function test_inequality_two_scalars_real_scalars() + model = Model() + @variable(model, -4 <= x <= 4) + @variable(model, -4 <= y <= 4) + c = @constraint(model, x != y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + @test sprint(show, c) == "x $ineq_sym y" + obj = constraint_object(c) + @test obj.func == [x; y] + @test obj.set == set + return +end + +function test_inequality_two_vectors_vectorized() + model = Model() + @variable(model, -4 <= x[1:3] <= 4, Int) + @variable(model, -4 <= y[1:3] <= 4, Int) + c = @constraint(model, x .!= y) + ineq_sym = JuMP._math_symbol(MIME("text/plain"), :(!=)) + set = MOI.AllDifferent(2) + for (i, ci) in enumerate(c) + @test sprint(show, ci) == "x[$i] $ineq_sym y[$i]" + obj = constraint_object(ci) + @test obj.func == [x[i]; y[i]] + @test obj.set == set + end + return +end + +function test_inequality_two_vectors_nonvectorized() + model = Model() + @variable(model, -4 <= x[1:3] <= 4, Int) + @variable(model, -4 <= y[1:3] <= 4, Int) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, x != y)`: Ineqality operator with " * + "vector operands must be explicitly vectorized, " * + "use `.!=` instead of `!=`.", + ), + @constraint(model, x != y) + ) + return +end + +function test_inequality_two_vectors_nonvectorized_len_mismatch() + model = Model() + @variable(model, -4 <= x[1:3] <= 4, Int) + @variable(model, -4 <= y[1:2] <= 4, Int) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, x != y)`: Ineqality operator with " * + "vector operands must be explicitly vectorized, " * + "use `.!=` instead of `!=`.", + ), + @constraint(model, x != y) + ) + return +end + +function test_inequality_two_vectors_vectorized_len_mismatch() + model = Model() + @variable(model, -4 <= x[1:3] <= 4, Int) + @variable(model, -4 <= y[1:2] <= 4, Int) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, x .!= y)`: " * + "Operand length mismatch, 3 vs 2.", + ), + @constraint(model, x .!= y) + ) + return +end + +function test_inequality_non_variables() + model = Model() + @variable(model, -4 <= x <= 4, Int) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, x != 0)`: Unsupported form of " * + "inequality constraint. The left- and right-hand sides must both " * + "be decision variables.", + ), + @constraint(model, x != 0) + ) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, 0 != x)`: Unsupported form of " * + "inequality constraint. The left- and right-hand sides must both " * + "be decision variables.", + ), + @constraint(model, 0 != x) + ) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, 2x != 0)`: Unsupported form of " * + "inequality constraint. The left- and right-hand sides must both " * + "be decision variables.", + ), + @constraint(model, 2 * x != 0) + ) + @test_throws_runtime( + ErrorException( + "In `@constraint(model, x != 2x)`: Unsupported form of " * + "inequality constraint. The left- and right-hand sides must both " * + "be decision variables.", + ), + @constraint(model, x != 2 * x) + ) + return +end + +end # module diff --git a/test/test_macros.jl b/test/test_macros.jl index 03652a0f679..ba944857904 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -1731,7 +1731,7 @@ function test_build_constraint_invalid() @test_throws_parsetime( ErrorException( "In `@build_constraint(x)`: Incomplete constraint specification " * - "x. Are you missing a comparison (<=, >=, or ==)?", + "x. Are you missing a comparison (<=, >=, == or !=)?", ), @build_constraint(x), )