Repository: PennyLaneAI/pennylane
Pull request #7433 (2025-05-14 19:21:33)

[FTQC] Add xz encoding method for Paulis
### Before submitting

Please complete the following checklist when submitting a PR:

- [x] All new features must include a unit test.
      If you've fixed a bug or added code that should be tested, add a test to the
      test directory!

- [x] All new functions and code must be clearly commented and documented.
      If you do make documentation changes, make sure that the docs build and
      render correctly by running `make docs`.

- [x] Ensure that the test suite passes, by running `make test`.

- [x] Add a new entry to the `doc/releases/changelog-dev.md` file, summarizing the
      change, and including a link back to the PR.

- [x] The PennyLane source code conforms to
      [PEP8 standards](https://www.python.org/dev/peps/pep-0008/).
      We check all of our code against [Pylint](https://www.pylint.org/).
      To lint modified files, simply `pip install pylint`, and then
      run `pylint pennylane/path/to/file.py`.

When all the above are checked, delete everything above the dashed
line and fill in the pull request template.

------------------------------------------------------------------------------------------------------------

**Context:**

[sc-91293]

This PR adds encode_xz related functions to a new `pauli_tacker` module.

Background

Paulis can be decomposed into $X$ and $Z$ together a phase information and it is referred as `xz_encoding` in this [ref](https://arxiv.org/abs/2103.02202).

A Pauli $P$ can be encoded as $encode_{xz}(P)=(x,z)=encode_{xz}(X^xZ^z)$. This means that:
 |$I$|$X$|$XZ$|$Z$|
|:-:|:-:|:-:|:-:|
 |$(0,0)$|$(1,0)$|$(1,1)$|$(0,1)$|

With this encoding, two Paulis can be multiplied up to global phase by xoring their encoded representation: $encode_{xz}(P_1P_2) = encode_{xz}(P_1) \oplus encode_{xz}(P_2) = (x_1 \oplus x_2, z_1 \oplus z_2)$


**Description of the Change:**

**Benefits:**

**Possible Drawbacks:**

**Related GitHub Issues:**


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.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7433?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.69%. Comparing base [(`e20baa8`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/e20baa87982a42bc97f23d18b3c4e68b479caa04?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`2ecfefe`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/2ecfefec948b1b3abc9258dbc311b301f01ddcc0?dropdown=coverage&el=desc&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           @@
##           master    #7433   +/-   ##
=======================================
  Coverage   99.69%   99.69%           
=======================================
  Files         529      529           
  Lines       50739    50757   +18     
=======================================
+ Hits        50584    50602   +18     
  Misses        155      155           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7433?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>
multiphaseCFD: > Member

> I think this makes a lot of sense as-is, since the full operator includes wire information that we would lose when we encode, and that's more clearly communicated by not accepting the wire information in the first place. But it's a little unexpected compared to a lot of behaviour in PennyLane (outside of resource estimation).

yep, that is the reason why the functions accept operator types. Thanks for pointing out how to ensure the consistency between ftqc and the rest of ecosystem. I updated the `pauli_prod` and  `pauli_to_xz` method to accept the instances of Operators , while keep `xz_to_pauli` independent of wire information. 

>  Can we update the docstring to give a very brief example, so it's immediately clear when skimming it what input in accepts?

Updated
multiphaseCFD: > I noticed that when you removed the `pauli_tracker` imports from `ftqc/__init__.py`, the docs for the new functions aren't created (or at least they don't show up in the table of contents for the `ftqc` module docs). Sphinx must have a way to generate docs for entire submodules, although I'm not sure how to do that.

Thanks @joeycarter . Nice catch! Let me try to fix it in the commuting PR
joeycarter: > > I noticed that when you removed the `pauli_tracker` imports from `ftqc/__init__.py`, the docs for the new functions aren't created (or at least they don't show up in the table of contents for the `ftqc` module docs). Sphinx must have a way to generate docs for entire submodules, although I'm not sure how to do that.
> 
> Thanks @joeycarter . Nice catch! Let me try to fix it in the commuting PR

I figured it out if you want to get it in here :slightly_smiling_face:. You can add the following lines to `doc/code/qml_ftqc.rst` (filing in the placeholders appropriately):

```rst
Pauli Tracker
^^^^^^^^^^^^^

.. currentmodule:: pennylane.ftqc.pauli_tracker

[Insert description of Pauli Tracker].

.. autosummary::
    :toctree: api

    pauli_to_xz
    xz_to_pauli
    pauli_prod
```

Tested locally and it looks good :heavy_check_mark: 

Patch:
diff --git a/doc/code/qml_ftqc.rst b/doc/code/qml_ftqc.rst
index fc1858855..6783cdabe 100644
--- a/doc/code/qml_ftqc.rst
+++ b/doc/code/qml_ftqc.rst
@@ -10,3 +10,20 @@ qml.ftqc
 .. automodapi:: pennylane.ftqc
     :no-heading:
     :include-all-objects:
+
+Overview
+--------
+
+Pauli Tracker
+^^^^^^^^^^^^^
+
+This module contains functions for tracking, commuting Pauli operations in a Clifford circuit as well as getting measurement corrections.
+
+.. currentmodule:: pennylane.ftqc.pauli_tracker
+
+.. autosummary::
+    :toctree: api
+
+    ~pauli_to_xz
+    ~xz_to_pauli
+    ~pauli_prod
\ No newline at end of file
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index f5dce342e..412560755 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -310,6 +310,9 @@
 * Alias for Identity (`I`) is now accessible from `qml.ops`.
   [(#7200)](https://github.com/PennyLaneAI/pennylane/pull/7200)
 
+* Add xz encoding related `pauli_to_xz`, `xz_to_pauli` and `pauli_prod` functions to the `ftqc` module.
+  [(#7433)](https://github.com/PennyLaneAI/pennylane/pull/7433)
+
 * The `ftqc` module `measure_arbitrary_basis`, `measure_x` and `measure_y` functions
   can now be captured when program capture is enabled.
   [(#7219)](https://github.com/PennyLaneAI/pennylane/pull/7219)
@@ -609,6 +612,7 @@ Anton Naim Ibrahim,
 Lee J. O'Riordan,
 Mudit Pandey,
 Andrija Paurevic,
+Shuli Shu,
 Kalman Szenes,
 David Wierichs,
 Jake Zaia
diff --git a/pennylane/ftqc/pauli_tracker.py b/pennylane/ftqc/pauli_tracker.py
new file mode 100644
index 000000000..a64b2634d
--- /dev/null
+++ b/pennylane/ftqc/pauli_tracker.py
@@ -0,0 +1,140 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+r"""
+This module contains Pauli Tracking functions.
+"""
+
+from typing import List, Tuple
+
+import numpy as np
+
+from pennylane import I, X, Y, Z
+from pennylane.operation import Operator
+
+_OPS_TO_XZ = {
+    I: (0, 0),
+    X: (1, 0),
+    Y: (1, 1),
+    Z: (0, 1),
+}
+
+_XZ_TO_OPS = {
+    (0, 0): I,
+    (1, 0): X,
+    (1, 1): Y,
+    (0, 1): Z,
+}
+
+_PAULIS = frozenset({X, Y, Z, I})
+
+
+def pauli_to_xz(op: Operator) -> Tuple[np.uint8, np.uint8]:
+    r"""
+    Convert a `Pauli` operator to its `xz` representation up to a global phase, i.e., :math:`encode_{xz}(Pauli)=(x,z)=X^xZ^z`, where
+    :math:`x` is the exponent of the :class:`~pennylane.X` and :math:`z` is the exponent of
+    the :class:`~pennylane.Z`, meaning :math:`encode_{xz}(I) = (0, 0)`, :math:`encode_{xz}(X) = (1, 0)`,
+    :math:`encode_{xz}(Y) = (1, 1)` and :math:`encode_{xz}(Z) = (0, 1)`.
+
+    Args:
+        op (qml.operation.Operator): A Pauli operator.
+
+    Return:
+        A tuple of xz encoding data, :math:`x` is the exponent of the :class:`~pennylane.X`, :math:`z` is the exponent of
+        the :class:`~pennylane.Z`.
+
+    **Example:**
+        The following example shows how the Pauli to XZ works.
+
+        .. code-block:: python3
+
+            from pennylane.ftqc.pauli_tracker import pauli_to_xz
+            from pennylane import I
+            >>> pauli_to_xz(I(0))
+            (0, 0)
+
+        A xz tuple representation is returned for a given Pauli operator.
+    """
+
+    if type(op) in _PAULIS:
+        return _OPS_TO_XZ[type(op)]
+    raise NotImplementedError(f"{op.name} gate does not support xz encoding.")
+
+
+def xz_to_pauli(x: np.uint8, z: np.uint8) -> Operator:
+    """
+    Convert x, z to a Pauli operator class.
+
+    Args:
+        x (np.uint8) : Exponent of :class:`~pennylane.X` in the Pauli record.
+        z (np.uint8) : Exponent of :class:`~pennylane.Z` in the Pauli record.
+
+    Return:
+        A Pauli operator class.
+
+    **Example:**
+        The following example shows how the XZ to Pauli works.
+
+        .. code-block:: python3
+
+            from pennylane.ftqc.pauli_tracker import xz_to_pauli
+            >>> xz_to_pauli(0, 0)(wires=0)
+            I(0)
+
+        A Pauli operator class is returned for a given xz tuple.
+    """
+    if x in [0, 1] and z in [0, 1]:
+        return _XZ_TO_OPS[(x, z)]
+    raise ValueError("x and z should either 0 or 1.")
+
+
+def pauli_prod(ops: List[Operator]) -> Operator:
+    """
+    Get the result of a product of a list of Pauli operators. The result is a new Pauli operator up to a global phase.
+
+    Args:
+        ops (List[qml.operation.Operator]): A list of Pauli operators with the same target wire.
+
+    Return:
+        A new Pauli operator.
+
+    **Example:**
+        The following example shows how the `pauli_prod` works.
+
+        .. code-block:: python3
+
+            from pennylane.ftqc.pauli_tracker import pauli_prod
+            from pennylane import I, X, Y, Z
+            >>> pauli_prod([I(0),X(0),Y(0),Z(0)])
+            I(0)
+
+        A Pauli operator is returned for a list of Pauli operator up to a global phase.
+    """
+
+    if len(ops) == 0:
+        raise ValueError("Please ensure that a valid list of operators are passed to the method.")
+    op0 = ops.pop()
+    res_x, res_z = pauli_to_xz(op0)
+    op0_wire = op0.wires
+
+    while len(ops) > 0:
+        op = ops.pop()
+        wire = op.wires
+        if wire != op0_wire:
+            raise ValueError("All operators should target at the same wire.")
+        x, z = pauli_to_xz(op)
+        res_x ^= x
+        res_z ^= z
+
+    return xz_to_pauli(res_x, res_z)(wires=op0_wire)


Test patch:
diff --git a/tests/ftqc/test_pauli_tracker.py b/tests/ftqc/test_pauli_tracker.py
new file mode 100644
index 000000000..716b72d15
--- /dev/null
+++ b/tests/ftqc/test_pauli_tracker.py
@@ -0,0 +1,85 @@
+# Copyright 2018-2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Unit tests for the ftqc.pauli_tracker module"""
+
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.pauli_tracker import pauli_prod, pauli_to_xz, xz_to_pauli
+
+
+class TestPauliTracker:
+    """Test for the pauli tracker related functions."""
+
+    @pytest.mark.parametrize(
+        "op, expected",
+        [(qml.I(0), (0, 0)), (qml.X(1), (1, 0)), (qml.Y(0), (1, 1)), (qml.Z(0), (0, 1))],
+    )
+    def test_pauli_to_xz(self, op, expected):
+        xz = pauli_to_xz(op)
+        assert xz == expected
+
+    @pytest.mark.parametrize("op", [qml.S(0), qml.CNOT(wires=[0, 1]), qml.H(2)])
+    def test_unsuppored_ops_pauli_to_xz(self, op):
+        with pytest.raises(NotImplementedError):
+            _ = pauli_to_xz(op)
+
+    @pytest.mark.parametrize(
+        "x, z, expected", [(0, 0, qml.I), (1, 0, qml.X), (1, 1, qml.Y), (0, 1, qml.Z)]
+    )
+    def test_xz_to_pauli(self, x, z, expected):
+        op = xz_to_pauli(x, z)
+        assert op == expected
+
+    @pytest.mark.parametrize("x, z", [(0, -1), (-1, 0), (-1, -1)])
+    def test_xz_decode_pauli_unsupported_error(self, x, z):
+        with pytest.raises(ValueError):
+            _ = xz_to_pauli(x, z)
+
+    @pytest.mark.parametrize(
+        "ops, expected",
+        [
+            ([qml.I(0)], qml.I(0)),
+            ([qml.X(1)], qml.X(1)),
+            ([qml.Y(2)], qml.Y(2)),
+            ([qml.Z(3)], qml.Z(3)),
+            ([qml.I(0), qml.I(0)], qml.I(0)),
+            ([qml.I(1), qml.X(1)], qml.X(1)),
+            ([qml.I(2), qml.Y(2)], qml.Y(2)),
+            ([qml.I(3), qml.Z(3)], qml.Z(3)),
+            ([qml.X(0), qml.I(0)], qml.X(0)),
+            ([qml.X(1), qml.X(1)], qml.I(1)),
+            ([qml.X(2), qml.Y(2)], qml.Z(2)),
+            ([qml.X(3), qml.Z(3)], qml.Y(3)),
+            ([qml.Y(0), qml.I(0)], qml.Y(0)),
+            ([qml.Y(1), qml.X(1)], qml.Z(1)),
+            ([qml.Y(2), qml.Y(2)], qml.I(2)),
+            ([qml.Y(3), qml.Z(3)], qml.X(3)),
+            ([qml.Z(0), qml.I(0)], qml.Z(0)),
+            ([qml.Z(1), qml.X(1)], qml.Y(1)),
+            ([qml.Z(2), qml.Y(2)], qml.X(2)),
+            ([qml.Z(3), qml.Z(3)], qml.I(3)),
+            ([qml.X(4), qml.Y(4), qml.Z(4), qml.I(4), qml.Z(4)], qml.Z(4)),
+        ],
+    )
+    def test_pauli_prod(self, ops, expected):
+        op = pauli_prod(ops)
+        assert op == expected
+
+    @pytest.mark.parametrize(
+        "ops", [([]), ([qml.X(0), qml.I(1)]), ([qml.X(0), qml.Y(0), qml.Z(1)])]
+    )
+    def test_pauli_prod_to_xz_unsupported_error(self, ops):
+        with pytest.raises(ValueError):
+            _ = pauli_prod(ops)