Repository: quantumlib/Cirq

Issue:
ControlledGate/Op allows measurements and channels as sub-op
IIUC a controlled channel is not well defined. https://quantumcomputing.stackexchange.com/a/12197/14638. And certainly a measurement cannot be controlled.

Should we check `has_mixture(sub_op)` and/or `not is_measurement(sub_op)` at construction time? Or is this too resource intensive?

Note parameterized sub-ops will have to be allowed until resolution time since `has_mixture` always returns false for parameterized things. However measurements should always be rejected (a CircuitOp can be both parameterized and contain measurements).


Discussion:

Resolving patch:
diff --git a/cirq-core/cirq/ops/controlled_gate.py b/cirq-core/cirq/ops/controlled_gate.py
--- a/cirq-core/cirq/ops/controlled_gate.py
+++ b/cirq-core/cirq/ops/controlled_gate.py
@@ -74,9 +74,10 @@ def __init__(
 
         Raises:
             ValueError: If the `control_values` or `control_qid_shape` does not
-                match with `num_conrols`, or if the `control_values` are out of
-                bounds.
+                match with `num_controls`, if the `control_values` are out of
+                bounds, or if the sub_gate is not a unitary or mixture.
         """
+        _validate_sub_object(sub_gate)
         if num_controls is None:
             if control_values is not None:
                 num_controls = len(control_values)
@@ -330,3 +331,10 @@ def _json_dict_(self) -> Dict[str, Any]:
             'control_qid_shape': self.control_qid_shape,
             'sub_gate': self.sub_gate,
         }
+
+
+def _validate_sub_object(sub_object: Union['cirq.Gate', 'cirq.Operation']):
+    if protocols.is_measurement(sub_object):
+        raise ValueError(f'Cannot control measurement {sub_object}')
+    if not protocols.has_mixture(sub_object) and not protocols.is_parameterized(sub_object):
+        raise ValueError(f'Cannot control channel with non-unitary operators: {sub_object}')
diff --git a/cirq-core/cirq/ops/controlled_operation.py b/cirq-core/cirq/ops/controlled_operation.py
--- a/cirq-core/cirq/ops/controlled_operation.py
+++ b/cirq-core/cirq/ops/controlled_operation.py
@@ -50,6 +50,25 @@ def __init__(
         sub_operation: 'cirq.Operation',
         control_values: Optional[Sequence[Union[int, Collection[int]]]] = None,
     ):
+        """Initializes the controlled operation.
+
+        Args:
+            controls: The qubits that control the sub-operation.
+            sub_operation: The operation that will be controlled.
+            control_values: Which control qubit values to apply the sub
+                operation.  A sequence of length `num_controls` where each
+                entry is an integer (or set of integers) corresponding to the
+                qubit value (or set of possible values) where that control is
+                enabled.  When all controls are enabled, the sub gate is
+                applied.  If unspecified, control values default to 1.
+
+        Raises:
+            ValueError: If the `control_values` or `control_qid_shape` does not
+                match the number of qubits, if the `control_values` are out of
+                bounds, if the qubits overlap, or if the sub_operation is not a
+                unitary or mixture.
+        """
+        controlled_gate._validate_sub_object(sub_operation)
         if control_values is None:
             control_values = ((1,),) * len(controls)
         if len(control_values) != len(controls):


Test patch:
diff --git a/cirq-core/cirq/contrib/quirk/export_to_quirk_test.py b/cirq-core/cirq/contrib/quirk/export_to_quirk_test.py
--- a/cirq-core/cirq/contrib/quirk/export_to_quirk_test.py
+++ b/cirq-core/cirq/contrib/quirk/export_to_quirk_test.py
@@ -178,7 +178,8 @@ def with_qubits(self, *new_qubits):
 
 
 class MysteryGate(cirq.testing.SingleQubitGate):
-    pass
+    def _has_mixture_(self):
+        return True
 
 
 def test_various_unknown_gate_types():
diff --git a/cirq-core/cirq/ops/controlled_gate_test.py b/cirq-core/cirq/ops/controlled_gate_test.py
--- a/cirq-core/cirq/ops/controlled_gate_test.py
+++ b/cirq-core/cirq/ops/controlled_gate_test.py
@@ -70,6 +70,9 @@ def __repr__(self):
 
 
 class RestrictedGate(cirq.testing.SingleQubitGate):
+    def _unitary_(self):
+        return True
+
     def __str__(self):
         return 'Restricted'
 
@@ -113,6 +116,10 @@ def test_init2():
         cirq.ControlledGate(cirq.Z, control_values=[(1, -1)])
     with pytest.raises(ValueError, match='Control values .*outside of range'):
         cirq.ControlledGate(cirq.Z, control_values=[3], control_qid_shape=[3])
+    with pytest.raises(ValueError, match='Cannot control measurement'):
+        cirq.ControlledGate(cirq.MeasurementGate(1))
+    with pytest.raises(ValueError, match='Cannot control channel'):
+        cirq.ControlledGate(cirq.PhaseDampingChannel(1))
 
     gate = cirq.ControlledGate(cirq.Z, 1)
     assert gate.sub_gate is cirq.Z
@@ -247,7 +254,11 @@ def test_eq():
 
 
 def test_control():
-    g = cirq.testing.SingleQubitGate()
+    class G(cirq.testing.SingleQubitGate):
+        def _has_mixture_(self):
+            return True
+
+    g = G()
 
     # Ignores empty.
     assert g.controlled() == cirq.ControlledGate(g)
@@ -436,6 +447,12 @@ def test_parameterizable(resolve_fn):
     assert not cirq.is_parameterized(cy)
     assert resolve_fn(cya, cirq.ParamResolver({'a': 1})) == cy
 
+    cchan = cirq.ControlledGate(
+        cirq.RandomGateChannel(sub_gate=cirq.PhaseDampingChannel(0.1), probability=a)
+    )
+    with pytest.raises(ValueError, match='Cannot control channel'):
+        resolve_fn(cchan, cirq.ParamResolver({'a': 0.1}))
+
 
 def test_circuit_diagram_info():
     assert cirq.circuit_diagram_info(CY) == cirq.CircuitDiagramInfo(
@@ -459,7 +476,8 @@ def test_circuit_diagram_info():
     )
 
     class UndiagrammableGate(cirq.testing.SingleQubitGate):
-        pass
+        def _has_unitary_(self):
+            return True
 
     assert (
         cirq.circuit_diagram_info(cirq.ControlledGate(UndiagrammableGate()), default=None) is None
@@ -484,6 +502,9 @@ def _circuit_diagram_info_(self, args: cirq.CircuitDiagramInfoArgs) -> cirq.Circ
             wire_symbols=tuple(f'H({q})' for q in args.known_qubits), connected=True
         )
 
+    def _has_unitary_(self):
+        return True
+
 
 def test_circuit_diagram():
     qubits = cirq.LineQubit.range(3)
@@ -525,6 +546,9 @@ def _circuit_diagram_info_(self, args: cirq.CircuitDiagramInfoArgs) -> cirq.Circ
         self.captured_diagram_args = args
         return cirq.CircuitDiagramInfo(wire_symbols=tuple(['M1', 'M2']), exponent=1, connected=True)
 
+    def _has_unitary_(self):
+        return True
+
 
 def test_uninformed_circuit_diagram_info():
     qbits = cirq.LineQubit.range(3)
@@ -542,7 +566,6 @@ def test_uninformed_circuit_diagram_info():
 def test_bounded_effect():
     assert cirq.trace_distance_bound(CY**0.001) < 0.01
     assert cirq.approx_eq(cirq.trace_distance_bound(CCH), 1.0)
-    assert cirq.approx_eq(cirq.trace_distance_bound(CRestricted), 1.0)
     foo = sympy.Symbol('foo')
     assert cirq.trace_distance_bound(cirq.ControlledGate(cirq.X**foo)) == 1
 
@@ -570,14 +593,6 @@ def test_str():
 
 
 def test_controlled_mixture():
-    class NoDetails(cirq.Gate):
-        def num_qubits(self) -> int:
-            return 1
-
-    c_no = cirq.ControlledGate(num_controls=1, sub_gate=NoDetails())
-    assert not cirq.has_mixture(c_no)
-    assert cirq.mixture(c_no, None) is None
-
     c_yes = cirq.ControlledGate(sub_gate=cirq.phase_flip(0.25), num_controls=1)
     assert cirq.has_mixture(c_yes)
     assert cirq.approx_eq(cirq.mixture(c_yes), [(0.75, np.eye(4)), (0.25, cirq.unitary(cirq.CZ))])
diff --git a/cirq-core/cirq/ops/controlled_operation_test.py b/cirq-core/cirq/ops/controlled_operation_test.py
--- a/cirq-core/cirq/ops/controlled_operation_test.py
+++ b/cirq-core/cirq/ops/controlled_operation_test.py
@@ -73,9 +73,13 @@ def __repr__(self):
 
 
 def test_controlled_operation_init():
+    class G(cirq.testing.SingleQubitGate):
+        def _has_mixture_(self):
+            return True
+
+    g = G()
     cb = cirq.NamedQubit('ctr')
     q = cirq.NamedQubit('q')
-    g = cirq.testing.SingleQubitGate()
     v = cirq.GateOperation(g, (q,))
     c = cirq.ControlledOperation([cb], v)
     assert c.sub_operation == v
@@ -111,6 +115,10 @@ def test_controlled_operation_init():
         _ = cirq.ControlledOperation([cb, cirq.LineQubit(0), cb], cirq.X(q))
     with pytest.raises(ValueError, match=re.escape("Sub-op and controls share qubits ['ctr']")):
         _ = cirq.ControlledOperation([cb, cirq.LineQubit(0)], cirq.CX(cb, q))
+    with pytest.raises(ValueError, match='Cannot control measurement'):
+        _ = cirq.ControlledOperation([cb], cirq.measure(q))
+    with pytest.raises(ValueError, match='Cannot control channel'):
+        _ = cirq.ControlledOperation([cb], cirq.PhaseDampingChannel(1)(q))
 
 
 def test_controlled_operation_eq():
@@ -156,6 +164,9 @@ def with_qubits(self, *new_qubits: cirq.Qid):
         def __str__(self):
             return "Op(q2)"
 
+        def _has_mixture_(self):
+            return True
+
     assert str(cirq.ControlledOperation([c1, c2], SingleQubitOp())) == "CC(c1, c2, Op(q2))"
 
     assert (
@@ -210,6 +221,9 @@ def _circuit_diagram_info_(
             wire_symbols=tuple(f'H({q})' for q in args.known_qubits), connected=True
         )
 
+    def _has_mixture_(self):
+        return True
+
 
 def test_circuit_diagram():
     qubits = cirq.LineQubit.range(3)
@@ -278,6 +292,9 @@ def _circuit_diagram_info_(
             connected=True,
         )
 
+    def _has_mixture_(self):
+        return True
+
 
 def test_controlled_diagram_exponent():
     for q in itertools.permutations(cirq.LineQubit.range(5)):
@@ -304,7 +321,8 @@ def test_non_diagrammable_subop():
     qbits = cirq.LineQubit.range(2)
 
     class UndiagrammableGate(cirq.testing.SingleQubitGate):
-        pass
+        def _has_mixture_(self):
+            return True
 
     undiagrammable_op = UndiagrammableGate()(qbits[1])
 
@@ -402,6 +420,13 @@ def test_parameterizable(resolve_fn):
     assert not cirq.is_parameterized(cz)
     assert resolve_fn(cza, cirq.ParamResolver({'a': 1})) == cz
 
+    cchan = cirq.ControlledOperation(
+        [qubits[0]],
+        cirq.RandomGateChannel(sub_gate=cirq.PhaseDampingChannel(0.1), probability=a)(qubits[1]),
+    )
+    with pytest.raises(ValueError, match='Cannot control channel'):
+        resolve_fn(cchan, cirq.ParamResolver({'a': 0.1}))
+
 
 def test_bounded_effect():
     qubits = cirq.LineQubit.range(3)
@@ -411,8 +436,6 @@ def test_bounded_effect():
     scy = cirq.ControlledOperation(qubits[:1], cirq.Y(qubits[1]) ** foo)
     assert cirq.trace_distance_bound(scy) == 1.0
     assert cirq.approx_eq(cirq.trace_distance_bound(cy), 1.0)
-    mock = cirq.ControlledOperation(qubits[:1], MockGate().on(*qubits[1:]))
-    assert cirq.approx_eq(cirq.trace_distance_bound(mock), 1)
 
 
 def test_controlled_operation_gate():
@@ -428,25 +451,15 @@ def qubits(self):
         def with_qubits(self, *new_qubits):
             return self  # coverage: ignore
 
+        def _has_mixture_(self):
+            return True
+
     op = Gateless().controlled_by(cirq.LineQubit(0))
     assert op.gate is None
 
 
 def test_controlled_mixture():
     a, b = cirq.LineQubit.range(2)
-
-    class NoDetails(cirq.Operation):
-        @property
-        def qubits(self):
-            return (a,)
-
-        def with_qubits(self, *new_qubits):
-            raise NotImplementedError()
-
-    c_no = cirq.ControlledOperation(controls=[b], sub_operation=NoDetails())
-    assert not cirq.has_mixture(c_no)
-    assert cirq.mixture(c_no, None) is None
-
     c_yes = cirq.ControlledOperation(controls=[b], sub_operation=cirq.phase_flip(0.25).on(a))
     assert cirq.has_mixture(c_yes)
     assert cirq.approx_eq(cirq.mixture(c_yes), [(0.75, np.eye(4)), (0.25, cirq.unitary(cirq.CZ))])
diff --git a/cirq-core/cirq/ops/raw_types_test.py b/cirq-core/cirq/ops/raw_types_test.py
--- a/cirq-core/cirq/ops/raw_types_test.py
+++ b/cirq-core/cirq/ops/raw_types_test.py
@@ -104,6 +104,9 @@ def validate_args(self, qubits):
             return  # Bypass check for some tests
         super().validate_args(qubits)
 
+    def _has_mixture_(self):
+        return True
+
 
 def test_gate():
     a, b, c = cirq.LineQubit.range(3)
diff --git a/cirq-core/cirq/testing/consistent_protocols.py b/cirq-core/cirq/testing/consistent_protocols.py
--- a/cirq-core/cirq/testing/consistent_protocols.py
+++ b/cirq-core/cirq/testing/consistent_protocols.py
@@ -163,7 +163,7 @@ def _assert_meets_standards_helper(
     assert protocols.measurement_key_objs(val) == protocols.measurement_key_names(val)
     if isinstance(val, ops.EigenGate):
         assert_eigen_shifts_is_consistent_with_eigen_components(val)
-    if isinstance(val, ops.Gate):
+    if isinstance(val, ops.Gate) and protocols.has_mixture(val):
         assert_controlled_and_controlled_by_identical(val)