Coverage for qml_essentials / pauli.py: 95%
108 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-11 15:51 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-11 15:51 +0000
1"""Pauli-Clifford circuit transform for the Fourier-tree algorithm.
3This module hosts :class:`PauliCircuit`, which transpiles a circuit into the
4*canonical Pauli-Clifford normal form* used by the Nemkov et al. algorithm: all
5Clifford gates are commuted to the end and absorbed into the observable, leaving
6a sequence of Pauli rotations. A circuit is represented throughout as a plain
7``List[Operation]`` tape (the same type produced by
8:func:`qml_essentials.tape.recording`); the transform returns the rotated
9operations together with the Clifford-evolved observables.
11The Clifford conjugation that drives this transform is done **symbolically** via
12:class:`~qml_essentials.operations.PauliWord` (stabilizer-tableau updates, O(n)),
13replacing the previous matrix-based path
14(:func:`~qml_essentials.operations.evolve_pauli_with_clifford` +
15:func:`~qml_essentials.operations.pauli_decompose`, which was O(2^n)+O(4^n)).
16"""
18from __future__ import annotations
20from typing import List, Optional, Tuple
22import numpy as np
23import jax.numpy as jnp
25from qml_essentials.operations import (
26 Operation,
27 PauliWord,
28 Hermitian,
29 RX,
30 RY,
31 RZ,
32 PauliRot,
33 Barrier,
34 _cdtype,
35)
38class PauliCircuit:
39 """
40 Wrapper for Pauli-Clifford Circuits described by Nemkov et al.
41 (https://doi.org/10.1103/PhysRevA.108.032406). The code is inspired
42 by the corresponding implementation: https://github.com/idnm/FourierVQA.
44 A Pauli Circuit only consists of parameterised Pauli-rotations and Clifford
45 gates, which is the default for the most common VQCs.
46 """
48 PAULI_ROTATION_GATES = (
49 RX,
50 RY,
51 RZ,
52 PauliRot,
53 )
55 SKIPPABLE_OPERATIONS = (Barrier,)
57 @staticmethod
58 def from_parameterised_circuit(
59 tape: List[Operation],
60 observables: Optional[List[Operation]] = None,
61 n_qubits: Optional[int] = None,
62 ) -> Tuple[List[Operation], List[Operation]]:
63 """
64 Transforms a list of operations into a Pauli-Clifford circuit.
66 Args:
67 tape: List of operations recorded from the circuit.
68 observables: List of observable operations. If ``None``, defaults
69 to an empty list.
70 n_qubits: Total number of qubits. Inferred from the maximum wire
71 index if ``None``.
73 Returns:
74 Tuple[List[Operation], List[Operation]]:
75 The Pauli rotations of the canonical circuit and the
76 (Clifford-evolved) observables.
77 """
78 if observables is None:
79 observables = []
81 operations = PauliCircuit.get_clifford_pauli_gates(tape)
83 if n_qubits is None:
84 n_qubits = PauliCircuit._infer_n_qubits(operations, observables)
86 pauli_gates, final_cliffords = PauliCircuit.commute_all_cliffords_to_the_end(
87 operations, n_qubits
88 )
90 observables = PauliCircuit.cliffords_in_observable(
91 final_cliffords, observables, n_qubits
92 )
94 return pauli_gates, observables
96 @staticmethod
97 def get_parameters(operations: List[Operation]) -> list:
98 """Flatten the parameter values of a tape (list of operations)."""
99 return [p for op in operations for p in op.parameters]
101 @staticmethod
102 def _infer_n_qubits(
103 operations: List[Operation], observables: List[Operation]
104 ) -> int:
105 """Infer the register size from the maximum wire index used."""
106 max_wire = -1
107 for op in list(operations) + list(observables):
108 if op.wires:
109 max_wire = max(max_wire, max(op.wires))
110 return max_wire + 1
112 @staticmethod
113 def commute_all_cliffords_to_the_end(
114 operations: List[Operation],
115 n_qubits: int,
116 ) -> Tuple[List[Operation], List[Operation]]:
117 """
118 This function moves all clifford gates to the end of the circuit,
119 accounting for commutation rules.
121 Args:
122 operations (List[Operation]): The operations in the tape of the
123 circuit
124 n_qubits (int): Total number of qubits.
126 Returns:
127 Tuple[List[Operation], List[Operation]]:
128 - List of the resulting Pauli-rotations
129 - List of the resulting Clifford gates
130 """
131 first_clifford = -1
132 for i in range(len(operations) - 2, -1, -1):
133 j = i
134 while (
135 j + 1 < len(operations) # Clifford has not alredy reached the end
136 and PauliCircuit._is_clifford(operations[j])
137 and PauliCircuit._is_pauli_rotation(operations[j + 1])
138 ):
139 pauli, clifford = PauliCircuit._evolve_clifford_rotation(
140 operations[j], operations[j + 1], n_qubits
141 )
142 operations[j] = pauli
143 operations[j + 1] = clifford
144 j += 1
145 first_clifford = j
147 # No Clifford gates are in the circuit
148 if not PauliCircuit._is_clifford(operations[-1]):
149 return operations, []
151 pauli_rotations = operations[:first_clifford]
152 clifford_gates = operations[first_clifford:]
154 return pauli_rotations, clifford_gates
156 @staticmethod
157 def get_clifford_pauli_gates(tape: List[Operation]) -> List[Operation]:
158 """
159 This function decomposes all gates in the circuit to clifford and
160 pauli-rotation gates.
162 Args:
163 tape: List of operations recorded from the circuit.
165 Returns:
166 List[Operation]: A list of operations consisting only of clifford
167 and Pauli-rotation gates.
168 """
169 operations = []
170 for operation in tape:
171 if PauliCircuit._is_clifford(operation) or PauliCircuit._is_pauli_rotation(
172 operation
173 ):
174 operations.append(operation)
175 elif PauliCircuit._is_skippable(operation):
176 continue
177 else:
178 # Composite gates (Rot, CRX/CRY/CRZ, ...) expose their own
179 # Clifford + Pauli-rotation decomposition.
180 try:
181 operations.extend(operation.decompose())
182 except NotImplementedError:
183 raise NotImplementedError(
184 f"Gate {operation.name} cannot be decomposed into "
185 "Pauli rotations and Clifford gates. Consider using a "
186 "circuit ansatz that only uses RX, RY, RZ, PauliRot, "
187 "Rot, and standard Clifford gates."
188 )
190 return operations
192 @staticmethod
193 def _is_skippable(operation: Operation) -> bool:
194 """Whether an operation can be ignored (currently only barriers)."""
195 return isinstance(operation, PauliCircuit.SKIPPABLE_OPERATIONS)
197 @staticmethod
198 def _is_clifford(operation: Operation) -> bool:
199 """Whether an operation is a Clifford gate (reads ``Operation.is_clifford``).
201 Clifford gates are commuted to the end via symbolic conjugation
202 (:meth:`PauliWord.conjugate_by_clifford`); see ``Operation.is_clifford``.
203 """
204 return getattr(operation, "is_clifford", False)
206 @staticmethod
207 def _is_pauli_rotation(operation: Operation) -> bool:
208 """Whether an operation is a Pauli rotation gate."""
209 return isinstance(operation, PauliCircuit.PAULI_ROTATION_GATES)
211 @staticmethod
212 def _evolve_clifford_rotation(
213 clifford: Operation, pauli: Operation, n_qubits: int
214 ) -> Tuple[Operation, Operation]:
215 """
216 Compute the resulting operations when switching a Clifford gate and a
217 Pauli rotation in the circuit, i.e. move the Clifford past the rotation:
219 ``... C R_P(phi) ... -> ... R_{C P C^dagger}(phi) C ...``
221 The evolved Pauli rotation is obtained by **symbolic** Clifford
222 conjugation of the rotation generator (no matrices).
224 Args:
225 clifford (Operation): Clifford gate to move.
226 pauli (Operation): Pauli rotation gate to move the clifford past.
227 n_qubits (int): Total number of qubits.
229 Returns:
230 Tuple[Operation, Operation]:
231 - Evolved Pauli rotation operator
232 - The (unchanged) Clifford operator
233 """
234 if not any(p_c in clifford.wires for p_c in pauli.wires):
235 return pauli, clifford
237 param = pauli.parameters[0]
239 gen_word = PauliWord.from_operation(pauli, n_qubits)
240 evolved = gen_word.conjugate_by_clifford(clifford, adjoint_left=False)
241 bare, phase = evolved.to_pauli_string_and_phase()
243 # Clifford conjugation of a (Hermitian) Pauli generator yields +-1.
244 param_factor = float(np.real(phase))
246 pauli_str, qubits = PauliCircuit._remove_identities_from_paulistr(
247 bare, list(range(n_qubits))
248 )
249 new_pauli = PauliRot(param * param_factor, pauli_str, qubits)
251 return new_pauli, clifford
253 @staticmethod
254 def _remove_identities_from_paulistr(
255 pauli_str: str, qubits: List[int]
256 ) -> Tuple[str, List[int]]:
257 """
258 Removes identities from Pauli string and its corresponding qubits.
260 Args:
261 pauli_str (str): Pauli string
262 qubits (List[int]): Corresponding qubit indices.
264 Returns:
265 Tuple[str, List[int]]:
266 - Pauli string without identities
267 - Qubits indices without the identities
268 """
270 reduced_qubits = []
271 reduced_pauli_str = ""
272 for i, p in enumerate(pauli_str):
273 if p != "I":
274 reduced_pauli_str += p
275 reduced_qubits.append(qubits[i])
277 return reduced_pauli_str, reduced_qubits
279 @staticmethod
280 def cliffords_in_observable(
281 operations: List[Operation],
282 original_obs: List[Operation],
283 n_qubits: int,
284 ) -> List[Operation]:
285 """
286 Integrates Clifford gates into the observables of the original ansatz,
287 by symbolically conjugating each observable through the final Clifford
288 sequence (``O -> C^dagger O C`` for each Clifford, applied in reverse).
290 Args:
291 operations (List[Operation]): Clifford gates
292 original_obs (List[Operation]): Original observables from the
293 circuit
294 n_qubits (int): Total number of qubits.
296 Returns:
297 List[Operation]: Observables with Clifford operations absorbed.
298 Each carries a cached symbolic ``_pauli_word`` for the
299 Fourier-tree algorithm and a matrix for simulation.
300 """
301 observables = []
302 for ob in original_obs:
303 word = PauliWord.from_operation(ob, n_qubits)
304 for clifford in operations[::-1]:
305 word = word.conjugate_by_clifford(clifford, adjoint_left=True)
306 observables.append(PauliCircuit._pauli_operation_from_word(word))
307 return observables
309 @staticmethod
310 def _pauli_operation_from_word(word: PauliWord) -> Operation:
311 """Build an observable :class:`Operation` from a symbolic Pauli word.
313 The returned operation carries both a dense ``matrix`` (for the
314 statevector simulator) and a cached ``_pauli_word`` / ``_pauli_label``
315 (for symbolic consumers such as the Fourier tree).
316 """
317 bare, phase = word.to_pauli_string_and_phase()
318 reduced_str, reduced_wires = PauliCircuit._remove_identities_from_paulistr(
319 bare, list(range(word.n_qubits))
320 )
322 if not reduced_str:
323 obs = Hermitian(
324 matrix=phase * jnp.eye(2, dtype=_cdtype()), wires=[0], record=False
325 )
326 obs._pauli_label = "I"
327 else:
328 # Reuse the canonical Pauli matrix construction (bare string, then
329 # multiply by the leading +-1/+-i phase).
330 reduced_word = PauliWord.from_pauli_string(
331 reduced_str, list(range(len(reduced_str))), len(reduced_str)
332 )
333 obs = Hermitian(
334 matrix=phase * reduced_word.to_matrix(),
335 wires=reduced_wires,
336 record=False,
337 )
338 obs._pauli_label = reduced_str
340 obs._pauli_word = word
341 return obs