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

1"""Pauli-Clifford circuit transform for the Fourier-tree algorithm. 

2 

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. 

10 

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""" 

17 

18from __future__ import annotations 

19 

20from typing import List, Optional, Tuple 

21 

22import numpy as np 

23import jax.numpy as jnp 

24 

25from qml_essentials.operations import ( 

26 Operation, 

27 PauliWord, 

28 Hermitian, 

29 RX, 

30 RY, 

31 RZ, 

32 PauliRot, 

33 Barrier, 

34 _cdtype, 

35) 

36 

37 

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. 

43 

44 A Pauli Circuit only consists of parameterised Pauli-rotations and Clifford 

45 gates, which is the default for the most common VQCs. 

46 """ 

47 

48 PAULI_ROTATION_GATES = ( 

49 RX, 

50 RY, 

51 RZ, 

52 PauliRot, 

53 ) 

54 

55 SKIPPABLE_OPERATIONS = (Barrier,) 

56 

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. 

65 

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``. 

72 

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 = [] 

80 

81 operations = PauliCircuit.get_clifford_pauli_gates(tape) 

82 

83 if n_qubits is None: 

84 n_qubits = PauliCircuit._infer_n_qubits(operations, observables) 

85 

86 pauli_gates, final_cliffords = PauliCircuit.commute_all_cliffords_to_the_end( 

87 operations, n_qubits 

88 ) 

89 

90 observables = PauliCircuit.cliffords_in_observable( 

91 final_cliffords, observables, n_qubits 

92 ) 

93 

94 return pauli_gates, observables 

95 

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] 

100 

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 

111 

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. 

120 

121 Args: 

122 operations (List[Operation]): The operations in the tape of the 

123 circuit 

124 n_qubits (int): Total number of qubits. 

125 

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 

146 

147 # No Clifford gates are in the circuit 

148 if not PauliCircuit._is_clifford(operations[-1]): 

149 return operations, [] 

150 

151 pauli_rotations = operations[:first_clifford] 

152 clifford_gates = operations[first_clifford:] 

153 

154 return pauli_rotations, clifford_gates 

155 

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. 

161 

162 Args: 

163 tape: List of operations recorded from the circuit. 

164 

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 ) 

189 

190 return operations 

191 

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) 

196 

197 @staticmethod 

198 def _is_clifford(operation: Operation) -> bool: 

199 """Whether an operation is a Clifford gate (reads ``Operation.is_clifford``). 

200 

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) 

205 

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) 

210 

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: 

218 

219 ``... C R_P(phi) ... -> ... R_{C P C^dagger}(phi) C ...`` 

220 

221 The evolved Pauli rotation is obtained by **symbolic** Clifford 

222 conjugation of the rotation generator (no matrices). 

223 

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. 

228 

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 

236 

237 param = pauli.parameters[0] 

238 

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() 

242 

243 # Clifford conjugation of a (Hermitian) Pauli generator yields +-1. 

244 param_factor = float(np.real(phase)) 

245 

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) 

250 

251 return new_pauli, clifford 

252 

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. 

259 

260 Args: 

261 pauli_str (str): Pauli string 

262 qubits (List[int]): Corresponding qubit indices. 

263 

264 Returns: 

265 Tuple[str, List[int]]: 

266 - Pauli string without identities 

267 - Qubits indices without the identities 

268 """ 

269 

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]) 

276 

277 return reduced_pauli_str, reduced_qubits 

278 

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). 

289 

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. 

295 

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 

308 

309 @staticmethod 

310 def _pauli_operation_from_word(word: PauliWord) -> Operation: 

311 """Build an observable :class:`Operation` from a symbolic Pauli word. 

312 

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 ) 

321 

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 

339 

340 obs._pauli_word = word 

341 return obs