Repository: Pyomo/pyomo

Issue:
Bug in `logical_to_disjunctive` transformation of `land`!
## Summary

Our transformation of logical and is incorrect--calling the auxiliary variable we introduce $z$, we only formulate $z \implies a \land b$, but the reverse implication is not enforced.

### Steps to reproduce the issue

<!-- Please include the command run and/or provide an example script that demonstrates the problem. -->

```
from pyomo.environ import *

m = ConcreteModel()

m.t = BooleanVar()
m.a = BooleanVar()
m.d = BooleanVar()

m.c = LogicalConstraint(expr=m.t.equivalent_to(m.a.land(m.d)))

m.a.fix(True)
m.d.fix(True)

m.binary = Var(domain=Binary)
m.t.associate_binary_var(m.binary)

m.obj = Objective(expr=m.binary)

TransformationFactory('contrib.logical_to_disjunctive').apply_to(m)
TransformationFactory('gdp.bigm').apply_to(m)

# Should be 1 (we forced it), but we get 0!
SolverFactory('gurobi').solve(m, tee=True)

assert value(m.obj) == 1, "This model just cheated"
```

### Error Message

We get the wrong answer!

```
Read LP format model from file /tmp/tmpaqmpbtia.pyomo.lp
Reading time = 0.00 seconds
x1: 11 rows, 5 columns, 21 nonzeros
Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)

CPU model: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz, instruction set [SSE2]
Thread count: 4 physical cores, 4 logical processors, using up to 4 threads

Optimize a model with 11 rows, 5 columns and 21 nonzeros
Model fingerprint: 0x0fd5c2a5
Variable types: 0 continuous, 5 integer (5 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+00, 1e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+00]
Found heuristic solution: objective 1.0000000
Presolve removed 11 rows and 5 columns
Presolve time: 0.00s
Presolve: All rows and columns removed

Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 4 available processors)

Solution count 2: 0 1 

Optimal solution found (tolerance 1.00e-04)
Best objective 0.000000000000e+00, best bound 0.000000000000e+00, gap 0.0000%
Traceback (most recent call last):
  File "/home/esjohn/src/pyomo/pyomo/contrib/cp/heres_the_bug.py", line 34, in <module>
    assert value(m.obj) == 1, "This model just cheated"
           ^^^^^^^^^^^^^^^^^
AssertionError: This model just cheated
```

### Information on your system

Pyomo version: main
Python version: 3.11
Operating system: linux
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable):


Discussion:
Fixing this now, but wanted to put up the bug report because this might be causing GDPs to give the wrong answer...

Resolving patch:
diff --git a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
--- a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
+++ b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
@@ -107,6 +107,7 @@ def _dispatch_and(visitor, node, *args):
     z = visitor.z_vars.add()
     for arg in args:
         visitor.constraints.add(arg >= z)
+    visitor.constraints.add(len(args) - sum(args) >= 1 - z)
     return z
 
 
@@ -152,7 +153,7 @@ def _get_integer_value(n, node):
 
 
 def _dispatch_exactly(visitor, node, *args):
-    # z = sum(args[1:] == args[0]
+    # z = sum(args[1:]) == args[0]
     # This is currently implemented as:
     # [sum(args[1:] = n] v [[sum(args[1:]) < n] v [sum(args[1:]) > n]]
     M = len(args) - 1


Test patch:
diff --git a/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py b/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py
--- a/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py
+++ b/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py
@@ -18,6 +18,10 @@
     LogicalToDisjunctiveVisitor,
 )
 from pyomo.core.expr.compare import assertExpressionsEqual
+from pyomo.core.plugins.transform.logical_to_linear import (
+    update_boolean_vars_from_binary,
+)
+from pyomo.gdp import Disjunct
 from pyomo.environ import (
     atmost,
     atleast,
@@ -26,18 +30,23 @@
     BooleanVar,
     Binary,
     ConcreteModel,
+    Constraint,
     Expression,
     Integers,
     land,
     lnot,
     lor,
     LogicalConstraint,
+    Objective,
     Param,
+    SolverFactory,
     value,
     Var,
     TransformationFactory,
 )
 
+gurobi_available = SolverFactory('gurobi').available(exception_flag=False)
+
 
 class TestLogicalToDisjunctiveVisitor(unittest.TestCase):
     def make_model(self):
@@ -93,12 +102,15 @@ def test_logical_and(self):
         self.assertIs(m.b.get_associated_binary(), m.z[2])
         self.assertIs(m.c.get_associated_binary(), m.z[3])
 
-        self.assertEqual(len(m.cons), 4)
+        self.assertEqual(len(m.cons), 5)
         self.assertEqual(len(m.z), 4)
         assertExpressionsEqual(self, m.cons[1].expr, m.z[4] <= m.z[1])
         assertExpressionsEqual(self, m.cons[2].expr, m.z[4] <= m.z[2])
         assertExpressionsEqual(self, m.cons[3].expr, m.z[4] <= m.z[3])
-        assertExpressionsEqual(self, m.cons[4].expr, m.z[4] >= 1)
+        assertExpressionsEqual(
+            self, m.cons[4].expr, 1 - m.z[4] <= 3 - (m.z[1] + m.z[2] + m.z[3])
+        )
+        assertExpressionsEqual(self, m.cons[5].expr, m.z[4] >= 1)
 
     def test_logical_not(self):
         m = self.make_model()
@@ -128,22 +140,25 @@ def test_implication(self):
         self.assertIs(m.b.get_associated_binary(), m.z[2])
         self.assertIs(m.c.get_associated_binary(), m.z[3])
 
-        self.assertEqual(len(m.cons), 6)
+        self.assertEqual(len(m.cons), 7)
         # z4 = b ^ c
         assertExpressionsEqual(self, m.cons[1].expr, m.z[4] <= m.z[2])
         assertExpressionsEqual(self, m.cons[2].expr, m.z[4] <= m.z[3])
+        assertExpressionsEqual(
+            self, m.cons[3].expr, 1 - m.z[4] <= 2 - (m.z[2] + m.z[3])
+        )
         # z5 = a -> z4
         # which means z5 = !a v z4
         assertExpressionsEqual(
-            self, m.cons[3].expr, (1 - m.z[5]) + (1 - m.z[1]) + m.z[4] >= 1
+            self, m.cons[4].expr, (1 - m.z[5]) + (1 - m.z[1]) + m.z[4] >= 1
         )
         # z5 >= 1 - z1
-        assertExpressionsEqual(self, m.cons[4].expr, m.z[5] + (1 - (1 - m.z[1])) >= 1)
+        assertExpressionsEqual(self, m.cons[5].expr, m.z[5] + (1 - (1 - m.z[1])) >= 1)
         # z5 >= z4
-        assertExpressionsEqual(self, m.cons[5].expr, m.z[5] + (1 - m.z[4]) >= 1)
+        assertExpressionsEqual(self, m.cons[6].expr, m.z[5] + (1 - m.z[4]) >= 1)
 
         # z5 is constrained to be 'True'
-        assertExpressionsEqual(self, m.cons[6].expr, m.z[5] >= 1)
+        assertExpressionsEqual(self, m.cons[7].expr, m.z[5] >= 1)
 
     def test_equivalence(self):
         m = self.make_model()
@@ -157,24 +172,30 @@ def test_equivalence(self):
         self.assertIs(m.a.get_associated_binary(), m.z[1])
         self.assertIs(m.c.get_associated_binary(), m.z[2])
         self.assertEqual(len(m.z), 5)
-        self.assertEqual(len(m.cons), 9)
+        self.assertEqual(len(m.cons), 10)
 
+        # z[3] == !a v c
         assertExpressionsEqual(
             self, m.cons[1].expr, (1 - m.z[3]) + (1 - m.z[1]) + m.z[2] >= 1
         )
         assertExpressionsEqual(self, m.cons[2].expr, 1 - (1 - m.z[1]) + m.z[3] >= 1)
         assertExpressionsEqual(self, m.cons[3].expr, m.z[3] + (1 - m.z[2]) >= 1)
 
+        # z[4] == a v ! c
         assertExpressionsEqual(
             self, m.cons[4].expr, (1 - m.z[4]) + (1 - m.z[2]) + m.z[1] >= 1
         )
         assertExpressionsEqual(self, m.cons[5].expr, m.z[4] + (1 - m.z[1]) >= 1)
         assertExpressionsEqual(self, m.cons[6].expr, 1 - (1 - m.z[2]) + m.z[4] >= 1)
 
+        # z[5] == z[3] ^ z[4]
         assertExpressionsEqual(self, m.cons[7].expr, m.z[5] <= m.z[3])
         assertExpressionsEqual(self, m.cons[8].expr, m.z[5] <= m.z[4])
+        assertExpressionsEqual(
+            self, m.cons[9].expr, 1 - m.z[5] <= 2 - (m.z[3] + m.z[4])
+        )
 
-        assertExpressionsEqual(self, m.cons[9].expr, m.z[5] >= 1)
+        assertExpressionsEqual(self, m.cons[10].expr, m.z[5] >= 1)
 
     def test_xor(self):
         m = self.make_model()
@@ -232,13 +253,16 @@ def test_at_most(self):
         c = m.z[4]
 
         self.assertEqual(len(m.z), 4)
-        self.assertEqual(len(m.cons), 3)
+        self.assertEqual(len(m.cons), 4)
         self.assertEqual(len(m.disjuncts), 2)
         self.assertEqual(len(m.disjunctions), 1)
 
         # z3 = a ^ b
         assertExpressionsEqual(self, m.cons[1].expr, m.z[3] <= a)
         assertExpressionsEqual(self, m.cons[2].expr, m.z[3] <= b)
+        m.cons.pprint()
+        print(m.cons[3].expr)
+        assertExpressionsEqual(self, m.cons[3].expr, 1 - m.z[3] <= 2 - sum([a, b]))
 
         # atmost in disjunctive form
         assertExpressionsEqual(
@@ -248,7 +272,7 @@ def test_at_most(self):
             self, m.disjuncts[1].constraint.expr, m.z[1] + m.z[3] + m.z[4] >= 3
         )
         assertExpressionsEqual(
-            self, m.cons[3].expr, m.disjuncts[0].binary_indicator_var >= 1
+            self, m.cons[4].expr, m.disjuncts[0].binary_indicator_var >= 1
         )
 
     def test_at_least(self):
@@ -283,6 +307,138 @@ def test_at_least(self):
             self, m.cons[1].expr, m.disjuncts[0].binary_indicator_var >= 1
         )
 
+    @unittest.skipUnless(gurobi_available, "Gurobi is not available")
+    def test_logical_integration(self):
+        """
+        This is kind of a ridiculous test, but I bothered type it and it has
+        a lot of logical things together, so adding it.
+        """
+        m = self.make_model()
+        m.d = BooleanVar()
+        m.t = BooleanVar()
+        e = m.t.equivalent_to(lnot(lor(m.a, m.b)).land(exactly(1, [m.c, m.d])))
+
+        # We're forcing t to be True.
+        m.c.fix(True)
+        m.d.fix(False)
+        m.a.fix(False)
+        m.b.fix(False)
+
+        visitor = LogicalToDisjunctiveVisitor()
+        m.cons = visitor.constraints
+        m.z = visitor.z_vars
+        m.disjuncts = visitor.disjuncts
+        m.disjunctions = visitor.disjunctions
+
+        visitor.walk_expression(e)
+
+        self.assertEqual(len(m.z), 11)
+        self.assertIs(m.a.get_associated_binary(), m.z[2])
+        a = m.z[2]
+        self.assertIs(m.b.get_associated_binary(), m.z[3])
+        b = m.z[3]
+        # apologies to the universe for this one, but because my own notation
+        # is awful:
+        z3 = m.z[4]
+        z4 = m.z[5]
+        z5 = m.z[8]
+        self.assertIs(m.t.get_associated_binary(), m.z[1])
+        t = m.z[1]
+        z6 = m.z[9]
+        z7 = m.z[10]
+        self.assertIs(m.c.get_associated_binary(), m.z[6])
+        c = m.z[6]
+        self.assertIs(m.d.get_associated_binary(), m.z[7])
+        d = m.z[7]
+        z8 = m.z[11]
+
+        self.assertEqual(len(m.disjuncts), 2)
+        self.assertEqual(
+            len(
+                list(
+                    m.disjuncts[0].component_data_objects(
+                        Constraint, descend_into=False
+                    )
+                )
+            ),
+            1,
+        )
+        assertExpressionsEqual(self, m.disjuncts[0].constraint.expr, c + d == 1)
+        # not nested
+        self.assertEqual(
+            len(
+                list(
+                    m.disjuncts[0].component_data_objects(Disjunct, descend_into=False)
+                )
+            ),
+            0,
+        )
+
+        # nested
+        self.assertEqual(
+            len(
+                list(
+                    m.disjuncts[1].component_data_objects(
+                        Constraint, descend_into=False
+                    )
+                )
+            ),
+            0,
+        )
+        self.assertEqual(
+            len(
+                list(
+                    m.disjuncts[1].component_data_objects(Disjunct, descend_into=False)
+                )
+            ),
+            2,
+        )
+        self.assertEqual(len(m.disjuncts[1].disjunction.disjuncts), 2)
+        assertExpressionsEqual(
+            self, m.disjuncts[1].disjunction.disjuncts[0].constraint[1].expr, c + d <= 0
+        )
+        assertExpressionsEqual(
+            self, m.disjuncts[1].disjunction.disjuncts[1].constraint[1].expr, c + d >= 2
+        )
+
+        self.assertEqual(len(m.disjunctions), 1)
+        self.assertIs(m.disjunctions[0].disjuncts[0], m.disjuncts[0])
+        self.assertIs(m.disjunctions[0].disjuncts[1], m.disjuncts[1])
+
+        self.assertEqual(len(m.cons), 17)
+        assertExpressionsEqual(self, m.cons[1].expr, (1 - z3) + a + b >= 1)
+        assertExpressionsEqual(self, m.cons[2].expr, z3 + (1 - a) >= 1)
+        assertExpressionsEqual(self, m.cons[3].expr, z3 + (1 - b) >= 1)
+        assertExpressionsEqual(self, m.cons[4].expr, z4 == 1 - z3)
+        assertExpressionsEqual(self, m.cons[5].expr, z5 <= z4)
+        assertExpressionsEqual(
+            self, m.cons[6].expr, z5 <= m.disjuncts[0].binary_indicator_var
+        )
+        assertExpressionsEqual(
+            self,
+            m.cons[7].expr,
+            1 - z5 <= 2 - (z4 + m.disjuncts[0].binary_indicator_var),
+        )
+        assertExpressionsEqual(self, m.cons[8].expr, (1 - z6) + (1 - t) + z5 >= 1)
+        assertExpressionsEqual(self, m.cons[9].expr, 1 - (1 - t) + z6 >= 1)
+        assertExpressionsEqual(self, m.cons[10].expr, z6 + (1 - z5) >= 1)
+        assertExpressionsEqual(self, m.cons[11].expr, (1 - z7) + (1 - z5) + t >= 1)
+        assertExpressionsEqual(self, m.cons[12].expr, z7 + (1 - t) >= 1)
+        assertExpressionsEqual(self, m.cons[13].expr, 1 - (1 - z5) + z7 >= 1)
+        assertExpressionsEqual(self, m.cons[14].expr, z8 <= z6)
+        assertExpressionsEqual(self, m.cons[15].expr, z8 <= z7)
+        assertExpressionsEqual(self, m.cons[16].expr, 1 - z8 <= 2 - (z6 + z7))
+        assertExpressionsEqual(self, m.cons[17].expr, z8 >= 1)
+
+        TransformationFactory('gdp.bigm').apply_to(m)
+        m.obj = Objective(expr=m.t.get_associated_binary())
+        SolverFactory('gurobi').solve(m, tee=True)
+        update_boolean_vars_from_binary(m)
+
+        self.assertTrue(value(e))
+        self.assertEqual(value(m.obj), 1)
+        self.assertTrue(value(m.t))
+
     def test_boolean_fixed_true(self):
         m = self.make_model()
         e = m.a.implies(m.b)
@@ -325,7 +481,7 @@ def test_boolean_fixed_false(self):
         visitor.walk_expression(e)
         # we'll get !a v b
         self.assertEqual(len(m.z), 3)
-        self.assertEqual(len(m.cons), 3)
+        self.assertEqual(len(m.cons), 4)
 
         self.assertIs(m.a.get_associated_binary(), m.z[1])
         self.assertTrue(m.z[1].fixed)
@@ -334,7 +490,10 @@ def test_boolean_fixed_false(self):
 
         assertExpressionsEqual(self, m.cons[1].expr, m.z[1] >= m.z[3])
         assertExpressionsEqual(self, m.cons[2].expr, m.z[2] >= m.z[3])
-        assertExpressionsEqual(self, m.cons[3].expr, m.z[3] >= 1)
+        assertExpressionsEqual(
+            self, m.cons[3].expr, 1 - m.z[3] <= 2 - (m.z[1] + m.z[2])
+        )
+        assertExpressionsEqual(self, m.cons[4].expr, m.z[3] >= 1)
 
     def test_boolean_fixed_none(self):
         m = self.make_model()
@@ -352,7 +511,7 @@ def test_boolean_fixed_none(self):
         visitor.walk_expression(e)
         # we'll get !a v b
         self.assertEqual(len(m.z), 3)
-        self.assertEqual(len(m.cons), 3)
+        self.assertEqual(len(m.cons), 4)
 
         self.assertIs(m.a.get_associated_binary(), m.z[1])
         self.assertTrue(m.z[1].fixed)
@@ -361,7 +520,10 @@ def test_boolean_fixed_none(self):
 
         assertExpressionsEqual(self, m.cons[1].expr, m.z[1] >= m.z[3])
         assertExpressionsEqual(self, m.cons[2].expr, m.z[2] >= m.z[3])
-        assertExpressionsEqual(self, m.cons[3].expr, m.z[3] >= 1)
+        assertExpressionsEqual(
+            self, m.cons[3].expr, 1 - m.z[3] <= 2 - (m.z[1] + m.z[2])
+        )
+        assertExpressionsEqual(self, m.cons[4].expr, m.z[3] >= 1)
 
     def test_no_need_to_walk(self):
         m = self.make_model()
@@ -392,10 +554,13 @@ def test_binary_already_associated(self):
 
         self.assertEqual(len(m.z), 2)
         self.assertIs(m.b.get_associated_binary(), m.z[1])
-        self.assertEqual(len(m.cons), 3)
+        self.assertEqual(len(m.cons), 4)
         assertExpressionsEqual(self, m.cons[1].expr, m.z[2] <= m.mine)
         assertExpressionsEqual(self, m.cons[2].expr, m.z[2] <= m.z[1])
-        assertExpressionsEqual(self, m.cons[3].expr, m.z[2] >= 1)
+        assertExpressionsEqual(
+            self, m.cons[3].expr, 1 - m.z[2] <= 2 - (m.mine + m.z[1])
+        )
+        assertExpressionsEqual(self, m.cons[4].expr, m.z[2] >= 1)
 
     # [ESJ 11/22]: We'll probably eventually support all of these examples, but
     # for now test that we handle them gracefully:
@@ -507,7 +672,10 @@ def check_and_constraints(self, a, b1, z, transBlock):
         assertExpressionsEqual(
             self, transBlock.transformed_constraints[2].expr, z <= b1
         )
-        assertExpressionsEqual(self, transBlock.transformed_constraints[3].expr, z >= 1)
+        assertExpressionsEqual(
+            self, transBlock.transformed_constraints[3].expr, 1 - z <= 2 - (a + b1)
+        )
+        assertExpressionsEqual(self, transBlock.transformed_constraints[4].expr, z >= 1)
 
     def check_block_c1_transformed(self, m, transBlock):
         self.assertFalse(m.block.c1.active)
@@ -525,13 +693,13 @@ def check_block_exactly(self, a, b1, b2, z4, transBlock):
 
         # z[4] = b[2] v b[1]
         assertExpressionsEqual(
-            self, transBlock.transformed_constraints[4].expr, (1 - z4) + b2 + b1 >= 1
+            self, transBlock.transformed_constraints[5].expr, (1 - z4) + b2 + b1 >= 1
         )
         assertExpressionsEqual(
-            self, transBlock.transformed_constraints[5].expr, z4 + (1 - b2) >= 1
+            self, transBlock.transformed_constraints[6].expr, z4 + (1 - b2) >= 1
         )
         assertExpressionsEqual(
-            self, transBlock.transformed_constraints[6].expr, z4 + (1 - b1) >= 1
+            self, transBlock.transformed_constraints[7].expr, z4 + (1 - b1) >= 1
         )
 
         # exactly in disjunctive form
@@ -559,7 +727,7 @@ def check_block_exactly(self, a, b1, b2, z4, transBlock):
 
         assertExpressionsEqual(
             self,
-            transBlock.transformed_constraints[7].expr,
+            transBlock.transformed_constraints[8].expr,
             transBlock.auxiliary_disjuncts[0].binary_indicator_var >= 1,
         )
 
@@ -567,7 +735,7 @@ def check_block_transformed(self, m):
         self.assertFalse(m.block.c2.active)
         transBlock = m.block._logical_to_disjunctive
         self.assertEqual(len(transBlock.auxiliary_vars), 5)
-        self.assertEqual(len(transBlock.transformed_constraints), 7)
+        self.assertEqual(len(transBlock.transformed_constraints), 8)
         self.assertEqual(len(transBlock.auxiliary_disjuncts), 2)
         self.assertEqual(len(transBlock.auxiliary_disjunctions), 1)
 
@@ -589,7 +757,7 @@ def test_constraint_target(self):
 
         transBlock = m.block._logical_to_disjunctive
         self.assertEqual(len(transBlock.auxiliary_vars), 3)
-        self.assertEqual(len(transBlock.transformed_constraints), 3)
+        self.assertEqual(len(transBlock.transformed_constraints), 4)
         self.assertEqual(len(transBlock.auxiliary_disjuncts), 0)
         self.assertEqual(len(transBlock.auxiliary_disjunctions), 0)
         self.check_block_c1_transformed(m, transBlock)
@@ -649,8 +817,36 @@ def test_transform_model(self):
         # and everything on the block is transformed too
         transBlock = m.block._logical_to_disjunctive
         self.assertEqual(len(transBlock.auxiliary_vars), 2)
-        self.assertEqual(len(transBlock.transformed_constraints), 7)
+        self.assertEqual(len(transBlock.transformed_constraints), 8)
         self.assertEqual(len(transBlock.auxiliary_disjuncts), 2)
         self.assertEqual(len(transBlock.auxiliary_disjunctions), 1)
         self.check_and_constraints(a, b1, transBlock.auxiliary_vars[1], transBlock)
         self.check_block_exactly(a, b1, b2, transBlock.auxiliary_vars[2], transBlock)
+
+    @unittest.skipUnless(gurobi_available, "Gurobi is not available")
+    def test_reverse_implication_for_land(self):
+        m = ConcreteModel()
+
+        m.t = BooleanVar()
+        m.a = BooleanVar()
+        m.d = BooleanVar()
+
+        m.c = LogicalConstraint(expr=m.t.equivalent_to(m.a.land(m.d)))
+
+        m.a.fix(True)
+        m.d.fix(True)
+
+        m.binary = Var(domain=Binary)
+        m.t.associate_binary_var(m.binary)
+
+        m.obj = Objective(expr=m.binary)
+
+        TransformationFactory('contrib.logical_to_disjunctive').apply_to(m)
+        TransformationFactory('gdp.bigm').apply_to(m)
+
+        SolverFactory('gurobi').solve(m)
+
+        update_boolean_vars_from_binary(m)
+        # Should be 1 (we forced it)
+        self.assertEqual(value(m.obj), 1)
+        self.assertTrue(value(m.t))
diff --git a/pyomo/gdp/tests/test_hull.py b/pyomo/gdp/tests/test_hull.py
--- a/pyomo/gdp/tests/test_hull.py
+++ b/pyomo/gdp/tests/test_hull.py
@@ -2571,12 +2571,25 @@ def test_logical_constraints_transformed(self):
         )
         assertExpressionsStructurallyEqual(self, simplified, z3d - z2d)
 
-        # hull transformation of z3 >= 1
+        # hull transformation of 1 - z3 <= 2 - (z1 + z2)
         cons = hull.get_transformed_constraints(
             m.d[4]._logical_to_disjunctive.transformed_constraints[9]
         )
         self.assertEqual(len(cons), 1)
         cons = cons[0]
+        assertExpressionsStructurallyEqual(
+            self,
+            cons.expr,
+            1 - z3d - (2 - (z1d + z2d)) - (1 - m.d[4].binary_indicator_var) * (-1)
+            <= 0 * m.d[4].binary_indicator_var,
+        )
+
+        # hull transformation of z3 >= 1
+        cons = hull.get_transformed_constraints(
+            m.d[4]._logical_to_disjunctive.transformed_constraints[10]
+        )
+        self.assertEqual(len(cons), 1)
+        cons = cons[0]
         assertExpressionsStructurallyEqual(
             self,
             cons.expr,