Repository: Qiskit/qiskit

Issue:
Add support for `Expr` values to `DAGCircuit.compose`
### What should we add?

Similar to #10226 and #10227, we need support for mapping `Expr` values in `DAGCircuit.compose`.  A lot of the work will likely be quite similar to those two issues.

Depends on #10223 and #10224.


Discussion:

Resolving patch:
diff --git a/qiskit/circuit/_classical_resource_map.py b/qiskit/circuit/_classical_resource_map.py
new file mode 100644
--- /dev/null
+++ b/qiskit/circuit/_classical_resource_map.py
@@ -0,0 +1,144 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2023.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Shared helper utility for mapping classical resources from one circuit or DAG to another."""
+
+from __future__ import annotations
+
+import typing
+
+from .bit import Bit
+from .classical import expr
+from .classicalregister import ClassicalRegister, Clbit
+
+
+class VariableMapper(expr.ExprVisitor[expr.Expr]):
+    """Stateful helper class that manages the mapping of variables in conditions and expressions.
+
+    This is designed to be used by both :class:`.QuantumCircuit` and :class:`.DAGCircuit` when
+    managing operations that need to map classical resources from one circuit to another.
+
+    The general usage is to initialise this at the start of a many-block mapping operation, then
+    call its :meth:`map_condition`, :meth:`map_target` or :meth:`map_expr` methods as appropriate,
+    which will return the new object that should be used.
+
+    If an ``add_register`` callable is given to the initialiser, the mapper will use it to attempt
+    to add new aliasing registers to the outer circuit object, if there is not already a suitable
+    register for the mapping available in the circuit.  If this parameter is not given, a
+    ``ValueError`` will be raised instead.  The given ``add_register`` callable may choose to raise
+    its own exception."""
+
+    __slots__ = ("target_cregs", "register_map", "bit_map", "add_register")
+
+    def __init__(
+        self,
+        target_cregs: typing.Iterable[ClassicalRegister],
+        bit_map: typing.Mapping[Bit, Bit],
+        add_register: typing.Callable[[ClassicalRegister], None] | None = None,
+    ):
+        self.target_cregs = tuple(target_cregs)
+        self.register_map = {}
+        self.bit_map = bit_map
+        self.add_register = add_register
+
+    def _map_register(self, theirs: ClassicalRegister) -> ClassicalRegister:
+        """Map the target's registers to suitable equivalents in the destination, adding an
+        extra one if there's no exact match."""
+        if (mapped_theirs := self.register_map.get(theirs.name)) is not None:
+            return mapped_theirs
+        mapped_bits = [self.bit_map[bit] for bit in theirs]
+        for ours in self.target_cregs:
+            if mapped_bits == list(ours):
+                mapped_theirs = ours
+                break
+        else:
+            if self.add_register is None:
+                raise ValueError(f"Register '{theirs.name}' has no counterpart in the destination.")
+            mapped_theirs = ClassicalRegister(bits=mapped_bits)
+            self.add_register(mapped_theirs)
+        self.register_map[theirs.name] = mapped_theirs
+        return mapped_theirs
+
+    def map_condition(self, condition, /, *, allow_reorder=False):
+        """Map the given ``condition`` so that it only references variables in the destination
+        circuit (as given to this class on initialisation).
+
+        If ``allow_reorder`` is ``True``, then when a legacy condition (the two-tuple form) is made
+        on a register that has a counterpart in the destination with all the same (mapped) bits but
+        in a different order, then that register will be used and the value suitably modified to
+        make the equality condition work.  This is maintaining legacy (tested) behaviour of
+        :meth:`.DAGCircuit.compose`; nowhere else does this, and in general this would require *far*
+        more complex classical rewriting than Terra needs to worry about in the full expression era.
+        """
+        if condition is None:
+            return None
+        if isinstance(condition, expr.Expr):
+            return self.map_expr(condition)
+        target, value = condition
+        if isinstance(target, Clbit):
+            return (self.bit_map[target], value)
+        if not allow_reorder:
+            return (self._map_register(target), value)
+        # This is maintaining the legacy behaviour of `DAGCircuit.compose`.  We don't attempt to
+        # speed-up this lookup with a cache, since that would just make the more standard cases more
+        # annoying to deal with.
+        mapped_bits_order = [self.bit_map[bit] for bit in target]
+        mapped_bits_set = set(mapped_bits_order)
+        for register in self.target_cregs:
+            if mapped_bits_set == set(register):
+                mapped_theirs = register
+                break
+        else:
+            if self.add_register is None:
+                raise self.exc_type(
+                    f"Register '{target.name}' has no counterpart in the destination."
+                )
+            mapped_theirs = ClassicalRegister(bits=mapped_bits_order)
+            self.add_register(mapped_theirs)
+        new_order = {bit: i for i, bit in enumerate(mapped_bits_order)}
+        value_bits = f"{value:0{len(target)}b}"[::-1]  # Little-index-indexed binary bitstring.
+        mapped_value = int("".join(value_bits[new_order[bit]] for bit in mapped_theirs)[::-1], 2)
+        return (mapped_theirs, mapped_value)
+
+    def map_target(self, target, /):
+        """Map the runtime variables in a ``target`` of a :class:`.SwitchCaseOp` to the new circuit,
+        as defined in the ``circuit`` argument of the initialiser of this class."""
+        if isinstance(target, Clbit):
+            return self.bit_map[target]
+        if isinstance(target, ClassicalRegister):
+            return self._map_register(target)
+        return self.map_expr(target)
+
+    def map_expr(self, node: expr.Expr, /) -> expr.Expr:
+        """Map the variables in an :class:`~.expr.Expr` node to the new circuit."""
+        return node.accept(self)
+
+    def visit_var(self, node, /):
+        if isinstance(node.var, Clbit):
+            return expr.Var(self.bit_map[node.var], node.type)
+        if isinstance(node.var, ClassicalRegister):
+            return expr.Var(self._map_register(node.var), node.type)
+        # Defensive against the expansion of the variable system; we don't want to silently do the
+        # wrong thing (which would be `return node` without mapping, right now).
+        raise RuntimeError(f"unhandled variable in 'compose': {node}")  # pragma: no cover
+
+    def visit_value(self, node, /):
+        return expr.Value(node.value, node.type)
+
+    def visit_unary(self, node, /):
+        return expr.Unary(node.op, node.operand.accept(self), node.type)
+
+    def visit_binary(self, node, /):
+        return expr.Binary(node.op, node.left.accept(self), node.right.accept(self), node.type)
+
+    def visit_cast(self, node, /):
+        return expr.Cast(node.operand.accept(self), node.type, implicit=node.implicit)
diff --git a/qiskit/circuit/quantumcircuit.py b/qiskit/circuit/quantumcircuit.py
--- a/qiskit/circuit/quantumcircuit.py
+++ b/qiskit/circuit/quantumcircuit.py
@@ -48,6 +48,7 @@
 from qiskit.circuit.parameter import Parameter
 from qiskit.circuit.exceptions import CircuitError
 from qiskit.utils import optionals as _optionals
+from . import _classical_resource_map
 from .classical import expr
 from .parameterexpression import ParameterExpression, ParameterValueType
 from .quantumregister import QuantumRegister, Qubit, AncillaRegister, AncillaQubit
@@ -949,7 +950,9 @@ def compose(
                 )
             edge_map.update(zip(other.clbits, dest.cbit_argument_conversion(clbits)))
 
-        variable_mapper = _ComposeVariableMapper(dest, edge_map)
+        variable_mapper = _classical_resource_map.VariableMapper(
+            dest.cregs, edge_map, dest.add_register
+        )
         mapped_instrs: list[CircuitInstruction] = []
         for instr in other.data:
             n_qargs: list[Qubit] = [edge_map[qarg] for qarg in instr.qubits]
@@ -5225,79 +5228,3 @@ def _bit_argument_conversion_scalar(specifier, bit_sequence, bit_set, type_):
         else f"Invalid bit index: '{specifier}' of type '{type(specifier)}'"
     )
     raise CircuitError(message)
-
-
-class _ComposeVariableMapper(expr.ExprVisitor[expr.Expr]):
-    """Stateful helper class that manages the mapping of variables in conditions and expressions to
-    items in the destination ``circuit``.
-
-    This mutates ``circuit`` by adding registers as required."""
-
-    __slots__ = ("circuit", "register_map", "bit_map")
-
-    def __init__(self, circuit, bit_map):
-        self.circuit = circuit
-        self.register_map = {}
-        self.bit_map = bit_map
-
-    def _map_register(self, theirs):
-        """Map the target's registers to suitable equivalents in the destination, adding an
-        extra one if there's no exact match."""
-        if (mapped_theirs := self.register_map.get(theirs.name)) is not None:
-            return mapped_theirs
-        mapped_bits = [self.bit_map[bit] for bit in theirs]
-        for ours in self.circuit.cregs:
-            if mapped_bits == list(ours):
-                mapped_theirs = ours
-                break
-        else:
-            mapped_theirs = ClassicalRegister(bits=mapped_bits)
-            self.circuit.add_register(mapped_theirs)
-        self.register_map[theirs.name] = mapped_theirs
-        return mapped_theirs
-
-    def map_condition(self, condition, /):
-        """Map the given ``condition`` so that it only references variables in the destination
-        circuit (as given to this class on initialisation)."""
-        if condition is None:
-            return None
-        if isinstance(condition, expr.Expr):
-            return self.map_expr(condition)
-        target, value = condition
-        if isinstance(target, Clbit):
-            return (self.bit_map[target], value)
-        return (self._map_register(target), value)
-
-    def map_target(self, target, /):
-        """Map the runtime variables in a ``target`` of a :class:`.SwitchCaseOp` to the new circuit,
-        as defined in the ``circuit`` argument of the initialiser of this class."""
-        if isinstance(target, Clbit):
-            return self.bit_map[target]
-        if isinstance(target, ClassicalRegister):
-            return self._map_register(target)
-        return self.map_expr(target)
-
-    def map_expr(self, node: expr.Expr, /) -> expr.Expr:
-        """Map the variables in an :class:`~.expr.Expr` node to the new circuit."""
-        return node.accept(self)
-
-    def visit_var(self, node, /):
-        if isinstance(node.var, Clbit):
-            return expr.Var(self.bit_map[node.var], node.type)
-        if isinstance(node.var, ClassicalRegister):
-            return expr.Var(self._map_register(node.var), node.type)
-        # Defensive against the expansion of the variable system; we don't want to silently do the
-        # wrong thing (which would be `return node` without mapping, right now).
-        raise CircuitError(f"unhandled variable in 'compose': {node}")  # pragma: no cover
-
-    def visit_value(self, node, /):
-        return expr.Value(node.value, node.type)
-
-    def visit_unary(self, node, /):
-        return expr.Unary(node.op, node.operand.accept(self), node.type)
-
-    def visit_binary(self, node, /):
-        return expr.Binary(node.op, node.left.accept(self), node.right.accept(self), node.type)
-
-    def visit_cast(self, node, /):
-        return expr.Cast(node.operand.accept(self), node.type, implicit=node.implicit)
diff --git a/qiskit/dagcircuit/dagcircuit.py b/qiskit/dagcircuit/dagcircuit.py
--- a/qiskit/dagcircuit/dagcircuit.py
+++ b/qiskit/dagcircuit/dagcircuit.py
@@ -29,7 +29,14 @@
 import numpy as np
 import rustworkx as rx
 
-from qiskit.circuit import ControlFlowOp, ForLoopOp, IfElseOp, WhileLoopOp, SwitchCaseOp
+from qiskit.circuit import (
+    ControlFlowOp,
+    ForLoopOp,
+    IfElseOp,
+    WhileLoopOp,
+    SwitchCaseOp,
+    _classical_resource_map,
+)
 from qiskit.circuit.controlflow import condition_resources, node_resources
 from qiskit.circuit.quantumregister import QuantumRegister, Qubit
 from qiskit.circuit.classicalregister import ClassicalRegister, Clbit
@@ -627,101 +634,6 @@ def apply_operation_front(self, op, qargs=(), cargs=()):
         )
         return self._multi_graph[node_index]
 
-    @staticmethod
-    def _map_condition(wire_map, condition, target_cregs):
-        """Use the wire_map dict to change the condition tuple's creg name.
-
-        Args:
-            wire_map (dict): a map from source wires to destination wires
-            condition (tuple or None): (ClassicalRegister,int)
-            target_cregs (list[ClassicalRegister]): List of all cregs in the
-              target circuit onto which the condition might possibly be mapped.
-        Returns:
-            tuple(ClassicalRegister,int): new condition
-        Raises:
-            DAGCircuitError: if condition register not in wire_map, or if
-                wire_map maps condition onto more than one creg, or if the
-                specified condition is not present in a classical register.
-        """
-
-        if condition is None:
-            new_condition = None
-        else:
-            # if there is a condition, map the condition bits to the
-            # composed cregs based on the wire_map
-            is_reg = False
-            if isinstance(condition[0], Clbit):
-                cond_creg = [condition[0]]
-            else:
-                cond_creg = condition[0]
-                is_reg = True
-            cond_val = condition[1]
-            new_cond_val = 0
-            new_creg = None
-            bits_in_condcreg = [bit for bit in wire_map if bit in cond_creg]
-            for bit in bits_in_condcreg:
-                if is_reg:
-                    try:
-                        candidate_creg = next(
-                            creg for creg in target_cregs if wire_map[bit] in creg
-                        )
-                    except StopIteration as ex:
-                        raise DAGCircuitError(
-                            "Did not find creg containing mapped clbit in conditional."
-                        ) from ex
-                else:
-                    # If cond is on a single Clbit then the candidate_creg is
-                    # the target Clbit to which 'bit' is mapped to.
-                    candidate_creg = wire_map[bit]
-                if new_creg is None:
-                    new_creg = candidate_creg
-                elif new_creg != candidate_creg:
-                    # Raise if wire_map maps condition creg on to more than one
-                    # creg in target DAG.
-                    raise DAGCircuitError(
-                        "wire_map maps conditional register onto more than one creg."
-                    )
-
-                if not is_reg:
-                    # If the cond is on a single Clbit then the new_cond_val is the
-                    # same as the cond_val since the new_creg is also a single Clbit.
-                    new_cond_val = cond_val
-                elif 2 ** (cond_creg[:].index(bit)) & cond_val:
-                    # If the conditional values of the Clbit 'bit' is 1 then the new_cond_val
-                    # is updated such that the conditional value of the Clbit to which 'bit'
-                    # is mapped to in new_creg is 1.
-                    new_cond_val += 2 ** (new_creg[:].index(wire_map[bit]))
-            if new_creg is None:
-                raise DAGCircuitError("Condition registers not found in wire_map.")
-            new_condition = (new_creg, new_cond_val)
-        return new_condition
-
-    def _map_classical_resource_with_import(self, resource, wire_map, creg_map):
-        """Map the classical ``resource`` (a bit or register) in its counterpart in ``self`` using
-        ``wire_map`` and ``creg_map`` as lookup caches.  All single-bit conditions should have a
-        cache hit in the ``wire_map``, but registers may involve a full linear search the first time
-        they are encountered.  ``creg_map`` is mutated by this function.  ``wire_map`` is not; it is
-        an error if a wire is not in the map.
-
-        This is different to the logic in ``_map_condition`` because it always succeeds; since the
-        mapping for all wires in the condition is assumed to exist, there can be no fragmented
-        registers.  If there is no matching register (has the same bits in the same order) in
-        ``self``, a new register alias is added to represent the condition.  This does not change
-        the bits available to ``self``, it just adds a new aliased grouping of them."""
-        if isinstance(resource, Clbit):
-            return wire_map[resource]
-        if resource.name not in creg_map:
-            mapped_bits = [wire_map[bit] for bit in resource]
-            for our_creg in self.cregs.values():
-                if mapped_bits == list(our_creg):
-                    new_resource = our_creg
-                    break
-            else:
-                new_resource = ClassicalRegister(bits=[wire_map[bit] for bit in resource])
-                self.add_creg(new_resource)
-            creg_map[resource.name] = new_resource
-        return creg_map[resource.name]
-
     def compose(self, other, qubits=None, clbits=None, front=False, inplace=True):
         """Compose the ``other`` circuit onto the output of this circuit.
 
@@ -798,6 +710,13 @@ def compose(self, other, qubits=None, clbits=None, front=False, inplace=True):
         for gate, cals in other.calibrations.items():
             dag._calibrations[gate].update(cals)
 
+        # Ensure that the error raised here is a `DAGCircuitError` for backwards compatiblity.
+        def _reject_new_register(reg):
+            raise DAGCircuitError(f"No register with '{reg.bits}' to map this expression onto.")
+
+        variable_mapper = _classical_resource_map.VariableMapper(
+            dag.cregs.values(), edge_map, _reject_new_register
+        )
         for nd in other.topological_nodes():
             if isinstance(nd, DAGInNode):
                 # if in edge_map, get new name, else use existing name
@@ -816,16 +735,13 @@ def compose(self, other, qubits=None, clbits=None, front=False, inplace=True):
                 # ignore output nodes
                 pass
             elif isinstance(nd, DAGOpNode):
-                condition = dag._map_condition(
-                    edge_map, getattr(nd.op, "condition", None), dag.cregs.values()
-                )
-                dag._check_condition(nd.op.name, condition)
                 m_qargs = [edge_map.get(x, x) for x in nd.qargs]
                 m_cargs = [edge_map.get(x, x) for x in nd.cargs]
                 op = nd.op.copy()
-                if condition and not isinstance(op, Instruction):
-                    raise DAGCircuitError("Cannot add a condition on a generic Operation.")
-                op.condition = condition
+                if (condition := getattr(op, "condition", None)) is not None:
+                    op.condition = variable_mapper.map_condition(condition, allow_reorder=True)
+                elif isinstance(op, SwitchCaseOp):
+                    op.target = variable_mapper.map_target(op.target)
                 dag.apply_operation_back(op, m_qargs, m_cargs)
             else:
                 raise DAGCircuitError("bad node type %s" % type(nd))
@@ -1210,10 +1126,16 @@ def substitute_node_with_dag(self, node, input_dag, wires=None, propagate_condit
                 )
 
         reverse_wire_map = {b: a for a, b in wire_map.items()}
-        creg_map = {}
         op_condition = getattr(node.op, "condition", None)
         if propagate_condition and op_condition is not None:
             in_dag = input_dag.copy_empty_like()
+            # The remapping of `condition` below is still using the old code that assumes a 2-tuple.
+            # This is because this remapping code only makes sense in the case of non-control-flow
+            # operations being replaced.  These can only have the 2-tuple conditions, and the
+            # ability to set a condition at an individual node level will be deprecated and removed
+            # in favour of the new-style conditional blocks.  The extra logic in here to add
+            # additional wires into the map as necessary would hugely complicate matters if we tried
+            # to abstract it out into the `VariableMapper` used elsewhere.
             target, value = op_condition
             if isinstance(target, Clbit):
                 new_target = reverse_wire_map.get(target, Clbit())
@@ -1227,7 +1149,6 @@ def substitute_node_with_dag(self, node, input_dag, wires=None, propagate_condit
                     # Update to any new dummy bits we just created to the wire maps.
                     wire_map[theirs], reverse_wire_map[ours] = ours, theirs
                 new_target = ClassicalRegister(bits=mapped_bits)
-                creg_map[new_target.name] = target
                 in_dag.add_creg(new_target)
                 target_cargs = set(new_target)
             new_condition = (new_target, value)
@@ -1315,6 +1236,9 @@ def edge_weight_map(wire):
         )
         self._decrement_op(node.op)
 
+        variable_mapper = _classical_resource_map.VariableMapper(
+            self.cregs.values(), wire_map, self.add_creg
+        )
         # Iterate over nodes of input_circuit and update wires in node objects migrated
         # from in_dag
         for old_node_index, new_node_index in node_map.items():
@@ -1322,19 +1246,13 @@ def edge_weight_map(wire):
             old_node = in_dag._multi_graph[old_node_index]
             if isinstance(old_node.op, SwitchCaseOp):
                 m_op = SwitchCaseOp(
-                    self._map_classical_resource_with_import(
-                        old_node.op.target, wire_map, creg_map
-                    ),
+                    variable_mapper.map_target(old_node.op.target),
                     old_node.op.cases_specifier(),
                     label=old_node.op.label,
                 )
             elif getattr(old_node.op, "condition", None) is not None:
-                cond_target, cond_value = old_node.op.condition
                 m_op = copy.copy(old_node.op)
-                m_op.condition = (
-                    self._map_classical_resource_with_import(cond_target, wire_map, creg_map),
-                    cond_value,
-                )
+                m_op.condition = variable_mapper.map_condition(m_op.condition)
             else:
                 m_op = old_node.op
             m_qargs = [wire_map[x] for x in old_node.qargs]


Test patch:
diff --git a/test/python/dagcircuit/test_compose.py b/test/python/dagcircuit/test_compose.py
--- a/test/python/dagcircuit/test_compose.py
+++ b/test/python/dagcircuit/test_compose.py
@@ -14,7 +14,17 @@
 
 import unittest
 
-from qiskit.circuit import QuantumRegister, ClassicalRegister, QuantumCircuit
+from qiskit.circuit import (
+    QuantumRegister,
+    ClassicalRegister,
+    QuantumCircuit,
+    IfElseOp,
+    WhileLoopOp,
+    SwitchCaseOp,
+    CASE_DEFAULT,
+)
+from qiskit.circuit.classical import expr, types
+from qiskit.dagcircuit import DAGCircuit
 from qiskit.converters import circuit_to_dag, dag_to_circuit
 from qiskit.test import QiskitTestCase
 from qiskit.pulse import Schedule
@@ -423,6 +433,113 @@ def test_compose_condition_multiple_classical(self):
 
         self.assertEqual(dag_composed, dag_expected)
 
+    def test_compose_expr_condition(self):
+        """Test that compose correctly maps clbits and registers in expression conditions."""
+        inner = QuantumCircuit(1)
+        inner.x(0)
+        qr_src = QuantumRegister(1)
+        a_src = ClassicalRegister(2, "a_src")
+        b_src = ClassicalRegister(2, "b_src")
+        source = DAGCircuit()
+        source.add_qreg(qr_src)
+        source.add_creg(a_src)
+        source.add_creg(b_src)
+
+        test_1 = lambda: expr.lift(a_src[0])
+        test_2 = lambda: expr.logic_not(b_src[1])
+        test_3 = lambda: expr.cast(expr.bit_and(b_src, 2), types.Bool())
+        node_1 = source.apply_operation_back(IfElseOp(test_1(), inner.copy(), None), qr_src, [])
+        node_2 = source.apply_operation_back(
+            IfElseOp(test_2(), inner.copy(), inner.copy()), qr_src, []
+        )
+        node_3 = source.apply_operation_back(WhileLoopOp(test_3(), inner.copy()), qr_src, [])
+
+        qr_dest = QuantumRegister(1)
+        a_dest = ClassicalRegister(2, "a_dest")
+        b_dest = ClassicalRegister(2, "b_dest")
+        dest = DAGCircuit()
+        dest.add_qreg(qr_dest)
+        dest.add_creg(a_dest)
+        dest.add_creg(b_dest)
+
+        dest.compose(source)
+
+        # Check that the input conditions weren't mutated.
+        for in_condition, node in zip((test_1, test_2, test_3), (node_1, node_2, node_3)):
+            self.assertEqual(in_condition(), node.op.condition)
+
+        expected = QuantumCircuit(qr_dest, a_dest, b_dest)
+        expected.if_test(expr.lift(a_dest[0]), inner.copy(), [0], [])
+        expected.if_else(expr.logic_not(b_dest[1]), inner.copy(), inner.copy(), [0], [])
+        expected.while_loop(expr.cast(expr.bit_and(b_dest, 2), types.Bool()), inner.copy(), [0], [])
+        self.assertEqual(dest, circuit_to_dag(expected))
+
+    def test_compose_expr_target(self):
+        """Test that compose correctly maps clbits and registers in expression targets."""
+        inner1 = QuantumCircuit(1)
+        inner1.x(0)
+        inner2 = QuantumCircuit(1)
+        inner2.z(0)
+
+        qr_src = QuantumRegister(1)
+        a_src = ClassicalRegister(2, "a_src")
+        b_src = ClassicalRegister(2, "b_src")
+        source = DAGCircuit()
+        source.add_qreg(qr_src)
+        source.add_creg(a_src)
+        source.add_creg(b_src)
+
+        test_1 = lambda: expr.lift(a_src[0])
+        test_2 = lambda: expr.logic_not(b_src[1])
+        test_3 = lambda: expr.lift(b_src)
+        test_4 = lambda: expr.bit_and(b_src, 2)
+        node_1 = source.apply_operation_back(
+            SwitchCaseOp(test_1(), [(False, inner1.copy()), (True, inner2.copy())]), qr_src, []
+        )
+        node_2 = source.apply_operation_back(
+            SwitchCaseOp(test_2(), [(False, inner1.copy()), (True, inner2.copy())]), qr_src, []
+        )
+        node_3 = source.apply_operation_back(
+            SwitchCaseOp(test_3(), [(0, inner1.copy()), (CASE_DEFAULT, inner2.copy())]), qr_src, []
+        )
+        node_4 = source.apply_operation_back(
+            SwitchCaseOp(test_4(), [(0, inner1.copy()), (CASE_DEFAULT, inner2.copy())]), qr_src, []
+        )
+
+        qr_dest = QuantumRegister(1)
+        a_dest = ClassicalRegister(2, "a_dest")
+        b_dest = ClassicalRegister(2, "b_dest")
+        dest = DAGCircuit()
+        dest.add_qreg(qr_dest)
+        dest.add_creg(a_dest)
+        dest.add_creg(b_dest)
+        dest.compose(source)
+
+        # Check that the input expressions weren't mutated.
+        for in_target, node in zip(
+            (test_1, test_2, test_3, test_4), (node_1, node_2, node_3, node_4)
+        ):
+            self.assertEqual(in_target(), node.op.target)
+
+        expected = QuantumCircuit(qr_dest, a_dest, b_dest)
+        expected.switch(
+            expr.lift(a_dest[0]), [(False, inner1.copy()), (True, inner2.copy())], [0], []
+        )
+        expected.switch(
+            expr.logic_not(b_dest[1]), [(False, inner1.copy()), (True, inner2.copy())], [0], []
+        )
+        expected.switch(
+            expr.lift(b_dest), [(0, inner1.copy()), (CASE_DEFAULT, inner2.copy())], [0], []
+        )
+        expected.switch(
+            expr.bit_and(b_dest, 2),
+            [(0, inner1.copy()), (CASE_DEFAULT, inner2.copy())],
+            [0],
+            [],
+        )
+
+        self.assertEqual(dest, circuit_to_dag(expected))
+
     def test_compose_calibrations(self):
         """Test that compose carries over the calibrations."""
         dag_cal = QuantumCircuit(1)