Repository: PennyLaneAI/pennylane (python)

Issue:
[BUG] `defer_measurements` does not work with `qml.cond` applied to controlled gates
### Expected behavior

`qml.cond` can be applied to any valid `Operation` and `defer_measurements` can deal with the resulting operation as well.

### Actual behavior

Using `qml.cond` on a controlled operation and deferring measurements afterwards raises an error.

### Additional information

The control values seem to be the problem, with defaults of whether we use lists or tuples for those differing between parts of the codebase, apparently.

### Source code

```shell
dev = qml.device("default.qubit")#, shots= 100) # with shots the code runs because defer_measurements is no longer used by default.

@qml.qnode(dev)
def node(x):
    qml.Hadamard(0)
    mcm1 = qml.measure(0)
    qml.cond(mcm1, qml.CRY)(x, wires=(0, 1))
    return qml.expval(qml.Z(1))

node(0.6)
```


### Tracebacks

```shell
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[13], line 10
      7     qml.cond(mcm1, qml.CRY)(x, wires=(0, 1))
      8     return qml.expval(qml.Z(1))
---> 10 node(0.6)

File ~/repos/pennylane/pennylane/workflow/qnode.py:1098, in QNode.__call__(self, *args, **kwargs)
   1095 self._update_gradient_fn(shots=override_shots, tape=self._tape)
   1097 try:
-> 1098     res = self._execution_component(args, kwargs, override_shots=override_shots)
   1099 finally:
   1100     if old_interface == "auto":

File ~/repos/pennylane/pennylane/workflow/qnode.py:1052, in QNode._execution_component(self, args, kwargs, override_shots)
   1049 full_transform_program.prune_dynamic_transform()
   1051 # pylint: disable=unexpected-keyword-arg
-> 1052 res = qml.execute(
   1053     (self._tape,),
   1054     device=self.device,
   1055     gradient_fn=self.gradient_fn,
   1056     interface=self.interface,
   1057     transform_program=full_transform_program,
   1058     config=config,
   1059     gradient_kwargs=self.gradient_kwargs,
   1060     override_shots=override_shots,
   1061     **self.execute_kwargs,
   1062 )
   1063 res = res[0]
   1065 # convert result to the interface in case the qfunc has no parameters

File ~/repos/pennylane/pennylane/workflow/execution.py:601, in execute(tapes, device, gradient_fn, interface, transform_program, config, grad_on_execution, gradient_kwargs, cache, cachesize, max_diff, override_shots, expand_fn, max_expansion, device_batch_transform, device_vjp)
    596     if not device_batch_transform:
    597         warnings.warn(
    598             "device batch transforms cannot be turned off with the new device interface.",
    599             UserWarning,
    600         )
--> 601     tapes, post_processing = transform_program(tapes)
    602 else:
    603     # TODO: Remove once old device are removed
    604     tapes, program_post_processing = transform_program(tapes)

File ~/repos/pennylane/pennylane/transforms/core/transform_program.py:509, in TransformProgram.__call__(self, tapes)
    507 if self._argnums is not None and self._argnums[i] is not None:
    508     tape.trainable_params = self._argnums[i][j]
--> 509 new_tapes, fn = transform(tape, *targs, **tkwargs)
    510 execution_tapes.extend(new_tapes)
    512 fns.append(fn)

File ~/repos/pennylane/pennylane/devices/preprocess.py:158, in mid_circuit_measurements(tape, device)
    156 if tape.shots:
    157     return qml.dynamic_one_shot(tape)
--> 158 return qml.defer_measurements(tape, device=device)

File ~/repos/pennylane/pennylane/transforms/core/transform_dispatcher.py:113, in TransformDispatcher.__call__(self, *targs, **tkwargs)
    110         return expand_processing(processed_results)
    112 else:
--> 113     transformed_tapes, processing_fn = self._transform(obj, *targs, **tkwargs)
    115 if self.is_informative:
    116     return processing_fn(transformed_tapes)

File ~/repos/pennylane/pennylane/transforms/defer_measurements.py:336, in defer_measurements(tape, reduce_postselected, **kwargs)
    334 elif op.__class__.__name__ == "Conditional":
    335     with QueuingManager.stop_recording():
--> 336         new_operations.extend(_add_control_gate(op, control_wires, reduce_postselected))
    337 else:
    338     new_operations.append(op)

File ~/repos/pennylane/pennylane/transforms/defer_measurements.py:411, in _add_control_gate(op, control_wires, reduce_postselected)
    409             new_ops.append(op.then_op)
    410             continue
--> 411         qscript = qml.tape.make_qscript(
    412             ctrl(
    413                 lambda: qml.apply(op.then_op),  # pylint: disable=cell-var-from-loop
    414                 control=Wires(control),
    415                 control_values=branch,
    416             )
    417         )()
    418         new_ops.extend(qscript.circuit)
    419 return new_ops

File ~/repos/pennylane/pennylane/tape/qscript.py:1213, in make_qscript.<locals>.wrapper(*args, **kwargs)
   1211 def wrapper(*args, **kwargs):
   1212     with AnnotatedQueue() as q:
-> 1213         fn(*args, **kwargs)
   1215     return QuantumScript.from_queue(q, shots)

File ~/repos/pennylane/pennylane/ops/op_math/controlled.py:190, in create_controlled_op.<locals>.wrapper(*args, **kwargs)
    187 if flip_control_on_zero:
    188     _ = [qml.X(w) for w, val in zip(control, control_values) if not val]
--> 190 _ = [
    191     ctrl(op, control=control, control_values=op_control_values, work_wires=work_wires)
    192     for op in qscript.operations
    193 ]
    195 if flip_control_on_zero:
    196     _ = [qml.X(w) for w, val in zip(control, control_values) if not val]

File ~/repos/pennylane/pennylane/ops/op_math/controlled.py:191, in <listcomp>(.0)
    187 if flip_control_on_zero:
    188     _ = [qml.X(w) for w, val in zip(control, control_values) if not val]
    190 _ = [
--> 191     ctrl(op, control=control, control_values=op_control_values, work_wires=work_wires)
    192     for op in qscript.operations
    193 ]
    195 if flip_control_on_zero:
    196     _ = [qml.X(w) for w, val in zip(control, control_values) if not val]

File ~/repos/pennylane/pennylane/ops/op_math/controlled.py:132, in ctrl(op, control, control_values, work_wires)
    130 if qml.math.is_abstract(op):
    131     return Controlled(op, control, control_values=control_values, work_wires=work_wires)
--> 132 return create_controlled_op(op, control, control_values=control_values, work_wires=work_wires)

File ~/repos/pennylane/pennylane/ops/op_math/controlled.py:164, in create_controlled_op(op, control, control_values, work_wires)
    159 if isinstance(op, Controlled):
    160     work_wires = work_wires or []
    161     return ctrl(
    162         op.base,
    163         control=control + op.control_wires,
--> 164         control_values=control_values + op.control_values,
    165         work_wires=work_wires + op.work_wires,
    166     )
    168 if isinstance(op, Operator):
    169     return Controlled(
    170         op, control_wires=control, control_values=control_values, work_wires=work_wires
    171     )

TypeError: can only concatenate tuple (not "list") to tuple
```


### System information

```shell
pl dev
```


### Existing GitHub issues

- [X] I have searched existing GitHub issues to make sure the issue does not already exist.

Pull request description:
Allow tuple-valued `control_values` with `qml.ctrl` on a `Controlled` instance.
**Context:**
By default, `control_values` of `Controlled` instances are lists.
`qml.defer_measurements` passes a tuple as `control_values` to `ctrl`.
`create_controlled_op`, called by `ctrl`, tries to merge `control_values` of the base operation and the provided `control_values`. 
The consequence is an attempted addition of `list` and `tuple`, if `qml.defer_measurements` hits a `qml.cond` of a `Controlled` instance, leading to #5717 

**Description of the Change:**
This PR fixes the above problem in two (redundant!) ways: 
- tuples are allowed to be passed as `control_values` to `ctrl`, which casts them to a `list`.
- `defer_measurements` passes a `list` itself.

Clearly those changes are redundant for the bug #5717, with the former also providing users with the option of using `tuple`s for `control_values`. We could skip the change to `defer_measurements`, thus.

**Benefits:**

**Possible Drawbacks:**

**Related GitHub Issues:**
Fixes #5717 

[sc-63645]


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 052a8975c..bd4e5f0ff 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -9,6 +9,9 @@
 
 <h3>Improvements 🛠</h3>
 
+* `ctrl` now works with tuple-valued `control_values` when applied to any already controlled operation.
+  [(#5725)](https://github.com/PennyLaneAI/pennylane/pull/5725)
+
 * Add support for 3 new pytest markers: `unit`, `integration` and `system`.
   [(#5517)](https://github.com/PennyLaneAI/pennylane/pull/5517)
 
@@ -141,6 +144,9 @@
 
 <h3>Bug fixes 🐛</h3>
 
+* `qml.cond` can now be applied to `ControlledOp` operations when deferring measurements.
+  [(#5725)](https://github.com/PennyLaneAI/pennylane/pull/5725)
+
 * The legacy `Tensor` class can now handle a `Projector` with abstract tracer input.
   [(#5720)](https://github.com/PennyLaneAI/pennylane/pull/5720)
 
diff --git a/pennylane/ops/op_math/controlled.py b/pennylane/ops/op_math/controlled.py
index 262897628..33d23ca4e 100644
--- a/pennylane/ops/op_math/controlled.py
+++ b/pennylane/ops/op_math/controlled.py
@@ -140,6 +140,8 @@ def create_controlled_op(op, control, control_values=None, work_wires=None):
         control_values = [control_values]
     elif control_values is None:
         control_values = [True] * len(control)
+    elif isinstance(control_values, tuple):
+        control_values = list(control_values)
 
     ctrl_op = _try_wrap_in_custom_ctrl_op(
         op, control, control_values=control_values, work_wires=work_wires


Test patch:
diff --git a/tests/ops/op_math/test_controlled.py b/tests/ops/op_math/test_controlled.py
index b9630d206..2d4234a77 100644
--- a/tests/ops/op_math/test_controlled.py
+++ b/tests/ops/op_math/test_controlled.py
@@ -163,6 +163,17 @@ class TestControlledInit:
         op = Controlled(self.temp_op, (0, 1), control_values=[0, 1])
         assert op.control_values == [False, True]
 
+    @pytest.mark.parametrize("control_values", [True, False, 0, 1])
+    def test_scalar_control_values(self, control_values):
+        """Test assignment of provided control_values."""
+        op = Controlled(self.temp_op, 0, control_values=control_values)
+        assert op.control_values == [control_values]
+
+    def test_tuple_control_values(self):
+        """Test assignment of provided control_values."""
+        op = Controlled(self.temp_op, (0, 1), control_values=(0, 1))
+        assert op.control_values == [False, True]
+
     def test_non_boolean_control_values(self):
         """Test control values are converted to booleans."""
         op = Controlled(self.temp_op, (0, 1, 2), control_values=["", None, 5])
diff --git a/tests/ops/op_math/test_controlled_ops.py b/tests/ops/op_math/test_controlled_ops.py
index a0bbc879d..415502e96 100644
--- a/tests/ops/op_math/test_controlled_ops.py
+++ b/tests/ops/op_math/test_controlled_ops.py
@@ -737,3 +737,15 @@ def test_controlled_method(base, cbase):
     """Tests the _controlled method for parametric ops."""
     # pylint: disable=protected-access
     assert qml.equal(base._controlled("a"), cbase)
+
+
+@pytest.mark.parametrize(
+    "control, control_values",
+    [([0, 1], [True, False]), ([10, "a"], (0, 0)), ([2], 1), (2, (True,))],
+)
+@pytest.mark.parametrize(
+    "base_op", [qml.CRX(0.2, [21, 22]), qml.CNOT([21, 22]), qml.CPhase(0.6, [21, 22])]
+)
+def test_controlling_a_controlled_operation(control, control_values, base_op):
+    """Test that a controlled op can be controlled again."""
+    qml.ctrl(base_op, control=control, control_values=control_values)
diff --git a/tests/transforms/test_defer_measurements.py b/tests/transforms/test_defer_measurements.py
index cdc1fd5f1..4d4faea65 100644
--- a/tests/transforms/test_defer_measurements.py
+++ b/tests/transforms/test_defer_measurements.py
@@ -1063,7 +1063,7 @@ class TestConditionalOperations:
     )
     def test_cond_qfunc(self, device):
         """Test that a qfunc can also used with qml.cond."""
-        dev = qml.device(device, wires=3)
+        dev = qml.device(device, wires=4)
 
         r = 2.324
 
@@ -1074,12 +1074,14 @@ class TestConditionalOperations:
             qml.CNOT(wires=[0, 1])
             qml.CRY(rads, wires=[0, 1])
             qml.CZ(wires=[0, 1])
-            return qml.probs(wires=1)
+            qml.ctrl(qml.CRX, control=0, control_values=[1])(0.5, [1, 2])
+            return qml.probs(wires=[1, 2])
 
         def f(x):
             qml.PauliX(1)
             qml.RY(x, wires=1)
             qml.PauliZ(1)
+            qml.CRX(0.5, [1, 2])
 
         @qml.defer_measurements
         @qml.qnode(dev)
@@ -1087,7 +1089,7 @@ class TestConditionalOperations:
             qml.Hadamard(0)
             m_0 = qml.measure(0)
             qml.cond(m_0, f)(r)
-            return qml.probs(wires=1)
+            return qml.probs(wires=[1, 2])
 
         exp = normal_circuit(r)
         cond_probs = quantum_control_circuit(r)