Repository: PennyLaneAI/pennylane
Pull request #7355 (2025-04-29 21:17:15)

Transform to convert to MBQC formalism
**Context:**

We have a transform to go from an arbitrary circuit to the gateset compatible with textbook MBQC. Now we want to convert that the MBQC formalism, expressed using resource states, parametrized MCMs and byproduct corrections.

**Description of the Change:**

We add a transform that performs a simple conversion from that gate-set to the MBQC formalism. "Simple" here means:

- No consolidation of Pauli operations and/or byproduct operations
- Byproducts are applied online after each measurement protocol is complete, no offline corrections
- we use the qubit manager to allocate qubits on the tape before execution (not capture compatible, we unroll all program structure)

This is a very direct, naive conversion to the MBQC formalism. But it does the thing, so it's a good place to start - and we can use it as a baseline for quantifying future improvements in our circuit conversion.

**Benefits:**
We can convert a circuit to a simple, un-optimized implementation of the same circuit in the MBQC formalism.

[sc-88533]

github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
JerryChen97: The test fail looks familiar. Is it meant to be resolved by the other PR that is about to introduce the measure transform?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7355?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
Attention: Patch coverage is `98.68421%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 99.69%. Comparing base [(`0f5cb96`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/0f5cb9655182f4fe2fa9e9a4b62ed9d729615558?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`7820f48`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/7820f48fbb8020b40be3e32350677fd0f59aaef4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 5 commits behind head on add_measurements_from_counts.

| [Files with missing lines](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7355?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Patch % | Lines |
|---|---|---|
| [pennylane/ftqc/decomposition.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7355?src=pr&el=tree&filepath=pennylane%2Fftqc%2Fdecomposition.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL2Z0cWMvZGVjb21wb3NpdGlvbi5weQ==) | 98.67% | [2 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7355?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                       Coverage Diff                        @@
##           add_measurements_from_counts    #7355      +/-   ##
================================================================
- Coverage                         99.69%   99.69%   -0.01%     
================================================================
  Files                               529      529              
  Lines                             50665    50814     +149     
================================================================
+ Hits                              50510    50657     +147     
- Misses                              155      157       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7355?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
lillian542: > Is it meant to be resolved by the other PR that is about to introduce the measure transform?

Sorry, yes, that was from the branch it's based on. It's resolved now that I've updated with the most recent changes from there.

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index dc76977bb..80049ef63 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -88,7 +88,7 @@
 
 * The transform `convert_to_mbqc_gateset` is added to the `ftqc` module to convert arbitrary 
   circuits to a limited gate-set that can be translated to the MBQC formalism.
-  [(7271)](https://github.com/PennyLaneAI/pennylane/pull/7271)
+  [(#7271)](https://github.com/PennyLaneAI/pennylane/pull/7271)
 
 * Classical shadows with mixed quantum states are now computed with a dedicated method that uses an
   iterative algorithm similar to the handling of shadows with state vectors. This makes shadows with density 
@@ -98,11 +98,16 @@
 
 * The `RotXZX` operation is added to the `ftqc` module to support definition of a universal
   gate-set that can be translated to the MBQC formalism.
-  [(7271)](https://github.com/PennyLaneAI/pennylane/pull/7271)
+  [(#7271)](https://github.com/PennyLaneAI/pennylane/pull/7271)
 
 * Two new functions called :func:`~.math.convert_to_su2` and :func:`~.math.convert_to_su4` have been added to `qml.math`, which convert unitary matrices to SU(2) or SU(4), respectively, and optionally a global phase.
   [(#7211)](https://github.com/PennyLaneAI/pennylane/pull/7211)
 
+* The transform `convert_to_mbqc_formalism` is added to the `ftqc` module to convert a circuit already
+  expressed in a limited, compatible gate-set into the MBQC formalism. Circuits can be converted to the 
+  relevant gate-set with the `convert_to_mbqc_gateset` transform.
+  [(#7355)](https://github.com/PennyLaneAI/pennylane/pull/7355)
+
 <h4>Resource-efficient Decompositions 🔎</h4>
 
 * The :func:`~.transforms.decompose` transform now supports weighting gates in the target `gate_set`, allowing for 
diff --git a/pennylane/ftqc/__init__.py b/pennylane/ftqc/__init__.py
index 868dc1e88..11bfe0be7 100644
--- a/pennylane/ftqc/__init__.py
+++ b/pennylane/ftqc/__init__.py
@@ -26,7 +26,7 @@ Modules
 
 """
 from .operations import RotXZX
-from .decomposition import convert_to_mbqc_gateset
+from .decomposition import convert_to_mbqc_formalism, convert_to_mbqc_gateset
 from .parametric_midmeasure import (
     ParametricMidMeasureMP,
     XMidMeasureMP,
@@ -58,6 +58,7 @@ __all__ = [
     "measure_y",
     "measure_z",
     "diagonalize_mcms",
+    "convert_to_mbqc_formalism",
     "convert_to_mbqc_gateset",
     "generate_lattice",
     "make_graph_state",
diff --git a/pennylane/ftqc/decomposition.py b/pennylane/ftqc/decomposition.py
index 338ce435e..280c219b7 100644
--- a/pennylane/ftqc/decomposition.py
+++ b/pennylane/ftqc/decomposition.py
@@ -14,15 +14,26 @@
 """
 Contains functions to convert a PennyLane tape to the textbook MBQC formalism
 """
+from functools import partial, singledispatch
+
+import networkx as nx
 
 from pennylane import math
 from pennylane.decomposition import enabled_graph, register_resources
-from pennylane.ops import CNOT, RZ, GlobalPhase, H, Identity, Rot, S, X, Y, Z
+from pennylane.devices.preprocess import null_postprocessing
+from pennylane.measurements import SampleMP, sample
+from pennylane.ops import CNOT, CZ, RZ, GlobalPhase, H, Identity, Rot, S, X, Y, Z, cond
+from pennylane.queuing import AnnotatedQueue
+from pennylane.tape import QuantumScript
 from pennylane.transforms import decompose, transform
 
+from .conditional_measure import cond_measure
+from .graph_state_preparation import make_graph_state
 from .operations import RotXZX
+from .parametric_midmeasure import measure_arbitrary_basis, measure_x, measure_y
+from .utils import QubitMgr, parity
 
-mbqc_gate_set = {CNOT, H, S, RotXZX, RZ, X, Y, Z, Identity, GlobalPhase}
+mbqc_gate_set = frozenset({CNOT, H, S, RotXZX, RZ, X, Y, Z, Identity, GlobalPhase})
 
 
 @register_resources({RotXZX: 1})
@@ -43,3 +54,306 @@ def convert_to_mbqc_gateset(tape):
         )
     tapes, fn = decompose(tape, gate_set=mbqc_gate_set, alt_decomps={Rot: [_rot_to_xzx]})
     return tapes, fn
+
+
+@transform
+def convert_to_mbqc_formalism(tape):
+    """Convert a circuit to the textbook MBQC formalism based on the procedures outlined in
+    Raussendorf et al. 2003, https://doi.org/10.1103/PhysRevA.68.022312. The circuit must
+    be decomposed to the gate set {CNOT, H, S, RotXZX, RZ, X, Y, Z, Identity, GlobalPhase}
+    before applying the transform.
+
+    Note that this transform leaves all Paulis and Identities as physical gates, and applies
+    all byproduct operations online immediately after their respective measurement procedures."""
+
+    if len(tape.measurements) != 1 or not isinstance(tape.measurements[0], (SampleMP)):
+        raise NotImplementedError(
+            "Transforming to the MBQC formalism is not implemented for circuits where the "
+            "final measurements have not been converted to a single samples measurement"
+        )
+
+    mp = tape.measurements[0]
+    meas_wires = mp.wires if mp.wires else tape.wires
+
+    # we include 13 auxillary wires - the largest number needed is 13 (for CNOT)
+    num_qubits = len(tape.wires) + 13
+    q_mgr = QubitMgr(num_qubits=num_qubits, start_idx=0)
+
+    wire_map = {w: q_mgr.acquire_qubit() for w in tape.wires}
+
+    with AnnotatedQueue() as q:
+        for op in tape.operations:
+            if isinstance(op, GlobalPhase):  # no wires
+                GlobalPhase(*op.data)
+            elif isinstance(op, CNOT):  # two wires
+                ctrl, tgt = op.wires[0], op.wires[1]
+                wire_map[ctrl], wire_map[tgt], measurements = queue_cnot(
+                    q_mgr, wire_map[ctrl], wire_map[tgt]
+                )
+                cnot_corrections(measurements)(wire_map[ctrl], wire_map[tgt])
+            else:  # one wire
+                # pylint: disable=isinstance-second-argument-not-valid-type
+                if isinstance(op, (X, Y, Z, Identity)):
+                    # else branch because Identity may not have wires
+                    wire = wire_map[op.wires[0]] if op.wires else ()
+                    op.__class__(wire)
+                else:
+                    w = op.wires[0]
+                    wire_map[w], measurements = queue_single_qubit_gate(
+                        q_mgr, op, in_wire=wire_map[w]
+                    )
+                    queue_corrections(op, measurements)(wire_map[w])
+
+    temp_tape = QuantumScript.from_queue(q)
+
+    new_wires = [wire_map[w] for w in meas_wires]
+
+    new_tape = tape.copy(operations=temp_tape.operations, measurements=[sample(wires=new_wires)])
+
+    return (new_tape,), null_postprocessing
+
+
+def queue_single_qubit_gate(q_mgr, op, in_wire):
+    """Queue the resource state preparation, measurements and byproducts
+    to execute the operation in the MBQC formalism. This implementation
+    follows the procedures defined in Raussendorf et al. 2003,
+    https://doi.org/10.1103/PhysRevA.68.022312, see Fig. 2"""
+
+    graph_wires = q_mgr.acquire_qubits(4)
+    wires = [in_wire] + graph_wires
+
+    make_graph_state(nx.grid_graph((4,)), wires=graph_wires)
+    CZ([wires[0], wires[1]])
+
+    measurements = queue_measurements(op, wires)
+
+    # release input qubit and intermediate graph qubits
+    q_mgr.release_qubits(wires[0:-1])
+    return wires[-1], measurements
+
+
+@singledispatch
+def queue_measurements(op, wires):
+    """Queue the measurements needed to execute the operation in the MBQC formalism"""
+    raise NotImplementedError(f"Received unsupported gate of type {op}")
+
+
+@queue_measurements.register(RotXZX)
+def _rot_measurements(op: RotXZX, wires):
+    """Queue the measurements needed to execute RotXZX in the MBQC formalism"""
+
+    phi, theta, omega = op.data
+
+    m1 = measure_x(wires[0], reset=True)
+    m2 = cond_measure(
+        m1,
+        partial(measure_arbitrary_basis, angle=phi),
+        partial(measure_arbitrary_basis, angle=-phi),
+    )(plane="XY", wires=wires[1], reset=True)
+    m3 = cond_measure(
+        m2,
+        partial(measure_arbitrary_basis, angle=theta),
+        partial(measure_arbitrary_basis, angle=-theta),
+    )(plane="XY", wires=wires[2], reset=True)
+    m4 = cond_measure(
+        m1 ^ m3,
+        partial(measure_arbitrary_basis, angle=omega),
+        partial(measure_arbitrary_basis, angle=-omega),
+    )(plane="XY", wires=wires[3], reset=True)
+
+    return [m1, m2, m3, m4]
+
+
+@queue_measurements.register(RZ)
+def _rz_measurements(op: RZ, wires):
+    """Queue the measurements needed to execute RZ in the MBQC formalism"""
+
+    angle = op.parameters[0]
+
+    m1 = measure_x(wires[0], reset=True)
+    m2 = measure_x(wires[1], reset=True)
+    m3 = cond_measure(
+        m2,
+        partial(measure_arbitrary_basis, angle=angle, reset=True),
+        partial(measure_arbitrary_basis, angle=-angle, reset=True),
+    )(plane="XY", wires=wires[2])
+    m4 = measure_x(wires[3], reset=True)
+
+    return [m1, m2, m3, m4]
+
+
+@queue_measurements.register(H)
+def _hadamard_measurements(op: H, wires):
+    """Queue the measurements needed to execute Hadamard in the MBQC formalism"""
+    m1 = measure_x(wires[0], reset=True)
+    m2 = measure_y(wires[1], reset=True)
+    m3 = measure_y(wires[2], reset=True)
+    m4 = measure_y(wires[3], reset=True)
+
+    return [m1, m2, m3, m4]
+
+
+@queue_measurements.register(S)
+def _s_measurements(op: S, wires):
+    """Queue the measurements needed to execute S in the MBQC formalism"""
+    m1 = measure_x(wires[0], reset=True)
+    m2 = measure_x(wires[1], reset=True)
+    m3 = measure_y(wires[2], reset=True)
+    m4 = measure_x(wires[3], reset=True)
+
+    return [m1, m2, m3, m4]
+
+
+@singledispatch
+def queue_corrections(op, measurements):
+    """Queue the byproduct corrections associated with the operation in the
+    MBQC formalism, based on the operation and the measurement results"""
+    raise NotImplementedError(f"Received unsupported gate of type {op}")
+
+
+@queue_corrections.register(RotXZX)
+@queue_corrections.register(RZ)
+def _rotation_corrections(op, measurements):
+    """Queue the byproduct corrections associated with the rotation
+    gate RotXZX in the MBQC formalism, based on the operation and the
+    measurement results. Note that these corrections also apply in the
+    more specific rotation case, RZ = RotXZX(0, Z, 0)"""
+
+    m1, m2, m3, m4 = measurements
+
+    def correction_func(wire):
+        cond(m1 ^ m3, Z)(wire)
+        cond(m2 ^ m4, X)(wire)
+
+    return correction_func
+
+
+@queue_corrections.register(H)
+def _hadamard_corrections(op, measurements):
+    """Queue the byproduct corrections associated with the Hadamard gate in
+    the MBQC formalism, based on the operation and the measurement results"""
+    m1, m2, m3, m4 = measurements
+
+    def correction_func(wire):
+        cond(m2 ^ m3, Z)(wire)
+        cond(parity(m1, m3, m4), X)(wire)
+
+    return correction_func
+
+
+@queue_corrections.register(S)
+def _s_corrections(op, measurements):
+    """Queue the byproduct corrections associated with the S gate in
+    the MBQC formalism, based on the operation and the measurement results"""
+
+    m1, m2, m3, m4 = measurements
+
+    def correction_func(wire):
+        cond(parity(m1, m2, m3, 1), Z)(wire)
+        cond(m2 ^ m4, X)(wire)
+
+    return correction_func
+
+
+def queue_cnot(q_mgr, ctrl_idx, target_idx):
+    """Queue the resource state preparation, measurements and byproducts to execute
+    the operation in the MBQC formalism. This is the 15-qubit procedure from
+    Raussendorf et al. 2003, https://doi.org/10.1103/PhysRevA.68.022312, Fig. 2"""
+
+    graph_wires = q_mgr.acquire_qubits(13)
+
+    # Denote the index for the final output state
+    output_ctrl_idx = graph_wires[5]
+    output_target_idx = graph_wires[12]
+
+    # Prepare the state
+    make_graph_state(_generate_cnot_graph(), wires=graph_wires)
+
+    # entangle input and graph using first qubit
+    CZ([ctrl_idx, graph_wires[0]])
+    CZ([target_idx, graph_wires[7]])
+
+    measurements = cnot_measurements((ctrl_idx, target_idx, graph_wires))
+
+    q_mgr.release_qubit(ctrl_idx)
+    q_mgr.release_qubit(target_idx)
+
+    # We can now free all but the last qubit, which has become the new input_idx
+    q_mgr.release_qubits(graph_wires[0:5] + graph_wires[6:-1])
+    return output_ctrl_idx, output_target_idx, measurements
+
+
+def cnot_measurements(wires):
+    """Queue the measurements needed to execute CNOT in the MBQC formalism.
+    Numbering convention follows the procedure in Raussendorf et al. 2003,
+    https://doi.org/10.1103/PhysRevA.68.022312, see Fig. 2"""
+    ctrl_idx, target_idx, graph_wires = wires
+
+    m1 = measure_x(ctrl_idx, reset=True)
+    m2 = measure_y(graph_wires[0], reset=True)
+    m3 = measure_y(graph_wires[1], reset=True)
+    m4 = measure_y(graph_wires[2], reset=True)
+    m5 = measure_y(graph_wires[3], reset=True)
+    m6 = measure_y(graph_wires[4], reset=True)
+
+    m8 = measure_y(graph_wires[6], reset=True)
+
+    m9 = measure_x(target_idx, reset=True)
+    m10 = measure_x(graph_wires[7], reset=True)
+    m11 = measure_x(graph_wires[8], reset=True)
+    m12 = measure_y(graph_wires[9], reset=True)
+    m13 = measure_x(graph_wires[10], reset=True)
+    m14 = measure_x(graph_wires[11], reset=True)
+
+    return [m1, m2, m3, m4, m5, m6, m8, m9, m10, m11, m12, m13, m14]
+
+
+def cnot_corrections(measurements):
+    """Queue the byproduct corrections associated with the CNOT gate in
+    the MBQC formalism, based on measurement results"""
+
+    # Numbering convention follows the procedure in Raussendorf et al. 2003,
+    # https://doi.org/10.1103/PhysRevA.68.022312, Fig 2
+    m1, m2, m3, m4, m5, m6, m8, m9, m10, m11, m12, m13, m14 = measurements
+
+    # corrections on control
+    x_cor_ctrl = parity(m2, m3, m5, m6)
+    z_cor_ctrl = parity(m1, m3, m4, m5, m8, m9, m11, 1)
+
+    # corrections on target
+    x_cor_tgt = parity(m2, m3, m8, m10, m12, m14)
+    z_cor_tgt = parity(m9, m11, m13)
+
+    def correction_func(ctrl_wire, target_wire):
+        cond(z_cor_ctrl, Z)(ctrl_wire)
+        cond(x_cor_ctrl, X)(ctrl_wire)
+        cond(z_cor_tgt, Z)(target_wire)
+        cond(x_cor_tgt, X)(target_wire)
+
+    return correction_func
+
+
+def _generate_cnot_graph():
+    """Generate a graph for creating the resource state for a
+    CNOT gate. Raussendorf et al. 2003, Fig. 2a.
+    https://doi.org/10.1103/PhysRevA.68.022312"""
+    wires = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
+    g = nx.Graph()
+    g.add_nodes_from(wires)
+    g.add_edges_from(
+        [
+            (0, 1),
+            (1, 2),
+            (2, 3),
+            (3, 4),
+            (4, 5),
+            (2, 6),
+            (6, 9),
+            (7, 8),
+            (8, 9),
+            (9, 10),
+            (10, 11),
+            (11, 12),
+        ]
+    )
+    return g
diff --git a/pennylane/ftqc/utils.py b/pennylane/ftqc/utils.py
index d555cb984..1551a6cf5 100644
--- a/pennylane/ftqc/utils.py
+++ b/pennylane/ftqc/utils.py
@@ -18,6 +18,19 @@ ftqc module.
 """
 from threading import RLock
 
+from pennylane import math
+from pennylane.measurements import MeasurementValue
+
+
+def parity(*args):
+    """Get the parity of the arguments"""
+    if isinstance(args[0], MeasurementValue):
+        # special handling needed until we stop casting everything from the pennylane namespace to autograd
+        return math.reduce(math.bitwise_xor, math.array([*args]))
+    return math.reduce(
+        math.bitwise_xor, math.array([*args], like=math.interface_utils.get_interface(args[0]))
+    )
+
 
 class QubitMgr:
     r"""


Test patch:
diff --git a/tests/ftqc/test_decomposition.py b/tests/ftqc/test_decomposition.py
index 15485ab51..6db7ba4d3 100644
--- a/tests/ftqc/test_decomposition.py
+++ b/tests/ftqc/test_decomposition.py
@@ -27,12 +27,22 @@ from pennylane.ftqc import (
     GraphStatePrep,
     RotXZX,
     cond_measure,
+    convert_to_mbqc_formalism,
     convert_to_mbqc_gateset,
     diagonalize_mcms,
     measure_arbitrary_basis,
     measure_x,
 )
-from pennylane.ftqc.decomposition import _rot_to_xzx
+from pennylane.ftqc.decomposition import (
+    _rot_to_xzx,
+    cnot_corrections,
+    cnot_measurements,
+    queue_cnot,
+    queue_corrections,
+    queue_measurements,
+    queue_single_qubit_gate,
+)
+from pennylane.ftqc.utils import QubitMgr
 
 
 class TestGateSetDecomposition:
@@ -192,3 +202,353 @@ class TestGateSetDecomposition:
         xzx_op = q.queue[0]
 
         assert np.allclose(rot_ref(angles), rot_mbqc(xzx_op))
+
+
+class TestMBQCFormalismConversion:
+    """Test the transform convert_to_mbqc_formalism, converting to the MBQC formalism with
+    online corrections immediately after each gate"""
+
+    @pytest.mark.parametrize("op", [qml.S(7), qml.H(1), qml.RZ(1.2, 2), RotXZX(1.2, 2.3, 3.4, 2)])
+    def test_queue_measurements(self, op):
+        """Test that queue_measurements returns MeasurementValues as expected"""
+
+        wires = [i + 10 for i in range(5)]
+        mvs = queue_measurements(op, wires=wires)
+
+        assert len(mvs) == 4
+        for mv in mvs:
+            assert mv.measurements[0].wires[0] in wires
+
+    def test_cnot_measurements(self):
+        """Test that cnot_measurements returns MeasurementValues as expected"""
+
+        wires = (10, 11, [i + 12 for i in range(13)])
+        mvs = cnot_measurements(wires)
+
+        # expected number of MVs tied to the expected set of wires
+        assert len(mvs) == 13
+        for mv in mvs:
+            assert mv.measurements[0].wires[0] in [i + 10 for i in range(15)]
+
+    def test_queue_measurements_gate_not_implemented(self):
+        """Test that a NotImplemented error is raised if queue_single_qubit_gate is
+        passed an unsupported operation"""
+
+        with pytest.raises(NotImplementedError, match="Received unsupported gate of type"):
+            queue_measurements(qml.Identity, wires="a")
+
+    @pytest.mark.parametrize("op", [qml.S(7), qml.H(1), qml.RZ(1.2, 2), RotXZX(1.2, 2.3, 3.4, 2)])
+    def test_queue_corrections(self, op):
+        """Test that queue_corrections returns byproduct operators as expected."""
+
+        # Note: this tests the basic behaviour - the accuracy of the returned ops is tested further
+        # down by running over many shots and ensuring the average result is as expected.
+
+        # measurement selected because it queues at least one correction for all ops
+        correction_function = queue_corrections(op, [0, 1, 1, 0])
+
+        with qml.queuing.AnnotatedQueue() as q:
+            correction_function("a")
+
+        assert q.queue
+        for byprodcut_op in q.queue:
+            assert qml.equal(byprodcut_op, qml.X("a")) or qml.equal(byprodcut_op, qml.Z("a"))
+
+    def test_cnot_corrections(self):
+        """Test that the function produced by cnot_corrections queues the byproduct operations as expected"""
+
+        # Note: this tests the basic behaviour - the accuracy of the returned ops is tested further
+        # down by running over many shots and ensuring the average result is as expected.
+
+        # measurement selected because it queues all the correction ops
+        correction_function = cnot_corrections([0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0])
+
+        with qml.queuing.AnnotatedQueue() as q:
+            correction_function(ctrl_wire="ctrl", target_wire="target")
+
+        assert len(q.queue) == 4
+        for op, expected_op in zip(
+            q.queue, [qml.Z("ctrl"), qml.X("ctrl"), qml.Z("target"), qml.X("target")]
+        ):
+            assert qml.equal(op, expected_op)
+
+    def test_queue_corrections_gate_not_implemented(self):
+        """Test that a NotImplemented error is raised if queue_single_qubit_gate is
+        passed an unsupported operation"""
+
+        with pytest.raises(NotImplementedError, match="Received unsupported gate of type"):
+            queue_corrections(qml.Identity, measurements=[0, 0, 0, 0])
+
+    def test_invalid_op_in_tape_raises_error(self):
+        """Test that a NotImplemented error is raised if the tape isn't valid for conversion
+        to the MBQC formalism using this transform"""
+
+        tape = qml.tape.QuantumScript(
+            [qml.H(0), qml.RZ(0.23, 0), qml.RX(1.23, 0)], measurements=[qml.sample(wires=0)]
+        )
+
+        with pytest.raises(NotImplementedError, match="unsupported gate"):
+            _, _ = convert_to_mbqc_formalism(tape)
+
+    @pytest.mark.parametrize(
+        "op",
+        [qml.H(2), qml.S(2), qml.RZ(1.23, 2), RotXZX(0, 1.23, 0, 2), RotXZX(0.12, 0.34, 0.56, 2)],
+    )
+    def test_queue_single_qubit_gate(self, op):
+        """Test that the queue_single_qubit_gate function queues state preparation, MCMs
+        and byproduct corrections that are equivalent to the input operator"""
+
+        dev = qml.device("lightning.qubit", wires=5)
+        q_mgr = QubitMgr(num_qubits=5, start_idx=0)
+        wire_map = {2: q_mgr.acquire_qubit()}
+        w = op.wires[0]
+
+        ref_tape = qml.tape.QuantumScript(
+            [qml.Rot(1.2, 0.34, 0.7, w), op],
+            measurements=[qml.expval(qml.X(w)), qml.expval(qml.Y(w)), qml.expval(qml.Z(w))],
+        )
+
+        # queue ops with queue_single_qubit_gate
+        with qml.queuing.AnnotatedQueue() as q:
+            qml.Rot(1.2, 0.34, 0.7, wire_map[w])
+            wire_map[w], measurements = queue_single_qubit_gate(q_mgr, op=op, in_wire=wire_map[w])
+            queue_corrections(op, measurements)(wire_map[w])
+            qml.expval(qml.X(wire_map[w]))
+            qml.expval(qml.Y(wire_map[w]))
+            qml.expval(qml.Z(wire_map[w]))
+
+        tape = qml.tape.QuantumScript.from_queue(q, shots=3000)
+
+        # tape contains expected ops
+        assert isinstance(tape.operations[1], GraphStatePrep)
+        assert isinstance(tape.operations[2], qml.CZ)
+        for tape_op in tape.operations[3:]:
+            assert isinstance(tape_op, tuple([qml.measurements.MidMeasureMP, qml.ops.Conditional]))
+
+        # tape yields expected results
+        (diagonalized_tape,), _ = diagonalize_mcms(tape)
+        res, res_ref = qml.execute([diagonalized_tape, ref_tape], device=dev, mcm_method="one-shot")
+        assert np.allclose(res, res_ref, atol=0.05)
+
+    def test_queue_cnot(self):
+        """Test that the queue_cnot function queues state preparation, MCMs and byproduct
+        corrections that are equivalent to the input operator"""
+
+        dev = qml.device("lightning.qubit", wires=15, seed=42)
+        q_mgr = QubitMgr(num_qubits=15, start_idx=0)
+
+        op = qml.CNOT([2, 3])
+        ctrl = op.wires[0]
+        target = op.wires[1]
+        wire_map = {w: q_mgr.acquire_qubit() for w in op.wires}
+
+        ref_tape = qml.tape.QuantumScript(
+            [qml.Rot(1.2, 0.34, 0.7, ctrl), qml.Rot(0.65, 0.43, 0.21, target), op],
+            measurements=[
+                qml.expval(qml.X(ctrl)),
+                qml.expval(qml.Y(ctrl)),
+                qml.expval(qml.Z(ctrl)),
+                qml.expval(qml.X(target)),
+                qml.expval(qml.Y(target)),
+                qml.expval(qml.Z(target)),
+            ],
+        )
+
+        # queue ops with queue_cnot
+        with qml.queuing.AnnotatedQueue() as q:
+            qml.Rot(1.2, 0.34, 0.7, wire_map[ctrl])
+            qml.Rot(0.65, 0.43, 0.21, wire_map[target])
+            wire_map[ctrl], wire_map[target], measurements = queue_cnot(
+                q_mgr, wire_map[ctrl], wire_map[target]
+            )
+            cnot_corrections(measurements)(wire_map[ctrl], wire_map[target])
+            qml.expval(qml.X(wire_map[ctrl]))
+            qml.expval(qml.Y(wire_map[ctrl]))
+            qml.expval(qml.Z(wire_map[ctrl]))
+            qml.expval(qml.X(wire_map[target]))
+            qml.expval(qml.Y(wire_map[target]))
+            qml.expval(qml.Z(wire_map[target]))
+
+        tape = qml.tape.QuantumScript.from_queue(q, shots=2000)
+
+        # tape contains expected ops
+        assert isinstance(tape.operations[2], GraphStatePrep)
+        assert isinstance(tape.operations[3], qml.CZ)
+        assert isinstance(tape.operations[4], qml.CZ)
+        for op in tape.operations[5:]:
+            assert isinstance(op, tuple([qml.measurements.MidMeasureMP, qml.ops.Conditional]))
+
+        # tape yields expected results
+        (diagonalized_tape,), _ = diagonalize_mcms(tape)
+
+        res, res_ref = qml.execute([diagonalized_tape, ref_tape], device=dev, mcm_method="one-shot")
+        assert np.allclose(res, res_ref, atol=0.1)
+
+    @pytest.mark.parametrize(
+        "gate, wire",
+        [(qml.X, 2), (qml.Y, 1), (qml.Z, 0)],
+    )
+    def test_pauli_gates_are_updated(self, gate, wire):
+        """Test that the wires on Pauli operators are updated to match the physical
+        location of the state after application of the preceding MCM operations"""
+
+        tape = qml.tape.QuantumScript(
+            [qml.H(wire), gate(wire)],
+            measurements=[qml.sample(wires=[0, 1])],
+            shots=1000,
+        )
+
+        (transformed_tape,), _ = convert_to_mbqc_formalism(tape)
+
+        graph_op = transformed_tape.operations[0]
+        entanglement_op = transformed_tape.operations[1]
+        measurements = transformed_tape.operations[2:6]
+        byproducts = transformed_tape.operations[6:8]
+        final_op = transformed_tape.operations[8]
+
+        # tape consists of converted Hadamard, and the pauli gate
+        assert isinstance(graph_op, GraphStatePrep)
+        assert isinstance(entanglement_op, qml.CZ)
+        for m in measurements:
+            assert isinstance(m, qml.measurements.MidMeasureMP)
+        for bp in byproducts:
+            assert isinstance(bp, qml.ops.Conditional)
+        assert isinstance(final_op, gate)
+
+        # the wire the pauli op is applied on is updated to reflect the
+        # location of the relevant state after the MBQC operation is applied
+        assert final_op.wires[0] != wire
+        assert graph_op.wires[-1] == final_op.wires[0]
+
+    @pytest.mark.parametrize(
+        "gate, args",
+        [
+            (qml.Identity, [3]),
+            (qml.Identity, []),
+            (qml.GlobalPhase, [1.23]),
+            (qml.GlobalPhase, [1.23, 3]),
+        ],
+    )
+    def test_identity_gates_are_supported(self, gate, args):
+        """Test that Identity gates are left on the tape as-is"""
+
+        initial_op = gate(*args)
+
+        tape = qml.tape.QuantumScript(
+            [qml.H(3), initial_op],
+            measurements=[qml.sample(wires=[0, 1])],
+            shots=1000,
+        )
+
+        # after transform, only state prep and MCMs are present on the tape
+        (transformed_tape,), _ = convert_to_mbqc_formalism(tape)
+
+        graph_op = transformed_tape.operations[0]
+        final_op = transformed_tape.operations[-1]
+
+        assert isinstance(graph_op, GraphStatePrep)
+        assert isinstance(final_op, gate)
+
+        if gate is qml.Identity and initial_op.wires:
+            assert final_op.wires[0] != 3
+            assert graph_op.wires[-1] == final_op.wires[0]
+        # both GlobalPhases and the no-wire Identity have no wires, phase is retained
+        else:
+            assert final_op.wires == ()
+            assert final_op.data == initial_op.data
+
+    @pytest.mark.parametrize(
+        "measurements",
+        (
+            [qml.expval(qml.X(0))],
+            [qml.sample(wires=0), qml.counts(wires=2)],
+            [qml.sample(wires=0), qml.sample(wires=2)],
+        ),
+    )
+    def test_error_raised_for_bad_measurements(self, measurements):
+        """Test that an error is raised if the final output isn't a single sample measurement"""
+
+        tape = qml.tape.QuantumScript([qml.H(1), qml.X(0)], measurements=measurements, shots=1000)
+
+        with pytest.raises(
+            NotImplementedError,
+            match="final measurements have not been converted to a single samples measurement",
+        ):
+            _, _ = convert_to_mbqc_formalism(tape)
+
+    @pytest.mark.parametrize("mp", (qml.sample(wires=[2, 3]), qml.sample()))
+    def test_tape_wires_if_no_mp_wires(self, mp):
+        """Test that wires are taken the measurement process if possible, and otherwise
+        taken from the tape (no wires specified is interpreted as "all tape wires")"""
+
+        tape = qml.tape.QuantumScript([qml.H(1), qml.X(0), qml.Y(2)], measurements=[mp], shots=1000)
+
+        (transformed_tape,), _ = convert_to_mbqc_formalism(tape)
+
+        if mp.wires:
+            assert len(transformed_tape.measurements[0].wires) == len(mp.wires)
+        else:
+            assert len(transformed_tape.measurements[0].wires) == len(tape.wires)
+
+    @pytest.mark.slow
+    def test_conversion_of_multi_wire_circuit(self):
+        """Test that the transform converts the tape to the expected set of gates
+        correctly, and the returned tape continues to produce the expected output"""
+
+        dev = qml.device("lightning.qubit", seed=1234)
+
+        theta = 2.5
+        with qml.queuing.AnnotatedQueue() as q:
+            RotXZX(theta, 0, theta / 2, 0)
+            RotXZX(theta / 2, 0, theta / 4, 1)
+            qml.RZ(theta / 3, 0)
+            qml.X(0)
+            qml.H(1)
+            qml.S(1)
+            qml.Y(1)
+            qml.CNOT([0, 1])
+
+        base_tape = qml.tape.QuantumScript.from_queue(q)
+        tape = base_tape.copy(measurements=[qml.sample(wires=[0, 1])])
+        reference_tape = base_tape.copy(
+            measurements=[
+                qml.expval(qml.X(0)),
+                qml.expval(qml.X(1)),
+                qml.expval(qml.Y(0)),
+                qml.expval(qml.Y(1)),
+                qml.expval(qml.Z(0)),
+                qml.expval(qml.Z(1)),
+            ]
+        )
+
+        # after transform, only state prep and MCMs are present on the tape
+        (transformed_tape,), _ = convert_to_mbqc_formalism(tape)
+        expected_gates = (GraphStatePrep, qml.CZ, qml.measurements.MidMeasureMP, qml.X, qml.Y)
+        for op in transformed_tape.operations:
+            if isinstance(op, qml.ops.Conditional):
+                assert isinstance(op.base, (qml.X, qml.Z, qml.measurements.MidMeasureMP))
+            else:
+                assert isinstance(op, expected_gates)
+
+        # diagonalize final measurements, convert to the mbqc formalism, and execute
+        res = []
+        for obs in (qml.X, qml.Y, qml.Z):
+            ops = base_tape.operations + obs(0).diagonalizing_gates() + obs(1).diagonalizing_gates()
+            tape = base_tape.copy(
+                operations=ops, measurements=[qml.sample(wires=[0, 1])], shots=500
+            )
+            (mbqc_tape,), _ = convert_to_mbqc_formalism(tape)
+            (diagonalized_tape,), _ = diagonalize_mcms(mbqc_tape)
+
+            samples = qml.execute([diagonalized_tape], dev)[0]
+            for wire in (0, 1):
+                mp = qml.expval(obs(wire))
+                res.append(mp.process_samples(samples, wire_order=[0, 1]))
+
+        reference_result = qml.execute([reference_tape], dev)[0]
+
+        # analytic results, to 2 s.f., are (-0.40, 0.95, -0.37, -0.25, 0.82, 6.2e-17)
+        # an atol of 0.1 is not ideal for comparing to these results, but it's enough
+        # to catch changes that modify the results, and we have to choose here between
+        # very slow, or fairly noisy
+        assert np.allclose(res, reference_result, atol=0.1)
diff --git a/tests/ftqc/test_utils.py b/tests/ftqc/test_utils.py
index 88d16bd56..de37da8fb 100644
--- a/tests/ftqc/test_utils.py
+++ b/tests/ftqc/test_utils.py
@@ -16,9 +16,12 @@
 import itertools as it
 import random
 
+import numpy as np
 import pytest
 
 from pennylane.ftqc import QubitMgr
+from pennylane.ftqc.utils import parity
+from pennylane.measurements import MeasurementValue, MidMeasureMP
 
 # pylint: disable=too-few-public-methods, too-many-public-methods
 
@@ -28,6 +31,76 @@ acquire_num_vals = [0, 1, 5]
 offsets_vals = [0, 8, 9]
 
 
+@pytest.mark.parametrize("arg_type", [tuple, np.array])
+@pytest.mark.parametrize(
+    "args, expected_outcome",
+    [((0, 1, 1), 0), ((0, 0, 1), 1), ((1, 1, 1), 1), ((0, 0, 0, 0), 0), ((0, 1), 1)],
+)
+def test_parity_numeric(arg_type, args, expected_outcome):
+    """Test the parity function behaves as expected"""
+    assert parity(*arg_type(args)) == expected_outcome
+
+
+@pytest.mark.jax
+@pytest.mark.parametrize(
+    "args, expected_outcome",
+    [((0, 1, 1), 0), ((0, 0, 1), 1), ((1, 1, 1), 1), ((0, 0, 0, 0), 0), ((0, 1), 1)],
+)
+def test_parity_numeric_jax(args, expected_outcome):
+    """Test the parity function behaves as expected with jax and jax-jit"""
+    import jax
+
+    values = jax.numpy.array(args)
+
+    assert parity(*values) == expected_outcome
+    assert jax.jit(parity)(*values) == expected_outcome
+
+
+@pytest.mark.parametrize(
+    "args, expected_outcome",
+    [((0, 1, 1), 0), ((0, 0, 1), 1), ((1, 1, 1), 1), ((0, 0, 0, 0), 0), ((0, 1), 1)],
+)
+def test_parity_measurement_values(args, expected_outcome):
+    """Test that passing a sequence of MeasurementValues to the parity function
+    returns a single MeasurementValue referencing all the relevant measurements,
+    with a processing function that applied the parity check when concrete values
+    are provided."""
+    mvs = [
+        MeasurementValue([MidMeasureMP(0, id=3)], lambda x: x),
+        MeasurementValue([MidMeasureMP(1, id=4)], lambda x: x),
+        MeasurementValue([MidMeasureMP(2, id=5)], lambda x: x),
+        MeasurementValue([MidMeasureMP(3, id=6)], lambda x: x),
+    ]
+
+    # only use as many MeasurementValues as matches the input arguments
+    mvs = mvs[: len(args)]
+    par = parity(*mvs)
+
+    assert isinstance(par, MeasurementValue)
+    # measurements stored are all the input MeasurementValue's measurements
+    assert par.measurements == [mv.measurements[0] for mv in mvs]
+    # processing function on the new MeasurementValue applies the parity check
+    assert par.processing_fn(*args) == expected_outcome
+
+
+@pytest.mark.parametrize(
+    "args, expected_outcome",
+    [((0, 1, 1), 1), ((0, 0, 1), 0), ((1, 1, 1), 0)],
+)
+def test_parity_mvs_and_constant(args, expected_outcome):
+    """Test that passing a sequence of MeasurementValues and a constant to the parity
+    function returns the expected MeasurementValue with the correct processing function."""
+    m1 = MeasurementValue([MidMeasureMP(0, id=3)], lambda x: x)
+    m2 = MeasurementValue([MidMeasureMP(1, id=4)], lambda x: x)
+    m3 = MeasurementValue([MidMeasureMP(2, id=5)], lambda x: x)
+
+    par = parity(m1, m2, m3, 1)
+    assert isinstance(par, MeasurementValue)
+    assert par.measurements == [mv.measurements[0] for mv in (m1, m2, m3)]
+    # processing function includes adding 1 in the parity check
+    assert par.processing_fn(*args) == expected_outcome
+
+
 def _is_valid(num_q, acquire_q):
     "Utility function to select for valid/invalid parameters in tests"
     if num_q < acquire_q: