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