Coverage for qml_essentials / ansaetze.py: 95%

370 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-03 12:38 +0000

1from abc import ABC, abstractmethod 

2import functools 

3from typing import Any, Optional, List, Union, Callable, Tuple 

4import jax.numpy as np 

5import logging 

6import warnings 

7 

8from jaqsi.gates import Gates, PulseInformation 

9from jaqsi.gateset import DiagonalQubitUnitary 

10from jaqsi.unitary import UnitaryGates 

11 

12from qml_essentials.topologies import Topology 

13 

14log = logging.getLogger(__name__) 

15 

16 

17class Circuit(ABC): 

18 """Abstract base class for quantum circuit ansätze.""" 

19 

20 def __init__(self) -> None: 

21 """Initialize the circuit.""" 

22 pass 

23 

24 @abstractmethod 

25 def n_params_per_layer(self, n_qubits: int) -> int: 

26 """ 

27 Get the number of parameters per circuit layer. 

28 

29 Args: 

30 n_qubits (int): Number of qubits in the circuit. 

31 

32 Returns: 

33 int: Number of parameters required per layer. 

34 

35 Raises: 

36 NotImplementedError: Must be implemented by subclasses. 

37 """ 

38 raise NotImplementedError("n_params_per_layer method is not implemented") 

39 

40 def n_pulse_params_per_layer(self, n_qubits: int) -> int: 

41 """ 

42 Get the number of pulse parameters per circuit layer. 

43 

44 Subclasses that do not use pulse-level simulation do not need to 

45 override this method. 

46 

47 Args: 

48 n_qubits (int): Number of qubits in the circuit. 

49 

50 Returns: 

51 int: Number of pulse parameters required per layer. 

52 

53 Raises: 

54 NotImplementedError: If called but not overridden by subclass. 

55 """ 

56 raise NotImplementedError("n_pulse_params_per_layer method is not implemented") 

57 

58 @abstractmethod 

59 def get_control_indices(self, n_qubits: int) -> Optional[List[int]]: 

60 """ 

61 Get indices for controlled rotation gates in one layer. 

62 

63 Returns slice indices [start:stop:step] for extracting controlled 

64 gate parameters from a full parameter array for one layer. 

65 

66 Args: 

67 n_qubits (int): Number of qubits in the circuit. 

68 

69 Returns: 

70 Optional[List[int]]: List of three integers [start, stop, step] 

71 for slicing, or None if the circuit contains no controlled 

72 rotation gates. 

73 

74 Raises: 

75 NotImplementedError: Must be implemented by subclasses. 

76 """ 

77 raise NotImplementedError("get_control_indices method is not implemented") 

78 

79 def get_control_angles(self, w: np.ndarray, n_qubits: int) -> Optional[np.ndarray]: 

80 """ 

81 Extract angles for controlled rotation gates from parameter array. 

82 

83 Args: 

84 w (np.ndarray): Parameter array for one layer. 

85 n_qubits (int): Number of qubits in the circuit. 

86 

87 Returns: 

88 Optional[np.ndarray]: Array of controlled gate parameters, 

89 or empty array if circuit contains no controlled gates. 

90 """ 

91 indices = self.get_control_indices(n_qubits) 

92 if indices is None: 

93 return np.array([]) 

94 

95 if len(indices) == 3 and None in indices: 

96 return w[indices[0] : indices[1] : indices[2]] 

97 else: 

98 return w.take(np.array(indices)) 

99 

100 def _build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any: 

101 """ 

102 Build one layer of the circuit using unitary or pulse-level parameters. 

103 

104 Internal method that handles pulse parameter validation and context 

105 management before delegating to the build() method. 

106 

107 Args: 

108 w (np.ndarray): Parameter array for the current layer. 

109 n_qubits (int): Number of qubits in the circuit. 

110 **kwargs: Additional keyword arguments: 

111 - pulse (bool): Whether to run the gates at pulse level. 

112 Defaults to False. 

113 - pulse_params (np.ndarray): Pulse parameters if pulse=True. 

114 - noise_params (Dict): Noise parameters dictionary. 

115 

116 Returns: 

117 Any: Result from the build() method. 

118 

119 Raises: 

120 ValueError: If pulse_params length doesn't match expected count. 

121 """ 

122 pulse = kwargs.get("pulse", False) 

123 

124 if pulse and "pulse_params" in kwargs: 

125 pulse_params_per_layer = self.n_pulse_params_per_layer(n_qubits) 

126 

127 if len(kwargs["pulse_params"]) != pulse_params_per_layer: 

128 raise ValueError( 

129 f"Pulse params length {len(kwargs['pulse_params'])} " 

130 f"does not match expected {pulse_params_per_layer} " 

131 f"for {n_qubits} qubits" 

132 ) 

133 

134 with Gates.pulse_manager_context(kwargs["pulse_params"]): 

135 return self.build(w, n_qubits, **kwargs) 

136 else: 

137 return self.build(w, n_qubits, **kwargs) 

138 

139 @abstractmethod 

140 def build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any: 

141 """ 

142 Build one layer of the quantum circuit. 

143 

144 Args: 

145 w (np.ndarray): Parameter array for the current layer. 

146 n_qubits (int): Number of qubits in the circuit. 

147 **kwargs: Additional keyword arguments passed from _build. 

148 

149 Returns: 

150 Any: Circuit construction result. 

151 

152 Raises: 

153 NotImplementedError: Must be implemented by subclasses. 

154 """ 

155 raise NotImplementedError("build method is not implemented") 

156 

157 def __call__(self, *args: Any, **kwds: Any) -> Any: 

158 """Call the _build method with provided arguments.""" 

159 self._build(*args, **kwds) 

160 

161 

162class DeclarativeCircuit(Circuit): 

163 """ 

164 A circuit defined entirely by a sequence of Block descriptors. 

165 

166 Subclasses only need to set the class attribute `structure` — a tuple of 

167 

168 All of `n_params_per_layer`, `n_pulse_params_per_layer`, 

169 `get_control_indices`, and `build` are derived automatically. 

170 """ 

171 

172 @classmethod 

173 def structure(cls) -> Tuple[Any, ...]: 

174 """Override in subclass to return the structure tuple.""" 

175 raise NotImplementedError 

176 

177 @classmethod 

178 def n_params_per_layer(cls, n_qubits: int) -> int: 

179 return sum(block.n_params(n_qubits) for block in cls.structure()) 

180 

181 @classmethod 

182 def n_pulse_params_per_layer(cls, n_qubits: int) -> int: 

183 return sum(block.n_pulse_params(n_qubits) for block in cls.structure()) 

184 

185 @classmethod 

186 def get_control_indices(cls, n_qubits: int) -> Optional[List]: 

187 """ 

188 Computes parameter indices for controlled rotation Gates. 

189 Scans the structure for Block with 

190 [start, stop, step] into the flat parameter vector, or None. 

191 """ 

192 structure = cls.structure() 

193 total_params = sum(block.n_params(n_qubits) for block in structure) 

194 

195 # Collect which parameter indices correspond to controlled rotations 

196 controlled_indices = [] 

197 offset = 0 

198 for block in structure: 

199 n = block.n_params(n_qubits) 

200 if block.is_controlled_rotation: 

201 controlled_indices.extend(range(offset, offset + n)) 

202 offset += n 

203 

204 # FIXME: this last part should be reworked 

205 

206 if not controlled_indices: 

207 return None 

208 

209 # Check if indices form a contiguous tail (the common case) 

210 # This preserves backwards compatibility with the [start, None, None] format 

211 if controlled_indices == list( 

212 range(total_params - len(controlled_indices), total_params) 

213 ): 

214 return [-len(controlled_indices), None, None] 

215 

216 # Fallback: return raw indices (future-proof) 

217 return controlled_indices 

218 

219 @classmethod 

220 def build(cls, w: np.ndarray, n_qubits: int, **kwargs: Any) -> None: 

221 structure = cls.structure() 

222 w_idx = 0 

223 for block in structure: 

224 w_idx = block.apply(n_qubits, w, w_idx, **kwargs) 

225 Gates.Barrier(wires=list(range(n_qubits)), **kwargs) 

226 

227 

228class Block: 

229 def __init__( 

230 self, 

231 gate: str, 

232 topology: Any = None, 

233 shared: bool = False, 

234 wires: Optional[List[int]] = None, 

235 **kwargs, 

236 ): 

237 """ 

238 Initialize a Block object; the atoms of Ansatzes. 

239 

240 Args: 

241 gate (str): Name of the Gate class to use. 

242 topology (Any, optional): Topology of the gate for entangling gates. 

243 Defaults to None. 

244 shared (bool, optional): Tie all gates of the block to a single 

245 parameter (per-gate width), instead of one parameter per gate. 

246 Defaults to False. 

247 wires (Optional[List[int]], optional): Fixed wires for a 

248 non-entangling block. If None, the block spans all qubits. 

249 Defaults to None. 

250 kwargs (Any): Additional keyword arguments passed to the topology function. 

251 """ 

252 if isinstance(gate, str): 

253 self.gate = getattr(Gates, gate) 

254 else: 

255 self.gate = gate 

256 

257 if self.is_entangling: 

258 assert topology is not None, ( 

259 "Topology must be specified for entangling gates" 

260 ) 

261 

262 self.topology = topology 

263 self.shared = shared 

264 self.wires = wires 

265 self.kwargs = kwargs 

266 

267 def __repr__(self): 

268 if self.topology is None: 

269 return f"{self.__class__.__name__}({self.gate.__name__})" 

270 else: 

271 return ( 

272 f"{self.__class__.__name__}" 

273 f"({self.topology.__name__}[{self.gate.__name__}])" 

274 ) 

275 

276 @property 

277 def is_entangling(self): 

278 return Gates.is_entangling(self.gate) 

279 

280 @property 

281 def is_rotational(self): 

282 return Gates.is_rotational(self.gate) 

283 

284 @property 

285 def is_controlled_rotation(self): 

286 return Gates.is_controlled(self.gate) and self.is_rotational 

287 

288 def enough_qubits(self, n_qubits): 

289 if self.is_entangling: 

290 # NOTE This must be adjusted if default values 

291 # in Topology change 

292 span = self.kwargs.get("span", 1) 

293 if callable(span): 

294 span = span(n_qubits) 

295 

296 return (n_qubits >= 2) and (n_qubits > span) 

297 

298 return n_qubits >= 1 

299 

300 def n_params(self, n_qubits: int) -> int: 

301 assert n_qubits > 0, "Number of qubits must be positive" 

302 

303 if not self.is_rotational: 

304 return 0 

305 

306 per_gate = 3 if self.gate.__name__ == "Rot" else 1 

307 

308 if self.is_entangling: 

309 if not self.enough_qubits(n_qubits): 

310 warnings.warn( 

311 f"Skipping {self.topology.__name__} with n_qubits={n_qubits} " 

312 f"as there are not enough qubits" 

313 f"for this topology." 

314 ) 

315 return 0 

316 n_gates = len(self.topology(n_qubits=n_qubits, **self.kwargs)) 

317 else: 

318 n_gates = len(self.wires) if self.wires is not None else n_qubits 

319 

320 if n_gates == 0: # an empty block consumes no parameters, shared or not 

321 return 0 

322 

323 return per_gate if self.shared else per_gate * n_gates 

324 

325 def n_pulse_params(self, n_qubits: int) -> int: 

326 assert n_qubits > 0, "Number of qubits must be positive" 

327 

328 n_pulse_params = PulseInformation.num_params(self.gate) 

329 if self.is_entangling: 

330 if not self.enough_qubits(n_qubits): 

331 warnings.warn( 

332 f"Skipping {self.topology.__name__} with n_qubits={n_qubits} " 

333 f"as there are not enough qubits" 

334 f"for this topology." 

335 ) 

336 return 0 

337 else: 

338 return n_pulse_params * len( 

339 self.topology(n_qubits=n_qubits, **self.kwargs) 

340 ) 

341 n_gates = len(self.wires) if self.wires is not None else n_qubits 

342 return n_pulse_params * n_gates 

343 

344 def apply( 

345 self, n_qubits: int, w: np.ndarray = None, w_idx: int = None, **kwargs 

346 ) -> int: 

347 """ 

348 Applies the block to the given circuit. 

349 

350 Args: 

351 n_qubits (int): Number of qubits, the block is applied to. 

352 w (np.ndarray, optional): Weights to use for rotational gates. 

353 Defaults to None. 

354 w_idx (int, optional): Index of weights to use for rotational gates. 

355 Defaults to None. 

356 **kwargs (Any): Keyword arguments passed to the gate. 

357 

358 Returns: 

359 int: The new index of weights after applying the block. 

360 """ 

361 assert n_qubits > 0, "Number of qubits must be positive" 

362 

363 if self.is_entangling: 

364 iterator = self.topology(n_qubits=n_qubits, **self.kwargs) 

365 else: 

366 iterator = self.wires if self.wires is not None else range(n_qubits) 

367 

368 per_gate = 3 if self.gate.__name__ == "Rot" else 1 

369 base = w_idx # start index, reused for every gate when shared 

370 applied = False 

371 

372 for wires in iterator: 

373 if self.is_entangling and not self.enough_qubits(n_qubits): 

374 warnings.warn( 

375 f"Skipping {self.topology.__name__} with n_qubits={n_qubits} " 

376 f"as there are not enough qubits" 

377 f"for this topology." 

378 ) 

379 continue 

380 

381 if self.is_rotational: 

382 assert w is not None, "w must be provided for rotational gates" 

383 assert w_idx is not None, "w_idx must be provided for rotational gates" 

384 

385 i = base if self.shared else w_idx 

386 if per_gate == 3: 

387 self.gate(w[i], w[i + 1], w[i + 2], wires=wires, **kwargs) 

388 else: 

389 self.gate(w[i], wires=wires, **kwargs) 

390 if not self.shared: 

391 w_idx += per_gate 

392 applied = True 

393 else: 

394 self.gate(wires=wires, **kwargs) 

395 

396 if self.is_rotational and self.shared and applied: 

397 w_idx = base + per_gate 

398 return w_idx 

399 

400 

401class Ansaetze: 

402 def get_available(parameterized_only=False): 

403 # list of parameterized ansaetze 

404 ansaetze = [ 

405 Ansaetze.Circuit_1, 

406 Ansaetze.Circuit_2, 

407 Ansaetze.Circuit_3, 

408 Ansaetze.Circuit_4, 

409 Ansaetze.Circuit_5, 

410 Ansaetze.Circuit_6, 

411 Ansaetze.Circuit_7, 

412 Ansaetze.Circuit_8, 

413 Ansaetze.Circuit_9, 

414 Ansaetze.Circuit_10, 

415 Ansaetze.Circuit_13, 

416 Ansaetze.Circuit_14, 

417 Ansaetze.Circuit_15, 

418 Ansaetze.Circuit_16, 

419 Ansaetze.Circuit_17, 

420 Ansaetze.Circuit_18, 

421 Ansaetze.Circuit_19, 

422 Ansaetze.Circuit_20, 

423 Ansaetze.No_Entangling, 

424 Ansaetze.Strongly_Entangling, 

425 Ansaetze.Hardware_Efficient, 

426 Ansaetze.Permutation_Equivariant, 

427 Ansaetze.Matchgate, 

428 Ansaetze.XY_Brickwork, 

429 ] 

430 

431 # extend by the non-parameterized ones 

432 if not parameterized_only: 

433 ansaetze += [ 

434 Ansaetze.No_Ansatz, 

435 Ansaetze.GHZ, 

436 ] 

437 

438 return ansaetze 

439 

440 class No_Ansatz(DeclarativeCircuit): 

441 @classmethod 

442 def structure(cls): 

443 return () 

444 

445 class GHZ(DeclarativeCircuit): 

446 @classmethod 

447 def structure(cls): 

448 return ( 

449 Block(gate=Gates.H, wires=[0]), 

450 Block( 

451 gate=Gates.CX, 

452 topology=Topology.stairs, 

453 reverse=False, 

454 mirror=False, 

455 ), 

456 ) 

457 

458 class Circuit_1(DeclarativeCircuit): 

459 @classmethod 

460 def structure(cls): 

461 return ( 

462 Block(gate=Gates.RX), 

463 Block(gate=Gates.RZ), 

464 ) 

465 

466 class Circuit_2(DeclarativeCircuit): 

467 @classmethod 

468 def structure(cls): 

469 return ( 

470 Block(gate=Gates.RX), 

471 Block(gate=Gates.RZ), 

472 Block( 

473 gate=Gates.CX, 

474 topology=Topology.stairs, 

475 ), 

476 ) 

477 

478 class Circuit_3(DeclarativeCircuit): 

479 @classmethod 

480 def structure(cls): 

481 return ( 

482 Block(gate=Gates.RX), 

483 Block(gate=Gates.RZ), 

484 Block(gate=Gates.CRZ, topology=Topology.stairs), 

485 ) 

486 

487 class Circuit_4(DeclarativeCircuit): 

488 @classmethod 

489 def structure(cls): 

490 return ( 

491 Block(gate=Gates.RX), 

492 Block(gate=Gates.RZ), 

493 Block(gate=Gates.CRX, topology=Topology.stairs), 

494 ) 

495 

496 class Circuit_5(DeclarativeCircuit): 

497 @classmethod 

498 def structure(cls): 

499 return ( 

500 Block(gate=Gates.RX), 

501 Block(gate=Gates.RZ), 

502 Block(gate=Gates.CRZ, topology=Topology.all_to_all), 

503 Block(gate=Gates.RX), 

504 Block(gate=Gates.RZ), 

505 ) 

506 

507 class Circuit_6(DeclarativeCircuit): 

508 @classmethod 

509 def structure(cls): 

510 return ( 

511 Block(gate=Gates.RX), 

512 Block(gate=Gates.RZ), 

513 Block(gate=Gates.CRX, topology=Topology.all_to_all), 

514 Block(gate=Gates.RX), 

515 Block(gate=Gates.RZ), 

516 ) 

517 

518 class Circuit_7(DeclarativeCircuit): 

519 @classmethod 

520 def structure(cls): 

521 return ( 

522 Block(gate=Gates.RX), 

523 Block(gate=Gates.RZ), 

524 Block( 

525 gate=Gates.CRZ, 

526 topology=Topology.bricks, 

527 ), 

528 Block(gate=Gates.RX), 

529 Block(gate=Gates.RZ), 

530 Block( 

531 gate=Gates.CRZ, 

532 topology=Topology.bricks, 

533 offset=1, 

534 ), 

535 ) 

536 

537 class Circuit_8(DeclarativeCircuit): 

538 @classmethod 

539 def structure(cls): 

540 return ( 

541 Block(gate=Gates.RX), 

542 Block(gate=Gates.RZ), 

543 Block( 

544 gate=Gates.CRX, 

545 topology=Topology.bricks, 

546 ), 

547 Block(gate=Gates.RX), 

548 Block(gate=Gates.RZ), 

549 Block( 

550 gate=Gates.CRX, 

551 topology=Topology.bricks, 

552 offset=1, 

553 ), 

554 ) 

555 

556 class Circuit_9(DeclarativeCircuit): 

557 @classmethod 

558 def structure(cls): 

559 return ( 

560 Block(gate=Gates.H), 

561 Block(gate="CZ", topology=Topology.stairs), 

562 Block(gate=Gates.RX), 

563 ) 

564 

565 class Circuit_10(DeclarativeCircuit): 

566 @classmethod 

567 def structure(cls): 

568 return ( 

569 Block(gate=Gates.RY), 

570 Block(gate="CZ", topology=Topology.stairs, offset=-1, wrap=True), 

571 Block(gate=Gates.RY), 

572 ) 

573 

574 class Circuit_13(DeclarativeCircuit): 

575 @classmethod 

576 def structure(cls): 

577 return ( 

578 Block(gate=Gates.RY), 

579 Block( 

580 gate=Gates.CRZ, 

581 topology=Topology.stairs, 

582 wrap=True, 

583 reverse=True, 

584 mirror=False, 

585 ), 

586 Block(gate=Gates.RY), 

587 Block( 

588 gate=Gates.CRZ, 

589 topology=Topology.stairs, 

590 reverse=False, 

591 mirror=False, 

592 offset=lambda n: n - 1, 

593 span=3, 

594 wrap=True, 

595 ), 

596 ) 

597 

598 class Circuit_14(DeclarativeCircuit): 

599 @classmethod 

600 def structure(cls): 

601 return ( 

602 Block(gate=Gates.RY), 

603 Block( 

604 gate=Gates.CRX, 

605 topology=Topology.stairs, 

606 wrap=True, 

607 reverse=True, 

608 mirror=False, 

609 ), 

610 Block(gate=Gates.RY), 

611 Block( 

612 gate=Gates.CRX, 

613 topology=Topology.stairs, 

614 reverse=False, 

615 mirror=False, 

616 offset=lambda n: n - 1, 

617 span=3, 

618 wrap=True, 

619 ), 

620 ) 

621 

622 class Circuit_15(DeclarativeCircuit): 

623 @classmethod 

624 def structure(cls): 

625 return ( 

626 Block(gate=Gates.RY), 

627 Block( 

628 gate=Gates.CX, 

629 topology=Topology.stairs, 

630 wrap=True, 

631 reverse=True, 

632 mirror=False, 

633 ), 

634 Block(gate=Gates.RY), 

635 Block( 

636 gate=Gates.CX, 

637 topology=Topology.stairs, 

638 reverse=False, 

639 mirror=False, 

640 offset=lambda n: n - 1, 

641 span=3, 

642 wrap=True, 

643 ), 

644 ) 

645 

646 class Circuit_16(DeclarativeCircuit): 

647 @classmethod 

648 def structure(cls): 

649 return ( 

650 Block(gate=Gates.RX), 

651 Block(gate=Gates.RZ), 

652 Block( 

653 gate=Gates.CRZ, 

654 topology=Topology.bricks, 

655 ), 

656 Block( 

657 gate=Gates.CRZ, 

658 topology=Topology.bricks, 

659 offset=1, 

660 ), 

661 ) 

662 

663 class Circuit_17(DeclarativeCircuit): 

664 @classmethod 

665 def structure(cls): 

666 return ( 

667 Block(gate=Gates.RX), 

668 Block(gate=Gates.RZ), 

669 Block( 

670 gate=Gates.CRX, 

671 topology=Topology.bricks, 

672 ), 

673 Block( 

674 gate=Gates.CRX, 

675 topology=Topology.bricks, 

676 offset=1, 

677 ), 

678 ) 

679 

680 class Circuit_18(DeclarativeCircuit): 

681 @classmethod 

682 def structure(cls): 

683 return ( 

684 Block(gate=Gates.RX), 

685 Block(gate=Gates.RZ), 

686 Block( 

687 gate=Gates.CRZ, 

688 topology=Topology.stairs, 

689 wrap=True, 

690 mirror=False, 

691 ), 

692 ) 

693 

694 class Circuit_19(DeclarativeCircuit): 

695 @classmethod 

696 def structure(cls): 

697 return ( 

698 Block(gate=Gates.RX), 

699 Block(gate=Gates.RZ), 

700 Block( 

701 gate=Gates.CRX, 

702 topology=Topology.stairs, 

703 wrap=True, 

704 mirror=False, 

705 ), 

706 ) 

707 

708 class Circuit_20(DeclarativeCircuit): 

709 @classmethod 

710 def structure(cls): 

711 return ( 

712 Block(gate=Gates.RY), 

713 Block( 

714 gate=Gates.CX, 

715 topology=Topology.stairs, 

716 wrap=True, 

717 reverse=True, 

718 mirror=False, 

719 ), 

720 Block(gate=Gates.RY), 

721 Block( 

722 gate=Gates.CX, 

723 topology=Topology.stairs, 

724 reverse=False, 

725 offset=lambda n: n - 2, 

726 span=1, 

727 wrap=True, 

728 ), 

729 ) 

730 

731 class No_Entangling(DeclarativeCircuit): 

732 @classmethod 

733 def structure(cls): 

734 return (Block(gate=Gates.Rot),) 

735 

736 class Hardware_Efficient(DeclarativeCircuit): 

737 @classmethod 

738 def structure(cls): 

739 return ( 

740 Block(gate=Gates.RY), 

741 Block(gate=Gates.RZ), 

742 Block(gate=Gates.RY), 

743 Block( 

744 gate=Gates.CX, 

745 topology=Topology.bricks, 

746 mirror=False, 

747 ), 

748 Block( 

749 gate=Gates.CX, 

750 topology=Topology.bricks, 

751 offset=-1, 

752 modulo=True, 

753 wrap=True, 

754 mirror=False, 

755 ), 

756 ) 

757 

758 class Strongly_Entangling(DeclarativeCircuit): 

759 @classmethod 

760 def structure(cls): 

761 return ( 

762 Block(gate=Gates.Rot), 

763 Block( 

764 gate=Gates.CX, 

765 topology=Topology.stairs, 

766 wrap=True, 

767 reverse=False, 

768 mirror=False, 

769 ), 

770 Block(gate=Gates.Rot), 

771 Block( 

772 gate=Gates.CX, 

773 topology=Topology.stairs, 

774 reverse=False, 

775 span=lambda n: n // 2, 

776 wrap=True, 

777 mirror=False, 

778 ), 

779 ) 

780 

781 class Permutation_Equivariant(DeclarativeCircuit): 

782 r"""$S_n$ permutation-equivariant layer (Schatzki et al., arXiv:2210.09974). 

783 

784 Shared-angle RX and RY on every qubit followed by a shared-angle RZZ on 

785 every qubit pair, realising $\exp(-i \frac{a}{2} \sum_k X_k) 

786 \exp(-i \frac{b}{2} \sum_k Y_k) \exp(-i \frac{c}{2} \sum_{j<k} Z_j Z_k)$ 

787 for the rotation convention $R_P(\theta) = \exp(-i \frac{\theta}{2} P)$. 

788 The three parameters are tied (shared across all gates), so the layer 

789 width is 3 independent of the qubit count. 

790 

791 Gradients assume JAX autodiff; a parameter-shift differentiator would need 

792 special handling for the shared parameters. 

793 """ 

794 

795 @classmethod 

796 def structure(cls): 

797 return ( 

798 Block(gate=Gates.RX, shared=True), 

799 Block(gate=Gates.RY, shared=True), 

800 Block(gate=Gates.RZZ, topology=Topology.all_pairs, shared=True), 

801 ) 

802 

803 class Matchgate(DeclarativeCircuit): 

804 r"""Matchgate LASA layer: RZ on every qubit + nearest-neighbour RXX. 

805 

806 Generators $\{Z_k\} \cup \{X_k X_{k+1}\}$; the Lie closure is the 

807 matchgate algebra $\mathfrak{so}(2n)$ with $\dim = n(2n-1)$ (Kokcu et 

808 al., arXiv:2104.00728). RXX is applied on the even nearest-neighbour 

809 bonds and then the odd bonds of the open chain, so the layer width is 

810 $n + (n-1)$. Gradients assume JAX autodiff. 

811 """ 

812 

813 @classmethod 

814 def structure(cls): 

815 return ( 

816 Block(gate=Gates.RZ), 

817 Block( 

818 gate=Gates.RXX, 

819 topology=Topology.bricks, 

820 offset=0, 

821 reverse=False, 

822 mirror=False, 

823 ), 

824 Block( 

825 gate=Gates.RXX, 

826 topology=Topology.bricks, 

827 offset=1, 

828 reverse=False, 

829 mirror=False, 

830 ), 

831 ) 

832 

833 class XY_Brickwork(DeclarativeCircuit): 

834 r"""Off-diagonal XY brickwork: nearest-neighbour RXX then RYY. 

835 

836 Generators $\{X_k X_{k+1}, Y_k Y_{k+1}\}$; the Lie closure is the 

837 off-diagonal algebra $\mathfrak{so}(n) \oplus \mathfrak{so}(n)$ with no 

838 single-qubit $Z$, hence no deterministic $\mathfrak{g}$-purity floor. 

839 RXX on the even then odd bonds, followed by RYY on the even then odd 

840 bonds, so the layer width is $2(n-1)$. Gradients assume JAX autodiff. 

841 """ 

842 

843 @classmethod 

844 def structure(cls): 

845 return ( 

846 Block( 

847 gate=Gates.RXX, 

848 topology=Topology.bricks, 

849 offset=0, 

850 reverse=False, 

851 mirror=False, 

852 ), 

853 Block( 

854 gate=Gates.RXX, 

855 topology=Topology.bricks, 

856 offset=1, 

857 reverse=False, 

858 mirror=False, 

859 ), 

860 Block( 

861 gate=Gates.RYY, 

862 topology=Topology.bricks, 

863 offset=0, 

864 reverse=False, 

865 mirror=False, 

866 ), 

867 Block( 

868 gate=Gates.RYY, 

869 topology=Topology.bricks, 

870 offset=1, 

871 reverse=False, 

872 mirror=False, 

873 ), 

874 ) 

875 

876 

877# Cache for computed rulers 

878_GOLOMB_RULER_CACHE: dict = {} 

879 

880 

881def _greedy_golomb(d: int) -> Tuple[int, ...]: 

882 """Construct a valid Golomb ruler of order *d* using a greedy algorithm. 

883 

884 Starting from mark 0, each subsequent mark is the smallest integer 

885 whose pairwise differences with all existing marks are distinct. 

886 This always succeeds and produces a valid ruler, though it may not 

887 be optimal (i.e. the max mark may not be minimal). 

888 

889 Args: 

890 d: Order of the ruler (number of marks). 

891 

892 Returns: 

893 Tuple of *d* non-negative integers forming a valid Golomb ruler. 

894 """ 

895 if d <= 0: 

896 return () 

897 marks = [0] 

898 diffs: set = set() 

899 candidate = 1 

900 while len(marks) < d: 

901 new_diffs: set = set() 

902 valid = True 

903 for existing in marks: 

904 diff = candidate - existing 

905 if diff in diffs or diff in new_diffs: 

906 valid = False 

907 break 

908 new_diffs.add(diff) 

909 if valid: 

910 marks.append(candidate) 

911 diffs |= new_diffs 

912 candidate += 1 

913 return tuple(marks) 

914 

915 

916def golomb_ruler(d: int) -> Tuple[int, ...]: 

917 """Return a valid Golomb ruler of order *d*. 

918 

919 A Golomb ruler is a set of *d* non-negative integers such that all 

920 pairwise differences are distinct. When used as the diagonal of a 

921 data-encoding Hamiltonian ``H = diag(marks)``, the resulting Fourier 

922 spectrum ``\\Omega`` has ``|\\Omega| = d(d-1) + 1`` distinct frequencies 

923 with ``|R(k)| = 1`` for all ``k ≠ 0`` — the minimal possible degeneracy 

924 for any *d*-dimensional Hamiltonian. 

925 

926 Uses a greedy construction that always produces a valid ruler. 

927 Results are cached for efficiency. 

928 

929 Args: 

930 d: Order of the ruler (number of marks, equal to the Hilbert 

931 space dimension ``2^n_qubits``). 

932 

933 Returns: 

934 Tuple of *d* non-negative integers forming a Golomb ruler. 

935 

936 Raises: 

937 ValueError: If ``d <= 0``. 

938 

939 References: 

940 Peters et al., "Generalization despite overfitting in quantum 

941 machine learning models", arXiv:2209.05523, Appendix C.4. 

942 """ 

943 if d <= 0: 

944 raise ValueError(f"Golomb ruler order must be positive, got {d}") 

945 if d not in _GOLOMB_RULER_CACHE: 

946 _GOLOMB_RULER_CACHE[d] = _greedy_golomb(d) 

947 return _GOLOMB_RULER_CACHE[d] 

948 

949 

950def GolombEncoding( 

951 w: Union[float, np.ndarray], 

952 wires: Union[int, List[int]], 

953 noise_params: Optional[dict] = None, 

954 random_key: Optional[Any] = None, 

955 **kwargs: Any, 

956) -> None: 

957 """Apply Golomb encoding as a diagonal unitary on all given wires. 

958 

959 Implements ``S(x) = exp(-i H x)`` where 

960 ``H = diag(g_0, g_1, ..., g_{d-1})`` and the ``g_j`` are the marks 

961 of a Golomb ruler of order ``d = 2^len(wires)``. This produces a 

962 maximally non-degenerate Fourier spectrum with 

963 ``|\\Omega| = d(d-1) + 1`` distinct frequencies, each with degeneracy 

964 ``|R(k)| = 1``. 

965 

966 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4. 

967 

968 Args: 

969 w: Scalar input value (the data point *x* to encode). 

970 wires: Qubit indices this encoding acts on. All qubits are 

971 acted upon simultaneously via a single multi-qubit diagonal 

972 gate. 

973 noise_params: Optional noise parameters dictionary. 

974 random_key: JAX random key for stochastic noise. 

975 **kwargs: Ignored; accepted so that the callable matches the 

976 signature of the per-qubit encoding gates. 

977 

978 Returns: 

979 None: Gate and noise are applied in-place to the circuit. 

980 

981 Raises: 

982 NotImplementedError: If called in pulse mode, which the Golomb 

983 encoding has no pulse parametrization for. 

984 """ 

985 if kwargs.pop("pulse", False): 

986 raise NotImplementedError("Golomb encoding has no pulse parametrization") 

987 

988 wires_list = list(wires) if isinstance(wires, (list, tuple)) else [wires] 

989 d = 2 ** len(wires_list) 

990 marks = np.array(golomb_ruler(d), dtype=float) 

991 

992 # Apply gate error to the input angle 

993 w, random_key = UnitaryGates.GateError(w, noise_params, random_key) 

994 

995 # Build diagonal: exp(-i * mark_j * x) 

996 diag = np.exp(-1j * marks * w) 

997 

998 # Pass the real generator (marks) and scalar (w) so the analytical 

999 # Fourier tree can decompose the gate into commuting Pauli-Z rotations. 

1000 DiagonalQubitUnitary(diag, wires=wires_list, generator=marks, scale=w) 

1001 UnitaryGates.Noise(wires_list, noise_params) 

1002 

1003 

1004# Encoding wrappers live at module level, 

1005# so that a model holding them can be pickled. 

1006def _scaled_encoding(inputs, wires, *, enc, base, **kwargs): 

1007 """Apply `enc` with the input scaled by ``base**wires``.""" 

1008 return enc(inputs * (base**wires), wires, **kwargs) 

1009 

1010 

1011def _golomb_encoding(inputs, wires, **kwargs): 

1012 # `wires` here is a list of all qubit indices, set by _iec 

1013 GolombEncoding(w=inputs, wires=wires, **kwargs) 

1014 

1015 

1016class Encoding: 

1017 def __init__( 

1018 self, strategy: str, gates: Union[str, Callable, List[Union[str, Callable]]] 

1019 ): 

1020 """ 

1021 Initializes an Encoding object. 

1022 

1023 Implementations closely follow https://doi.org/10.22331/q-2023-12-20-1210 

1024 

1025 Parameters 

1026 ---------- 

1027 strategy : str 

1028 The encoding strategy to use. Available options: 

1029 ['hamming', 'binary', 'ternary'] 

1030 gates : Union[str, Callable, List[Union[str, Callable]]] 

1031 The gates to use for encoding. Can be a string, a callable or a list 

1032 of strings or callables. 

1033 

1034 Returns 

1035 ------- 

1036 None 

1037 

1038 Raises 

1039 ------- 

1040 ValueError 

1041 If the encoding strategy is not implemented. 

1042 ValueError 

1043 If there is an error parsing the Gates. 

1044 """ 

1045 if strategy not in ["hamming", "binary", "ternary", "golomb"]: 

1046 raise ValueError( 

1047 f"Encoding strategy {strategy} not implemented. " 

1048 "Available options: ['hamming', 'binary', 'ternary', 'golomb']" 

1049 ) 

1050 self._strategy = strategy 

1051 strategy_fn = getattr(self, strategy) 

1052 

1053 log.debug(f"Using encoding strategy: '{strategy_fn.__name__}'") 

1054 

1055 if self._strategy == "golomb": 

1056 self._gates = [] 

1057 self.callable = [strategy_fn(None)] 

1058 else: 

1059 try: 

1060 self._gates = Gates.parse_gates(gates, Gates) 

1061 except ValueError as e: 

1062 raise ValueError(f"Error parsing encodings: {e}") 

1063 

1064 self.callable = [strategy_fn(g) for g in self._gates] 

1065 

1066 def __len__(self): 

1067 return len(self.callable) 

1068 

1069 def __getitem__(self, idx): 

1070 return self.callable[idx] 

1071 

1072 def get_n_freqs(self, data_reupload): 

1073 """ 

1074 Number of reachable frequencies (positive + negative + DC) for the 

1075 encoding strategy, given the ``(n_layers, n_qubits)`` data-reupload mask. 

1076 """ 

1077 return int(self.get_spectrum(data_reupload).size) 

1078 

1079 def get_spectrum(self, data_reupload): 

1080 """ 

1081 Reachable Fourier frequency comb for the encoding strategy. 

1082 

1083 Computed exactly from the ``(n_layers, n_qubits)`` data-reupload mask as 

1084 the Minkowski sum of the per-gate generator frequencies: 

1085 

1086 - hamming: every encoding gate contributes +/-1, so the comb is 

1087 ``{-k, ..., k}`` with ``k`` the total number of encoding gates. 

1088 - binary / ternary: qubit ``q`` is scaled by ``base**q`` (base 2 / 3), 

1089 applied once per active layer, so the comb is the Minkowski sum over 

1090 qubits of ``{k * base**q : |k| <= count_q}`` with ``count_q`` the 

1091 number of layers that re-upload on qubit ``q``. 

1092 - golomb: a single multi-qubit diagonal gate per *active layer* (see 

1093 ``Model._iec``), each spanning ``[-max_mark, max_mark]``; ``k`` active 

1094 layers give ``{-k*max_mark, ..., k*max_mark}``. The contiguous range 

1095 is returned (not the sparse mark-difference set) because the FFT in 

1096 ``Coefficients._fourier_transform`` samples at ``model.degree`` 

1097 resolution and must cover the max frequency; residual sparse gaps 

1098 carry ~0 coefficients. 

1099 

1100 See https://doi.org/10.22331/q-2023-12-20-1210 for more details. 

1101 

1102 Parameters 

1103 ---------- 

1104 data_reupload : np.ndarray 

1105 Boolean mask of shape ``(n_layers, n_qubits)`` (or ``(n_qubits,)`` 

1106 for a single layer) marking where the encoding re-uploads. 

1107 

1108 Returns 

1109 ------- 

1110 np.ndarray 

1111 The sorted reachable spectrum of the encoding strategy. 

1112 """ 

1113 mask = np.asarray(data_reupload, dtype=bool) 

1114 if mask.ndim == 1: # (n_qubits,) -> treat as a single layer 

1115 mask = mask[None, :] 

1116 

1117 if self._strategy not in ("hamming", "binary", "ternary", "golomb"): 

1118 raise NotImplementedError 

1119 if self._strategy == "golomb": 

1120 n_qubits = getattr(self, "_n_qubits", None) 

1121 if n_qubits is None: 

1122 raise ValueError("Golomb encoding requires n_qubits to be set") 

1123 apps = int(np.count_nonzero(mask.any(axis=1))) # one gate per active layer 

1124 limit = apps * max(golomb_ruler(2**n_qubits)) 

1125 return np.arange(-limit, limit + 1) 

1126 

1127 base = {"hamming": 1, "binary": 2, "ternary": 3}[self._strategy] 

1128 counts = mask.sum(axis=0) # per-qubit re-upload count (index == wire) 

1129 reach = {0} 

1130 for q, c in enumerate(counts): 

1131 scale = base**q 

1132 reach = { 

1133 a + k 

1134 for a in reach 

1135 for k in range(-int(c) * scale, int(c) * scale + 1, scale) 

1136 } 

1137 return np.array(sorted(reach)) 

1138 

1139 def get_weights(self, n_qubits): 

1140 """ 

1141 Per-qubit weight vector w for the separable weighted encodings. 

1142 

1143 The encoding loads the scaled input phi_q = w_q * x on qubit q, so the 

1144 returned weights match the per-qubit scaling of the strategy callables 

1145 (see :meth:`binary` and :meth:`ternary`). 

1146 

1147 Parameters 

1148 ---------- 

1149 n_qubits : int 

1150 The number of qubits carrying the encoding. 

1151 

1152 Returns 

1153 ------- 

1154 np.ndarray 

1155 The weight vector of shape ``(n_qubits,)``. 

1156 

1157 Raises 

1158 ------ 

1159 ValueError 

1160 If the strategy is non-separable (golomb) and has no per-qubit weights. 

1161 """ 

1162 if self._strategy == "hamming": 

1163 return np.ones(n_qubits) 

1164 elif self._strategy == "binary": 

1165 return 2.0 ** np.arange(n_qubits) 

1166 elif self._strategy == "ternary": 

1167 return 3.0 ** np.arange(n_qubits) 

1168 elif self._strategy == "golomb": 

1169 raise ValueError( 

1170 "Golomb encoding is non-separable and has no per-qubit weights." 

1171 ) 

1172 else: 

1173 raise NotImplementedError 

1174 

1175 def hamming(self, enc): 

1176 """ 

1177 Hamming encoding strategy. 

1178 

1179 Returns an encoding function that uses the Hamming encoding strategy 

1180 which uses 2 * omegas + 1 frequencies for the encoding. 

1181 See https://doi.org/10.22331/q-2023-12-20-1210 for more details. 

1182 

1183 Parameters 

1184 ---------- 

1185 enc : Callable 

1186 The encoding function to be wrapped. 

1187 

1188 Returns 

1189 ------- 

1190 Callable 

1191 The wrapped encoding function. 

1192 """ 

1193 return enc 

1194 

1195 def binary(self, enc): 

1196 """ 

1197 Binary encoding strategy. 

1198 

1199 Returns an encoding function that scales the input by a factor of 2^wires. 

1200 

1201 Binary encoding uses 2^(omegas + 1) - 1 frequencies for the encoding. 

1202 See https://doi.org/10.22331/q-2023-12-20-1210 for more details. 

1203 

1204 Parameters 

1205 ---------- 

1206 enc : Callable 

1207 The encoding function to be wrapped. 

1208 

1209 Returns 

1210 ------- 

1211 Callable 

1212 The wrapped encoding function. 

1213 """ 

1214 

1215 return functools.partial(_scaled_encoding, enc=enc, base=2) 

1216 

1217 def ternary(self, enc): 

1218 """ 

1219 Ternary encoding strategy. 

1220 

1221 Returns an encoding function that scales the input by a factor of 3^wires. 

1222 

1223 Ternary encoding uses 3^omegas frequencies for the encoding. 

1224 See https://doi.org/10.22331/q-2023-12-20-1210 for more details. 

1225 

1226 Parameters 

1227 ---------- 

1228 enc : Callable 

1229 The encoding function to be wrapped. 

1230 

1231 Returns 

1232 ------- 

1233 Callable 

1234 The wrapped encoding function. 

1235 """ 

1236 

1237 return functools.partial(_scaled_encoding, enc=enc, base=3) 

1238 

1239 @property 

1240 def is_golomb(self): 

1241 """Whether this encoding uses the Golomb (multi-qubit diagonal) strategy.""" 

1242 return self._strategy == "golomb" 

1243 

1244 def golomb(self, enc): 

1245 """Golomb encoding strategy. 

1246 

1247 Returns a callable that applies a multi-qubit diagonal unitary 

1248 ``S(x) = exp(-i H x)`` where ``H = diag(golomb_marks)`` to all 

1249 qubits simultaneously. This produces the largest possible 

1250 ``|Ω| = d(d-1)+1`` for any *d*-dimensional Hamiltonian, with 

1251 ``|R(k)| = 1`` for all nonzero frequencies *k*. 

1252 

1253 Unlike the other strategies, Golomb encoding does *not* wrap a 

1254 per-qubit gate. Instead, the model's ``_iec`` method detects 

1255 ``is_golomb`` and applies a single ``GolombEncoding`` gate on 

1256 all qubits. 

1257 

1258 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4. 

1259 

1260 Parameters 

1261 ---------- 

1262 enc : Callable or None 

1263 Ignored (Golomb encoding uses its own multi-qubit gate). 

1264 

1265 Returns 

1266 ------- 

1267 Callable 

1268 A callable with the same signature as per-qubit encoding 

1269 functions but that applies :func:`GolombEncoding`. 

1270 """ 

1271 

1272 return _golomb_encoding