Repository: Pyomo/pyomo

Issue:
Native type handling in expressions (breaks MindtPy)
## Summary

I have a `ConcreteModel` with a few indexed Params, Vars, and Constraints that solves fine on Pyomo 6.5.0.  However, upgrading to 6.6.0 causes the `MindtPySolver` to throw a nonsensical exception when I call `solve`.

No other changes at all are made.  Downgrading to Pyomo 6.5.0 and changing nothing else clears the error.  Something in 6.6.0 appears to have caused a regression.

### Steps to reproduce the issue

The full codebase is large and private but I can reproduce the relevant portion.

The solver is constructed and run as:
```py
    def build_solver(neos: bool = False) -> Callable[[ConcreteModel], SolverResults]:
        # irrelevant lines omitted …

        from pyomo.contrib.mindtpy.MindtPy import MindtPySolver  # pylint: disable=import-outside-toplevel
        backend = 'mindtpy'
        solver: MindtPySolver = SolverFactory(backend)
        opt_kwargs = {'mip_solver': 'glpk', 'nlp_solver': 'ipopt', 'tee': True}
        return lambda instance: solver.solve(instance, **opt_kwargs)

def splitter_mass_balance():
    instance = ConcreteModel()

    # initialize Params, Vars, Constraints …

    def splitter_objective_rule(_instance):
        return 1  # fine for now
    instance.Obj_splitter = Objective(rule=splitter_objective_rule, sense=maximize, doc='Objective')
    results = build_solver()(instance)

splitter_mass_balance()  # basically how it's called
```

### Error Message

```python
… (pile of wrappers) …

  File "/Users/alex/proj/proj.py", line 2606, in splitter_mass_balance
    results = build_solver()(instance)
              ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/proj/proj.py", line 2430, in <lambda>
    return lambda instance: solver.solve(instance, **opt_kwargs)
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/proj/.venv/lib/python3.11/site-packages/pyomo/contrib/mindtpy/MindtPy.py", line 118, in solve
    return SolverFactory(_supported_algorithms[config.strategy][0]).solve(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/proj/.venv/lib/python3.11/site-packages/pyomo/contrib/mindtpy/algorithm_base_class.py", line 2748, in solve
    self.set_up_solve_data(model, config)
  File "/Users/alex/proj/.venv/lib/python3.11/site-packages/pyomo/contrib/mindtpy/algorithm_base_class.py", line 785, in set_up_solve_data
    if obj.expr.polynomial_degree() == 0:
       ^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'int' object has no attribute 'polynomial_degree'
```

### Information on your system

Pyomo version: 6.6.0
Python version: 3.11.4
Operating system: macOS 13.4.1
How Pyomo was installed (PyPI, conda, source): PyPI
Solver (if applicable): MindtPy



Discussion:
Note the objective is trivial, for debugging purposes.  The [Objective](https://pyomo.readthedocs.io/en/stable/pyomo_modeling_components/Objectives.html) documentation does not require the return value of the function to be of type `Expression`, and returning Python constants like `1` works in 6.5.0.
Does Pyomo 6.6.0 now require us to import `NumericConstant` and wrap constants in that, like this?

```py
    from pyomo.core.expr.numvalue import NumericConstant
    def splitter_objective_rule(_instance):
        return NumericConstant(1)
```
We can write a hack for MindtPy to deal with constant objectives, which seems to be a corner case for using MindtPy as it would represent some feasibility problems. Before we do that, does this reveal some deeper regression within Pyomo? @jsiirola 
In case not, we will prepare a quick fix.
@bernalde I don't *think* it's MindtPy specific, as that code hasn't changed in many months (since before the release of 6.5.0).  Note that returning a wrapped constant like `NumericConstant(1)` instead of the native value works around the issue.

It seems like Pyomo 6.6.0 no longer permits the return value of the objective function to be a Python native value.  Before, it might've wrapped them in an `Expression` of some kind.
I did a simple test. This results from the change of pyomo core and is not MindtPy specific.

```python
from pyomo.environ import *

m = ConcreteModel()
m.obj = Objective(expr=1)

print(m.obj.expr.polynomial_degree())
```
When running this with Pyomo 6.5.0, the output is `0`.
When running this with Pyomo 6.6.0, the output is 
```
Traceback (most recent call last):
  File "from pyomo.py", line 32, in <module>
    print(m.obj.expr.polynomial_degree())
AttributeError: 'int' object has no attribute 'polynomial_degree'
```

As I suspected.  I suspect this new restriction was unintentional, but either way, can we document the correct behavior on [Objectives](https://pyomo.readthedocs.io/en/stable/pyomo_modeling_components/Objectives.html) and add a regression test?
We discussed this during the Pyomo dev meeting today and are going to fix this in Pyomo core. The `expr` property will always return a Pyomo type.
Does that mean that objective functions that simply return a native constant will be lifted to Pyomo expressions returning a Pyomo constant value?

Resolving patch:
diff --git a/pyomo/contrib/fbbt/fbbt.py b/pyomo/contrib/fbbt/fbbt.py
--- a/pyomo/contrib/fbbt/fbbt.py
+++ b/pyomo/contrib/fbbt/fbbt.py
@@ -443,10 +443,11 @@ def _prop_bnds_leaf_to_root_GeneralExpression(node, bnds_dict, feasibility_tol):
         region is removed due to floating point arithmetic and to prevent math domain errors (a larger value
         is more conservative).
     """
-    if node.expr.__class__ in native_types:
-        expr_lb = expr_ub = node.expr
+    (expr,) = node.args
+    if expr.__class__ in native_types:
+        expr_lb = expr_ub = expr
     else:
-        expr_lb, expr_ub = bnds_dict[node.expr]
+        expr_lb, expr_ub = bnds_dict[expr]
     bnds_dict[node] = (expr_lb, expr_ub)
 
 
diff --git a/pyomo/core/base/expression.py b/pyomo/core/base/expression.py
--- a/pyomo/core/base/expression.py
+++ b/pyomo/core/base/expression.py
@@ -59,10 +59,11 @@ class _ExpressionData(numeric_expr.NumericValue):
 
     def __call__(self, exception=True):
         """Compute the value of this expression."""
-        if self.expr.__class__ in native_types:
+        (arg,) = self._args_
+        if arg.__class__ in native_types:
             # Note: native_types includes NoneType
-            return self.expr
-        return self.expr(exception=exception)
+            return arg
+        return arg(exception=exception)
 
     def is_named_expression_type(self):
         """A boolean indicating whether this in a named expression."""
@@ -73,17 +74,13 @@ def is_expression_type(self, expression_system=None):
         return expression_system is None or expression_system == self.EXPRESSION_SYSTEM
 
     def arg(self, index):
-        if index < 0 or index >= 1:
+        if index != 0:
             raise KeyError("Invalid index for expression argument: %d" % index)
-        return self.expr
-
-    @property
-    def _args_(self):
-        return (self.expr,)
+        return self._args_[0]
 
     @property
     def args(self):
-        return (self.expr,)
+        return self._args_
 
     def nargs(self):
         return 1
@@ -91,7 +88,7 @@ def nargs(self):
     def _to_string(self, values, verbose, smap):
         if verbose:
             return "%s{%s}" % (str(self), values[0])
-        if self.expr is None:
+        if self._args_[0] is None:
             return "%s{None}" % str(self)
         return values[0]
 
@@ -106,8 +103,8 @@ def _apply_operation(self, result):
 
     def polynomial_degree(self):
         """A tuple of subexpressions involved in this expressions operation."""
-        if self.expr.__class__ in native_types:
-            return 0
+        if self._args_[0] is None:
+            return None
         return self.expr.polynomial_degree()
 
     def _compute_polynomial_degree(self, result):
@@ -122,8 +119,14 @@ def _is_fixed(self, values):
 
     @property
     def expr(self):
-        """Return expression on this expression."""
-        raise NotImplementedError
+        (arg,) = self._args_
+        if arg is None:
+            return None
+        return as_numeric(arg)
+
+    @expr.setter
+    def expr(self, value):
+        self.set_value(value)
 
     def set_value(self, expr):
         """Set the expression on this expression."""
@@ -158,7 +161,7 @@ class _GeneralExpressionDataImpl(_ExpressionData):
     __slots__ = ()
 
     def __init__(self, expr=None):
-        self._expr = as_numeric(expr) if (expr is not None) else None
+        self._args_ = (expr,)
 
     def create_node_with_local_data(self, values):
         """
@@ -170,34 +173,25 @@ def create_node_with_local_data(self, values):
         """
         obj = ScalarExpression()
         obj.construct()
-        obj.expr = values[0]
+        obj._args_ = values
         return obj
 
     #
     # Abstract Interface
     #
 
-    @property
-    def expr(self):
-        """Return expression on this expression."""
-        return self._expr
-
-    @expr.setter
-    def expr(self, expr):
-        self.set_value(expr)
-
     def set_value(self, expr):
         """Set the expression on this expression."""
         if expr is None or expr.__class__ in native_numeric_types:
-            self._expr = expr
+            self._args_ = (expr,)
             return
         try:
             if expr.is_numeric_type():
-                self._expr = expr
+                self._args_ = (expr,)
                 return
         except AttributeError:
             if check_if_numeric_type(expr):
-                self._expr = expr
+                self._args_ = (expr,)
                 return
         raise ValueError(
             f"Cannot assign {expr.__class__.__name__} to "
@@ -213,33 +207,34 @@ def is_constant(self):
 
     def is_fixed(self):
         """A boolean indicating whether this expression is fixed."""
-        return self._expr.__class__ in native_types or self._expr.is_fixed()
+        (e,) = self._args_
+        return e.__class__ in native_types or e.is_fixed()
 
     # Override the in-place operators here so that we can redirect the
     # dispatcher based on the current contained expression type and not
     # this Expression object (which would map to "other")
 
     def __iadd__(self, other):
-        e = self._expr
+        (e,) = self._args_
         return numeric_expr._add_dispatcher[e.__class__, other.__class__](e, other)
 
     # Note: the default implementation of __isub__ leverages __iadd__
     # and doesn't need to be reimplemented here
 
     def __imul__(self, other):
-        e = self._expr
+        (e,) = self._args_
         return numeric_expr._mul_dispatcher[e.__class__, other.__class__](e, other)
 
     def __idiv__(self, other):
-        e = self._expr
+        (e,) = self._args_
         return numeric_expr._div_dispatcher[e.__class__, other.__class__](e, other)
 
     def __itruediv__(self, other):
-        e = self._expr
+        (e,) = self._args_
         return numeric_expr._div_dispatcher[e.__class__, other.__class__](e, other)
 
     def __ipow__(self, other):
-        e = self._expr
+        (e,) = self._args_
         return numeric_expr._pow_dispatcher[e.__class__, other.__class__](e, other)
 
 
@@ -258,7 +253,7 @@ class _GeneralExpressionData(_GeneralExpressionDataImpl, ComponentData):
         _component  The expression component.
     """
 
-    __slots__ = ('_expr',)
+    __slots__ = ('_args_',)
 
     def __init__(self, expr=None, component=None):
         _GeneralExpressionDataImpl.__init__(self, expr)
@@ -418,13 +413,23 @@ def __init__(self, *args, **kwds):
     # construction
     #
 
+    def __call__(self, exception=True):
+        """Return expression on this expression."""
+        if self._constructed:
+            return super().__call__(exception)
+        raise ValueError(
+            "Evaluating the expression of Expression '%s' "
+            "before the Expression has been constructed (there "
+            "is currently no value to return)." % (self.name)
+        )
+
     @property
     def expr(self):
         """Return expression on this expression."""
         if self._constructed:
             return _GeneralExpressionData.expr.fget(self)
         raise ValueError(
-            "Accessing the expression of expression '%s' "
+            "Accessing the expression of Expression '%s' "
             "before the Expression has been constructed (there "
             "is currently no value to return)." % (self.name)
         )
@@ -442,7 +447,7 @@ def set_value(self, expr):
         if self._constructed:
             return _GeneralExpressionData.set_value(self, expr)
         raise ValueError(
-            "Setting the expression of expression '%s' "
+            "Setting the expression of Expression '%s' "
             "before the Expression has been constructed (there "
             "is currently no object to set)." % (self.name)
         )
@@ -452,7 +457,7 @@ def is_constant(self):
         if self._constructed:
             return _GeneralExpressionData.is_constant(self)
         raise ValueError(
-            "Accessing the is_constant flag of expression '%s' "
+            "Accessing the is_constant flag of Expression '%s' "
             "before the Expression has been constructed (there "
             "is currently no value to return)." % (self.name)
         )
@@ -462,7 +467,7 @@ def is_fixed(self):
         if self._constructed:
             return _GeneralExpressionData.is_fixed(self)
         raise ValueError(
-            "Accessing the is_fixed flag of expression '%s' "
+            "Accessing the is_fixed flag of Expression '%s' "
             "before the Expression has been constructed (there "
             "is currently no value to return)." % (self.name)
         )
diff --git a/pyomo/core/base/objective.py b/pyomo/core/base/objective.py
--- a/pyomo/core/base/objective.py
+++ b/pyomo/core/base/objective.py
@@ -154,7 +154,7 @@ class _GeneralObjectiveData(
         _active         A boolean that indicates whether this data is active
     """
 
-    __slots__ = ("_sense", "_expr")
+    __slots__ = ("_sense", "_args_")
 
     def __init__(self, expr=None, sense=minimize, component=None):
         _GeneralExpressionDataImpl.__init__(self, expr)
@@ -271,20 +271,7 @@ def __init__(
 
     def __init__(self, *args, **kwargs):
         _sense = kwargs.pop('sense', minimize)
-        _init = tuple(
-            _arg
-            for _arg in (kwargs.pop('rule', None), kwargs.pop('expr', None))
-            if _arg is not None
-        )
-        if len(_init) == 1:
-            _init = _init[0]
-        elif not _init:
-            _init = None
-        else:
-            raise ValueError(
-                "Duplicate initialization: Objective() only "
-                "accepts one of 'rule=' and 'expr='"
-            )
+        _init = self._pop_from_kwargs('Objective', kwargs, ('rule', 'expr'), None)
 
         kwargs.setdefault('ctype', Objective)
         ActiveIndexedComponent.__init__(self, *args, **kwargs)
@@ -427,6 +414,22 @@ def __init__(self, *args, **kwd):
     # construction
     #
 
+    def __call__(self, exception=True):
+        if self._constructed:
+            if len(self._data) == 0:
+                raise ValueError(
+                    "Evaluating the expression of ScalarObjective "
+                    "'%s' before the Objective has been assigned "
+                    "a sense or expression (there is currently "
+                    "no value to return)." % (self.name)
+                )
+            return super().__call__(exception)
+        raise ValueError(
+            "Evaluating the expression of objective '%s' "
+            "before the Objective has been constructed (there "
+            "is currently no value to return)." % (self.name)
+        )
+
     @property
     def expr(self):
         """Access the expression of this objective."""
@@ -435,8 +438,8 @@ def expr(self):
                 raise ValueError(
                     "Accessing the expression of ScalarObjective "
                     "'%s' before the Objective has been assigned "
-                    "a sense or expression. There is currently "
-                    "nothing to access." % (self.name)
+                    "a sense or expression (there is currently "
+                    "no value to return)." % (self.name)
                 )
             return _GeneralObjectiveData.expr.fget(self)
         raise ValueError(
@@ -458,8 +461,8 @@ def sense(self):
                 raise ValueError(
                     "Accessing the sense of ScalarObjective "
                     "'%s' before the Objective has been assigned "
-                    "a sense or expression. There is currently "
-                    "nothing to access." % (self.name)
+                    "a sense or expression (there is currently "
+                    "no value to return)." % (self.name)
                 )
             return _GeneralObjectiveData.sense.fget(self)
         raise ValueError(


Test patch: