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

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:`jaqsi.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:`~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""" 

17 

18from __future__ import annotations 

19 

20from typing import List, Optional, Tuple 

21 

22import numpy as np 

23import jax.numpy as jnp 

24 

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) 

40 

41 

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. 

47 

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

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

50 """ 

51 

52 PAULI_ROTATION_GATES = ( 

53 RX, 

54 RY, 

55 RZ, 

56 PauliRot, 

57 ) 

58 

59 SKIPPABLE_OPERATIONS = (Barrier,) 

60 

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. 

69 

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

76 

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

84 

85 operations = PauliCircuit.get_clifford_pauli_gates(tape) 

86 

87 if n_qubits is None: 

88 n_qubits = PauliCircuit._infer_n_qubits(operations, observables) 

89 

90 pauli_gates, final_cliffords = PauliCircuit.commute_all_cliffords_to_the_end( 

91 operations, n_qubits 

92 ) 

93 

94 observables = PauliCircuit.cliffords_in_observable( 

95 final_cliffords, observables, n_qubits 

96 ) 

97 

98 return pauli_gates, observables 

99 

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] 

104 

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 

115 

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. 

124 

125 Args: 

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

127 circuit 

128 n_qubits (int): Total number of qubits. 

129 

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 

150 

151 # No Clifford gates are in the circuit 

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

153 return operations, [] 

154 

155 pauli_rotations = operations[:first_clifford] 

156 clifford_gates = operations[first_clifford:] 

157 

158 return pauli_rotations, clifford_gates 

159 

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. 

165 

166 Args: 

167 tape: List of operations recorded from the circuit. 

168 

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 ) 

193 

194 return operations 

195 

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) 

200 

201 @staticmethod 

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

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

204 

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) 

209 

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) 

214 

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: 

222 

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

224 

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

226 conjugation of the rotation generator (no matrices). 

227 

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. 

232 

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 

240 

241 param = pauli.parameters[0] 

242 

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

246 

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

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

249 

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) 

254 

255 return new_pauli, clifford 

256 

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. 

263 

264 Args: 

265 pauli_str (str): Pauli string 

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

267 

268 Returns: 

269 Tuple[str, List[int]]: 

270 - Pauli string without identities 

271 - Qubits indices without the identities 

272 """ 

273 

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

280 

281 return reduced_pauli_str, reduced_qubits 

282 

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

293 

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. 

299 

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 

312 

313 @staticmethod 

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

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

316 

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 ) 

325 

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 

343 

344 obs._pauli_word = word 

345 return obs