"""
This module defines :class:`PauliOp`, which represents a Pauli word as a pair
of bitmasks encoding X, Y, Z, and I operators across an arbitrary number of qubits.
Bitmask convention
------------------
Each qubit ``k`` is represented by bit ``k`` (i.e. ``1 << k``) in two integers:
===== ===== =========
``x`` ``z`` Operator
===== ===== =========
0 0 I
1 0 X
0 1 Z
1 1 Y
===== ===== =========
"""
from __future__ import annotations
from collections.abc import Iterable
from pennylane import Identity, X, Y, Z
[docs]
class PauliOp:
"""
A Pauli word represented as two integer bitmasks.
Using bitmasks instead of lists or dicts allows :class:`PauliOp` objects
to be hashed cheaply and compared in O(1), which is important because
Pauli propagation creates a very large number of them.
:attr:`__slots__` is used to minimise per-instance memory overhead.
The encoding maps each qubit ``k`` to bit ``k`` in two integers ``x`` and
``z`` according to the table below:
===== ===== =========
``x`` ``z`` Operator
===== ===== =========
0 0 I
1 0 X
0 1 Z
1 1 Y
===== ===== =========
Parameters
----------
x : int, optional
Bitmask encoding the qubits that carry an X or Y factor. Defaults to 0 (all identity).
z : int, optional
Bitmask encoding the qubits that carry a Z or Y factor. Defaults to 0 (all identity).
Examples
--------
>>> PauliOp(0b101, 0b110)
Y0 Z1 X2
>>> PauliOp(0b1001)
X0 X3
>>> PauliOp()
I
"""
__slots__ = ("x", "z")
def __init__(self, x: int = 0, z: int = 0) -> None:
self.x = x
self.z = z
def __hash__(self) -> int:
"""
Hash the Pauli word by its ``(x, z)`` bitmask pair.
Allows :class:`PauliOp` to be used as a dictionary key in
:class:`~pprop.pauli.sentence.PauliDict`.
Returns
-------
int
"""
return hash((self.x, self.z))
def __eq__(self, other: object) -> bool:
"""
Test equality with another :class:`PauliOp`.
Parameters
----------
other : object
The object to compare against.
Returns
-------
bool
``True`` if ``other`` is a :class:`PauliOp` with identical ``(x, z)`` masks.
"""
if not isinstance(other, PauliOp):
return NotImplemented
return self.x == other.x and self.z == other.z
def __getitem__(self, qubit: int) -> str:
"""
Return the single-qubit Pauli operator at ``qubit``.
Parameters
----------
qubit : int
Zero-based qubit index.
Returns
-------
str
One of ``"X"``, ``"Y"``, ``"Z"``, or ``"I"``.
"""
x_bit = (self.x >> qubit) & 1
z_bit = (self.z >> qubit) & 1
if x_bit and z_bit:
return "Y"
elif x_bit:
return "X"
elif z_bit:
return "Z"
else:
return "I"
[docs]
def set(self, qubit: int, op: str) -> None:
"""
Set the Pauli operator on a specific qubit in-place.
Updates the ``x`` and ``z`` bitmasks at bit position ``qubit`` to
reflect the requested operator according to the bitmask convention.
Parameters
----------
qubit : int
Zero-based qubit index to update.
op : str
Target operator; one of ``"I"``, ``"X"``, ``"Y"``, or ``"Z"``.
Raises
------
ValueError
If ``op`` is not one of the four valid Pauli operators.
"""
if op == "X":
self.x |= (1 << qubit) # set x bit
self.z &= ~(1 << qubit) # clear z bit
elif op == "Y":
self.x |= (1 << qubit) # set both bits
self.z |= (1 << qubit)
elif op == "Z":
self.x &= ~(1 << qubit) # clear x bit
self.z |= (1 << qubit) # set z bit
elif op == "I":
self.x &= ~(1 << qubit) # clear both bits
self.z &= ~(1 << qubit)
else:
raise ValueError(f"Invalid Pauli operator '{op}'; expected 'I', 'X', 'Y', or 'Z'")
[docs]
def qubits(self) -> set[int]:
"""
Return the set of qubits where this word acts non-trivially (not as I).
Returns
-------
set[int]
Qubit indices where ``self[k] != "I"``.
"""
active = set()
# Only need to inspect bits up to the highest set bit across both masks.
n = max(int(self.x).bit_length(), int(self.z).bit_length())
for i in range(n):
if ((self.x >> i) & 1) or ((self.z >> i) & 1):
active.add(i)
return active
[docs]
def weight(self) -> int:
"""
Return the Pauli weight, i.e. the number of non-identity single-qubit factors.
Computed as the popcount of ``x | z``: a qubit is non-identity if and
only if at least one of its ``x`` or ``z`` bits is set.
Returns
-------
int
Number of qubits where the operator is X, Y, or Z.
"""
return (self.x | self.z).bit_count()
[docs]
def zerobracket(self) -> bool:
"""
Return ``True`` if this Pauli word has zero expectation in all but the Z/I basis.
A Pauli word :math:`P` satisfies :math:`\\langle 0 | P | 0 \\rangle \\neq 0`
if and only if every single-qubit factor is either :math:`Z` or :math:`I`.
This is equivalent to checking that no X bit is set (``x == 0``).
Returns
-------
bool
"""
return self.x == 0
[docs]
def copy(self) -> PauliOp:
"""
Return a shallow copy of this :class:`PauliOp`.
Returns
-------
PauliOp
A new instance with identical ``x`` and ``z`` bitmasks.
"""
return PauliOp(self.x, self.z)
[docs]
def to_qml(self, indices: list[int]):
"""
Convert this :class:`PauliOp` to a PennyLane operator on a subset of qubits.
Only the qubits listed in ``indices`` are included; qubits acting as
identity are skipped. If all qubits are identity an
:class:`~pennylane.Identity` on wire 0 is returned as a fallback.
Parameters
----------
indices : list[int]
Qubit indices to include in the operator.
Returns
-------
pennylane.operation.Operator
Tensor product of single-qubit Pauli operators over ``indices``.
"""
ops = []
for k in indices:
p = self[k]
if p == "X":
ops.append(X(k))
elif p == "Y":
ops.append(Y(k))
elif p == "Z":
ops.append(Z(k))
# Identity factors are omitted from the tensor product.
if not ops:
return Identity(0)
# Build the tensor product left-to-right using PennyLane's @ operator.
result = ops[0]
for op in ops[1:]:
result @= op
return result
[docs]
@classmethod
def from_qml(cls, qml_op) -> PauliOp:
"""
Construct a :class:`PauliOp` from a PennyLane operator.
Accepts either a single-qubit PennyLane operator or an iterable of them
(e.g. the result of iterating over a tensor product).
Parameters
----------
qml_op : pennylane.operation.Operator or Iterable
A PennyLane X, Y, Z, or Identity operator, or an iterable thereof.
Returns
-------
PauliOp
Bitmask representation of the input operator.
Notes
-----
Identity operators are skipped; their bits remain 0, which is the
correct encoding for I.
"""
x_mask = 0
z_mask = 0
# Accept both a single operator and an iterable of operators.
ops = qml_op if isinstance(qml_op, Iterable) else [qml_op]
for op in ops:
wire = op.wires[0] # each op acts on exactly one qubit
cls_type = type(op)
if cls_type is X:
x_mask |= 1 << wire
elif cls_type is Y:
# Y = iXZ, so both bits are set
x_mask |= 1 << wire
z_mask |= 1 << wire
elif cls_type is Z:
z_mask |= 1 << wire
# Identity: both bits stay 0, nothing to do.
return cls(x=x_mask, z=z_mask)
def __repr__(self) -> str:
"""
Return a human-readable string representation of the Pauli word.
Returns
-------
str
Space-separated Pauli labels like ``"X0 Y2 Z3"``, or ``"I"`` for
the identity word.
"""
result = []
# Inspect all bit positions up to the highest set bit in either mask.
n_qubits = max(int(self.x).bit_length(), int(self.z).bit_length())
for k in range(n_qubits):
op = self[k]
if op != "I":
result.append(f"{op}{k}")
return " ".join(result) if result else "I"